cleanup frontend notifications
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m39s

This commit is contained in:
2026-03-01 20:47:29 +01:00
parent 0f2a342b75
commit 7812f8715f
7 changed files with 224 additions and 162 deletions

View File

@@ -1,8 +1,15 @@
// src/components/Sidebar.tsx // src/components/Sidebar.tsx
import { ApartmentOutlined, ApiOutlined, HomeOutlined, InfoCircleOutlined, SettingOutlined } from '@ant-design/icons'; import {
ApartmentOutlined,
HomeOutlined,
InfoCircleOutlined,
MonitorOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { Layout, Menu } from 'antd'; import { Layout, Menu } from 'antd';
import React from 'react'; import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import FirewallIcon from '../icons/FirewallIcon';
import TerminalIcon from '../icons/TerminalIcon'; import TerminalIcon from '../icons/TerminalIcon';
import { PATHS } from '../routes'; import { PATHS } from '../routes';
import '../theme/layout.less'; import '../theme/layout.less';
@@ -12,13 +19,13 @@ const { Sider } = Layout;
const menuItems = [ const menuItems = [
{ key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' }, { key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' },
{ key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' }, { key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' },
{ key: PATHS.SNIFFING, icon: <ApiOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' }, { key: PATHS.SNIFFING, icon: <MonitorOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' },
{ {
key: PATHS.SCRIPTING, key: PATHS.SCRIPTING,
icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />, icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />,
label: 'Scripting', label: 'Scripting',
}, },
{ key: PATHS.FIREWALL, icon: <ApiOutlined style={{ fontSize: '18px' }} />, label: 'Firewall' }, { key: PATHS.FIREWALL, icon: <FirewallIcon style={{ fontSize: '18px' }} />, label: 'Firewall' },
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' }, { key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' }, { key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
]; ];

View File

@@ -10,7 +10,7 @@
// //
// NOTE: This file replaces the token lists and per-field UI to strictly follow the user's canonical list. // NOTE: This file replaces the token lists and per-field UI to strictly follow the user's canonical list.
import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Card, Card,
@@ -22,11 +22,10 @@ import {
InputNumber, InputNumber,
message, message,
Modal, Modal,
notification,
Radio, Radio,
Row, Row,
Select, Select,
Space,
Tooltip,
Typography, Typography,
} from 'antd'; } from 'antd';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -584,9 +583,14 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
const out: ExecResult = await execFirewallRaw(cmd); const out: ExecResult = await execFirewallRaw(cmd);
const stderrText = out?.stderr ? String(out.stderr).trim() : ''; const stderrText = out?.stderr ? String(out.stderr).trim() : '';
if (stderrText) { if (stderrText) {
message.error(`Command produced stderr: ${stderrText}`); notification.error({
message: 'Command produced Error',
description: stderrText,
});
} else if (out && (out.rc === 0 || out.rc === -1)) { } else if (out && (out.rc === 0 || out.rc === -1)) {
message.success('Command executed (rc ' + out.rc + ')'); notification.success({
message: 'Command executed successfully',
});
if (props.refreshRules) await props.refreshRules(); if (props.refreshRules) await props.refreshRules();
if (props.onCreated) await props.onCreated(); if (props.onCreated) await props.onCreated();
} else { } else {
@@ -595,10 +599,16 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
(out.stdout ? ` stdout:${out.stdout}` : '') + (out.stdout ? ` stdout:${out.stdout}` : '') +
(out.stderr ? ` stderr:${out.stderr}` : '') (out.stderr ? ` stderr:${out.stderr}` : '')
: 'unknown result'; : 'unknown result';
message.error('Command failed: ' + info); notification.error({
message: 'Command failed',
description: info,
});
} }
} catch (err: any) { } catch (err: any) {
message.error('Execution failed: ' + (err?.message ?? String(err))); notification.error({
message: 'Execution failed',
description: err?.message ?? String(err),
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -670,28 +680,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}; };
return ( return (
<Card> <Card title="Firewall Rule Builder" extra>
<Row justify="space-between" align="middle">
<Col>
<Title level={4} style={{ margin: 0 }}>
Add Firewall Rule (raw)
</Title>
<Text type="secondary">
Build an nft rule using canonical match list (meta/ct/ip/ip6/tcp/udp/icmp/ether/vlan).
</Text>
</Col>
<Col>
<Space>
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={refreshing}>
Refresh tables
</Button>
</Space>
</Col>
</Row>
<Divider />
<Form <Form
layout="vertical" layout="vertical"
form={form} form={form}
@@ -1703,29 +1692,17 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Form.Item> <Form.Item>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}> <div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text strong>nft command preview:</Text> <Title level={5}>nft command preview:</Title>
<div style={{ marginTop: 6 }}> <div style={{ marginTop: 6 }}>
<Text code copyable={{ text: cmdPreview || '' }}> <Text code copyable={{ text: cmdPreview || '' }} style={{ fontSize: '16px' }}>
{cmdPreview || '(no textual preview available)'} {cmdPreview || '(no textual preview available)'}
</Text> </Text>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, minWidth: 320, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, minWidth: 320, justifyContent: 'flex-end' }}>
<Tooltip title="Copy command to clipboard">
<Button
icon={<CopyOutlined />}
size="small"
onClick={() => {
if (!cmdPreview) return;
navigator.clipboard?.writeText(cmdPreview);
message.success('Command copied');
}}
/>
</Tooltip>
<Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />} disabled={noTables}> <Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />} disabled={noTables}>
Run command Add Rule
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -132,7 +132,6 @@ type Props = {
export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement { export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement {
// local UI state, non-persistent // local UI state, non-persistent
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [results, setResults] = useState<CmdResult[]>([]);
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false); const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false); const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
@@ -148,8 +147,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
acc.push({ cmd, err: err?.message ?? String(err) }); acc.push({ cmd, err: err?.message ?? String(err) });
} }
} }
// prepend new results to history
setResults((prev) => [...acc, ...prev]);
return acc; return acc;
}, []); }, []);
@@ -466,38 +463,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
); );
})} })}
</Card> </Card>
{/* Results panel */}
{results.length > 0 && (
<Card title="Command Results" style={{ marginTop: 20 }}>
{results.map((r, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>{r.cmd}</Text>
{r.err ? (
<Paragraph style={{ color: 'var(--ant-error-color)', marginTop: 6 }}>{r.err}</Paragraph>
) : r.out ? (
<>
<Paragraph>
<Text type="secondary">rc:</Text> {r.out.rc}
</Paragraph>
{r.out.stdout ? (
<>
<Text type="secondary">stdout:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stdout}</pre>
</>
) : null}
{r.out.stderr ? (
<>
<Text type="secondary">stderr:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stderr}</pre>
</>
) : null}
</>
) : null}
</div>
))}
</Card>
)}
</> </>
); );
} }

View File

@@ -19,7 +19,7 @@ import {
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { clearPackets, fetchPackets } from '../api/apiClient'; import { clearPackets, fetchPackets } from '../api/apiClient';
const { Text } = Typography; const { Text, Title } = Typography;
const { Option } = Select; const { Option } = Select;
type PacketRow = { type PacketRow = {
@@ -409,12 +409,10 @@ export default function PacketViewer(): ReactElement {
); );
return ( return (
<div style={{ padding: 16 }}> <div>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}> <Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col> <Col>
<Text strong style={{ fontSize: 18 }}> <Title level={3}>Packets</Title>
Packets
</Text>
<div> <div>
<Text type="secondary">Live packet viewer — history + live stream</Text> <Text type="secondary">Live packet viewer — history + live stream</Text>
</div> </div>

View File

@@ -7,7 +7,21 @@ import {
ReloadOutlined, ReloadOutlined,
StopOutlined, StopOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Button, Card, Form, List, message, Modal, Radio, Select, Space, Spin, Tag, Tooltip, Typography } from 'antd'; import {
Button,
Card,
Form,
List,
Modal,
notification,
Radio,
Select,
Space,
Spin,
Tag,
Tooltip,
Typography,
} from 'antd';
import { ReactElement, useMemo, useState } from 'react'; import { ReactElement, useMemo, useState } from 'react';
import { startSniffer, stopSniffer, stopSnifferByInterface } from '../api/apiClient'; import { startSniffer, stopSniffer, stopSnifferByInterface } from '../api/apiClient';
import { BridgeInfo, InterfaceInfo } from '../types/network'; import { BridgeInfo, InterfaceInfo } from '../types/network';
@@ -52,18 +66,21 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
const handleStartSubmit = async (values: any) => { const handleStartSubmit = async (values: any) => {
const { target } = values; const { target } = values;
if (!target) { if (!target) {
message.warning('Please select a target to start sniffing on.'); notification.warning({ message: 'Warning', description: 'Please select a target to start sniffing on.' });
return; return;
} }
try { try {
const payload = startMode === 'interface' ? { interface: target } : { bridge: target }; const payload = startMode === 'interface' ? { interface: target } : { bridge: target };
const res = await startSniffer(payload); const res = await startSniffer(payload);
message.success(`Sniffer started on ${target} (session ${res.session_id})`); notification.success({
message: 'Sniffer started',
description: `Sniffer started on ${target} (session ${res.session_id})`,
});
await props.refreshAll(); await props.refreshAll();
setIsModalOpen(false); setIsModalOpen(false);
} catch (err: any) { } catch (err: any) {
console.error('startSniffer error', err); console.error('startSniffer error', err);
message.error(err?.message ?? 'Failed to start sniffer'); notification.error(err?.message ?? 'Failed to start sniffer');
} finally { } finally {
} }
}; };
@@ -72,11 +89,13 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
const handleStopAll = async () => { const handleStopAll = async () => {
try { try {
await stopSniffer(); await stopSniffer();
message.success('All sniffers stopped'); notification.success({
message: 'All sniffers stopped',
});
await props.refreshStatus(); await props.refreshStatus();
} catch (err: any) { } catch (err: any) {
console.error('stopSniffer error', err); console.error('stopSniffer error', err);
message.error(err?.message ?? 'Failed to stop sniffers'); notification.error(err?.message ?? 'Failed to stop sniffers');
} finally { } finally {
} }
}; };
@@ -86,7 +105,10 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
try { try {
// prefer stop by interface // prefer stop by interface
await stopSnifferByInterface(ifaceName); await stopSnifferByInterface(ifaceName);
message.success(`Stopped sniffing on ${ifaceName}`); notification.success({
message: 'Sniffer stopped',
description: `Sniffer stopped on interface ${ifaceName}`,
});
await props.refreshStatus(); await props.refreshStatus();
return; return;
} catch (err: any) { } catch (err: any) {
@@ -95,14 +117,20 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
if (session_id) { if (session_id) {
try { try {
await stopSniffer({ session_id }); await stopSniffer({ session_id });
message.success(`Stopped session ${session_id}`); notification.success({
message: 'Sniffer stopped',
description: `Sniffer stopped on interface ${ifaceName} (session ${session_id})`,
});
await props.refreshStatus(); await props.refreshStatus();
return; return;
} catch (err2: any) { } catch (err2: any) {
console.error('stopSniffer by session fallback failed', err2); console.error('stopSniffer by session fallback failed', err2);
} }
} }
message.error(err?.message ?? 'Failed to stop sniffer'); notification.error({
message: 'Failed to stop sniffer',
description: err?.message ?? 'Failed to stop sniffer',
});
} finally { } finally {
} }
}; };
@@ -169,11 +197,17 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
// start on this interface // start on this interface
try { try {
const res = await startSniffer({ interface: name }); const res = await startSniffer({ interface: name });
message.success(`Started sniffer on ${name} (session ${res.session_id})`); notification.success({
message: 'Sniffer started',
description: `Sniffer started on ${name} (session ${res.session_id})`,
});
await props.refreshStatus(); await props.refreshStatus();
} catch (err: any) { } catch (err: any) {
console.error('startSniffer quick', err); console.error('startSniffer quick', err);
message.error(err?.message ?? 'Failed to start sniffer'); notification.error({
message: 'Failed to start sniffer',
description: err?.message ?? 'Failed to start sniffer',
});
} finally { } finally {
} }
}} }}

View File

@@ -0,0 +1,59 @@
import React, { forwardRef } from 'react';
export type IconProps = React.SVGProps<SVGSVGElement> & {
/**
* Width/height of the icon. If a number is provided it will be used as px.
* Default: 24
*/
size?: number | string;
/**
* Icon color — will be used as the fill for paths.
* Default: 'currentColor' so color can be controlled via CSS.
*/
color?: string;
/**
* Accessible title. If provided, title will be rendered and aria-hidden will be false.
*/
title?: string;
};
const FirewallIcon = forwardRef<SVGSVGElement, IconProps>(
({ size = 24, color = 'currentColor', title, ...rest }, ref) => {
// If user passed a numeric size, treat as px
const sizeValue = typeof size === 'number' ? `${size}px` : size;
return (
<svg
width={sizeValue}
height={sizeValue}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
ref={ref}
>
<path
d="M17 12C17 12.6566 16.8707 13.3068 16.6194 13.9134C16.3681 14.52 15.9998 15.0712 15.5355 15.5355C15.0712 15.9998 14.52 16.3681 13.9134 16.6194C13.3068 16.8707 12.6566 17 12 17C11.3434 17 10.6932 16.8707 10.0866 16.6194C9.47995 16.3681 8.92876 15.9998 8.46447 15.5355C8.00017 15.0712 7.63188 14.52 7.3806 13.9134C7.12933 13.3068 7 12.6566 7 12C7 11.3434 7.12933 10.6932 7.3806 10.0866C7.63188 9.47995 8.00017 8.92876 8.46447 8.46447C8.92876 8.00017 9.47996 7.63188 10.0866 7.3806C10.6932 7.12933 11.3434 7 12 7C12.6566 7 13.3068 7.12933 13.9134 7.3806C14.52 7.63188 15.0712 8.00017 15.5355 8.46447C15.9998 8.92876 16.3681 9.47996 16.6194 10.0866C16.8707 10.6932 17 11.3434 17 12L17 12Z"
stroke={color}
stroke-width="1.5"
/>
<path
d="M13.8478 13.9134C13.9483 13.3068 14 12.6566 14 12C14 11.3434 13.9483 10.6932 13.8478 10.0866C13.7472 9.47996 13.5999 8.92876 13.4142 8.46447C13.2285 8.00017 13.008 7.63188 12.7654 7.3806C12.5227 7.12933 12.2626 7 12 7C11.7374 7 11.4773 7.12933 11.2346 7.3806C10.992 7.63188 10.7715 8.00017 10.5858 8.46447C10.4001 8.92876 10.2528 9.47995 10.1522 10.0866C10.0517 10.6932 10 11.3434 10 12C10 12.6566 10.0517 13.3068 10.1522 13.9134C10.2527 14.52 10.4001 15.0712 10.5858 15.5355C10.7715 15.9998 10.992 16.3681 11.2346 16.6194C11.4773 16.8707 11.7374 17 12 17C12.2626 17 12.5227 16.8707 12.7654 16.6194C13.008 16.3681 13.2285 15.9998 13.4142 15.5355C13.5999 15.0712 13.7472 14.52 13.8478 13.9134Z"
stroke={color}
stroke-width="1.5"
/>
<path d="M7 12H17" stroke={color} stroke-width="1.5" stroke-linecap="round" />
<path
d="M3 10.4167C3 7.21907 3 5.62028 3.37752 5.08241C3.75503 4.54454 5.25832 4.02996 8.26491 3.00079L8.83772 2.80472C10.405 2.26824 11.1886 2 12 2C12.8114 2 13.595 2.26824 15.1623 2.80472L15.7351 3.00079C18.7417 4.02996 20.245 4.54454 20.6225 5.08241C21 5.62028 21 7.21907 21 10.4167C21 10.8996 21 11.4234 21 11.9914C21 14.4963 20.1632 16.4284 19 17.9041M3.19284 14C4.05026 18.2984 7.57641 20.5129 9.89856 21.5273C10.62 21.8424 10.9807 22 12 22C13.0193 22 13.38 21.8424 14.1014 21.5273C14.6796 21.2747 15.3324 20.9478 16 20.5328"
stroke={color}
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
);
},
);
FirewallIcon.displayName = 'FirewallIcon';
export default FirewallIcon;

View File

@@ -1,48 +1,67 @@
// src/pages/BridgesManager.tsx // src/pages/BridgesManager.tsx
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Button, Col, Form, Input, message, Modal, Popconfirm, Row, Select, Space, Table, Tag, Typography } from 'antd'; import {
Button,
Card,
Col,
Form,
Input,
Modal,
notification,
Popconfirm,
Row,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useBackendAPI } from '../hooks/useBackendAPI'; import { createBridge, fetchFullState, removeBridge } from '../api/apiClient';
import type { BridgeInfo, InterfaceInfo } from '../types/network'; import type { BridgeInfo, InterfaceInfo } from '../types/network';
import { FullState } from '../types/network';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
export default function Network() { export default function Network() {
const {
interfacesQuery,
bridgesQuery,
createBridge,
removeBridge,
fetchBridges,
fetchInterfaces,
refreshBridges,
refreshInterfaces,
isCreating,
isRemoving,
} = useBackendAPI();
const [bridgeModalVisible, setBridgeModalVisible] = useState(false); const [bridgeModalVisible, setBridgeModalVisible] = useState(false);
const [bridgeForm] = Form.useForm(); const [bridgeForm] = Form.useForm();
const [networkState, setNetworkState] = useState<FullState>();
const getFullState = (auto: boolean = false) => {
fetchFullState()
.then((interfaces) => {
setNetworkState(interfaces);
if (!auto) {
notification.success({
message: 'Success',
description: 'Network state updated.',
});
}
})
.catch((err) => {
console.error('Failed to fetch interfaces:', err);
notification.error({
message: 'Error',
description: 'Failed to fetch network state.',
});
});
};
// fetch on mount (explicit, since queries are disabled by default) // fetch on mount (explicit, since queries are disabled by default)
useEffect(() => { useEffect(() => {
fetchInterfaces().catch(() => {}); getFullState(true);
fetchBridges().catch(() => {});
}, []); }, []);
// Table data
const interfaces = interfacesQuery.data ?? [];
const bridges = bridgesQuery.data ?? [];
// build select options from interfaces list // build select options from interfaces list
const interfaceOptions = useMemo( const interfaceOptions = useMemo(
() => () =>
interfaces.map((it: InterfaceInfo) => ({ networkState?.interfaces.map((it: InterfaceInfo) => ({
label: it.name, label: it.name,
value: it.name, value: it.name,
})), })) ?? [],
[interfaces], [networkState],
); );
// Columns for interfaces table (read-only) // Columns for interfaces table (read-only)
@@ -97,37 +116,49 @@ export default function Network() {
okText="Remove" okText="Remove"
cancelText="Cancel" cancelText="Cancel"
> >
<Button danger icon={<DeleteOutlined />} loading={isRemoving} size="small"> <Button danger icon={<DeleteOutlined />} />
Remove
</Button>
</Popconfirm> </Popconfirm>
), ),
}, },
], ],
[isRemoving], [networkState],
); );
async function handleCreateBridge(values: { name: string; interfaces?: string[] }) { async function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
const ifaceList = values.interfaces ?? []; const ifaceList = values.interfaces ?? [];
createBridge({ name: values.name, interfaces: ifaceList }) createBridge({ name: values.name, interfaces: ifaceList })
.then(() => { .then(() => {
message.success(`Bridge ${values.name} created`); notification.success({
message: 'Success',
description: `Bridge ${values.name} created`,
});
setBridgeModalVisible(false); setBridgeModalVisible(false);
getFullState(true);
bridgeForm.resetFields(); bridgeForm.resetFields();
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
message.error((err as Error).message ?? 'Failed to create bridge'); notification.error({
message: 'Error',
description: (err as Error).message ?? 'Failed to create bridge',
});
}); });
} }
function handleRemoveBridge(name: string) { function handleRemoveBridge(name: string) {
removeBridge({ name }) removeBridge({ name })
.then(() => { .then(() => {
message.success(`Bridge ${name} removed`); notification.success({
message: 'Success',
description: `Bridge ${name} removed`,
});
getFullState(true);
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
message.error((err as Error).message ?? 'Failed to remove bridge'); notification.error({
message: 'Error',
description: (err as Error).message ?? 'Failed to remove bridge',
});
}); });
} }
@@ -140,23 +171,10 @@ export default function Network() {
</Col> </Col>
<Col> <Col>
<Space> <Space>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
// ensure up-to-date interface list when opening modal
refreshInterfaces();
setBridgeModalVisible(true);
}}
>
Create bridge
</Button>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => { onClick={() => {
refreshBridges(); getFullState();
refreshInterfaces();
message.success('Refreshing…');
}} }}
> >
Refresh Refresh
@@ -167,35 +185,41 @@ export default function Network() {
<Row gutter={16}> <Row gutter={16}>
<Col span={14}> <Col span={14}>
<div style={{ background: '#fff', padding: 12, borderRadius: 6, minHeight: 220 }}> <Card title={`Interfaces (${networkState?.interfaces.length})`} style={{ overflow: 'auto' }}>
<Title level={5} style={{ marginBottom: 12 }}>
Interfaces ({interfaces.length})
</Title>
<Table <Table
rowKey={(r: InterfaceInfo) => r.ifindex} rowKey={(r: InterfaceInfo) => r.ifindex}
dataSource={interfaces} dataSource={networkState?.interfaces ?? []}
columns={interfaceColumns} columns={interfaceColumns}
pagination={{ pageSize: 8 }} pagination={{ pageSize: 8 }}
size="small" size="small"
loading={interfacesQuery.isFetching || interfacesQuery.isLoading}
/> />
</div> </Card>
</Col> </Col>
<Col span={10}> <Col span={10}>
<div style={{ background: '#fff', padding: 12, borderRadius: 6, minHeight: 220 }}> <Card
<Title level={5} style={{ marginBottom: 12 }}> title={`Bridges (${networkState?.bridges.length})`}
Bridges ({bridges.length}) style={{ overflow: 'auto' }}
</Title> extra={
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
// ensure up-to-date interface list when opening modal
getFullState(true);
setBridgeModalVisible(true);
}}
/>
}
>
<Table <Table
rowKey={(r: BridgeInfo) => String(r.ifindex)} rowKey={(r: BridgeInfo) => String(r.ifindex)}
dataSource={bridges} dataSource={networkState?.bridges ?? []}
columns={bridgeColumns} columns={bridgeColumns}
pagination={{ pageSize: 6 }} pagination={{ pageSize: 6 }}
size="small" size="small"
loading={bridgesQuery.isFetching || bridgesQuery.isLoading}
/> />
</div> </Card>
</Col> </Col>
</Row> </Row>
@@ -207,7 +231,6 @@ export default function Network() {
setBridgeModalVisible(false); setBridgeModalVisible(false);
bridgeForm.resetFields(); bridgeForm.resetFields();
}} }}
okButtonProps={{ loading: isCreating }}
onOk={() => bridgeForm.submit()} onOk={() => bridgeForm.submit()}
> >
<Form form={bridgeForm} layout="vertical" onFinish={handleCreateBridge}> <Form form={bridgeForm} layout="vertical" onFinish={handleCreateBridge}>
@@ -219,12 +242,11 @@ export default function Network() {
<Form.Item name="interfaces" label="Interfaces (select one or more)"> <Form.Item name="interfaces" label="Interfaces (select one or more)">
<Select <Select
mode="multiple" mode="multiple"
placeholder={interfaces.length ? 'Select interfaces...' : 'No interfaces available'} placeholder={networkState?.interfaces.length ? 'Select interfaces...' : 'No interfaces available'}
options={interfaceOptions} options={interfaceOptions}
showSearch showSearch
allowClear allowClear
loading={interfacesQuery.isFetching || interfacesQuery.isLoading} disabled={interfaceOptions.length === 0}
disabled={interfacesQuery.isFetching || interfacesQuery.isLoading || interfaceOptions.length === 0}
/> />
</Form.Item> </Form.Item>
</Form> </Form>