import { useState } from 'react' import { Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { DatabaseOutlined, PlusOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import DataTable from '../../components/DataTable' import PageHeader from '../../components/PageHeader' import ActionButtons from '../../components/ActionButtons' import StatusDot from '../../components/StatusDot' import apiClient, { isEnvelope } from '../../api/client' const { Text } = Typography interface Backend { id: number name: string scheme: string health_check_path?: string | null lb_algorithm: 'roundrobin' | 'leastconn' | 'source' websocket: boolean active: boolean created_at: string updated_at: string } interface BackendServer { id: number backend_id: number name: string address: string port: number weight: number backup: boolean active: boolean } interface BackendFormValues { name: string scheme: 'http' | 'https' health_check_path?: string lb_algorithm: 'roundrobin' | 'leastconn' | 'source' websocket: boolean active: boolean domain_ids?: number[] } interface ServerFormValues { name: string address: string port: number weight: number backup: boolean active: boolean } async function listBackends(): Promise { const r = await apiClient.get('/backends') if (!isEnvelope(r.data)) return [] return (r.data.data as { backends?: Backend[] }).backends ?? [] } async function listServers(backendID: number): Promise { const r = await apiClient.get(`/backends/${backendID}/servers`) if (!isEnvelope(r.data)) return [] return (r.data.data as { servers?: BackendServer[] }).servers ?? [] } interface DomainFull { id: number name: string active: boolean primary_backend_id?: number | null http_to_https: boolean hsts_enabled: boolean notes?: string | null } async function listDomains(): Promise { const r = await apiClient.get('/domains') if (!isEnvelope(r.data)) return [] return (r.data.data as { domains?: DomainFull[] }).domains ?? [] } export default function BackendsPage() { const { t } = useTranslation() const qc = useQueryClient() const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains }) // server-counts pro Backend laden wir lazy bei Expansion; in der // Tabelle reicht ein Hinweis ob 0 / N Server. const { data: serverCounts } = useQuery({ queryKey: ['backend-server-counts', (data ?? []).map(b => b.id).join(',')], queryFn: async () => { const out: Record = {} await Promise.all((data ?? []).map(async (b) => { out[b.id] = (await listServers(b.id)).length })) return out }, enabled: !!data && data.length > 0, }) const domainsForBackend = (id: number) => (domains ?? []).filter(d => d.primary_backend_id === id) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() async function syncDomainAttachments(backendID: number, selected: number[]) { const all = domains ?? [] const wasAttached = new Set(all.filter(d => d.primary_backend_id === backendID).map(d => d.id)) const want = new Set(selected) const adds = [...want].filter(id => !wasAttached.has(id)) const removes = [...wasAttached].filter(id => !want.has(id)) const puts: Promise[] = [] for (const id of adds) { const d = all.find(x => x.id === id) if (!d) continue puts.push(apiClient.put(`/domains/${id}`, { name: d.name, active: d.active, http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled, notes: d.notes ?? '', primary_backend_id: backendID, })) } for (const id of removes) { const d = all.find(x => x.id === id) if (!d) continue puts.push(apiClient.put(`/domains/${id}`, { name: d.name, active: d.active, http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled, notes: d.notes ?? '', primary_backend_id: null, })) } if (puts.length > 0) await Promise.all(puts) } const create = useMutation({ mutationFn: async (v: BackendFormValues) => { const { domain_ids, ...body } = v const r = await apiClient.post('/backends', body) const env = r.data const created = isEnvelope(env) ? (env.data as Backend) : null if (created && domain_ids && domain_ids.length > 0) { await syncDomainAttachments(created.id, domain_ids) } }, onSuccess: () => { message.success(t('common.save')) setCreating(false) form.resetFields() void qc.invalidateQueries({ queryKey: ['backends'] }) void qc.invalidateQueries({ queryKey: ['domains'] }) }, }) const update = useMutation({ mutationFn: async ({ id, v }: { id: number; v: BackendFormValues }) => { const { domain_ids, ...body } = v await apiClient.put(`/backends/${id}`, body) await syncDomainAttachments(id, domain_ids ?? []) }, onSuccess: () => { message.success(t('common.save')) setEditing(null) form.resetFields() void qc.invalidateQueries({ queryKey: ['backends'] }) void qc.invalidateQueries({ queryKey: ['domains'] }) }, }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/backends/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backends'] }) }, }) const columns: ColumnsType = [ { title: t('backends.name'), dataIndex: 'name', key: 'name' }, { title: t('backends.scheme'), dataIndex: 'scheme', key: 'scheme' }, { title: t('backends.servers'), key: 'srvcount', render: (_, row) => { const n = serverCounts?.[row.id] ?? 0 return n === 0 ? {t('backends.noServers')} : {t('backends.nServers', { n })} }, }, { title: t('backends.lbAlgo'), dataIndex: 'lb_algorithm', key: 'lb', render: (v: string, row) => ( {v} {row.websocket && WS} ), }, { title: t('backends.healthCheck'), dataIndex: 'health_check_path', key: 'hc', render: (v?: string) => v ?? '—' }, { title: t('backends.usedBy'), key: 'used_by', render: (_, row) => { const ds = domainsForBackend(row.id) if (ds.length === 0) return {t('backends.noDomain')} return {ds.map(d => {d.name})} }, }, { title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( { setEditing(row) form.setFieldsValue({ name: row.name, scheme: row.scheme as 'http' | 'https', health_check_path: row.health_check_path ?? undefined, lb_algorithm: row.lb_algorithm, websocket: row.websocket, active: row.active, domain_ids: domainsForBackend(row.id).map(d => d.id), }) }} onDelete={() => del.mutate(row.id)} deleteConfirm={t('backends.deleteConfirm', { name: row.name })} /> ), }, ] return (
} title={t('backends.title')} subtitle={t('backends.intro')} /> , rowExpandable: (record) => !!record.id, }} extraActions={ } /> { setEditing(null); setCreating(false) }} onOk={() => { void form.submit() }} confirmLoading={create.isPending || update.isPending} width={640} > {creating && ( )}
{ if (editing) update.mutate({ id: editing.id, v }) else create.mutate(v) }} >
) }