526 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|