From 1d5c98739b6dc3428e6e07647cced8bc44c8eec8 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sat, 28 Feb 2026 20:09:15 +0100 Subject: [PATCH] feat: add Config Detail page with tabbed entity management Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/ConfigDetail.tsx | 246 +++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 frontend/src/routes/ConfigDetail.tsx diff --git a/frontend/src/routes/ConfigDetail.tsx b/frontend/src/routes/ConfigDetail.tsx new file mode 100644 index 0000000..47a2bee --- /dev/null +++ b/frontend/src/routes/ConfigDetail.tsx @@ -0,0 +1,246 @@ +import { useState, useEffect } from 'react' +import { useParams, Link } from 'react-router-dom' +import Layout from '../components/Layout' +import DataTable, { Column } from '../components/DataTable' +import EntityForm, { FieldDef } from '../components/EntityForm' +import GenerateModal from '../components/GenerateModal' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Tabs from '@mui/material/Tabs' +import Tab from '@mui/material/Tab' +import Typography from '@mui/material/Typography' +import Breadcrumbs from '@mui/material/Breadcrumbs' +import AddIcon from '@mui/icons-material/Add' +import BuildIcon from '@mui/icons-material/Build' +import { zonesApi, interfacesApi, policiesApi, rulesApi, masqApi, configsApi } from '../api' + +// ---- Types ---- +interface Zone { id: number; name: string; type: string; options: string } +interface Iface { id: number; name: string; zone_id: number; options: string } +interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number } +interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; comment: string; position: number } +interface Masq { id: number; source_network: string; out_interface: string; to_address: string; comment: string } + +type AnyEntity = { id: number } & Record + +export default function ConfigDetail() { + const { id } = useParams<{ id: string }>() + const configId = Number(id) + + const [configName, setConfigName] = useState('') + const [tab, setTab] = useState(0) + const [zones, setZones] = useState([]) + const [interfaces, setInterfaces] = useState([]) + const [policies, setPolicies] = useState([]) + const [rules, setRules] = useState([]) + const [masq, setMasq] = useState([]) + const [formOpen, setFormOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [generateOpen, setGenerateOpen] = useState(false) + + useEffect(() => { + configsApi.get(configId).then((r) => setConfigName(r.data.name)) + zonesApi.list(configId).then((r) => setZones(r.data)) + interfacesApi.list(configId).then((r) => setInterfaces(r.data)) + policiesApi.list(configId).then((r) => setPolicies(r.data)) + rulesApi.list(configId).then((r) => setRules(r.data)) + masqApi.list(configId).then((r) => setMasq(r.data)) + }, [configId]) + + const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name })) + + // ---- Tab configs ---- + const tabConfig = [ + { + label: 'Zones', + rows: zones as AnyEntity[], + setRows: setZones as React.Dispatch>, + api: zonesApi, + columns: [ + { key: 'name' as const, label: 'Name' }, + { key: 'type' as const, label: 'Type' }, + { key: 'options' as const, label: 'Options' }, + ] as Column[], + fields: [ + { name: 'name', label: 'Name', required: true }, + { name: 'type', label: 'Type', required: true, type: 'select' as const, options: [{ value: 'ipv4', label: 'ipv4' }, { value: 'ipv6', label: 'ipv6' }, { value: 'firewall', label: 'firewall' }] }, + { name: 'options', label: 'Options' }, + ] as FieldDef[], + }, + { + label: 'Interfaces', + rows: interfaces as AnyEntity[], + setRows: setInterfaces as React.Dispatch>, + api: interfacesApi, + columns: [ + { key: 'name' as const, label: 'Interface' }, + { + key: 'zone_id' as const, + label: 'Zone', + render: (r: AnyEntity) => zones.find((z) => z.id === r['zone_id'])?.name ?? String(r['zone_id']), + }, + { key: 'options' as const, label: 'Options' }, + ] as Column[], + fields: [ + { name: 'name', label: 'Interface Name', required: true }, + { name: 'zone_id', label: 'Zone', required: true, type: 'select' as const, options: zoneOptions }, + { name: 'options', label: 'Options' }, + ] as FieldDef[], + }, + { + label: 'Policies', + rows: policies as AnyEntity[], + setRows: setPolicies as React.Dispatch>, + api: policiesApi, + columns: [ + { + key: 'src_zone_id' as const, + label: 'Source', + render: (r: AnyEntity) => zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id']), + }, + { + key: 'dst_zone_id' as const, + label: 'Destination', + render: (r: AnyEntity) => zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id']), + }, + { key: 'policy' as const, label: 'Policy' }, + { key: 'log_level' as const, label: 'Log Level' }, + { key: 'position' as const, label: 'Position' }, + ] as Column[], + fields: [ + { name: 'src_zone_id', label: 'Source Zone', required: true, type: 'select' as const, options: zoneOptions }, + { name: 'dst_zone_id', label: 'Destination Zone', required: true, type: 'select' as const, options: zoneOptions }, + { name: 'policy', label: 'Policy', required: true, type: 'select' as const, options: [{ value: 'ACCEPT', label: 'ACCEPT' }, { value: 'DROP', label: 'DROP' }, { value: 'REJECT', label: 'REJECT' }, { value: 'CONTINUE', label: 'CONTINUE' }] }, + { name: 'log_level', label: 'Log Level' }, + { name: 'comment', label: 'Comment' }, + { name: 'position', label: 'Position', type: 'number' as const }, + ] as FieldDef[], + }, + { + label: 'Rules', + rows: rules as AnyEntity[], + setRows: setRules as React.Dispatch>, + api: rulesApi, + columns: [ + { key: 'action' as const, label: 'Action' }, + { + key: 'src_zone_id' as const, + label: 'Source', + render: (r: AnyEntity) => r['src_zone_id'] ? (zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id'])) : 'all', + }, + { + key: 'dst_zone_id' as const, + label: 'Destination', + render: (r: AnyEntity) => r['dst_zone_id'] ? (zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id'])) : 'all', + }, + { key: 'proto' as const, label: 'Proto' }, + { key: 'dport' as const, label: 'DPort' }, + { key: 'position' as const, label: 'Position' }, + ] as Column[], + fields: [ + { name: 'action', label: 'Action', required: true }, + { name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: zoneOptions }, + { name: 'dst_zone_id', label: 'Dest Zone', type: 'select' as const, options: zoneOptions }, + { name: 'src_ip', label: 'Source IP/CIDR' }, + { name: 'dst_ip', label: 'Dest IP/CIDR' }, + { name: 'proto', label: 'Protocol' }, + { name: 'dport', label: 'Dest Port' }, + { name: 'sport', label: 'Source Port' }, + { name: 'comment', label: 'Comment' }, + { name: 'position', label: 'Position', type: 'number' as const }, + ] as FieldDef[], + }, + { + label: 'Masq/NAT', + rows: masq as AnyEntity[], + setRows: setMasq as React.Dispatch>, + api: masqApi, + columns: [ + { key: 'out_interface' as const, label: 'Out Interface' }, + { key: 'source_network' as const, label: 'Source Network' }, + { key: 'to_address' as const, label: 'To Address' }, + { key: 'comment' as const, label: 'Comment' }, + ] as Column[], + fields: [ + { name: 'out_interface', label: 'Out Interface', required: true }, + { name: 'source_network', label: 'Source Network', required: true }, + { name: 'to_address', label: 'To Address' }, + { name: 'comment', label: 'Comment' }, + ] as FieldDef[], + }, + ] + + const current = tabConfig[tab] + + const handleSubmit = async (values: Record) => { + if (editing) { + await current.api.update(configId, editing.id, values) + } else { + await current.api.create(configId, values) + } + const res = await current.api.list(configId) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + current.setRows(res.data as any) + setFormOpen(false) + setEditing(null) + } + + const handleDelete = async (row: AnyEntity) => { + if (!confirm('Delete this entry?')) return + await current.api.delete(configId, row.id) + const res = await current.api.list(configId) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + current.setRows(res.data as any) + } + + return ( + + + + + Configurations + + {configName} + + + + + + setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}> + {tabConfig.map((tc) => )} + + + + + + { setEditing(row); setFormOpen(true) }} + onDelete={handleDelete} + /> + + + + { setFormOpen(false); setEditing(null) }} + onSubmit={handleSubmit} + /> + + setGenerateOpen(false)} + /> + + ) +}