diff --git a/VERSION b/VERSION index 8ac3ef6..da44c7f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.49 +1.1.50 diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index bf8a513..c2f20fe 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -15,6 +15,7 @@ const ResetPasswordPage = lazy(() => import('./pages/ResetPassword')) const SetupPage = lazy(() => import('./pages/Setup')) const DashboardPage = lazy(() => import('./pages/Dashboard')) const DomainsPage = lazy(() => import('./pages/Domains')) +const DomainDetailPage = lazy(() => import('./pages/Domains/Detail')) const BackendsPage = lazy(() => import('./pages/Backends')) const BackendDetailPage = lazy(() => import('./pages/Backends/Detail')) const RoutingRulesPage = lazy(() => import('./pages/RoutingRules')) @@ -107,6 +108,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 73d44f3..d0e7032 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -242,6 +242,9 @@ "intro": "Verwalte FQDNs, die HAProxy terminiert. Optionales Primary-Backend als Catch-all; Pfad-Routing via Routing-Regeln.", "addDomain": "Domain hinzufügen", "editDomain": "Domain bearbeiten", + "backToList": "Zurück zur Übersicht", + "settingsCard": "Domain-Einstellungen", + "moreOnDetailPage": "HSTS, Rate-Limit, Wartungsmodus und mehr auf der Domain-Detailseite nach dem Speichern.", "emptyTitle": "Noch keine Domains.", "emptyDesc": "Lege deine erste Domain an — HAProxy terminiert dann TLS für diesen Hostnamen und routet an das gewählte Backend.", "name": "Name", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 03f19a5..edabe4e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -242,6 +242,9 @@ "intro": "Manage FQDNs that HAProxy terminates. Optional primary backend as catch-all; path-based routing via routing rules.", "addDomain": "Add domain", "editDomain": "Edit domain", + "backToList": "Back to domains", + "settingsCard": "Domain settings", + "moreOnDetailPage": "HSTS, rate-limit, maintenance and more on the domain detail page after saving.", "emptyTitle": "No domains yet.", "emptyDesc": "Add your first domain — HAProxy will then terminate TLS for that hostname and route to the chosen backend.", "name": "Name", diff --git a/management-ui/src/pages/Domains/Detail.tsx b/management-ui/src/pages/Domains/Detail.tsx new file mode 100644 index 0000000..07134f4 --- /dev/null +++ b/management-ui/src/pages/Domains/Detail.tsx @@ -0,0 +1,389 @@ +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + Button, Card, Col, Divider, Form, Input, InputNumber, Modal, + Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message, +} from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { + ArrowLeftOutlined, DeleteOutlined, EditOutlined, GlobalOutlined, + LockOutlined, PlusOutlined, SafetyCertificateOutlined, +} from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import PageHeader from '../../components/PageHeader' +import StatusDot from '../../components/StatusDot' + +const { Text } = Typography + +interface Domain { + id: number; name: string; active: boolean + primary_backend_id?: number | null + http_to_https: boolean + hsts_enabled: boolean; hsts_max_age: number + hsts_subdomains: boolean; hsts_preload: boolean + maintenance_mode: boolean; maintenance_message?: string | null + www_redirect: '' | 'to-naked' | 'to-www' + rate_limit_rps: number; max_body_kb: number + notes?: string | null +} + +interface DomainFormValues { + name: string; active: boolean + primary_backend_id?: number | null + http_to_https: boolean + hsts_enabled: boolean; hsts_max_age: number + hsts_subdomains: boolean; hsts_preload: boolean + maintenance_mode: boolean; maintenance_message?: string + www_redirect: '' | 'to-naked' | 'to-www' + rate_limit_rps: number; max_body_kb: number + notes?: string +} + +interface BackendLite { id: number; name: string; active: boolean } + +interface ResponseHeader { + id: number; domain_id: number; name: string; value: string; position: number +} + +interface TLSCertLite { + domain: string + status: 'pending' | 'active' | 'renewing' | 'expired' | 'error' + not_after?: string | null +} + +async function getDomain(id: number): Promise { + const r = await apiClient.get(`/domains/${id}`) + if (!isEnvelope(r.data)) return null + return r.data.data as Domain +} +async function listBackends(): Promise { + const r = await apiClient.get('/backends') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { backends?: BackendLite[] }).backends ?? [] +} +async function listHeaders(domainID: number): Promise { + const r = await apiClient.get(`/domains/${domainID}/headers`) + if (!isEnvelope(r.data)) return [] + return (r.data.data as { headers?: ResponseHeader[] }).headers ?? [] +} +async function listCerts(): Promise { + const r = await apiClient.get('/tls-certs') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? [] +} + +export default function DomainDetailPage() { + const { t } = useTranslation() + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const qc = useQueryClient() + const domainID = Number(id) + + const { data: domain, isLoading } = useQuery({ + queryKey: ['domain', domainID], + queryFn: () => getDomain(domainID), + enabled: !isNaN(domainID), + }) + const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) + const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts }) + + const [form] = Form.useForm() + + const update = useMutation({ + mutationFn: async (v: DomainFormValues) => { + await apiClient.put(`/domains/${domainID}`, v) + }, + onSuccess: () => { + message.success(t('common.save')) + void qc.invalidateQueries({ queryKey: ['domains'] }) + void qc.invalidateQueries({ queryKey: ['domain', domainID] }) + }, + onError: (e: Error) => message.error(e.message), + }) + + if (isLoading || !domain) return null + + const cert = certs?.find(c => c.domain === domain.name) + + const certBadge = () => { + if (!cert) return } color="default">{t('domains.tlsCertNone')} + if (cert.status === 'expired') return } color="red">{t('domains.tlsCertExpired')} + if (cert.status === 'error') return } color="red">{t('domains.tlsCertError')} + const days = cert.not_after + ? Math.round((new Date(cert.not_after).getTime() - Date.now()) / 86_400_000) + : null + if (days != null && days < 30) { + return ( + + } color="orange"> + {t('domains.tlsCertExpiring', { days })} + + + ) + } + return ( + + } color="green">{t('domains.tlsCertValid')} + + ) + } + + return ( +
+ } + title={domain.name} + subtitle={{certBadge()}} + extra={ + + } + /> + + + + +
update.mutate(v)} + > + + + + + + + + + + + p.maintenance_mode !== c.maintenance_mode}> + {({ getFieldValue }) => getFieldValue('maintenance_mode') ? ( + + + + ) : null} + + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ ) +} + +// ── Response Headers Panel ────────────────────────────────────────────── + +function HeadersPanel({ domainID, domainName }: { domainID: number; domainName: string }) { + const { t } = useTranslation() + const qc = useQueryClient() + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [hForm] = Form.useForm<{ name: string; value: string; position: number }>() + + const { data: headers, isLoading } = useQuery({ + queryKey: ['domain-headers', domainID], + queryFn: () => listHeaders(domainID), + }) + + const invalidate = () => void qc.invalidateQueries({ queryKey: ['domain-headers', domainID] }) + + const create = useMutation({ + mutationFn: async (v: { name: string; value: string; position: number }) => + apiClient.post(`/domains/${domainID}/headers`, v), + onSuccess: () => { + message.success(t('common.save')) + setOpen(false); hForm.resetFields() + invalidate() + }, + onError: (e: Error) => message.error(e.message), + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: { name: string; value: string; position: number } }) => + apiClient.put(`/domains/${domainID}/headers/${id}`, v), + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); hForm.resetFields() + invalidate() + }, + onError: (e: Error) => message.error(e.message), + }) + const del = useMutation({ + mutationFn: async (id: number) => apiClient.delete(`/domains/${domainID}/headers/${id}`), + onSuccess: invalidate, + onError: (e: Error) => message.error(e.message), + }) + + const columns: ColumnsType = [ + { title: t('domains.headerName'), dataIndex: 'name', key: 'name', + render: (s: string) => {s} }, + { title: t('domains.headerValue'), dataIndex: 'value', key: 'value', ellipsis: true }, + { title: '#', dataIndex: 'position', key: 'pos', width: 50 }, + { + title: t('common.actions'), key: 'a', width: 80, + render: (_, r) => ( + + + } + > + + {t('domains.headersHint')} + + + + + { setOpen(false); setEditing(null); hForm.resetFields() }} + onOk={() => { void hForm.submit() }} + confirmLoading={create.isPending || update.isPending} + destroyOnHidden + > +
editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)} + > + + + + + + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/Domains/index.tsx b/management-ui/src/pages/Domains/index.tsx index 91c1d33..7f8418f 100644 --- a/management-ui/src/pages/Domains/index.tsx +++ b/management-ui/src/pages/Domains/index.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' -import { Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd' +import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' -import { DeleteOutlined, EditOutlined, GlobalOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' +import { GlobalOutlined, PlusOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' @@ -100,6 +101,7 @@ async function listHAProxyStats(): Promise { export default function DomainsPage() { const { t } = useTranslation() const qc = useQueryClient() + const navigate = useNavigate() const { data, isLoading } = useQuery({ queryKey: ['domains'], @@ -129,9 +131,7 @@ export default function DomainsPage() { return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN' } - const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) - const [headersFor, setHeadersFor] = useState(null) const [quickBackendOpen, setQuickBackendOpen] = useState(false) const [form] = Form.useForm() const [quickBackendForm] = Form.useForm<{ name: string; scheme: 'http' | 'https'; address: string; port: number }>() @@ -176,19 +176,6 @@ export default function DomainsPage() { }, }) - const update = useMutation({ - mutationFn: async ({ id, v }: { id: number; v: DomainFormValues }) => { - const r = await apiClient.put(`/domains/${id}`, v) - return r.data - }, - onSuccess: () => { - message.success(t('common.save')) - setEditing(null) - form.resetFields() - void qc.invalidateQueries({ queryKey: ['domains'] }) - }, - }) - const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/domains/${id}`) @@ -260,34 +247,11 @@ export default function DomainsPage() { { title: t('common.actions'), key: 'actions', render: (_, row) => ( - - - { - setEditing(row) - form.setFieldsValue({ - name: row.name, - active: row.active, - http_to_https: row.http_to_https, - hsts_enabled: row.hsts_enabled, - hsts_max_age: row.hsts_max_age || 31536000, - hsts_subdomains: row.hsts_subdomains, - hsts_preload: row.hsts_preload, - maintenance_mode: row.maintenance_mode, - maintenance_message: row.maintenance_message ?? '', - www_redirect: row.www_redirect ?? '', - rate_limit_rps: row.rate_limit_rps ?? 0, - max_body_kb: row.max_body_kb ?? 0, - primary_backend_id: row.primary_backend_id ?? null, - notes: row.notes ?? '', - }) - }} - onDelete={() => del.mutate(row.id)} - deleteConfirm={t('domains.deleteConfirm', { name: row.name })} - /> - + navigate(`/domains/${row.id}`)} + onDelete={() => del.mutate(row.id)} + deleteConfirm={t('domains.deleteConfirm', { name: row.name })} + /> ), }, ] @@ -342,38 +306,24 @@ export default function DomainsPage() { } /> { setEditing(null); setCreating(false) }} + title={t('domains.addDomain')} + open={creating} + onCancel={() => setCreating(false)} onOk={() => { void form.submit() }} - confirmLoading={create.isPending || update.isPending} + confirmLoading={create.isPending} > -
{ - if (editing) update.mutate({ id: editing.id, v }) - else create.mutate(v) - }} - > + create.mutate(v)}> - + - - - - - - - prev.maintenance_mode !== curr.maintenance_mode} - > - {({ getFieldValue }) => getFieldValue('maintenance_mode') ? ( - - - - ) : null} - - - - - - - - - - - - -
- {headersFor && ( - setHeadersFor(null)} - /> - )} - {/* Quick-Add-Backend: schmales Sub-Modal direkt aus dem Domain- Anlegen-Flow heraus. Spart drei Navigations-Klicks (Backends- Seite öffnen → Backend anlegen → Server anlegen → zurück). */} @@ -536,162 +384,3 @@ export default function DomainsPage() { ) } - -// ── Response-Headers Modal ──────────────────────────────────────────── -// -// Eigenes Modal weil Headers eine 1:n-Relation sind die nicht in das -// Domain-Form passt. Lädt /domains/:id/headers, erlaubt Inline-Add via -// kleinem Sub-Form, Inline-Edit per Modal pro Row und Delete. - -interface HeadersModalProps { - domain: Domain - onClose: () => void -} - -async function listHeaders(domainID: number): Promise { - const r = await apiClient.get(`/domains/${domainID}/headers`) - if (!isEnvelope(r.data)) return [] - return (r.data.data as { headers?: ResponseHeader[] }).headers ?? [] -} - -function HeadersModal({ domain, onClose }: HeadersModalProps) { - const { t } = useTranslation() - const qc = useQueryClient() - const { data, isFetching } = useQuery({ - queryKey: ['domain-headers', domain.id], - queryFn: () => listHeaders(domain.id), - }) - - const [editing, setEditing] = useState(null) - const [addOpen, setAddOpen] = useState(false) - const [hForm] = Form.useForm<{ name: string; value: string; position: number }>() - - const invalidate = () => { - void qc.invalidateQueries({ queryKey: ['domain-headers', domain.id] }) - } - - const create = useMutation({ - mutationFn: async (v: { name: string; value: string; position: number }) => - apiClient.post(`/domains/${domain.id}/headers`, v), - onSuccess: () => { - message.success(t('common.save')) - setAddOpen(false); hForm.resetFields() - invalidate() - }, - }) - const update = useMutation({ - mutationFn: async ({ id, v }: { id: number; v: { name: string; value: string; position: number } }) => - apiClient.put(`/domains/${domain.id}/headers/${id}`, v), - onSuccess: () => { - message.success(t('common.save')) - setEditing(null); hForm.resetFields() - invalidate() - }, - }) - const del = useMutation({ - mutationFn: async (id: number) => - apiClient.delete(`/domains/${domain.id}/headers/${id}`), - onSuccess: invalidate, - }) - - const columns: ColumnsType = [ - { title: t('domains.headerName'), dataIndex: 'name', key: 'name' }, - { title: t('domains.headerValue'), dataIndex: 'value', key: 'value', ellipsis: true }, - { - title: t('common.actions'), key: 'actions', width: 100, - render: (_, row) => ( - - - - - - } - width={720} - > - - {t('domains.headersHint')} - -
- - - { setAddOpen(false); setEditing(null); hForm.resetFields() }} - onOk={() => { void hForm.submit() }} - confirmLoading={create.isPending || update.isPending} - > -
{ - if (editing) update.mutate({ id: editing.id, v }) - else create.mutate(v) - }} - > - - - - - - - - - - -
- - ) -}