diff --git a/frontend/src/App.css b/frontend/src/App.css index 027945e..44aafce 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3,4 +3,20 @@ margin: 0 auto; padding: 2rem; text-align: center; -} \ No newline at end of file +} + +.ant-notification-notice { + padding: 0px; + margin: 0; +} + +.ant-notification { + left: 70% !important; + transform: translateX(-50%); +} + +.ant-notification-topRight, +.ant-notification-topLeft { + left: 70% !important; + right: auto !important; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25bba22..8376555 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { Layout } from 'antd'; +import { Layout, notification } from 'antd'; import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import './App.css'; @@ -8,6 +8,14 @@ import Sidebar from './Layout/Sidebar'; const { Content, Footer } = Layout; +notification.config({ + placement: 'topRight', // topLeft | topRight | bottomLeft | bottomRight + top: 20, // distance from top + bottom: 24, // distance from bottom + duration: 3, // auto close time (seconds) + maxCount: 3, // max notifications shown +}); + export default function App() { const [collapsed, setCollapsed] = useState(false); return ( diff --git a/frontend/src/components/FireWallAddChainModal.tsx b/frontend/src/components/FireWallAddChainModal.tsx index 6e31719..9005f35 100644 --- a/frontend/src/components/FireWallAddChainModal.tsx +++ b/frontend/src/components/FireWallAddChainModal.tsx @@ -14,11 +14,11 @@ import { Space, Spin, Typography, - message, + notification, } from 'antd'; import { ReactElement, useEffect, useState } from 'react'; import { execFirewallRaw, fetchRuleset } from '../api/apiClient'; -import { CmdResult, ExecResult } from '../types/firewall'; +import type { CmdResult, ExecResult } from '../types/firewall'; const { Paragraph, Text } = Typography; const { Option } = Select; @@ -30,7 +30,12 @@ type TableProp = { type Props = { open: boolean; - onClose: () => void; + /** + * onClose may be called with (created?: boolean). + * If created === true the parent can decide to refresh and optionally show notifications. + * If undefined or false, it's a plain close. + */ + onClose?: (created?: boolean) => void; table?: TableProp; // if provided, family & tableName are prefilled & readonly startOnPreview?: boolean; onSuccess?: () => void; @@ -60,13 +65,14 @@ export default function FirewallAddChainModal({ const [results, setResults] = useState([]); const [selectedFamily, setSelectedFamily] = useState(table?.family ?? 'bridge'); - // set defaults and prefill when modal opens or table prop changes + const isPrefilled = Boolean(table?.family && table?.name); + + // init form values 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 + // Do not override chainName if the user has typed it previously type: 'filter', hook: 'forward', priority: 0, @@ -94,36 +100,36 @@ export default function FirewallAddChainModal({ setRulesetEmpty(false); } } catch (err: any) { - // don't block UI — warn user - message.warning('Could not load ruleset: ' + (err?.message ?? String(err))); + notification.warning({ + message: 'Could not load ruleset', + description: err?.message ?? String(err), + duration: 6, + }); + setRulesetEmpty(false); } 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'; + // Build add chain command — respects provided values or live form values + function buildCommands(values?: any): string[] { + const vals = values ?? form.getFieldsValue(); - // 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('hook'); - const chain = - chainFromValues || - (typeof chainFromForm === 'string' && chainFromForm.trim().length > 0 ? chainFromForm.trim() : undefined) || - 'mychain'; + const family = (vals.family ?? table?.family ?? 'bridge').trim(); + const tableName = (vals.tableName ?? table?.name ?? 'filter').trim(); - const type = values.type ?? form.getFieldValue('type') ?? 'filter'; - const hook = values.hook ?? form.getFieldValue('hook') ?? 'forward'; + const type = (vals.type ?? 'filter').trim(); + const hook = (vals.hook ?? 'forward').trim(); const priority = - typeof (values.priority ?? form.getFieldValue('priority')) === 'number' - ? (values.priority ?? form.getFieldValue('priority')) - : 0; - const policy = values.policy ?? form.getFieldValue('policy') ?? ''; + typeof vals.priority === 'number' + ? vals.priority + : Number.isFinite(Number(vals.priority)) + ? Number(vals.priority) + : 0; + const policy = vals.policy ?? ''; + + // chain name: prefer explicit chainName else fallback to hook (less ideal) else 'mychain' + const chain = hook; const policyPart = policy ? ` policy ${policy} ;` : ''; const cmd = `add chain ${family} ${tableName} ${chain} { type ${type} hook ${hook} priority ${priority} ;${policyPart} }`; @@ -150,20 +156,32 @@ export default function FirewallAddChainModal({ setResults(acc); setRunning(false); - // refresh ruleset after running + // refresh ruleset shown in modal try { await refreshRuleset(); } catch { - /* ignore - refreshRuleset handles messaging */ + /* ignored, refreshRuleset already notified on failure */ } const hadError = acc.some((r) => r.err); if (!hadError) { - message.success('Chain created and ruleset refreshed'); - onClose(); + // success notification inside the modal (not global refresh notification) + notification.success({ + message: 'Chain created', + description: 'Chain created and ruleset refreshed locally in the modal.', + duration: 4, + }); + + // Inform parent that a resource was created. + // Parent can decide whether to refresh and whether to show a notification. + onClose?.(true); if (onSuccess) onSuccess(); } else { - message.error('Some commands returned errors — see results in the modal'); + notification.error({ + message: 'Some commands returned errors', + description: 'See execution results below for details.', + duration: 6, + }); setStep(1); } } @@ -173,7 +191,7 @@ export default function FirewallAddChainModal({ + @@ -154,7 +184,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview = // show spinner while checking ruleset if (localLoadingRuleset) { return ( - + onClose?.(false)} footer={null} width={700}>
@@ -163,7 +193,14 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview = } return ( - + onClose?.(false)} + width={700} + footer={renderFooter()} + destroyOnClose + > {/* Step 0: minimal form */} {step === 0 && (
diff --git a/frontend/src/components/FirewallRulesetViewer.tsx b/frontend/src/components/FirewallRulesetViewer.tsx index 57fc057..5254048 100644 --- a/frontend/src/components/FirewallRulesetViewer.tsx +++ b/frontend/src/components/FirewallRulesetViewer.tsx @@ -1,6 +1,6 @@ // src/components/FirewallTables.tsx -import { ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons'; -import { Alert, Button, Card, Divider, message, Modal, Space, Table, Typography } from 'antd'; +import { ArrowDownOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Card, Divider, Modal, notification, Space, Spin, Table, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { ReactElement, useCallback, useMemo, useState } from 'react'; import { execFirewallRaw } from '../api/apiClient'; @@ -12,18 +12,10 @@ 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[] = []; @@ -107,7 +99,6 @@ function renderRuleFriendly(rule: RuleOut | any): string { 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); @@ -116,7 +107,6 @@ function renderRuleFriendly(rule: RuleOut | any): string { } } - // final fallback: stringify whole rule try { return JSON.stringify(rule, null, 2); } catch { @@ -180,12 +170,20 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }: 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'); + if (!first.err) { + notification.success({ message: 'Rule deleted', description: cmd }); + } else { + notification.error({ message: 'Delete returned error', description: first.err }); + } } catch (err: any) { - message.error('Delete failed: ' + (err?.message ?? String(err))); + notification.error({ message: 'Delete failed', description: err?.message ?? String(err) }); } finally { - await refresh(); + // auto-refresh after change (no refresh notification shown here) + try { + await refresh(); + } catch { + /* ignore */ + } } }, }); @@ -212,12 +210,20 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }: 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`); + if (!first.err) { + notification.success({ message: `Chain ${chain} deleted`, description: cmd }); + } else { + notification.error({ message: 'Chain deletion returned error', description: first.err }); + } } catch (err: any) { - message.error('Chain deletion failed: ' + (err?.message ?? String(err))); + notification.error({ message: 'Chain deletion failed', description: err?.message ?? String(err) }); } finally { - await refresh(); + // auto-refresh after change (no notification) + try { + await refresh(); + } catch { + /* ignore */ + } } }, }); @@ -244,12 +250,20 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }: 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`); + if (!first.err) { + notification.success({ message: `Table ${table} deleted`, description: cmd }); + } else { + notification.error({ message: 'Table deletion returned error', description: first.err }); + } } catch (err: any) { - message.error('Table deletion failed: ' + (err?.message ?? String(err))); + notification.error({ message: 'Table deletion failed', description: err?.message ?? String(err) }); } finally { - await refresh(); + // auto-refresh after change (no notification) + try { + await refresh(); + } catch { + /* ignore */ + } } }, }); @@ -262,9 +276,10 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }: setRefreshing(true); try { await refresh(); - message.success('Ruleset refreshed'); - } catch { - // ignore + // Only show notification when user pressed the refresh button + notification.success({ message: 'Ruleset refreshed' }); + } catch (err: any) { + notification.error({ message: 'Refresh failed', description: err?.message ?? String(err) }); } finally { setRefreshing(false); } @@ -277,22 +292,40 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }: return ( <> - setIsOpenTableCreatorModal(false)} /> + {/* Pass onClose that accepts optional 'created' boolean. If the modal + calls onClose(true) we will auto-refresh (no refresh notification). */} + { + setIsOpenTableCreatorModal(false); + if (created) { + // auto-refresh after create (no notification) + void refresh().catch(() => {}); + } + }} + /> + - + - } > {tablesToRender.length === 0 && ( @@ -321,20 +354,25 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
} > + {/* Chain modal: same optional 'created' signal */} setIsOpenChainCreatorModal(false)} + onClose={(created?: boolean) => { + setIsOpenChainCreatorModal(false); + if (created) { + void refresh().catch(() => {}); + } + }} table={{ family: table.family ?? '', name: table.name }} /> @@ -406,7 +444,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }: - - + + + @@ -821,6 +913,60 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
+ {/* Add Requirements Modal (for scripts without requirements) */} + { + setAddReqModalVisible(false); + setAddReqTarget(null); + setAddReqInlineValue(''); + setAddReqFile(null); + setAddReqUseInline(false); + }} + footer={ + + + + + } + width={700} + > +
+
+
Upload a requirements.txt file (preferred)
+ false} maxCount={1} accept=".txt" onChange={onAddReqUploadChange}> + + +
+ +
+ setAddReqUseInline(e.target.checked)}> + Edit requirements inline + +
If both a file and inline content are present, file takes precedence.
+
+ + {addReqUseInline && ( +
+ +
+ )} +
+
+ {/* Pip output modal */} { await load(); }, [load]); - return loading ? ( - - ) : ( + return (