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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -68,7 +68,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.3'
|
||||
const VERSION = '1.0.6'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -29,11 +29,23 @@
|
||||
"tabs": {
|
||||
"rules": "Regeln",
|
||||
"nat": "NAT",
|
||||
"zones": "Zonen",
|
||||
"addrObj": "Adress-Objekte",
|
||||
"addrGrp": "Adress-Gruppen",
|
||||
"services": "Services",
|
||||
"svcGrp": "Service-Gruppen"
|
||||
},
|
||||
"zone": {
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"builtin": "vordefiniert",
|
||||
"builtinHint": "Vordefinierte Zonen können nicht gelöscht werden — Renderer und Anti-Lockout-Regeln verlassen sich darauf.",
|
||||
"builtinNameLocked": "Name vordefiniert — kann nicht geändert werden, weil bestehende Regeln und Interfaces ihn referenzieren.",
|
||||
"namePattern": "Nur Kleinbuchstaben, Ziffern, _ und -; muss mit Buchstaben beginnen, max. 32 Zeichen.",
|
||||
"add": "Zone hinzufügen",
|
||||
"edit": "Zone bearbeiten",
|
||||
"deleteConfirm": "Zone {{name}} wirklich löschen?"
|
||||
},
|
||||
"ao": {
|
||||
"name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung",
|
||||
"add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten",
|
||||
@@ -110,7 +122,8 @@
|
||||
"membersRequired": "Mindestens ein Member-Interface erforderlich",
|
||||
"membersHintBridge": "Eine Bridge bündelt mehrere physische Ports auf L2 — typisch zwei Ports für einen Software-Switch.",
|
||||
"membersHintBond": "Ein Bond aggregiert mehrere physische Ports zu einem logischen Link (LACP / active-backup).",
|
||||
"role": "Rolle",
|
||||
"role": "Zone",
|
||||
"roleHint": "Zonen kommen aus Firewall → Zonen. Eigene Zonen (z.B. iot, guest) lassen sich dort anlegen.",
|
||||
"mtu": "MTU",
|
||||
"active": "Aktiv",
|
||||
"description": "Beschreibung",
|
||||
@@ -170,6 +183,9 @@
|
||||
"name": "Name",
|
||||
"active": "Aktiv",
|
||||
"primaryBackend": "Primary-Backend",
|
||||
"primaryBackendHint": "Catch-all-Backend für Requests, die kein Routing-Regel-Match haben. Optional — leer lassen, wenn alles über Routing-Regeln läuft.",
|
||||
"selectBackend": "Backend wählen",
|
||||
"noBackend": "kein Backend",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"notes": "Notizen",
|
||||
@@ -189,6 +205,8 @@
|
||||
"target": "Ziel",
|
||||
"healthCheck": "Health-Check-Pfad",
|
||||
"active": "Aktiv",
|
||||
"usedBy": "Genutzt von",
|
||||
"noDomain": "keine Domain",
|
||||
"actions": "Aktionen",
|
||||
"deleteConfirm": "Backend {{name}} wirklich löschen?"
|
||||
},
|
||||
|
||||
@@ -29,11 +29,23 @@
|
||||
"tabs": {
|
||||
"rules": "Rules",
|
||||
"nat": "NAT",
|
||||
"zones": "Zones",
|
||||
"addrObj": "Address objects",
|
||||
"addrGrp": "Address groups",
|
||||
"services": "Services",
|
||||
"svcGrp": "Service groups"
|
||||
},
|
||||
"zone": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"builtin": "built-in",
|
||||
"builtinHint": "Built-in zones cannot be deleted — the renderer and anti-lockout rules depend on them.",
|
||||
"builtinNameLocked": "Name is built-in — cannot be changed because existing rules and interfaces reference it.",
|
||||
"namePattern": "Lowercase letters, digits, _ and -; must start with a letter, up to 32 chars.",
|
||||
"add": "Add zone",
|
||||
"edit": "Edit zone",
|
||||
"deleteConfirm": "Really delete zone {{name}}?"
|
||||
},
|
||||
"ao": {
|
||||
"name": "Name", "kind": "Kind", "value": "Value", "description": "Description",
|
||||
"add": "Add address object", "edit": "Edit address object",
|
||||
@@ -110,7 +122,8 @@
|
||||
"membersRequired": "At least one member interface is required",
|
||||
"membersHintBridge": "A bridge joins multiple physical ports at L2 — typically two ports for a software switch.",
|
||||
"membersHintBond": "A bond aggregates multiple physical ports into one logical link (LACP / active-backup).",
|
||||
"role": "Role",
|
||||
"role": "Zone",
|
||||
"roleHint": "Zones are managed in Firewall → Zones. Custom zones (e.g. iot, guest) can be added there.",
|
||||
"mtu": "MTU",
|
||||
"active": "Active",
|
||||
"description": "Description",
|
||||
@@ -170,6 +183,9 @@
|
||||
"name": "Name",
|
||||
"active": "Active",
|
||||
"primaryBackend": "Primary backend",
|
||||
"primaryBackendHint": "Catch-all backend for requests with no matching routing rule. Optional — leave empty if all traffic is routed via routing rules.",
|
||||
"selectBackend": "Select backend",
|
||||
"noBackend": "no backend",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"notes": "Notes",
|
||||
@@ -189,6 +205,8 @@
|
||||
"target": "Target",
|
||||
"healthCheck": "Health check path",
|
||||
"active": "Active",
|
||||
"usedBy": "Used by",
|
||||
"noDomain": "no domain",
|
||||
"actions": "Actions",
|
||||
"deleteConfirm": "Really delete backend {{name}}?"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography, message } from 'antd'
|
||||
import { Button, Form, Input, 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,6 +35,19 @@ async function listDomains(): Promise<Domain[]> {
|
||||
return payload.domains ?? []
|
||||
}
|
||||
|
||||
interface BackendLite {
|
||||
id: number
|
||||
name: string
|
||||
address: string
|
||||
port: number
|
||||
active: boolean
|
||||
}
|
||||
async function listBackends(): Promise<BackendLite[]> {
|
||||
const r = await apiClient.get('/backends')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -43,6 +56,8 @@ export default function DomainsPage() {
|
||||
queryKey: ['domains'],
|
||||
queryFn: listDomains,
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||
const backendById = (id?: number | null) => backends?.find(b => b.id === id)
|
||||
|
||||
const [editing, setEditing] = useState<Domain | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -85,6 +100,16 @@ export default function DomainsPage() {
|
||||
|
||||
const columns: ColumnsType<Domain> = [
|
||||
{ title: t('domains.name'), dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id',
|
||||
render: (id?: number | null) => {
|
||||
if (!id) return <Tag color="default">{t('domains.noBackend')}</Tag>
|
||||
const b = backendById(id)
|
||||
return b
|
||||
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
||||
: <Tag color="orange">#{id}</Tag>
|
||||
},
|
||||
},
|
||||
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
@@ -150,6 +175,22 @@ export default function DomainsPage() {
|
||||
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('domains.primaryBackend')}
|
||||
name="primary_backend_id"
|
||||
extra={t('domains.primaryBackendHint')}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={t('domains.selectBackend')}
|
||||
options={(backends ?? []).filter(b => b.active).map(b => ({
|
||||
value: b.id,
|
||||
label: `${b.name} (${b.address}:${b.port})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { NATRule } from './types'
|
||||
import type { FwZone, NATRule } from './types'
|
||||
|
||||
interface FormValues {
|
||||
name?: string
|
||||
@@ -26,13 +26,16 @@ interface FormValues {
|
||||
comment?: string
|
||||
}
|
||||
|
||||
const ZONES_FOR_NAT = ['wan', 'lan', 'dmz', 'mgmt', 'cluster'] as const
|
||||
|
||||
async function listNAT(): Promise<NATRule[]> {
|
||||
const r = await apiClient.get('/firewall/nat-rules')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? []
|
||||
}
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
const KIND_COLORS: Record<NATRule['kind'], string> = {
|
||||
dnat: 'blue',
|
||||
@@ -44,6 +47,14 @@ export default function NATRulesTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['fw', 'nat'], queryFn: listNAT })
|
||||
const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones })
|
||||
|
||||
// NAT zones don't accept "any" — the renderer needs a concrete
|
||||
// iface group to attach DNAT/SNAT/masq chains to. Fallback to the
|
||||
// seed list while loading.
|
||||
const zoneOptions: string[] = zones && zones.length > 0
|
||||
? zones.map((z) => z.name)
|
||||
: ['wan', 'lan', 'dmz', 'mgmt', 'cluster']
|
||||
|
||||
const [editing, setEditing] = useState<NATRule | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -165,12 +176,12 @@ export default function NATRulesTab() {
|
||||
<>
|
||||
{kind === 'dnat' && (
|
||||
<Form.Item label={t('fw.nat.inZone')} name="in_zone" rules={[{ required: true }]}>
|
||||
<Select options={ZONES_FOR_NAT.map(z => ({ value: z, label: z }))} />
|
||||
<Select options={zoneOptions.map(z => ({ value: z, label: z }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{(kind === 'snat' || kind === 'masquerade') && (
|
||||
<Form.Item label={t('fw.nat.outZone')} name="out_zone" rules={[{ required: true }]}>
|
||||
<Select options={ZONES_FOR_NAT.map(z => ({ value: z, label: z }))} />
|
||||
<Select options={zoneOptions.map(z => ({ value: z, label: z }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={t('fw.nat.proto')} name="proto">
|
||||
|
||||
@@ -7,8 +7,8 @@ import DataTable from '../../components/DataTable'
|
||||
import SystemRulesCard from './SystemRules'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { AddressGroup, AddressObject, FwRule, FwService, ServiceGroup, Zone } from './types'
|
||||
import { ZONES } from './types'
|
||||
import type { AddressGroup, AddressObject, FwRule, FwService, FwZone, ServiceGroup, Zone } from './types'
|
||||
import { ZONES_FALLBACK } from './types'
|
||||
|
||||
interface FormValues {
|
||||
name?: string
|
||||
@@ -63,6 +63,11 @@ async function listSG(): Promise<ServiceGroup[]> {
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { service_groups?: ServiceGroup[] }).service_groups ?? []
|
||||
}
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
function buildPayload(v: FormValues) {
|
||||
const out: Partial<FwRule> = {
|
||||
@@ -91,6 +96,13 @@ export default function RulesTab() {
|
||||
const { data: ags } = useQuery({ queryKey: ['fw', 'addr-grp'], queryFn: listAG })
|
||||
const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv })
|
||||
const { data: sgs } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listSG })
|
||||
const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones })
|
||||
|
||||
// Picker options: 'any' (special) + every zone the operator has
|
||||
// declared. Fallback to the seed list while the query is loading.
|
||||
const zoneOptions: Zone[] = zones && zones.length > 0
|
||||
? ['any', ...zones.map((z) => z.name)]
|
||||
: ZONES_FALLBACK
|
||||
|
||||
const aoLabel = (id?: number | null) => aos?.find(o => o.id === id)?.name ?? `#${id}`
|
||||
const agLabel = (id?: number | null) => ags?.find(g => g.id === id)?.name ?? `#${id}`
|
||||
@@ -234,7 +246,7 @@ export default function RulesTab() {
|
||||
{(['src', 'dst'] as const).map((side) => (
|
||||
<Space key={side} size="middle" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
<Form.Item label={t(`fw.rule.${side}Zone`)} name={`${side}_zone`} rules={[{ required: true }]}>
|
||||
<Select style={{ width: 120 }} options={ZONES.map(z => ({ value: z, label: z }))} />
|
||||
<Select style={{ width: 140 }} options={zoneOptions.map(z => ({ value: z, label: z }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t(`fw.rule.${side}Kind`)} name={`${side}_kind`} rules={[{ required: true }]}>
|
||||
<Select style={{ width: 120 }} options={(['any','object','group','cidr'] as const).map(k => ({ value: k, label: k }))} />
|
||||
|
||||
116
management-ui/src/pages/Firewall/Zones.tsx
Normal file
116
management-ui/src/pages/Firewall/Zones.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import ServicesTab from './Services'
|
||||
import ServiceGroupsTab from './ServiceGroups'
|
||||
import RulesTab from './Rules'
|
||||
import NATRulesTab from './NATRules'
|
||||
import ZonesTab from './Zones'
|
||||
|
||||
export default function FirewallPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -14,6 +15,7 @@ export default function FirewallPage() {
|
||||
const tabs = [
|
||||
{ key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> },
|
||||
{ key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> },
|
||||
{ key: 'zones', label: t('fw.tabs.zones'), children: <ZonesTab /> },
|
||||
{ key: 'addrObj', label: t('fw.tabs.addrObj'), children: <AddressObjectsTab /> },
|
||||
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },
|
||||
{ key: 'services', label: t('fw.tabs.services'), children: <ServicesTab /> },
|
||||
|
||||
@@ -36,7 +36,20 @@ export interface ServiceGroup {
|
||||
member_ids?: number[]
|
||||
}
|
||||
|
||||
export type Zone = 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster' | 'any'
|
||||
// Zone is now a free-form string — operator-managed via the Zones
|
||||
// tab. 'any' is the special value the firewall_rules layer accepts
|
||||
// to mean "match any zone". The list at runtime is loaded from
|
||||
// /api/v1/firewall/zones.
|
||||
export type Zone = string
|
||||
|
||||
export interface FwZone {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
builtin: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FwRule {
|
||||
id: number
|
||||
@@ -77,4 +90,7 @@ export interface NATRule {
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
export const ZONES: Zone[] = ['any', 'wan', 'lan', 'dmz', 'mgmt', 'cluster']
|
||||
// Fallback list — used only while /firewall/zones hasn't loaded
|
||||
// yet (initial render of the rule modal). Real list comes from the
|
||||
// API.
|
||||
export const ZONES_FALLBACK: Zone[] = ['any', 'wan', 'lan', 'dmz', 'mgmt', 'cluster']
|
||||
|
||||
@@ -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