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

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Space, Tag, Tooltip, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import type { FwZone } from './types'
interface FormValues {
name: string
description?: string
}
async function listZones(): Promise<FwZone[]> {
const r = await apiClient.get('/firewall/zones')
if (!isEnvelope(r.data)) return []
return (r.data.data as { zones?: FwZone[] }).zones ?? []
}
export default function ZonesTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const [editing, setEditing] = useState<FwZone | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<FormValues>()
const { data, isLoading } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
const upsert = useMutation({
mutationFn: async (vals: FormValues) => {
if (editing) return (await apiClient.put(`/firewall/zones/${editing.id}`, vals)).data
return (await apiClient.post('/firewall/zones', vals)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['fw-zones'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/firewall/zones/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw-zones'] }) },
onError: (e: Error) => message.error(e.message),
})
const columns: ColumnsType<FwZone> = [
{ title: t('fw.zone.name'), dataIndex: 'name', key: 'name',
render: (s: string, row) => row.builtin
? <Space><code>{s}</code><Tag color="blue">{t('fw.zone.builtin')}</Tag></Space>
: <code>{s}</code>,
},
{ title: t('fw.zone.description'), dataIndex: 'description', key: 'description',
render: (v?: string | null) => v ?? '—' },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<Space>
<Button size="small" onClick={() => {
setEditing(row)
form.setFieldsValue({ name: row.name, description: row.description ?? undefined })
}}>{t('common.edit')}</Button>
{row.builtin
? <Tooltip title={t('fw.zone.builtinHint')}>
<Button size="small" danger disabled>{t('common.delete')}</Button>
</Tooltip>
: <Popconfirm
title={t('fw.zone.deleteConfirm', { name: row.name })}
onConfirm={() => del.mutate(row.id)}
>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>}
</Space>
),
},
]
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
}}>
{t('fw.zone.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
<Modal
title={editing ? t('fw.zone.edit') : t('fw.zone.add')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={520}
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Form.Item
label={t('fw.zone.name')}
name="name"
rules={[
{ required: true },
{ pattern: /^[a-z][a-z0-9_-]{0,31}$/, message: t('fw.zone.namePattern') },
]}
extra={editing?.builtin ? t('fw.zone.builtinNameLocked') : undefined}
>
<Input disabled={editing?.builtin} />
</Form.Item>
<Form.Item label={t('fw.zone.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</>
)
}