// src/components/FirewallTables.tsx import { ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons'; import { Alert, Button, Card, Divider, message, Modal, Space, Table, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { ReactElement, useCallback, useMemo, useState } from 'react'; import { execFirewallRaw } from '../api/apiClient'; import type { CmdResult, ExecResult, RuleOut, TableOut } from '../types/firewall'; import FirewallAddChainModal from './FireWallAddChainModal'; import FirewallAddTableModal from './FireWallAddTableModal'; const { Paragraph, Text, Title } = Typography; /* ---------- Helpers ---------- */ /** * Render a human friendly one-line representation of a rule. * Accepts RuleOut (typed) but remains defensive against unexpected shapes. */ function renderRuleFriendly(rule: RuleOut | any): string { // prefer `text` if present if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text; // if expr is a string, show it if (rule?.expr && typeof rule.expr === 'string') return rule.expr; // if expr is an array, try to pretty print common tokens const expr = rule?.expr ?? rule; if (Array.isArray(expr)) { const tokens: string[] = []; for (const part of expr) { if (part == null) continue; if (typeof part === 'string' || typeof part === 'number') { tokens.push(String(part)); continue; } if (typeof part === 'object') { if ('match' in part) { const m = (part as any).match; const left = m?.left; const right = m?.right; if (left && left.payload && (typeof right === 'string' || typeof right === 'number')) { const p = left.payload; const prot = p.protocol; const field = p.field; if (prot && field) { tokens.push(`${prot} ${field} ${right}`); continue; } } tokens.push('match'); continue; } if ('payload' in part) { const p = (part as any).payload; if (p?.protocol && p?.field) { tokens.push(`payload(${p.protocol}.${p.field})`); continue; } tokens.push('payload'); continue; } if ('tcp' in part) { const v = (part as any).tcp; if (v && v.dport) tokens.push(`tcp dport ${v.dport}`); else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`); else tokens.push('tcp'); continue; } if ('udp' in part) { const v = (part as any).udp; if (v && v.dport) tokens.push(`udp dport ${v.dport}`); else if (v && v.sport) tokens.push(`udp sport ${v.sport}`); else tokens.push('udp'); continue; } if ('drop' in part) { tokens.push('drop'); continue; } if ('accept' in part) { tokens.push('accept'); continue; } if ('counter' in part) { tokens.push('counter'); continue; } if ('queue' in part) { const q = (part as any).queue; let tok = 'queue'; if (typeof q === 'object' && q !== null) { const num = q.num ?? q.number ?? q.queue_number ?? q.from ?? q.range; if (num !== undefined) tok += ` num ${num}`; if (q.bypass) tok += ' bypass'; } else if (typeof q === 'number') { tok += ` num ${q}`; } else if (typeof q === 'string') { tok += ` num ${q}`; } tokens.push(tok); continue; } tokens.push(Object.keys(part).sort().join('+')); continue; } } if (tokens.length > 0) return tokens.join(' '); } // fallback: if expr is an object, stringify a short excerpt if (rule?.expr && typeof rule.expr === 'object') { try { return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500); } catch { // fallthrough } } // final fallback: stringify whole rule try { return JSON.stringify(rule, null, 2); } catch { return String(rule); } } /* Helper: success RC */ function isSuccessRc(out?: ExecResult | null): boolean { if (!out) return false; return out.rc === 0 || out.rc === -1; } /* ---------- Props ---------- */ type Props = { tables: TableOut[]; // passed from parent error?: Error | null; refreshRules: () => Promise; // trigger to re-fetch ruleset }; /* ---------- Component ---------- */ export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement { // local UI state, non-persistent const [refreshing, setRefreshing] = useState(false); const [results, setResults] = useState([]); const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false); const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false); // run raw nft commands sequentially and collect results (used for delete ops etc.) const runCommands = useCallback(async (cmds: string[]) => { const acc: CmdResult[] = []; for (const cmd of cmds) { try { const out = (await execFirewallRaw(cmd)) as ExecResult; if (isSuccessRc(out)) acc.push({ cmd, out }); else acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' }); } catch (err: any) { acc.push({ cmd, err: err?.message ?? String(err) }); } } // prepend new results to history setResults((prev) => [...acc, ...prev]); return acc; }, []); // Delete rule const handleDeleteRule = useCallback( async (family: string | null | undefined, table: string, chain: string, handle: number | string) => { const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`; Modal.confirm({ title: 'Delete Rule', content: ( <> Are you sure you want to delete this rule? {cmd} ), onOk: async () => { try { const res = await runCommands([cmd]); const first = res[0]; if (!first.err) message.success('Rule deleted'); else message.error('Delete returned error — check results panel'); } catch (err: any) { message.error('Delete failed: ' + (err?.message ?? String(err))); } finally { await refresh(); } }, }); }, [runCommands, refresh], ); // Delete chain const handleDeleteChain = useCallback( async (family: string | null | undefined, table: string, chain: string) => { const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`; Modal.confirm({ title: 'Delete Chain', content: ( <> This will delete the chain {chain} in table {table} unrevertably. {cmd} ), onOk: async () => { try { const res = await runCommands([cmd]); const first = res[0]; if (!first.err) message.success(`Chain ${chain} deleted`); else message.error(`Chain deletion returned error — check results panel`); } catch (err: any) { message.error('Chain deletion failed: ' + (err?.message ?? String(err))); } finally { await refresh(); } }, }); }, [runCommands, refresh], ); // Delete table const handleDeleteTable = useCallback( async (family: string | null | undefined, table: string) => { const cmd = `delete table ${family ?? 'inet'} ${table}`; Modal.confirm({ title: 'Delete Table', content: ( <> This will delete the table {table} including all its chains and rules unrevertably. {cmd} ), onOk: async () => { try { const res = await runCommands([cmd]); const first = res[0]; if (!first.err) message.success(`Table ${table} deleted`); else message.error(`Table deletion returned error — check results panel`); } catch (err: any) { message.error('Table deletion failed: ' + (err?.message ?? String(err))); } finally { await refresh(); } }, }); }, [runCommands, refresh], ); // manual refresh trigger (exposed to UI) const handleRefresh = useCallback(async () => { setRefreshing(true); try { await refresh(); message.success('Ruleset refreshed'); } catch { // ignore } finally { setRefreshing(false); } }, [refresh]); const tablesToRender = useMemo(() => tables ?? [], [tables]); if (error) return ; return ( <> setIsOpenTableCreatorModal(false)} /> } > {tablesToRender.length === 0 && ( )} {tablesToRender.map((table) => { const totalRules = table.chains.reduce((acc, c) => acc + (c.rules?.length ?? 0), 0); return (
Table "{table.name}" Family: {table.family ?? 'unknown'}   Chains: {table.chains.length}  {' '} Total Rules: {totalRules}
} > setIsOpenChainCreatorModal(false)} table={{ family: table.family ?? '', name: table.name }} /> {table.chains.map((chain) => { const columns: ColumnsType = [ { title: 'Rule #', dataIndex: 'idx', width: 80 }, { title: 'Handle', dataIndex: 'handle', width: 80, render: (v) => v ?? '-', }, { title: 'Rule', dataIndex: 'frontendParsed', render: (v) => {v}, }, { title: 'Actions', dataIndex: 'actions', width: 80, render: (_: any, rec: any) => rec.handle ? (