feat: Zonen als first-class Entity + Domain↔Backend-Verknüpfung sichtbar

* Migration 0012: firewall_zones (id, name UNIQUE, description, builtin),
  Seed wan/lan/dmz/mgmt/cluster als builtin. CHECK-Constraints auf
  network_interfaces.role + firewall_rules.{src,dst}_zone +
  firewall_nat_rules.{in,out}_zone gedroppt — Validation lebt jetzt
  app-side (Handler prüft Existenz in firewall_zones).
* Backend: firewall.ZonesRepo (CRUD + Exists + References-Lookup),
  /api/v1/firewall/zones, builtin geschützt (Name nicht änderbar,
  Delete blockiert), Rename eines Custom-Zone aktuell ohne Cascade
  (Handler-Sorge bei Rules/NAT/Networks).
* Handler-Validation in CreateRule/UpdateRule/CreateNAT/UpdateNAT +
  NetworksHandler: Zone-Existence-Check pro Mutation, 400 bei Tippfehler.
* Frontend: Firewall-Tab "Zonen" (CRUD mit builtin-Schutz). Networks-
  Form lädt Rollen aus /firewall/zones (statt hardcoded Liste); Rules-
  und NAT-Forms ziehen die Zone-Auswahl ebenfalls aus der API.
* Domain-Form bekommt Primary-Backend-Picker (Field war im Modell,
  fehlte im UI). Backends-Tabelle zeigt umgekehrt welche Domains
  darauf zeigen — bidirektionale Sicht ohne Schemaänderung.
* HAProxy-Renderer: safeID-FuncMap escaped Server-Namen mit Whitespace
  ("Control Master 1" → "Control_Master_1"). Vorher ist haproxy beim
  Reload an Spaces im Backend-Namen kaputt gegangen.
* Version 1.0.3 → 1.0.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-10 18:05:27 +02:00
parent aa14b6b2be
commit 51ea1fc802
23 changed files with 782 additions and 37 deletions

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Typography, message } from 'antd'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@@ -35,11 +35,31 @@ async function listBackends(): Promise<Backend[]> {
return payload.backends ?? []
}
interface DomainLite {
id: number
name: string
active: boolean
primary_backend_id?: number | null
}
async function listDomains(): Promise<DomainLite[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
return (r.data.data as { domains?: DomainLite[] }).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 })
// Reverse-lookup: which domains have this backend as primary?
// Read-only — domain ↔ backend coupling is owned by the Domains
// page, but showing it here makes the connection bi-directional
// in the UI.
const domainsForBackend = (id: number) =>
(domains ?? []).filter(d => d.primary_backend_id === id)
const [editing, setEditing] = useState<Backend | null>(null)
const [creating, setCreating] = useState(false)
@@ -82,6 +102,14 @@ export default function BackendsPage() {
render: (_, row) => `${row.address}:${row.port}`,
},
{ 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 <Tag color="default">{t('backends.noDomain')}</Tag>
return <Space size={4} wrap>{ds.map(d => <Tag key={d.id} color="blue">{d.name}</Tag>)}</Space>
},
},
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
{
title: t('backends.actions'), key: 'actions',