test nic state link api
This commit is contained in:
@@ -3,6 +3,8 @@ import axios from 'axios';
|
||||
import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
|
||||
import {
|
||||
BridgeCreateRequest,
|
||||
BridgeLinkStateEnableRequest,
|
||||
BridgeLinkStateWatcherStatus,
|
||||
BridgeInfo,
|
||||
BridgeRemoveRequest,
|
||||
FullState,
|
||||
@@ -90,6 +92,30 @@ export const removeBridge = async (req: BridgeRemoveRequest) => {
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchBridgeLinkStateWatchers = async (): Promise<BridgeLinkStateWatcherStatus[]> => {
|
||||
const res = await api.get<BridgeLinkStateWatcherStatus[]>('/network/bridge/link-state-watchers');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const enableBridgeLinkStateWatcher = async (
|
||||
bridgeName: string,
|
||||
req: BridgeLinkStateEnableRequest,
|
||||
): Promise<BridgeLinkStateWatcherStatus> => {
|
||||
const res = await api.post<BridgeLinkStateWatcherStatus>(
|
||||
`/network/bridge/${encodeURIComponent(bridgeName)}/link-state-watcher/enable`,
|
||||
req,
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const disableBridgeLinkStateWatcher = async (bridgeName: string): Promise<BridgeLinkStateWatcherStatus> => {
|
||||
const res = await api.post<BridgeLinkStateWatcherStatus>(
|
||||
`/network/bridge/${encodeURIComponent(bridgeName)}/link-state-watcher/disable`,
|
||||
{},
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const startSniffer = async (payload: SnifferStartRequest): Promise<SnifferStartResponse> => {
|
||||
const res = await api.post<SnifferStartResponse>('/sniffer/start', payload);
|
||||
return res.data;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,10 +46,36 @@ export interface BridgeCreateRequest {
|
||||
name: string;
|
||||
interfaces: string[];
|
||||
}
|
||||
|
||||
export interface BridgeRemoveRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BridgeLinkStateEnableRequest {
|
||||
poll_interval_seconds: number;
|
||||
}
|
||||
|
||||
export interface BridgeMemberLinkStateInfo {
|
||||
ifname: string;
|
||||
admin_up?: boolean | null;
|
||||
carrier_up?: boolean | null;
|
||||
operstate?: string | null;
|
||||
link_ready: boolean;
|
||||
suppressed: boolean;
|
||||
}
|
||||
|
||||
export interface BridgeLinkStateWatcherStatus {
|
||||
bridge: string;
|
||||
active: boolean;
|
||||
poll_interval_seconds?: number | null;
|
||||
last_poll_ts?: number | null;
|
||||
last_error?: string | null;
|
||||
last_action?: string | null;
|
||||
suppressed_members: string[];
|
||||
members: Record<string, BridgeMemberLinkStateInfo>;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
// full-state response shape
|
||||
export interface FullState {
|
||||
interfaces: InterfaceInfo[];
|
||||
|
||||
Reference in New Issue
Block a user