import { useState } from 'react' import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { GlobalOutlined, PlusOutlined, StopOutlined, ThunderboltOutlined } 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' import PageHeader from '../../components/PageHeader' import ActionButtons from '../../components/ActionButtons' import StatusDot from '../../components/StatusDot' import apiClient, { isEnvelope } from '../../api/client' import { useAuthStore } from '../../stores/auth' 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 disable_h3: boolean notes?: string created_at: string updated_at: string } interface DomainFormValues { name: string active: boolean 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 primary_backend_id?: number | null notes?: string } async function listDomains(): Promise { const r = await apiClient.get('/domains') if (!isEnvelope(r.data)) return [] const payload = r.data.data as { domains?: Domain[] } return payload.domains ?? [] } interface BackendLite { id: number name: string scheme: string active: boolean } async function listBackends(): Promise { const r = await apiClient.get('/backends') if (!isEnvelope(r.data)) return [] return (r.data.data as { backends?: BackendLite[] }).backends ?? [] } interface TLSCertLite { domain: string status: 'pending' | 'active' | 'renewing' | 'expired' | 'error' not_after?: string | null } 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 ?? [] } interface HAProxyStat { backend: string; server: string; status: string } // Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt, // das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier // IMMER die vollstaendige Antwort cachen und erst per `select` auf die // Backends reduzieren, die diese Seite braucht. // // Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing // es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag — // nach einem Wechsel hierher und zurueck riss das Dashboard mit // "Cannot read properties of undefined (reading 'length')" die ganze // Oberflaeche in die ErrorBoundary. interface HAProxyStatsPayload { backends: HAProxyStat[] frontends: unknown[] error?: string } async function fetchHAProxyStats(): Promise { try { const r = await apiClient.get('/haproxy/stats') if (!isEnvelope(r.data)) return { backends: [], frontends: [] } const d = r.data.data as Partial return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error } } catch { return { backends: [], frontends: [] } } } export default function DomainsPage() { const { t } = useTranslation() const qc = useQueryClient() const navigate = useNavigate() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data, isLoading } = useQuery({ queryKey: ['domains'], queryFn: listDomains, }) const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) // TLS-Certs nebenher laden, damit wir pro Domain den Cert-Status // (vorhanden / gültig / ablaufend / fehlt) als Spalte zeigen können. // Operator sieht so auf einen Blick welche Domains noch self-signed // sind und welche bereits ein gültiges ACME-Cert haben. const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts }) const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], queryFn: fetchHAProxyStats, select: (d: HAProxyStatsPayload) => d.backends, refetchInterval: 15_000, }) const certByDomain = new Map((certs ?? []).map(c => [c.domain, c])) const backendById = (id?: number | null) => backends?.find(b => b.id === id) // Gibt 'UP', 'DOWN' oder null zurück. HAProxy-Backend heißt eg_backend_. // Wir aggregieren alle Server: wenn mind. einer UP → UP, sonst DOWN. const backendHealth = (id?: number | null): 'UP' | 'DOWN' | null => { if (!id || !haproxyStats?.length) return null const name = `eg_backend_${id}` const servers = haproxyStats.filter(s => s.backend === name) if (!servers.length) return null return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN' } const [creating, setCreating] = useState(false) const [quickBackendOpen, setQuickBackendOpen] = useState(false) const [form] = Form.useForm() const [quickBackendForm] = Form.useForm<{ name: string; scheme: 'http' | 'https'; address: string; port: number }>() const quickCreateBackend = useMutation({ mutationFn: async (v: { name: string; scheme: 'http' | 'https'; address: string; port: number }) => { // 1) Backend anlegen const bRes = await apiClient.post('/backends', { name: v.name, scheme: v.scheme, lb_algorithm: 'roundrobin', websocket: false, active: true, }) const bId = (bRes.data?.data as { id?: number })?.id if (!bId) throw new Error('backend id missing in response') // 2) Ersten Server reinhängen await apiClient.post(`/backends/${bId}/servers`, { backend_id: bId, name: v.address.replace(/[^a-zA-Z0-9-]/g, '-'), address: v.address, port: v.port, weight: 100, active: true, }) return bId }, onSuccess: (bId) => { message.success(t('domains.quickBackendCreated')) void qc.invalidateQueries({ queryKey: ['backends'] }) // Frisch erstellten Backend automatisch im Domain-Form selektieren. form.setFieldsValue({ primary_backend_id: bId }) setQuickBackendOpen(false) quickBackendForm.resetFields() }, onError: (e: Error) => message.error(t('domains.quickBackendFailed') + ': ' + e.message), }) const create = useMutation({ mutationFn: async (v: DomainFormValues) => { const r = await apiClient.post('/domains', v) return r.data }, onSuccess: () => { message.success(t('common.save')) setCreating(false) form.resetFields() void qc.invalidateQueries({ queryKey: ['domains'] }) }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/domains/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['domains'] }) }, onError: (e: Error) => message.error(e.message), }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: Domain; checked: boolean }) => { const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row await apiClient.put(`/domains/${id}`, { ...body, active: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['domains'] }) }, onError: (e: Error) => message.error(e.message), }) const columns: ColumnsType = [ { title: t('domains.name'), dataIndex: 'name', key: 'name', render: (name: string, row: Domain) => ( {name} {row.maintenance_mode && ( } color="orange" style={{ margin: 0 }}> {t('domains.maintenanceTag')} )} {row.rate_limit_rps > 0 && ( } color="purple" style={{ margin: 0, fontSize: 11 }}> {t('domains.rateLimitTag')} )} ), }, { title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id', render: (id?: number | null) => { if (!id) return {t('domains.noBackend')} const b = backendById(id) const health = backendHealth(id) return ( {b ? {b.name} ({b.scheme}) : #{id}} {health === 'UP' && UP} {health === 'DOWN' && DOWN} ) }, }, { title: t('domains.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, row: Domain) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => }, { title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => }, { title: t('domains.tlsCert'), key: 'tlsCert', width: 120, render: (_, row) => { const cert = certByDomain.get(row.name) if (!cert) { return ( {t('domains.tlsCertNone')} ) } if (cert.status === 'expired') { return {t('domains.tlsCertExpired')} } if (cert.status === 'error') { return {t('domains.tlsCertError')} } // Days remaining 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 ( {t('domains.tlsCertExpiring', { days })} ) } return ( {t('domains.tlsCertValid')} ) }, }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( navigate(`/domains/${row.id}`)} onDelete={() => del.mutate(row.id)} deleteConfirm={t('domains.deleteConfirm', { name: row.name })} /> ), }, ] // openCreate: reused von extraActions-Button und EmptyState-Action. // Setzt die Defaults für die Create-Modal-Form. const openCreate = () => { setCreating(true); form.resetFields() form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false, hsts_max_age: 31536000, hsts_subdomains: false, hsts_preload: false, maintenance_mode: false, maintenance_message: '', www_redirect: '', rate_limit_rps: 0, max_body_kb: 0, }) } return (
} title={t('domains.title')} subtitle={t('domains.intro')} /> } emptyContent={ } title={t('domains.emptyTitle')} description={t('domains.emptyDesc')} action={ } /> } /> setCreating(false)} onOk={() => { void form.submit() }} confirmLoading={create.isPending} >
create.mutate(v)}>
) }