diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index fde8e3e..44b23e9 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -163,6 +163,12 @@ export const createRuleJson = async (req: CreateRuleRequest): Promise("/firewall/rules", req); return res.data; }; + +export const execFirewallRaw = async (cmd: string): Promise => { + const res = await api.post("/firewall/raw", { "cmd": cmd }); + return res.data; +} + /* ------------------------- Scripts ------------------------- */ diff --git a/frontend/src/components/FireWallAddChainModal.tsx b/frontend/src/components/FireWallAddChainModal.tsx new file mode 100644 index 0000000..2379d40 --- /dev/null +++ b/frontend/src/components/FireWallAddChainModal.tsx @@ -0,0 +1,399 @@ +// src/components/AddChainModal.tsx +import { CopyOutlined } from '@ant-design/icons'; +import { + Alert, + Button, + Card, + Col, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Spin, + Typography, + message, +} from 'antd'; +import { ReactElement, useEffect, useState } from 'react'; +import { execFirewallRaw, fetchRuleset } from '../api/apiClient'; +import { CmdResult, ExecResult } from '../types/firewall'; + +const { Paragraph, Text } = Typography; +const { Option } = Select; + +type TableProp = { + family: string; + name: string; +}; + +type Props = { + open: boolean; + onClose: () => void; + table?: TableProp; // if provided, family & tableName are prefilled & readonly + startOnPreview?: boolean; + onSuccess?: () => void; +}; + +const FAMILY_DESCRIPTIONS: Record = { + ip: 'IPv4 packets.', + ip6: 'IPv6 packets.', + inet: 'Both IPv4 and IPv6.', + arp: 'ARP packets.', + bridge: 'Packets passing through a bridge.', + netdev: 'Direct filtering on a network device (ingress).', +}; + +export default function FirewallAddChainModal({ + open, + onClose, + table, + startOnPreview = false, + onSuccess, +}: Props): ReactElement { + const [form] = Form.useForm(); + const [loadingRuleset, setLoadingRuleset] = useState(false); + const [rulesetEmpty, setRulesetEmpty] = useState(false); + const [step, setStep] = useState(startOnPreview ? 1 : 0); + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + const [selectedFamily, setSelectedFamily] = useState(table?.family ?? 'bridge'); + + // set defaults and prefill when modal opens or table prop changes + useEffect(() => { + // only prefill family/tableName if provided by prop + form.setFieldsValue({ + family: table?.family ?? 'bridge', + tableName: table?.name ?? 'filter', + // intentionally do NOT set chainName here so user input is preserved normally + type: 'filter', + hook: 'forward', + priority: 0, + policy: 'accept', + }); + setSelectedFamily(table?.family ?? 'bridge'); + + if (open) { + refreshRuleset(); + setResults([]); + setRunning(false); + setStep(startOnPreview ? 1 : 0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, table]); + + async function refreshRuleset() { + setLoadingRuleset(true); + try { + const res = await fetchRuleset(); + const ruleset = (res as any).ruleset; + if (ruleset && Array.isArray(ruleset.tables)) { + setRulesetEmpty(ruleset.tables.length === 0); + } else { + setRulesetEmpty(false); + } + } catch (err: any) { + // don't block UI — warn user + message.warning('Could not load ruleset: ' + (err?.message ?? String(err))); + } finally { + setLoadingRuleset(false); + } + } + + // Build add chain command — now reads chainName from values OR live form value + function buildCommands(values: any): string[] { + const family = values.family ?? form.getFieldValue('family') ?? table?.family ?? 'bridge'; + const tableName = values.tableName ?? form.getFieldValue('tableName') ?? table?.name ?? 'filter'; + + // prefer explicitly provided values.chainName, otherwise read from form live state + const chainFromValues = + values && typeof values.chainName === 'string' && values.chainName.trim().length > 0 + ? values.chainName.trim() + : undefined; + const chainFromForm = form.getFieldValue('chainName'); + const chain = + chainFromValues || + (typeof chainFromForm === 'string' && chainFromForm.trim().length > 0 ? chainFromForm.trim() : undefined) || + 'mychain'; + + const type = values.type ?? form.getFieldValue('type') ?? 'filter'; + const hook = values.hook ?? form.getFieldValue('hook') ?? 'forward'; + const priority = + typeof (values.priority ?? form.getFieldValue('priority')) === 'number' + ? (values.priority ?? form.getFieldValue('priority')) + : 0; + const policy = values.policy ?? form.getFieldValue('policy') ?? ''; + + const policyPart = policy ? ` policy ${policy} ;` : ''; + const cmd = `add chain ${family} ${tableName} ${chain} { type ${type} hook ${hook} priority ${priority} ;${policyPart} }`; + return [cmd]; + } + + // Execute commands sequentially + async function executeCommands(cmds: string[]) { + setRunning(true); + setResults([]); + const acc: CmdResult[] = []; + + for (const cmd of cmds) { + try { + const out = (await execFirewallRaw(cmd)) as ExecResult; + const success = out && (out.rc === 0 || out.rc === -1); + if (success) acc.push({ cmd, out }); + else acc.push({ cmd, out, err: `rc: ${out.rc} stderr: ${out.stderr ?? ''}` }); + } catch (err: any) { + acc.push({ cmd, err: err?.message ?? String(err) }); + } + } + + setResults(acc); + setRunning(false); + + // refresh ruleset after running + try { + await refreshRuleset(); + } catch { + /* ignore - refreshRuleset handles messaging */ + } + + const hadError = acc.some((r) => r.err); + if (!hadError) { + message.success('Chain created and ruleset refreshed'); + onClose(); + if (onSuccess) onSuccess(); + } else { + message.error('Some commands returned errors — see results in the modal'); + setStep(1); + } + } + + function renderFooter() { + return ( + + + + {step === 0 ? ( + + + + + ) : ( + + )} + + ); + } + + if (loadingRuleset) { + return ( + +
+ +
+
+ ); + } + + const isPrefilled = Boolean(table?.family && table?.name); + + return ( + + {/* Step 0: Form */} + {step === 0 && ( +
+ {rulesetEmpty && ( + + )} + +
{ + if (changed.family) setSelectedFamily(changed.family); + }} + > + + {/* family & table: show inputs only when not provided via props */} + + {isPrefilled ? ( + + {table!.family} + + {FAMILY_DESCRIPTIONS[table!.family]} + + + ) : ( + + + + )} + + + + {isPrefilled ? ( + + {table!.name} + + ) : ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ )} + + {/* Step 1: Preview & Results */} + {step === 1 && ( + <> + + + The command below will be executed on the server (server will prefix with nft): + +
+              {buildCommands(form.getFieldsValue()).join('\n')}
+            
+ + + + +
+ + {results.length > 0 && ( + + {results.map((r, i) => ( +
+ {r.cmd} + {r.err ? ( + {r.err} + ) : ( + <> + + rc: {r.out?.rc} + + {r.out?.stdout ? ( + <> + stdout: +
{r.out.stdout}
+ + ) : null} + {r.out?.stderr ? ( + <> + stderr: +
{r.out.stderr}
+ + ) : null} + + )} +
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/FireWallAddTableModal.tsx b/frontend/src/components/FireWallAddTableModal.tsx new file mode 100644 index 0000000..b40d184 --- /dev/null +++ b/frontend/src/components/FireWallAddTableModal.tsx @@ -0,0 +1,250 @@ +// src/components/FirewallManager.tsx +import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, message } from 'antd'; +import { ReactElement, useEffect, useState } from 'react'; +import { execFirewallRaw, fetchRuleset } from '../api/apiClient'; + +const { Paragraph, Text } = Typography; +const { Option } = Select; + +type ExecResult = { + rc: number; + stdout?: string | null; + stderr?: string | null; +}; + +type CmdResult = { + cmd: string; + out?: ExecResult; + err?: string; +}; + +type Props = { + open: boolean; + onClose: () => void; + startOnPreview?: boolean; +}; + +const FAMILY_DESCRIPTIONS: Record = { + ip: 'IPv4 packets.', + ip6: 'IPv6 packets.', + inet: 'Both IPv4 and IPv6.', + arp: 'ARP packets.', + bridge: 'Packets passing through a bridge.', + netdev: 'Direct filtering on a network device (ingress).', +}; + +export default function FirewallAddTableModal({ open, onClose, startOnPreview = false }: Props): ReactElement { + const [form] = Form.useForm(); + const [localLoadingRuleset, setLocalLoadingRuleset] = useState(false); + const [rulesetEmpty, setRulesetEmpty] = useState(false); + const [step, setStep] = useState(startOnPreview ? 1 : 0); + const [running, setRunning] = useState(false); + const [results, setResults] = useState([]); + const [selectedFamily, setSelectedFamily] = useState('bridge'); + + // initialize and refresh when modal opens + useEffect(() => { + form.setFieldsValue({ + family: 'bridge', + tableName: 'filter', + }); + setSelectedFamily('bridge'); + + if (open) { + refreshRuleset(); + setResults([]); + setRunning(false); + setStep(startOnPreview ? 1 : 0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + async function refreshRuleset() { + setLocalLoadingRuleset(true); + try { + const res = await fetchRuleset(); + const ruleset = (res as any).ruleset; + if (ruleset && Array.isArray(ruleset.tables)) { + setRulesetEmpty(ruleset.tables.length === 0); + } else { + setRulesetEmpty(false); + } + } catch (err: any) { + message.warning('Could not load ruleset: ' + (err?.message ?? String(err))); + } finally { + setLocalLoadingRuleset(false); + } + } + + // Build commands: only create table + function buildCommands(values: any): string[] { + const family = values.family ?? 'bridge'; + const table = values.tableName ?? 'filter'; + return [`add table ${family} ${table}`]; + } + + // Execute commands sequentially + async function executeCommands(cmds: string[]) { + setRunning(true); + setResults([]); + + const acc: CmdResult[] = []; + for (const cmd of cmds) { + try { + const out = (await execFirewallRaw(cmd)) as ExecResult; + const success = out && (out.rc === 0 || out.rc === -1); + if (success) acc.push({ cmd, out }); + else acc.push({ cmd, out, err: `rc: ${out.rc} stderr: ${out.stderr ?? ''}` }); + } catch (err: any) { + acc.push({ cmd, err: err?.message ?? String(err) }); + } + } + + setResults(acc); + setRunning(false); + + // refresh ruleset after running + try { + await refreshRuleset(); + } catch { + // ignore — refreshRuleset handles messaging + } + + const hadError = acc.some((r) => r.err); + if (!hadError) { + message.success('Table created and ruleset refreshed'); + onClose(); + } else { + message.error('Some commands returned errors — see results in the modal'); + setStep(1); + } + } + + function renderFooter() { + return ( + + + + {step === 0 ? ( + + + + ) : ( + + )} + + ); + } + + // show spinner while checking ruleset + if (localLoadingRuleset) { + return ( + +
+ +
+
+ ); + } + + return ( + + {/* Step 0: minimal form */} + {step === 0 && ( +
+
{ + if (changed.family) setSelectedFamily(changed.family); + }} + > + + + + + + + {FAMILY_DESCRIPTIONS[selectedFamily]} + + + + + + + + + +
+
+ )} + + {/* Step 1: preview + results */} + {step === 1 && ( + <> + + + The command below will be executed on the server (server will prefix with nft): + +
+              {buildCommands(form.getFieldsValue()).join('\n')}
+            
+
+ + {results.length > 0 && ( + + {results.map((r, i) => ( +
+ {r.cmd} + {r.err ? ( + {r.err} + ) : ( + <> + + rc: {r.out?.rc} + + {r.out?.stdout ? ( + <> + stdout: +
{r.out.stdout}
+ + ) : null} + {r.out?.stderr ? ( + <> + stderr: +
{r.out.stderr}
+ + ) : null} + + )} +
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/FirewallRulesetViewer.tsx b/frontend/src/components/FirewallRulesetViewer.tsx index 355a7fb..a1b8eae 100644 --- a/frontend/src/components/FirewallRulesetViewer.tsx +++ b/frontend/src/components/FirewallRulesetViewer.tsx @@ -1,239 +1,451 @@ -// src/components/RulesView.tsx -import { DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons'; -import { Button, Card, Collapse, Drawer, Empty, Popconfirm, Space, Spin, Table, Typography, message } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { deleteRule, fetchRuleset } from '../api/apiClient'; -import { ChainOut, RulesetModel, TableOut } from '../types/firewall'; +import { Alert, Button, Card, Modal, Space, Spin, Table, Typography, message } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import { ReactElement, useEffect, useState } from 'react'; +import { execFirewallRaw, fetchRuleset } from '../api/apiClient'; +import { CmdResult, ExecResult } from '../types/firewall'; +import FirewallAddChainModal from './FireWallAddChainModal'; +import FirewallAddTableModal from './FireWallAddTableModal'; -const { Panel } = Collapse; -const { Paragraph, Text } = Typography; +const { Paragraph, Text, Title } = Typography; -interface RuleRow { - key: string; - handle?: number | null; - text: string; - expr: any; - comment?: string | null; - position?: any; - // context for delete - family: string; - table: string; - chain: string; +type NFTRule = { + handle?: number | string; + expr?: any; + [k: string]: any; +}; + +type NFTChain = { + name: string; + type?: string | null; + hook?: string | null; + priority?: number | null; + policy?: string | null; + rules: NFTRule[]; +}; + +type NFTTable = { + family?: string | null; + name: string; + chains: NFTChain[]; +}; + +/* ------------------------- + Extract NFT structure +------------------------- */ +function extractTablesFromParsed(parsed: any): NFTTable[] { + if (!parsed) return []; + + if (Array.isArray(parsed.nftables)) { + const tablesMap = new Map(); + const chainsMap = new Map(); + + for (const item of parsed.nftables) { + if (item.table) { + const t = item.table; + const key = `${t.family ?? 'n/a'}:${t.name}`; + if (!tablesMap.has(key)) { + tablesMap.set(key, { family: t.family ?? null, name: t.name, chains: [] }); + } + } else if (item.chain) { + const c = item.chain; + const fam = c.family ?? 'n/a'; + const table = c.table ?? 'n/a'; + const tableKey = `${fam}:${table}`; + + if (!tablesMap.has(tableKey)) { + tablesMap.set(tableKey, { family: c.family ?? null, name: table, chains: [] }); + } + + const chainKey = `${fam}:${table}:${c.name}`; + const chainObj: NFTChain = { + name: c.name, + type: c.type ?? null, + hook: c.hook ?? null, + priority: (c.priority as number) ?? null, + policy: c.policy ?? null, + rules: [], + }; + + chainsMap.set(chainKey, chainObj); + tablesMap.get(tableKey)!.chains.push(chainObj); + } else if (item.rule) { + const r = item.rule; + const fam = r.family ?? r.table?.family ?? 'n/a'; + const table = r.table ?? r.table?.name ?? r.table_name ?? 'n/a'; + const chainName = r.chain ?? r.chain?.name ?? 'unknown'; + + const tableKey = `${fam}:${table}`; + const chainKey = `${fam}:${table}:${chainName}`; + + if (!tablesMap.has(tableKey)) { + tablesMap.set(tableKey, { family: fam ?? null, name: table, chains: [] }); + } + + if (!chainsMap.has(chainKey)) { + const newChain: NFTChain = { name: chainName, rules: [] }; + chainsMap.set(chainKey, newChain); + tablesMap.get(tableKey)!.chains.push(newChain); + } + + tablesMap + .get(tableKey)! + .chains.find((c) => c.name === chainName)! + .rules.push({ ...r }); + } + } + + return Array.from(tablesMap.values()); + } + + if (Array.isArray(parsed.tables)) { + return parsed.tables.map((t: any) => ({ + family: t.family ?? null, + name: t.name, + chains: t.chains ?? [], + })); + } + + return []; } -/** - * Render rules inside an antd Table. The UI layout: - * - Collapse per table (title = family:name) - * - Inside each table -> collapse per chain (only chains that have rules) - * - Each chain shows a Table of rules with delete and inspect actions - */ -export const RulesView: React.FC<{ apiBase?: string }> = ({ apiBase = '' }) => { - const [loading, setLoading] = useState(false); - const [ruleset, setRuleset] = useState(null); - const [rawFallback, setRawFallback] = useState(null); - const [exprDrawerVisible, setExprDrawerVisible] = useState(false); - const [exprForView, setExprForView] = useState(null); - const [selectedRuleContext, setSelectedRuleContext] = useState<{ - family: string; - table: string; - chain: string; - handle?: number | null; - } | null>(null); +/* ------------------------- + Rule preview +------------------------- */ +function rulePreview(rule: NFTRule): string { + if (typeof rule === 'string') return rule; + if (rule.expr && typeof rule.expr === 'string') return rule.expr; + if (rule.rule && typeof rule.rule === 'string') return rule.rule; + if (rule.handle && Object.keys(rule).length === 1) return `handle ${rule.handle}`; - const fetchRules = useCallback(async () => { + try { + return JSON.stringify(rule, null, 2); + } catch { + return String(rule); + } +} + +/* Helper: determine success by rc + Some endpoints return rc === -1 on success in your environment, + so treat rc === 0 or rc === -1 as success. */ +function isSuccessRc(out?: ExecResult | null): boolean { + if (!out) return false; + return out.rc === 0 || out.rc === -1; +} + +/* ------------------------- + Component +------------------------- */ +export default function FirewallTables(): ReactElement { + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [tables, setTables] = useState([]); + const [results, setResults] = useState([]); + + const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false); + const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false); + + async function loadRuleset() { setLoading(true); try { - const data = await fetchRuleset(); - if (!data || data.ruleset === null) { - setRuleset({ tables: [] }); - setRawFallback(null); - } else if (typeof data.ruleset === 'string') { - // fallback textual ruleset - setRawFallback(data.ruleset); - setRuleset({ tables: [] }); - } else { - setRuleset(data.ruleset as RulesetModel); - setRawFallback(null); - } + const res = await fetchRuleset(); + const parsed = extractTablesFromParsed((res as any).ruleset); + setTables(parsed); + setError(null); } catch (err: any) { - message.error(`Failed to load ruleset: ${err?.message ?? err}`); - setRuleset({ tables: [] }); - setRawFallback(null); + setError(err?.message ?? String(err)); } finally { setLoading(false); } - }, [apiBase]); - - useEffect(() => { - fetchRules(); - }, [fetchRules]); - - const onDelete = useCallback( - async (row: RuleRow) => { - if (!row.handle) { - message.error('Rule has no handle and cannot be deleted via API.'); - return; - } - setLoading(true); - try { - await deleteRule(row.handle, row.family, row.table, row.chain); - message.success('Rule deleted'); - await fetchRules(); - } catch (err: any) { - message.error(`Delete failed: ${err?.message ?? err}`); - } finally { - setLoading(false); - } - }, - [apiBase, fetchRules], - ); - - const openExprViewer = useCallback((expr: any) => { - setExprForView(expr); - setExprDrawerVisible(true); - }, []); - - const closeExprViewer = useCallback(() => { - setExprForView(null); - setExprDrawerVisible(false); - }, []); - - // columns for rule table - const columns: ColumnsType = useMemo( - () => [ - { - title: 'Handle', - dataIndex: 'handle', - key: 'handle', - width: 100, - render: (val) => val ?? '-', - }, - { - title: 'Rule', - dataIndex: 'text', - key: 'text', - render: (txt: string, rec: RuleRow) => ( - - {txt} - - ), - }, - { - title: 'Comment', - dataIndex: 'comment', - key: 'comment', - width: 200, - render: (c) => (c ? {c} : null), - }, - { - title: 'Actions', - key: 'actions', - width: 160, - align: 'right', - render: (_, rec) => ( - - - onDelete(rec)} okText="Delete" cancelText="Cancel"> - - - - ), - }, - ], - [onDelete, openExprViewer], - ); - - // flatten rules for each chain into a table datasource - const renderChainTable = (table: TableOut, chain: ChainOut) => { - const data: RuleRow[] = chain.rules.map((r, idx) => ({ - key: `${table.family}:${table.name}:${chain.name}:${String(r.handle ?? idx)}`, - handle: r.handle ?? null, - text: r.text, - expr: r.expr, - comment: r.comment ?? null, - position: r.position, - family: table.family, - table: table.name, - chain: chain.name, - })); - - return ( - rec.key} /> - ); - }; - - // UI when JSON is not available - if (!loading && rawFallback) { - return ( - - - Ruleset (raw) - {rawFallback} - - - - ); } - return ( - - - + useEffect(() => { + loadRuleset(); + }, []); + + // helper to run multiple commands sequentially and collect results + async function runCommands(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) }); } - > - {loading ? ( -
- -
- ) : ruleset && ruleset.tables.length > 0 ? ( - - {ruleset.tables.map((t) => { - // only show chains that have rules - const chainsWithRules = t.chains.filter((c) => c.rules && c.rules.length > 0); - if (chainsWithRules.length === 0) return null; - return ( - - - {chainsWithRules.map((c) => ( - - {renderChainTable(t, c)} - - ))} - - - ); - })} - - ) : ( - - )} + } + // prepend new results so the latest are visible first + setResults((prev) => [...acc, ...prev]); + return acc; + } - + 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 loadRuleset(); + } + }, + }); + } + + // delete a chain (non-force) + async function handleDeleteChain(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 loadRuleset(); + } + }, + }); + } + + // delete a table (non-force) + async function handleDeleteTable(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 loadRuleset(); + } + }, + }); + } + + // Refresh handler + async function handleRefresh() { + setRefreshing(true); + try { + await loadRuleset(); + message.success('Ruleset refreshed'); + } catch { + // loadRuleset sets error state + } finally { + setRefreshing(false); + } + } + + if (loading) return ; + + if (error) return ; + + return ( + <> + setIsOpenTableCreatorModal(false)} /> + + + + + } > - - Expression (raw JSON) - -
{JSON.stringify(exprForView, null, 2)}
-
-
- ); -}; + {tables.length === 0 && ( + + )} + {tables.map((table) => { + const totalRules = table.chains.reduce((acc, c) => acc + c.rules.length, 0); -export default RulesView; + return ( + +
+ + Table "{table.name}" + + + Family: {table.family ?? 'unknown'}   Chains: {table.chains.length}  {' '} + Total Rules: {totalRules} + +
+ + + + + + } + extra={} + > + 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', + render: (v) => v ?? '-', + }, + { + title: 'Rule Content', + dataIndex: 'raw', + render: (v) => {v}, + }, + { + title: 'Actions', + render: (_: any, rec: any) => + rec.handle ? ( + + + + ) : ( + + + + ), + }, + ]; + + const dataSource = chain.rules.map((r, idx) => ({ + key: idx, + idx: idx + 1, + handle: r.handle ?? null, + raw: rulePreview(r), + })); + + return ( + +
+ {chain.name} + + Hook: {chain.hook ?? '-'}   Policy: {chain.policy ?? '-'} + +
+ + + + + + } + > +
+ + ); + })} + + + ); + })} + + + ); +} diff --git a/frontend/src/types/firewall.ts b/frontend/src/types/firewall.ts index b1c4d03..23e8e80 100644 --- a/frontend/src/types/firewall.ts +++ b/frontend/src/types/firewall.ts @@ -40,3 +40,14 @@ export interface ExecResult { stdout?: string | null; stderr?: string | null; } + +export interface CmdResult { + cmd: string; + out?: ExecResult; + err?: string; +}; + +export interface TableProp { + family: string; + name: string; +};