firewall + scripts working
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-03-01 13:39:58 +01:00
parent 7c08aed33a
commit 153bd4e7c8
7 changed files with 391 additions and 115 deletions

View File

@@ -4,3 +4,19 @@
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
} }
.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;
}

View File

@@ -1,4 +1,4 @@
import { Layout } from 'antd'; import { Layout, notification } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import './App.css'; import './App.css';
@@ -8,6 +8,14 @@ import Sidebar from './Layout/Sidebar';
const { Content, Footer } = Layout; 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() { export default function App() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
return ( return (

View File

@@ -14,11 +14,11 @@ import {
Space, Space,
Spin, Spin,
Typography, Typography,
message, notification,
} from 'antd'; } from 'antd';
import { ReactElement, useEffect, useState } from 'react'; import { ReactElement, useEffect, useState } from 'react';
import { execFirewallRaw, fetchRuleset } from '../api/apiClient'; import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
import { CmdResult, ExecResult } from '../types/firewall'; import type { CmdResult, ExecResult } from '../types/firewall';
const { Paragraph, Text } = Typography; const { Paragraph, Text } = Typography;
const { Option } = Select; const { Option } = Select;
@@ -30,7 +30,12 @@ type TableProp = {
type Props = { type Props = {
open: boolean; 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 table?: TableProp; // if provided, family & tableName are prefilled & readonly
startOnPreview?: boolean; startOnPreview?: boolean;
onSuccess?: () => void; onSuccess?: () => void;
@@ -60,13 +65,14 @@ export default function FirewallAddChainModal({
const [results, setResults] = useState<CmdResult[]>([]); const [results, setResults] = useState<CmdResult[]>([]);
const [selectedFamily, setSelectedFamily] = useState<string>(table?.family ?? 'bridge'); const [selectedFamily, setSelectedFamily] = useState<string>(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(() => { useEffect(() => {
// only prefill family/tableName if provided by prop
form.setFieldsValue({ form.setFieldsValue({
family: table?.family ?? 'bridge', family: table?.family ?? 'bridge',
tableName: table?.name ?? 'filter', 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', type: 'filter',
hook: 'forward', hook: 'forward',
priority: 0, priority: 0,
@@ -94,36 +100,36 @@ export default function FirewallAddChainModal({
setRulesetEmpty(false); setRulesetEmpty(false);
} }
} catch (err: any) { } catch (err: any) {
// don't block UI — warn user notification.warning({
message.warning('Could not load ruleset: ' + (err?.message ?? String(err))); message: 'Could not load ruleset',
description: err?.message ?? String(err),
duration: 6,
});
setRulesetEmpty(false);
} finally { } finally {
setLoadingRuleset(false); setLoadingRuleset(false);
} }
} }
// Build add chain command — now reads chainName from values OR live form value // Build add chain command — respects provided values or live form values
function buildCommands(values: any): string[] { function buildCommands(values?: any): string[] {
const family = values.family ?? form.getFieldValue('family') ?? table?.family ?? 'bridge'; const vals = values ?? form.getFieldsValue();
const tableName = values.tableName ?? form.getFieldValue('tableName') ?? table?.name ?? 'filter';
// prefer explicitly provided values.chainName, otherwise read from form live state const family = (vals.family ?? table?.family ?? 'bridge').trim();
const chainFromValues = const tableName = (vals.tableName ?? table?.name ?? 'filter').trim();
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 type = values.type ?? form.getFieldValue('type') ?? 'filter'; const type = (vals.type ?? 'filter').trim();
const hook = values.hook ?? form.getFieldValue('hook') ?? 'forward'; const hook = (vals.hook ?? 'forward').trim();
const priority = const priority =
typeof (values.priority ?? form.getFieldValue('priority')) === 'number' typeof vals.priority === 'number'
? (values.priority ?? form.getFieldValue('priority')) ? vals.priority
: Number.isFinite(Number(vals.priority))
? Number(vals.priority)
: 0; : 0;
const policy = values.policy ?? form.getFieldValue('policy') ?? ''; 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 policyPart = policy ? ` policy ${policy} ;` : '';
const cmd = `add chain ${family} ${tableName} ${chain} { type ${type} hook ${hook} priority ${priority} ;${policyPart} }`; const cmd = `add chain ${family} ${tableName} ${chain} { type ${type} hook ${hook} priority ${priority} ;${policyPart} }`;
@@ -150,20 +156,32 @@ export default function FirewallAddChainModal({
setResults(acc); setResults(acc);
setRunning(false); setRunning(false);
// refresh ruleset after running // refresh ruleset shown in modal
try { try {
await refreshRuleset(); await refreshRuleset();
} catch { } catch {
/* ignore - refreshRuleset handles messaging */ /* ignored, refreshRuleset already notified on failure */
} }
const hadError = acc.some((r) => r.err); const hadError = acc.some((r) => r.err);
if (!hadError) { if (!hadError) {
message.success('Chain created and ruleset refreshed'); // success notification inside the modal (not global refresh notification)
onClose(); 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(); if (onSuccess) onSuccess();
} else { } 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); setStep(1);
} }
} }
@@ -173,7 +191,7 @@ export default function FirewallAddChainModal({
<Space> <Space>
<Button <Button
onClick={() => { onClick={() => {
if (step === 0) onClose(); if (step === 0) onClose?.(false);
else setStep(0); else setStep(0);
}} }}
> >
@@ -184,22 +202,30 @@ export default function FirewallAddChainModal({
<Space> <Space>
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => { onClick={async () => {
const txt = buildCommands(form.getFieldsValue()).join('\n'); const txt = buildCommands(form.getFieldsValue()).join('\n');
navigator.clipboard.writeText(txt).then(() => message.success('Command copied')); try {
await navigator.clipboard.writeText(txt);
notification.success({ message: 'Command copied to clipboard' });
} catch {
notification.warning({ message: 'Unable to copy to clipboard' });
}
}} }}
> >
Copy Command Copy Command
</Button> </Button>
<Button <Button
type="primary" type="primary"
onClick={async () => { onClick={async () => {
// validate required fields before going to preview
try { try {
await form.validateFields(['chainName', ...(table ? [] : ['tableName', 'family'])]); // validate name fields: chainName required; if not prefilled, tableName & family required
const requiredFields = ['chainName'];
if (!isPrefilled) requiredFields.push('tableName', 'family');
await form.validateFields(requiredFields as any);
setStep(1); setStep(1);
} catch (e) { } catch {
// validation errors shown by AntD // AntD will show validation messages; no extra handling required
} }
}} }}
> >
@@ -221,7 +247,7 @@ export default function FirewallAddChainModal({
if (loadingRuleset) { if (loadingRuleset) {
return ( return (
<Modal title="Add Chain" open={open} onCancel={onClose} footer={null} width={800}> <Modal title="Add Chain" open={open} onCancel={() => onClose?.(false)} footer={null} width={800} destroyOnClose>
<div style={{ textAlign: 'center', padding: 28 }}> <div style={{ textAlign: 'center', padding: 28 }}>
<Spin /> <Spin />
</div> </div>
@@ -229,10 +255,15 @@ export default function FirewallAddChainModal({
); );
} }
const isPrefilled = Boolean(table?.family && table?.name);
return ( return (
<Modal title="Add Chain" open={open} onCancel={onClose} width={800} footer={renderFooter()} destroyOnClose> <Modal
title="Add Chain"
open={open}
onCancel={() => onClose?.(false)}
width={800}
footer={renderFooter()}
destroyOnClose
>
{/* Step 0: Form */} {/* Step 0: Form */}
{step === 0 && ( {step === 0 && (
<div> <div>
@@ -346,9 +377,14 @@ export default function FirewallAddChainModal({
<Space style={{ marginTop: 12 }}> <Space style={{ marginTop: 12 }}>
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => { onClick={async () => {
const txt = buildCommands(form.getFieldsValue()).join('\n'); const txt = buildCommands(form.getFieldsValue()).join('\n');
navigator.clipboard.writeText(txt).then(() => message.success('Command copied')); try {
await navigator.clipboard.writeText(txt);
notification.success({ message: 'Command copied to clipboard' });
} catch {
notification.warning({ message: 'Unable to copy to clipboard' });
}
}} }}
> >
Copy command Copy command

View File

@@ -1,5 +1,5 @@
// src/components/FirewallManager.tsx // src/components/FirewallManager.tsx
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, message } from 'antd'; import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, notification } from 'antd';
import { ReactElement, useEffect, useState } from 'react'; import { ReactElement, useEffect, useState } from 'react';
import { execFirewallRaw, fetchRuleset } from '../api/apiClient'; import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
@@ -20,7 +20,7 @@ type CmdResult = {
type Props = { type Props = {
open: boolean; open: boolean;
onClose: () => void; onClose?: (created?: boolean) => void; // created === true when a table was created
startOnPreview?: boolean; startOnPreview?: boolean;
}; };
@@ -70,7 +70,13 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
setRulesetEmpty(false); setRulesetEmpty(false);
} }
} catch (err: any) { } catch (err: any) {
message.warning('Could not load ruleset: ' + (err?.message ?? String(err))); // use notification instead of message
notification.warning({
message: 'Could not load ruleset',
description: err?.message ?? String(err),
duration: 6,
});
setRulesetEmpty(false);
} finally { } finally {
setLocalLoadingRuleset(false); setLocalLoadingRuleset(false);
} }
@@ -78,8 +84,8 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
// Build commands: only create table // Build commands: only create table
function buildCommands(values: any): string[] { function buildCommands(values: any): string[] {
const family = values.family ?? 'bridge'; const family = (values.family ?? 'bridge').trim();
const table = values.tableName ?? 'filter'; const table = (values.tableName ?? 'filter').trim();
return [`add table ${family} ${table}`]; return [`add table ${family} ${table}`];
} }
@@ -107,15 +113,25 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
try { try {
await refreshRuleset(); await refreshRuleset();
} catch { } catch {
// ignore — refreshRuleset handles messaging // ignore — refreshRuleset handles notifications on error
} }
const hadError = acc.some((r) => r.err); const hadError = acc.some((r) => r.err);
if (!hadError) { if (!hadError) {
message.success('Table created and ruleset refreshed'); // success -> notify briefly and inform parent that a resource was created
onClose(); notification.success({
message: 'Table created',
description: 'Table was created and ruleset has been refreshed locally in the modal.',
duration: 4,
});
onClose?.(true); // signal parent to refresh and close modal
} else { } else {
message.error('Some commands returned errors — see results in the modal'); // error -> open results view and show notification
notification.error({
message: 'Some commands returned errors',
description: 'See execution results below for details.',
duration: 6,
});
setStep(1); setStep(1);
} }
} }
@@ -125,7 +141,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
<Space> <Space>
<Button <Button
onClick={() => { onClick={() => {
if (step === 0) onClose(); if (step === 0) onClose?.(false);
else setStep(0); else setStep(0);
}} }}
> >
@@ -134,7 +150,21 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
{step === 0 ? ( {step === 0 ? (
<Space> <Space>
<Button type="primary" onClick={() => setStep(1)}> <Button
type="primary"
onClick={() => {
// validate form before preview
form
.validateFields()
.then(() => setStep(1))
.catch(() =>
notification.warning({
message: 'Validation',
description: 'Please fill required fields before preview.',
}),
);
}}
>
Preview Preview
</Button> </Button>
</Space> </Space>
@@ -154,7 +184,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
// show spinner while checking ruleset // show spinner while checking ruleset
if (localLoadingRuleset) { if (localLoadingRuleset) {
return ( return (
<Modal title="Create Table" open={open} onCancel={onClose} footer={null} width={700}> <Modal title="Create Table" open={open} onCancel={() => onClose?.(false)} footer={null} width={700}>
<div style={{ textAlign: 'center', padding: 28 }}> <div style={{ textAlign: 'center', padding: 28 }}>
<Spin /> <Spin />
</div> </div>
@@ -163,7 +193,14 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
} }
return ( return (
<Modal title="Create Table" open={open} onCancel={onClose} width={700} footer={renderFooter()} destroyOnClose> <Modal
title="Create Table"
open={open}
onCancel={() => onClose?.(false)}
width={700}
footer={renderFooter()}
destroyOnClose
>
{/* Step 0: minimal form */} {/* Step 0: minimal form */}
{step === 0 && ( {step === 0 && (
<div> <div>

View File

@@ -1,6 +1,6 @@
// src/components/FirewallTables.tsx // src/components/FirewallTables.tsx
import { ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons'; import { ArrowDownOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Divider, message, Modal, Space, Table, Typography } from 'antd'; import { Alert, Button, Card, Divider, Modal, notification, Space, Spin, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import { ReactElement, useCallback, useMemo, useState } from 'react'; import { ReactElement, useCallback, useMemo, useState } from 'react';
import { execFirewallRaw } from '../api/apiClient'; import { execFirewallRaw } from '../api/apiClient';
@@ -12,18 +12,10 @@ const { Paragraph, Text, Title } = Typography;
/* ---------- Helpers ---------- */ /* ---------- 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 { function renderRuleFriendly(rule: RuleOut | any): string {
// prefer `text` if present
if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text; 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 (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; const expr = rule?.expr ?? rule;
if (Array.isArray(expr)) { if (Array.isArray(expr)) {
const tokens: string[] = []; const tokens: string[] = [];
@@ -107,7 +99,6 @@ function renderRuleFriendly(rule: RuleOut | any): string {
if (tokens.length > 0) return tokens.join(' '); if (tokens.length > 0) return tokens.join(' ');
} }
// fallback: if expr is an object, stringify a short excerpt
if (rule?.expr && typeof rule.expr === 'object') { if (rule?.expr && typeof rule.expr === 'object') {
try { try {
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500); 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 { try {
return JSON.stringify(rule, null, 2); return JSON.stringify(rule, null, 2);
} catch { } catch {
@@ -180,12 +170,20 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
try { try {
const res = await runCommands([cmd]); const res = await runCommands([cmd]);
const first = res[0]; const first = res[0];
if (!first.err) message.success('Rule deleted'); if (!first.err) {
else message.error('Delete returned error — check results panel'); notification.success({ message: 'Rule deleted', description: cmd });
} else {
notification.error({ message: 'Delete returned error', description: first.err });
}
} catch (err: any) { } catch (err: any) {
message.error('Delete failed: ' + (err?.message ?? String(err))); notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
} finally { } finally {
// auto-refresh after change (no refresh notification shown here)
try {
await refresh(); await refresh();
} catch {
/* ignore */
}
} }
}, },
}); });
@@ -212,12 +210,20 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
try { try {
const res = await runCommands([cmd]); const res = await runCommands([cmd]);
const first = res[0]; const first = res[0];
if (!first.err) message.success(`Chain ${chain} deleted`); if (!first.err) {
else message.error(`Chain deletion returned error — check results panel`); notification.success({ message: `Chain ${chain} deleted`, description: cmd });
} else {
notification.error({ message: 'Chain deletion returned error', description: first.err });
}
} catch (err: any) { } catch (err: any) {
message.error('Chain deletion failed: ' + (err?.message ?? String(err))); notification.error({ message: 'Chain deletion failed', description: err?.message ?? String(err) });
} finally { } finally {
// auto-refresh after change (no notification)
try {
await refresh(); await refresh();
} catch {
/* ignore */
}
} }
}, },
}); });
@@ -244,12 +250,20 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
try { try {
const res = await runCommands([cmd]); const res = await runCommands([cmd]);
const first = res[0]; const first = res[0];
if (!first.err) message.success(`Table ${table} deleted`); if (!first.err) {
else message.error(`Table deletion returned error — check results panel`); notification.success({ message: `Table ${table} deleted`, description: cmd });
} else {
notification.error({ message: 'Table deletion returned error', description: first.err });
}
} catch (err: any) { } catch (err: any) {
message.error('Table deletion failed: ' + (err?.message ?? String(err))); notification.error({ message: 'Table deletion failed', description: err?.message ?? String(err) });
} finally { } finally {
// auto-refresh after change (no notification)
try {
await refresh(); await refresh();
} catch {
/* ignore */
}
} }
}, },
}); });
@@ -262,9 +276,10 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
setRefreshing(true); setRefreshing(true);
try { try {
await refresh(); await refresh();
message.success('Ruleset refreshed'); // Only show notification when user pressed the refresh button
} catch { notification.success({ message: 'Ruleset refreshed' });
// ignore } catch (err: any) {
notification.error({ message: 'Refresh failed', description: err?.message ?? String(err) });
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@@ -277,22 +292,40 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
return ( return (
<> <>
<FirewallAddTableModal open={isOpenTableCreatorModal} onClose={() => setIsOpenTableCreatorModal(false)} /> {/* Pass onClose that accepts optional 'created' boolean. If the modal
calls onClose(true) we will auto-refresh (no refresh notification). */}
<FirewallAddTableModal
open={isOpenTableCreatorModal}
onClose={(created?: boolean) => {
setIsOpenTableCreatorModal(false);
if (created) {
// auto-refresh after create (no notification)
void refresh().catch(() => {});
}
}}
/>
<Card <Card
title="Firewall Tables" title="Firewall Tables"
extra={ extra={
<Space> <Space>
<Button onClick={handleRefresh} loading={refreshing}> <Button
Refresh onClick={handleRefresh}
loading={refreshing}
icon={refreshing ? <Spin size="small" /> : <ReloadOutlined />}
>
Refresh Ruleset
</Button>
<Button type="primary" onClick={() => setIsOpenTableCreatorModal(true)}>
Add Table
</Button> </Button>
<Button onClick={() => setIsOpenTableCreatorModal(true)}>Add Table</Button>
</Space> </Space>
} }
> >
{tablesToRender.length === 0 && ( {tablesToRender.length === 0 && (
<Alert <Alert
type="info" type="info"
title="No firewall tables found" message="No firewall tables found"
description="You can create a new table using the button above." description="You can create a new table using the button above."
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
/> />
@@ -321,20 +354,25 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
<Space> <Space>
<Button <Button
danger danger
size="small"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
onClick={() => handleDeleteTable(table.family, table.name)} onClick={() => handleDeleteTable(table.family, table.name)}
/> />
<Button size="small" onClick={() => setIsOpenChainCreatorModal(true)}> <Button type="primary" onClick={() => setIsOpenChainCreatorModal(true)}>
Add Chain Add Chain
</Button> </Button>
</Space> </Space>
</div> </div>
} }
> >
{/* Chain modal: same optional 'created' signal */}
<FirewallAddChainModal <FirewallAddChainModal
open={isOpenChainCreatorModal} open={isOpenChainCreatorModal}
onClose={() => setIsOpenChainCreatorModal(false)} onClose={(created?: boolean) => {
setIsOpenChainCreatorModal(false);
if (created) {
void refresh().catch(() => {});
}
}}
table={{ family: table.family ?? '', name: table.name }} table={{ family: table.family ?? '', name: table.name }}
/> />
@@ -406,7 +444,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
<Space> <Space>
<Button <Button
danger danger
size="small"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
onClick={() => handleDeleteChain(table.family, table.name, chain.name)} onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
/> />
@@ -437,9 +474,7 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
<div key={i} style={{ marginBottom: 12 }}> <div key={i} style={{ marginBottom: 12 }}>
<Text strong>{r.cmd}</Text> <Text strong>{r.cmd}</Text>
{r.err ? ( {r.err ? (
<Paragraph type="danger" style={{ marginTop: 6 }}> <Paragraph style={{ color: 'var(--ant-error-color)', marginTop: 6 }}>{r.err}</Paragraph>
{r.err}
</Paragraph>
) : r.out ? ( ) : r.out ? (
<> <>
<Paragraph> <Paragraph>

View File

@@ -3,6 +3,7 @@ import {
DeleteOutlined, DeleteOutlined,
DownloadOutlined, DownloadOutlined,
EditOutlined, EditOutlined,
FileAddOutlined,
PauseCircleOutlined, PauseCircleOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
@@ -60,6 +61,7 @@ const { Paragraph } = Typography;
* - Table action buttons are icon-only with Tooltip * - Table action buttons are icon-only with Tooltip
* - Expands nested rows by default * - Expands nested rows by default
* - Focus helper attempts to put caret at end inside editors when modals open * - Focus helper attempts to put caret at end inside editors when modals open
* - New: Add requirements UI for scripts that have no requirements
*/ */
export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (code: string) => void }) { export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (code: string) => void }) {
@@ -88,6 +90,13 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
const [reqEditorValue, setReqEditorValue] = useState<string>(''); const [reqEditorValue, setReqEditorValue] = useState<string>('');
const [reqEditingName, setReqEditingName] = useState<string | null>(null); const [reqEditingName, setReqEditingName] = useState<string | null>(null);
// add-requirements modal (for scripts that have no requirements)
const [addReqModalVisible, setAddReqModalVisible] = useState(false);
const [addReqTarget, setAddReqTarget] = useState<string | null>(null);
const [addReqUseInline, setAddReqUseInline] = useState(false);
const [addReqInlineValue, setAddReqInlineValue] = useState('');
const [addReqFile, setAddReqFile] = useState<File | null>(null);
// enable modal // enable modal
const [enableModalVisible, setEnableModalVisible] = useState(false); const [enableModalVisible, setEnableModalVisible] = useState(false);
const [enableTarget, setEnableTarget] = useState<string | null>(null); const [enableTarget, setEnableTarget] = useState<string | null>(null);
@@ -105,7 +114,31 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
const all = await fetchScriptsAll(); const all = await fetchScriptsAll();
setScripts(all || []); setScripts(all || []);
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Failed to fetch scripts', description: err?.message ?? String(err) }); notification.error({
message: 'Failed to fetch scripts',
description: err?.message ?? String(err),
});
} finally {
setLoading(false);
}
}, []);
const handleManualRefresh = useCallback(async () => {
setLoading(true);
try {
const all = await fetchScriptsAll();
setScripts(all || []);
const count = (all || []).length;
notification.success({
message: 'Refresh complete',
description: `Loaded ${count} script${count === 1 ? '' : 's'}.`,
});
} catch (err: any) {
notification.error({
message: 'Failed to fetch scripts',
description: err?.message ?? String(err),
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -168,6 +201,10 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
if (reqModalVisible) focusEditorInModal('.ant-modal'); if (reqModalVisible) focusEditorInModal('.ant-modal');
}, [reqModalVisible, focusEditorInModal]); }, [reqModalVisible, focusEditorInModal]);
useEffect(() => {
if (addReqModalVisible) focusEditorInModal('.ant-modal');
}, [addReqModalVisible, focusEditorInModal]);
// show pip modal if pip output is present // show pip modal if pip output is present
const showPipIfPresent = useCallback( const showPipIfPresent = useCallback(
(resp?: ScriptUploadResponse | { pip?: { stdout?: string; stderr?: string } } | null) => { (resp?: ScriptUploadResponse | { pip?: { stdout?: string; stderr?: string } } | null) => {
@@ -472,6 +509,57 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
); );
} }
// --------------------
// Add Requirements (for scripts missing requirements)
// --------------------
const openAddRequirements = useCallback((scriptName: string) => {
setAddReqTarget(scriptName);
setAddReqUseInline(false);
setAddReqInlineValue('');
setAddReqFile(null);
setAddReqModalVisible(true);
}, []);
const saveAddRequirements = useCallback(async () => {
if (!addReqTarget) return;
let payload: File | Blob | null = null;
if (addReqFile) {
payload = addReqFile;
} else if (addReqUseInline && addReqInlineValue.trim() !== '') {
payload = new Blob([addReqInlineValue], { type: 'text/plain' });
} else {
notification.warning({
message: 'No requirements provided',
description: 'Upload a file or enter inline content.',
});
return;
}
try {
const resp = await uploadRequirements(addReqTarget, payload);
notification.success({ message: 'requirements uploaded', description: `${addReqTarget} updated` });
showPipIfPresent(resp);
setAddReqModalVisible(false);
setAddReqTarget(null);
setAddReqInlineValue('');
setAddReqFile(null);
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Failed to upload requirements', description: err?.message ?? String(err) });
}
}, [addReqFile, addReqInlineValue, addReqTarget, refreshAll, showPipIfPresent]);
// helper for Upload change inside add-req modal
const onAddReqUploadChange = useCallback((info: any) => {
const list: UploadFile[] = info.fileList || [];
if (list.length === 0) {
setAddReqFile(null);
return;
}
const last = list[list.length - 1] as any;
setAddReqFile(last.originFileObj ?? last);
}, []);
// -------------------- // --------------------
// Table columns // Table columns
// -------------------- // --------------------
@@ -513,7 +601,13 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
/> />
</Space> </Space>
) : ( ) : (
<span style={{ color: '#999' }}>—</span> <Space>
<IconButtonTooltip
title="Add requirements"
onClick={() => openAddRequirements(record.name)}
icon={<FileAddOutlined />}
/>
</Space>
), ),
}, },
{ {
@@ -627,15 +721,13 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
return ( return (
<div> <div>
<Row justify="space-between" style={{ marginBottom: 12 }}> <Row justify="space-between" style={{ marginBottom: 12 }}>
<Col> <Col span={24} style={{ display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
Upload / New Upload / New
</Button> </Button>
<Button onClick={refreshAll} icon={loading ? <Spin size="small" /> : <ReloadOutlined />}> <Button onClick={handleManualRefresh} icon={loading ? <Spin size="small" /> : <ReloadOutlined />}>
Refresh Refresh
</Button> </Button>
</Space>
</Col> </Col>
</Row> </Row>
@@ -821,6 +913,60 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
<PythonEditor value={reqEditorValue} onChange={setReqEditorValue} height={420} /> <PythonEditor value={reqEditorValue} onChange={setReqEditorValue} height={420} />
</Modal> </Modal>
{/* Add Requirements Modal (for scripts without requirements) */}
<Modal
open={addReqModalVisible}
title={addReqTarget ? `Add requirements — ${addReqTarget}` : 'Add requirements'}
onCancel={() => {
setAddReqModalVisible(false);
setAddReqTarget(null);
setAddReqInlineValue('');
setAddReqFile(null);
setAddReqUseInline(false);
}}
footer={
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setAddReqModalVisible(false);
setAddReqTarget(null);
setAddReqInlineValue('');
setAddReqFile(null);
setAddReqUseInline(false);
}}
>
Cancel
</Button>
<Button type="primary" onClick={saveAddRequirements}>
Save requirements
</Button>
</Space>
}
width={700}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ marginBottom: 8 }}>Upload a requirements.txt file (preferred)</div>
<Upload beforeUpload={() => false} maxCount={1} accept=".txt" onChange={onAddReqUploadChange}>
<Button icon={<UploadOutlined />}>Choose requirements file</Button>
</Upload>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Checkbox checked={addReqUseInline} onChange={(e) => setAddReqUseInline(e.target.checked)}>
Edit requirements inline
</Checkbox>
<div style={{ color: '#666' }}>If both a file and inline content are present, file takes precedence.</div>
</div>
{addReqUseInline && (
<div>
<PythonEditor value={addReqInlineValue} onChange={setAddReqInlineValue} height={220} wrap />
</div>
)}
</div>
</Modal>
{/* Pip output modal */} {/* Pip output modal */}
<Modal <Modal
open={pipModalVisible} open={pipModalVisible}

View File

@@ -1,5 +1,5 @@
// src/pages/Firewall.tsx // src/pages/Firewall.tsx
import { Alert, Col, Row, Spin } from 'antd'; import { Alert, Col, Row } from 'antd';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { fetchRuleset } from '../api/apiClient'; import { fetchRuleset } from '../api/apiClient';
import RuleBuilder from '../components/FirewallRuleBuilder'; import RuleBuilder from '../components/FirewallRuleBuilder';
@@ -41,9 +41,7 @@ export const Firewall: React.FC = () => {
await load(); await load();
}, [load]); }, [load]);
return loading ? ( return (
<Spin tip="Loading firewall rules..." />
) : (
<div className="firewall-page"> <div className="firewall-page">
<Row gutter={16}> <Row gutter={16}>
<Col xs={24}> <Col xs={24}>