import { useMemo, useState } from 'react' import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { AppstoreOutlined, ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, FireOutlined, PlusOutlined, UnorderedListOutlined, WarningOutlined, } from '@ant-design/icons' import { InlineNote, InlineLabels } from './InlineEditors' const { Text } = Typography import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import apiClient, { isEnvelope } from '../../api/client' import { useAuthStore } from '../../stores/auth' import type { AddressGroup, AddressObject, FwRule, FwService, FwZone, ServiceGroup, Zone } from './types' import { ZONES_FALLBACK } from './types' interface FormValues { name?: string priority: number enabled: boolean action: FwRule['action'] src_zone: Zone src_kind: 'object' | 'group' | 'cidr' | 'any' src_address_object_id?: number src_address_group_id?: number src_cidr?: string dst_zone: Zone dst_kind: 'object' | 'group' | 'cidr' | 'any' dst_address_object_id?: number dst_address_group_id?: number dst_cidr?: string service_kind: 'object' | 'group' | 'any' service_object_id?: number service_group_id?: number log: boolean comment?: string } interface RuleCounter { rule_id: number; packets: number; bytes: number } async function listCounters(): Promise { try { const r = await apiClient.get('/firewall/counters') if (!isEnvelope(r.data)) return [] return (r.data.data as { counters?: RuleCounter[] }).counters ?? [] } catch { return [] } } function fmtBytes(n: number): string { if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB' if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB' if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' return n + ' B' } async function listRules(): Promise { const r = await apiClient.get('/firewall/rules') if (!isEnvelope(r.data)) return [] return (r.data.data as { rules?: FwRule[] }).rules ?? [] } async function listAO(): Promise { const r = await apiClient.get('/firewall/address-objects') if (!isEnvelope(r.data)) return [] return (r.data.data as { address_objects?: AddressObject[] }).address_objects ?? [] } async function listAG(): Promise { const r = await apiClient.get('/firewall/address-groups') if (!isEnvelope(r.data)) return [] return (r.data.data as { address_groups?: AddressGroup[] }).address_groups ?? [] } async function listSv(): Promise { const r = await apiClient.get('/firewall/services') if (!isEnvelope(r.data)) return [] return (r.data.data as { services?: FwService[] }).services ?? [] } async function listSG(): Promise { const r = await apiClient.get('/firewall/service-groups') if (!isEnvelope(r.data)) return [] return (r.data.data as { service_groups?: ServiceGroup[] }).service_groups ?? [] } async function listZones(): Promise { 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 = { name: v.name, priority: v.priority, enabled: v.enabled, action: v.action, src_zone: v.src_zone, dst_zone: v.dst_zone, log: v.log, comment: v.comment, src_address_object_id: null, src_address_group_id: null, src_cidr: null, dst_address_object_id: null, dst_address_group_id: null, dst_cidr: null, service_object_id: null, service_group_id: null, } if (v.src_kind === 'object') out.src_address_object_id = v.src_address_object_id ?? null if (v.src_kind === 'group') out.src_address_group_id = v.src_address_group_id ?? null if (v.src_kind === 'cidr') out.src_cidr = v.src_cidr ?? null if (v.dst_kind === 'object') out.dst_address_object_id = v.dst_address_object_id ?? null if (v.dst_kind === 'group') out.dst_address_group_id = v.dst_address_group_id ?? null if (v.dst_kind === 'cidr') out.dst_cidr = v.dst_cidr ?? null if (v.service_kind === 'object') out.service_object_id = v.service_object_id ?? null if (v.service_kind === 'group') out.service_group_id = v.service_group_id ?? null return out } // ── Zone badge with semantic colors ────────────────────────────── const ZONE_CLASSES: Record = { wan: 'fw-zone-badge--wan', lan: 'fw-zone-badge--lan', dmz: 'fw-zone-badge--dmz', mgmt: 'fw-zone-badge--mgmt', cluster: 'fw-zone-badge--cluster', vpn: 'fw-zone-badge--vpn', any: 'fw-zone-badge--any', } function ZoneBadge({ zone }: { zone: string }) { const cls = ZONE_CLASSES[zone] ?? 'fw-zone-badge--default' return {zone} } function ActionBadge({ action }: { action: FwRule['action'] }) { return {action.toUpperCase()} } export default function RulesTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data: rules, isLoading } = useQuery({ queryKey: ['fw', 'rules'], queryFn: listRules }) const { data: aos } = useQuery({ queryKey: ['fw', 'addr-obj'], queryFn: listAO }) 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 }) const { data: counters } = useQuery({ queryKey: ['fw', 'counters'], queryFn: listCounters, refetchInterval: 10_000, }) const counterByID = new Map((counters ?? []).map(c => [c.rule_id, c])) 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}` const svLabel = (id?: number | null) => svs?.find(s => s.id === id)?.name ?? `#${id}` const sgLabel = (id?: number | null) => sgs?.find(g => g.id === id)?.name ?? `#${id}` const renderAddrCompact = (objID?: number | null, grpID?: number | null, cidr?: string | null): string => { if (objID) return aoLabel(objID) if (grpID) return `⊂ ${agLabel(grpID)}` if (cidr) return cidr return 'any' } // Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS" const autoDescription = (r: FwRule): string => { const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr) const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr) const svc = r.service_object_id ? svLabel(r.service_object_id) : r.service_group_id ? sgLabel(r.service_group_id) : 'any' return `${r.src_zone}/${src} → ${r.dst_zone}/${dst} · ${svc}` } const renderService = (objID?: number | null, grpID?: number | null) => { if (objID) return {svLabel(objID)} if (grpID) return ⊂ {sgLabel(grpID)} return any } const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => { const label = renderAddrCompact(objID, grpID, cidr) if (label === 'any') return any return {label} } // ── Filter state ───────────────────────────────────────────── const [searchText, setSearchText] = useState('') const [filterAction, setFilterAction] = useState('') const [filterZone, setFilterZone] = useState('') const [groupByZone, setGroupByZone] = useState(true) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const sortedRules = useMemo( () => [...(rules ?? [])].sort((a, b) => a.priority - b.priority), [rules], ) const filteredRules = useMemo(() => { let r = sortedRules if (searchText) { const q = searchText.toLowerCase() r = r.filter(rule => (rule.name ?? '').toLowerCase().includes(q) || (rule.comment ?? '').toLowerCase().includes(q), ) } if (filterAction) r = r.filter(rule => rule.action === filterAction) if (filterZone) r = r.filter(rule => rule.src_zone === filterZone || rule.dst_zone === filterZone) return r }, [sortedRules, searchText, filterAction, filterZone]) const groupedSections = useMemo(() => { const map = new Map() for (const r of filteredRules) { const key = `${r.src_zone}→${r.dst_zone}` if (!map.has(key)) map.set(key, { srcZone: r.src_zone, dstZone: r.dst_zone, rules: [] }) map.get(key)!.rules.push(r) } return Array.from(map.entries()).map(([key, v]) => ({ key, ...v })) }, [filteredRules]) const rowClassFn = (row: FwRule) => { const c = counterByID.get(row.id) const zeroHit = row.enabled && (!c || c.packets === 0) return [ !row.enabled ? 'fw-rule-row--disabled' : '', zeroHit ? 'fw-rule-row--zero-hit' : '', ].filter(Boolean).join(' ') } const create = useMutation({ mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/rules', buildPayload(v)) }, onSuccess: () => { message.success(t('common.save')); setCreating(false); form.resetFields() void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const update = useMutation({ mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/rules/${id}`, buildPayload(v)) }, onSuccess: () => { message.success(t('common.save')); setEditing(null); form.resetFields() void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/firewall/rules/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: FwRule; checked: boolean }) => { await apiClient.put(`/firewall/rules/${id}`, { ...row, enabled: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const swap = useMutation({ mutationFn: async ({ a, b }: { a: FwRule; b: FwRule }) => { await apiClient.put(`/firewall/rules/${a.id}`, { ...a, priority: b.priority }) await apiClient.put(`/firewall/rules/${b.id}`, { ...b, priority: a.priority }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const patchNote = useMutation({ mutationFn: async ({ id, note }: { id: number; note: string }) => { await apiClient.patch(`/firewall/rules/${id}`, { note }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const patchLabels = useMutation({ mutationFn: async ({ id, labels }: { id: number; labels: string[] }) => { await apiClient.patch(`/firewall/rules/${id}`, { labels }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const duplicate = useMutation({ mutationFn: async (r: FwRule) => { const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as FwRule & { created_at?: unknown; updated_at?: unknown } await apiClient.post('/firewall/rules', { ...rest, name: r.name ? `${r.name} (copy)` : undefined, priority: r.priority + 1, enabled: false, }) }, onSuccess: () => { message.success(t('fw.rule.duplicated')) void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, onError: (e: Error) => message.error(e.message), }) const editFromRow = (r: FwRule) => { setEditing(r) form.setFieldsValue({ name: r.name ?? undefined, priority: r.priority, enabled: r.enabled, action: r.action, src_zone: r.src_zone, dst_zone: r.dst_zone, log: r.log, comment: r.comment ?? undefined, src_kind: r.src_address_object_id ? 'object' : r.src_address_group_id ? 'group' : r.src_cidr ? 'cidr' : 'any', src_address_object_id: r.src_address_object_id ?? undefined, src_address_group_id: r.src_address_group_id ?? undefined, src_cidr: r.src_cidr ?? undefined, dst_kind: r.dst_address_object_id ? 'object' : r.dst_address_group_id ? 'group' : r.dst_cidr ? 'cidr' : 'any', dst_address_object_id: r.dst_address_object_id ?? undefined, dst_address_group_id: r.dst_address_group_id ?? undefined, dst_cidr: r.dst_cidr ?? undefined, service_kind: r.service_object_id ? 'object' : r.service_group_id ? 'group' : 'any', service_object_id: r.service_object_id ?? undefined, service_group_id: r.service_group_id ?? undefined, }) } const columns: ColumnsType = [ { title: '', key: 'dot', width: 28, render: (_, row) => ( ), }, { title: '#', dataIndex: 'priority', key: 'priority', width: 52, render: (v: number) => ( {v} ), }, { title: t('fw.rule.action'), key: 'action', width: 90, render: (_, r) => , }, { title: t('fw.rule.src'), key: 'src', render: (_, r) => (
{renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
), }, { title: '', key: 'arrow', width: 24, align: 'center' as const, render: () => , }, { title: t('fw.rule.dst'), key: 'dst', render: (_, r) => (
{renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
), }, { title: t('fw.rule.service'), key: 'svc', width: 130, render: (_, r) => renderService(r.service_object_id, r.service_group_id), }, { title: t('fw.rule.name'), key: 'name', ellipsis: true, render: (_, r) => (
{r.name ? {r.name} : {t('fw.rule.unnamed')} } patchLabels.mutate({ id: r.id, labels })} /> patchNote.mutate({ id: r.id, note })} />
{r.comment ?
{r.comment}
:
{autoDescription(r)}
}
), }, { title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const, render: (_, r) => { const c = counterByID.get(r.id) if (!c || c.packets === 0) return ( {r.enabled ? <>0 : '—'} ) return ( {c.packets >= 1_000_000 ? `${(c.packets / 1_000_000).toFixed(1)}M` : c.packets >= 1_000 ? `${(c.packets / 1_000).toFixed(1)}k` : c.packets.toLocaleString()} ) }, }, { title: 'Log', key: 'log', width: 38, align: 'center' as const, render: (_, r) => r.log ? : null, }, { title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', width: 68, render: (v: boolean, row: FwRule) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: '', key: 'move', width: 52, render: (_, row) => { const idx = sortedRules.findIndex(r => r.id === row.id) const swapping = swap.isPending return ( {groupByZone ? ( groupedSections.length === 0 ? ( } title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')} description={activeFilters ? t('fw.filter.noResultsHint') : t('fw.rule.emptyDesc')} action={ !activeFilters ? ( ) : undefined } /> ) : ( groupedSections.map(({ key, srcZone, dstZone, rules: groupRules }) => (
{groupRules.length} {t('fw.filter.rules')}
)) ) ) : ( } title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')} description={activeFilters ? t('fw.filter.noResultsHint') : t('fw.rule.emptyDesc')} action={ !activeFilters ? ( ) : undefined } /> } /> )} { setEditing(null); setCreating(false) }} onOk={() => { void form.submit() }} confirmLoading={create.isPending || update.isPending} width={620} destroyOnHidden >
{ if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }} > ({ value: z, label: z }))} /> ({ value: o.id, label: `${o.name} (${o.kind}: ${o.value})` }))} /> } if (k === 'group') { return } return null }} ))} ({ value: s.id, label: `${s.name} (${s.proto}${s.port_start ? ' '+s.port_start : ''})`, }))} /> } if (k === 'group') { return
) }