Files
mitm-webserver/frontend/src/pages/Network.tsx
malmert 665c0b4475
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 9s
link watcher switch to event based instead of polling
2026-03-09 15:49:13 +01:00

526 lines
17 KiB
TypeScript

import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
Modal,
notification,
Popconfirm,
Row,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react';
import {
createBridge,
disableBridgeLinkStateWatcher,
enableBridgeLinkStateWatcher,
fetchBridgeLinkStateWatchers,
fetchFullState,
removeBridge,
} from '../api/apiClient';
import type {
BridgeInfo,
BridgeLinkStateWatcherStatus,
FullState,
InterfaceInfo,
} from '../types/network';
const { Title, Paragraph } = Typography;
export default function Network() {
const [bridgeModalVisible, setBridgeModalVisible] = useState(false);
const [watcherModalVisible, setWatcherModalVisible] = useState(false);
const [bridgeForm] = Form.useForm();
const [watcherForm] = Form.useForm();
const [networkState, setNetworkState] = useState<FullState>();
const [watcherStatuses, setWatcherStatuses] = useState<Record<string, BridgeLinkStateWatcherStatus>>({});
const [watcherBridgeName, setWatcherBridgeName] = useState<string>();
const [watcherBusyBridge, setWatcherBusyBridge] = useState<string>();
const getNetworkPageState = (silent = false) => {
Promise.all([fetchFullState(), fetchBridgeLinkStateWatchers()])
.then(([state, watchers]) => {
setNetworkState(state);
setWatcherStatuses(
watchers.reduce<Record<string, BridgeLinkStateWatcherStatus>>((acc, watcher) => {
acc[watcher.bridge] = watcher;
return acc;
}, {}),
);
if (!silent) {
notification.success({
message: 'Success',
description: 'Network state updated.',
});
}
})
.catch((error) => {
console.error('Failed to fetch network state:', error);
notification.error({
message: 'Error',
description: 'Failed to fetch network state.',
});
});
};
useEffect(() => {
getNetworkPageState(true);
}, []);
const hasActiveWatchers = useMemo(
() => Object.values(watcherStatuses).some((watcher) => watcher.active),
[watcherStatuses],
);
useEffect(() => {
if (!hasActiveWatchers) {
return undefined;
}
const intervalId = window.setInterval(() => {
getNetworkPageState(true);
}, 2000);
return () => window.clearInterval(intervalId);
}, [hasActiveWatchers]);
const interfaceOptions = useMemo(
() =>
networkState?.interfaces.map((iface: InterfaceInfo) => ({
label: iface.name,
value: iface.name,
})) ?? [],
[networkState],
);
const watcherData = useMemo(
() =>
(networkState?.bridges ?? []).map((bridge) => ({
bridge,
watcher: watcherStatuses[bridge.ifname],
})),
[networkState, watcherStatuses],
);
const watcherMemberColumns = useMemo(
() => [
{ title: 'Member', dataIndex: 'ifname', key: 'ifname' },
{
title: 'Admin',
dataIndex: 'admin_up',
key: 'admin_up',
width: 90,
render: (value: boolean | null | undefined) => <Tag color={value ? 'green' : 'default'}>{value ? 'up' : 'down'}</Tag>,
},
{
title: 'Carrier',
dataIndex: 'carrier_up',
key: 'carrier_up',
width: 100,
render: (value: boolean | null | undefined) => <Tag color={value ? 'green' : 'default'}>{value ? 'up' : 'down'}</Tag>,
},
{
title: 'Operstate',
dataIndex: 'operstate',
key: 'operstate',
render: (value: string | null | undefined) => <Tag>{value ?? 'unknown'}</Tag>,
},
{
title: 'Role',
key: 'role',
width: 120,
render: (_: unknown, record: { link_ready: boolean; suppressed: boolean }) => {
if (record.suppressed) {
return <Tag color="orange">suppressed</Tag>;
}
return <Tag color={record.link_ready ? 'green' : 'red'}>{record.link_ready ? 'ready' : 'failing'}</Tag>;
},
},
],
[],
);
const interfaceColumns: ColumnsType<InterfaceInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state}</Tag> },
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (mac) => mac ?? '—' },
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90 },
{
title: 'Addresses',
dataIndex: 'addresses',
key: 'addresses',
render: (addresses: any[]) =>
addresses?.length ? (
<Space direction="vertical">
{addresses.map((address) => (
<span key={`${address.address}/${address.prefixlen}`}>
{address.address}/{address.prefixlen} ({address.family})
</span>
))}
</Space>
) : (
<span>—</span>
),
},
],
[],
);
const bridgeColumns: ColumnsType<BridgeInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'ifname', key: 'ifname' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state ?? '—'}</Tag> },
{
title: 'Members',
dataIndex: 'members',
key: 'members',
render: (members: any[]) => (members?.length ? members.map((member) => member.ifname).join(', ') : '—'),
},
{
title: 'Link Watcher',
key: 'watcher',
width: 160,
render: (_, record: BridgeInfo) => {
const watcher = watcherStatuses[record.ifname];
if (!watcher?.active) {
return <Tag>disabled</Tag>;
}
if (watcher.last_error) {
return <Tag color="red">error</Tag>;
}
if (watcher.suppressed_members.length > 0) {
return <Tag color="orange">propagating</Tag>;
}
return <Tag color="green">active</Tag>;
},
},
{
title: 'Actions',
key: 'actions',
width: 260,
render: (_, record: BridgeInfo) => (
<Space>
{watcherStatuses[record.ifname]?.active ? (
<Button
onClick={() => handleDisableWatcher(record.ifname)}
loading={watcherBusyBridge === record.ifname}
>
Disable watcher
</Button>
) : (
<Button
onClick={() => openEnableWatcherModal(record.ifname)}
loading={watcherBusyBridge === record.ifname}
>
Enable watcher
</Button>
)}
<Popconfirm
title={`Remove bridge ${record.ifname}?`}
onConfirm={() => handleRemoveBridge(record.ifname)}
okText="Remove"
cancelText="Cancel"
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
],
[watcherBusyBridge, watcherStatuses],
);
function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
const interfaces = values.interfaces ?? [];
createBridge({ name: values.name, interfaces })
.then(() => {
notification.success({
message: 'Success',
description: `Bridge ${values.name} created`,
});
setBridgeModalVisible(false);
getNetworkPageState(true);
bridgeForm.resetFields();
})
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (error as Error).message ?? 'Failed to create bridge',
});
});
}
function handleRemoveBridge(name: string) {
removeBridge({ name })
.then(() => {
notification.success({
message: 'Success',
description: `Bridge ${name} removed`,
});
getNetworkPageState(true);
})
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (error as Error).message ?? 'Failed to remove bridge',
});
});
}
function openEnableWatcherModal(bridgeName: string) {
setWatcherBridgeName(bridgeName);
watcherForm.setFieldsValue({ recovery_holdoff_seconds: 1.0 });
setWatcherModalVisible(true);
}
function handleEnableWatcher(values: { recovery_holdoff_seconds: number }) {
if (!watcherBridgeName) {
return;
}
setWatcherBusyBridge(watcherBridgeName);
enableBridgeLinkStateWatcher(watcherBridgeName, values)
.then(() => {
notification.success({
message: 'Success',
description: `Link-state watcher enabled for ${watcherBridgeName}`,
});
setWatcherModalVisible(false);
setWatcherBridgeName(undefined);
watcherForm.resetFields();
getNetworkPageState(true);
})
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (error as Error).message ?? 'Failed to enable link-state watcher',
});
})
.finally(() => {
setWatcherBusyBridge(undefined);
});
}
function handleDisableWatcher(bridgeName: string) {
setWatcherBusyBridge(bridgeName);
disableBridgeLinkStateWatcher(bridgeName)
.then(() => {
notification.success({
message: 'Success',
description: `Link-state watcher disabled for ${bridgeName}`,
});
getNetworkPageState(true);
})
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (error as Error).message ?? 'Failed to disable link-state watcher',
});
})
.finally(() => {
setWatcherBusyBridge(undefined);
});
}
return (
<div style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Title level={2}>Network Management</Title>
<Paragraph type="secondary">View system interfaces and manage network bridges.</Paragraph>
</Col>
<Col>
<Button icon={<ReloadOutlined />} onClick={() => getNetworkPageState()}>
Refresh
</Button>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Card title={`Interfaces (${networkState?.interfaces.length ?? 0})`} style={{ overflow: 'auto' }}>
<Table
rowKey={(row: InterfaceInfo) => row.ifindex}
dataSource={networkState?.interfaces ?? []}
columns={interfaceColumns}
pagination={{ pageSize: 8 }}
size="small"
/>
</Card>
</Col>
<Col span={10}>
<Card
title={`Bridges (${networkState?.bridges.length ?? 0})`}
style={{ overflow: 'auto' }}
extra={
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
getNetworkPageState(true);
setBridgeModalVisible(true);
}}
/>
}
>
<Table
rowKey={(row: BridgeInfo) => String(row.ifindex)}
dataSource={networkState?.bridges ?? []}
columns={bridgeColumns}
pagination={{ pageSize: 6 }}
size="small"
/>
</Card>
</Col>
</Row>
<Row style={{ marginTop: 16 }}>
<Col span={24}>
<Card title="Bridge Link-State Propagation">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
Enable a watcher on a bridge to force the sibling bridge ports down when one member loses link.
</Paragraph>
{(networkState?.bridges.length ?? 0) === 0 ? (
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
No bridges available.
</Paragraph>
) : (
watcherData.map(({ bridge, watcher }) => {
const members = Object.values(watcher?.members ?? {});
return (
<Card
key={bridge.ifname}
size="small"
title={
<Space>
<span>{bridge.ifname}</span>
<Tag color={watcher?.active ? 'green' : 'default'}>
{watcher?.active ? 'watching' : 'disabled'}
</Tag>
{watcher?.last_error ? <Tag color="red">error</Tag> : null}
</Space>
}
extra={
watcher?.active ? (
<Button onClick={() => handleDisableWatcher(bridge.ifname)} loading={watcherBusyBridge === bridge.ifname}>
Disable
</Button>
) : (
<Button onClick={() => openEnableWatcherModal(bridge.ifname)} loading={watcherBusyBridge === bridge.ifname}>
Enable
</Button>
)
}
>
<Descriptions size="small" column={4} style={{ marginBottom: members.length ? 12 : 0 }}>
<Descriptions.Item label="Members">{bridge.members.map((member) => member.ifname).join(', ') || '—'}</Descriptions.Item>
<Descriptions.Item label="Mode">{watcher?.event_driven ? 'netlink events' : '—'}</Descriptions.Item>
<Descriptions.Item label="Recovery holdoff">
{watcher?.recovery_holdoff_seconds ? `${watcher.recovery_holdoff_seconds}s` : '—'}
</Descriptions.Item>
<Descriptions.Item label="Suppressed">
{watcher?.suppressed_members.length ? watcher.suppressed_members.join(', ') : 'none'}
</Descriptions.Item>
<Descriptions.Item label="Last action">{watcher?.last_action ?? watcher?.message ?? '—'}</Descriptions.Item>
</Descriptions>
{members.length ? (
<Table
rowKey={(row) => row.ifname}
dataSource={members}
columns={watcherMemberColumns}
pagination={false}
size="small"
/>
) : (
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{watcher?.active ? 'Waiting for watcher state.' : 'Watcher is disabled.'}
</Paragraph>
)}
</Card>
);
})
)}
</Space>
</Card>
</Col>
</Row>
<Modal
title="Create Bridge"
open={bridgeModalVisible}
onCancel={() => {
setBridgeModalVisible(false);
bridgeForm.resetFields();
}}
onOk={() => bridgeForm.submit()}
>
<Form form={bridgeForm} layout="vertical" onFinish={handleCreateBridge}>
<Form.Item name="name" label="Bridge name" rules={[{ required: true }]}>
<Input placeholder="e.g. br0" />
</Form.Item>
<Form.Item name="interfaces" label="Interfaces (select one or more)">
<Select
mode="multiple"
placeholder={networkState?.interfaces.length ? 'Select interfaces...' : 'No interfaces available'}
options={interfaceOptions}
showSearch
allowClear
disabled={interfaceOptions.length === 0}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={watcherBridgeName ? `Enable Link-State Watcher for ${watcherBridgeName}` : 'Enable Link-State Watcher'}
open={watcherModalVisible}
onCancel={() => {
setWatcherModalVisible(false);
setWatcherBridgeName(undefined);
watcherForm.resetFields();
}}
onOk={() => watcherForm.submit()}
>
<Form
form={watcherForm}
layout="vertical"
onFinish={handleEnableWatcher}
initialValues={{ recovery_holdoff_seconds: 1.0 }}
>
<Form.Item
name="recovery_holdoff_seconds"
label="Recovery holdoff (seconds)"
rules={[{ required: true, message: 'Please provide a recovery holdoff' }]}
>
<InputNumber min={0.05} max={10} step={0.05} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
The backend reacts to kernel link events and waits for this holdoff before restoring sibling ports after recovery.
</Paragraph>
</Form>
</Modal>
</div>
);
}