test nic state link api
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-03-09 15:29:53 +01:00
parent d2179b2813
commit 4c491cf4b1
7 changed files with 799 additions and 22 deletions

View File

@@ -3,8 +3,10 @@ import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
Modal,
notification,
Popconfirm,
@@ -18,20 +20,43 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react';
import { createBridge, fetchFullState, removeBridge } from '../api/apiClient';
import type { BridgeInfo, FullState, InterfaceInfo } from '../types/network';
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 getFullState = (silent = false) => {
fetchFullState()
.then((state) => {
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',
@@ -49,9 +74,26 @@ export default function Network() {
};
useEffect(() => {
getFullState(true);
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) => ({
@@ -61,6 +103,53 @@ export default function Network() {
[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 },
@@ -100,23 +189,58 @@ export default function Network() {
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: 140,
width: 260,
render: (_, record: BridgeInfo) => (
<Popconfirm
title={`Remove bridge ${record.ifname}?`}
onConfirm={() => handleRemoveBridge(record.ifname)}
okText="Remove"
cancelText="Cancel"
>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
<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[] }) {
@@ -128,7 +252,7 @@ export default function Network() {
description: `Bridge ${values.name} created`,
});
setBridgeModalVisible(false);
getFullState(true);
getNetworkPageState(true);
bridgeForm.resetFields();
})
.catch((error) => {
@@ -147,7 +271,7 @@ export default function Network() {
message: 'Success',
description: `Bridge ${name} removed`,
});
getFullState(true);
getNetworkPageState(true);
})
.catch((error) => {
console.error(error);
@@ -158,6 +282,63 @@ export default function Network() {
});
}
function openEnableWatcherModal(bridgeName: string) {
setWatcherBridgeName(bridgeName);
watcherForm.setFieldsValue({ poll_interval_seconds: 0.25 });
setWatcherModalVisible(true);
}
function handleEnableWatcher(values: { poll_interval_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 }}>
@@ -166,7 +347,7 @@ export default function Network() {
<Paragraph type="secondary">View system interfaces and manage network bridges.</Paragraph>
</Col>
<Col>
<Button icon={<ReloadOutlined />} onClick={() => getFullState()}>
<Button icon={<ReloadOutlined />} onClick={() => getNetworkPageState()}>
Refresh
</Button>
</Col>
@@ -194,7 +375,7 @@ export default function Network() {
icon={<PlusOutlined />}
type="primary"
onClick={() => {
getFullState(true);
getNetworkPageState(true);
setBridgeModalVisible(true);
}}
/>
@@ -211,6 +392,78 @@ export default function Network() {
</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="Poll interval">
{watcher?.poll_interval_seconds ? `${watcher.poll_interval_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}
@@ -237,6 +490,30 @@ export default function Network() {
</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={{ poll_interval_seconds: 0.25 }}>
<Form.Item
name="poll_interval_seconds"
label="Poll interval (seconds)"
rules={[{ required: true, message: 'Please provide a poll interval' }]}
>
<InputNumber min={0.05} max={10} step={0.05} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
When enabled, the backend suppresses sibling bridge ports if one member loses link and restores them after recovery.
</Paragraph>
</Form>
</Modal>
</div>
);
}