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:
@@ -14,7 +14,7 @@ interface NetworkInterface {
|
||||
parent?: string | null
|
||||
vlan_id?: number | null
|
||||
members: string[]
|
||||
role: 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster'
|
||||
role: string
|
||||
mtu?: number | null
|
||||
active: boolean
|
||||
description?: string | null
|
||||
@@ -28,7 +28,7 @@ interface IfaceFormValues {
|
||||
parent?: string
|
||||
vlan_id?: number
|
||||
members?: string[]
|
||||
role: NetworkInterface['role']
|
||||
role: string
|
||||
mtu?: number
|
||||
active: boolean
|
||||
description?: string
|
||||
@@ -54,12 +54,20 @@ async function listSystemInterfaces(): Promise<SystemInterface[]> {
|
||||
return (r.data.data as { interfaces?: SystemInterface[] }).interfaces ?? []
|
||||
}
|
||||
|
||||
interface FwZone { id: number; name: string; description?: string | null; builtin: boolean }
|
||||
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 NetworksPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: ifs, isLoading } = useQuery({ queryKey: ['network-interfaces'], queryFn: listInterfaces })
|
||||
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces, refetchInterval: 60_000 })
|
||||
const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
|
||||
|
||||
const [editing, setEditing] = useState<NetworkInterface | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -86,8 +94,16 @@ export default function NetworksPage() {
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) },
|
||||
})
|
||||
|
||||
const roleColor: Record<NetworkInterface['role'], string> = {
|
||||
wan: 'blue', lan: 'green', dmz: 'orange', mgmt: 'purple', cluster: 'magenta',
|
||||
// Stable colour palette for role tags. Builtin zones get a fixed
|
||||
// colour; custom zones cycle through the palette by name hash so
|
||||
// the same custom zone always shows up in the same shade.
|
||||
const PALETTE = ['blue', 'green', 'orange', 'purple', 'magenta', 'cyan', 'gold', 'volcano', 'geekblue']
|
||||
const FIXED: Record<string, string> = { wan: 'blue', lan: 'green', dmz: 'orange', mgmt: 'purple', cluster: 'magenta' }
|
||||
const roleColor = (r: string): string => {
|
||||
if (FIXED[r]) return FIXED[r]
|
||||
let h = 0
|
||||
for (let i = 0; i < r.length; i++) h = (h * 31 + r.charCodeAt(i)) >>> 0
|
||||
return PALETTE[h % PALETTE.length]
|
||||
}
|
||||
|
||||
const columns: ColumnsType<NetworkInterface> = [
|
||||
@@ -105,7 +121,7 @@ export default function NetworksPage() {
|
||||
},
|
||||
{
|
||||
title: t('networks.role'), dataIndex: 'role', key: 'role',
|
||||
render: (r: NetworkInterface['role']) => <Tag color={roleColor[r]}>{r.toUpperCase()}</Tag>,
|
||||
render: (r: string) => <Tag color={roleColor(r)}>{r.toUpperCase()}</Tag>,
|
||||
},
|
||||
{ title: t('networks.mtu'), dataIndex: 'mtu', key: 'mtu', render: (v?: number) => v ?? '—' },
|
||||
{ title: t('networks.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
@@ -229,8 +245,19 @@ export default function NetworksPage() {
|
||||
return null
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item label={t('networks.role')} name="role" rules={[{ required: true }]}>
|
||||
<Select options={(['wan','lan','dmz','mgmt','cluster'] as const).map(r => ({ value: r, label: r.toUpperCase() }))} />
|
||||
<Form.Item
|
||||
label={t('networks.role')}
|
||||
name="role"
|
||||
rules={[{ required: true }]}
|
||||
extra={t('networks.roleHint')}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
options={(zones ?? []).map(z => ({
|
||||
value: z.name,
|
||||
label: z.builtin ? z.name.toUpperCase() : `${z.name.toUpperCase()} (custom)`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('networks.mtu')} name="mtu">
|
||||
<InputNumber min={68} max={9216} style={{ width: '100%' }} placeholder="1500" />
|
||||
|
||||
Reference in New Issue
Block a user