This commit is contained in:
@@ -139,7 +139,7 @@ class DatabasePool:
|
|||||||
""",
|
""",
|
||||||
limit,
|
limit,
|
||||||
)
|
)
|
||||||
|
logger.exception("Fetched %d packets from DB", len(rows))
|
||||||
out: List[Dict[str, Any]] = []
|
out: List[Dict[str, Any]] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
|
|||||||
482
frontend/src/components/PacketViewer.tsx
Normal file
482
frontend/src/components/PacketViewer.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
// src/components/PacketViewer.tsx
|
||||||
|
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { fetchPackets } from '../api/apiClient';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
type PacketRow = {
|
||||||
|
id?: number | string;
|
||||||
|
timestamp?: string; // ISO or DB formatted
|
||||||
|
iface?: string | string[]; // may be single iface or list
|
||||||
|
src_mac?: string | null;
|
||||||
|
dst_mac?: string | null;
|
||||||
|
eth_type?: string | number | null;
|
||||||
|
ip_proto?: string | number | null;
|
||||||
|
src_ip?: string | null;
|
||||||
|
dst_ip?: string | null;
|
||||||
|
src_port?: number | null;
|
||||||
|
dst_port?: number | null;
|
||||||
|
vlan_id?: number | null;
|
||||||
|
length?: number | null;
|
||||||
|
raw_b64?: string | null;
|
||||||
|
[k: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 200;
|
||||||
|
const MAX_PACKETS = 2000; // in-memory cap
|
||||||
|
|
||||||
|
function base64ToHex(b64: string) {
|
||||||
|
try {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join(' ');
|
||||||
|
} catch {
|
||||||
|
return '(invalid base64)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBlob(b64: string) {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const len = bin.length;
|
||||||
|
const arr = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) arr[i] = bin.charCodeAt(i);
|
||||||
|
return new Blob([arr.buffer], { type: 'application/octet-stream' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Improved deterministic color generation
|
||||||
|
*
|
||||||
|
* - returns { background: hsl(...), color: '#000' | '#fff' }
|
||||||
|
* - uses cached Map so results are stable/fast
|
||||||
|
*/
|
||||||
|
const _colorCache = new Map<string, { background: string; color: string }>();
|
||||||
|
function colorForName(name: string) {
|
||||||
|
const key = String(name);
|
||||||
|
const cached = _colorCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// simple hash to produce a hue in [0, 359]
|
||||||
|
let h = 2166136261 >>> 0; // FNV offset basis
|
||||||
|
for (let i = 0; i < key.length; i++) {
|
||||||
|
h ^= key.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619) >>> 0; // FNV prime
|
||||||
|
}
|
||||||
|
const hue = h % 360;
|
||||||
|
|
||||||
|
// Choose saturation and lightness to ensure good contrast
|
||||||
|
// We vary lightness slightly by name so colors differ more
|
||||||
|
const sat = 68; // %
|
||||||
|
// map part of hash to lightness in range [38, 58]
|
||||||
|
const light = 38 + (h % 21); // 38..58
|
||||||
|
|
||||||
|
// Use comma syntax for compatibility
|
||||||
|
const background = `hsl(${hue}, ${sat}%, ${light}%)`;
|
||||||
|
|
||||||
|
// Choose text color for contrast: use white for darker backgrounds
|
||||||
|
const textColor = light < 50 ? '#ffffff' : '#0b0b0b';
|
||||||
|
|
||||||
|
const result = { background, color: textColor };
|
||||||
|
_colorCache.set(key, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse iface field into an array of strings */
|
||||||
|
function parseIfaces(ifaceField?: string | string[]) {
|
||||||
|
if (!ifaceField) return [];
|
||||||
|
if (Array.isArray(ifaceField)) return ifaceField;
|
||||||
|
return String(ifaceField)
|
||||||
|
.split(/[,\|;]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format timestamp to European locale with ms */
|
||||||
|
function formatTimestamp(ts?: string) {
|
||||||
|
if (!ts) return '-';
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(ts);
|
||||||
|
return (
|
||||||
|
d.toLocaleString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}) + `.${String(d.getMilliseconds()).padStart(3, '0')}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return String(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PacketViewer component
|
||||||
|
*/
|
||||||
|
export default function PacketViewer(): ReactElement {
|
||||||
|
const [packets, setPackets] = useState<PacketRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
||||||
|
const [limit, setLimit] = useState<number>(DEFAULT_LIMIT);
|
||||||
|
const [paused, setPaused] = useState<boolean>(false);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const [hexModalOpen, setHexModalOpen] = useState(false);
|
||||||
|
const [rawHex, setRawHex] = useState<string | null>(null);
|
||||||
|
const [rawB64ForDownload, setRawB64ForDownload] = useState<string | null>(null);
|
||||||
|
const [selectedPacket, setSelectedPacket] = useState<PacketRow | null>(null);
|
||||||
|
const queuedDuringPause = useRef<PacketRow[]>([]);
|
||||||
|
|
||||||
|
// helper: prepend new packets (newest first). Keep list capped.
|
||||||
|
const pushNew = useCallback((newPkts: PacketRow[] | PacketRow) => {
|
||||||
|
setPackets((prev) => {
|
||||||
|
const arr = Array.isArray(newPkts) ? newPkts : [newPkts];
|
||||||
|
const normalized = arr.map((p) => p);
|
||||||
|
const merged = [...normalized, ...prev];
|
||||||
|
if (merged.length > MAX_PACKETS) return merged.slice(0, MAX_PACKETS);
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// fetch historical packets
|
||||||
|
const fetchHistory = useCallback(async (limitVal: number) => {
|
||||||
|
setStatusLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchPackets(limitVal);
|
||||||
|
const list: PacketRow[] = (res.packets ?? []).map((p: any) => p);
|
||||||
|
setPackets(list);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('fetchHistory error', err);
|
||||||
|
message.error(err?.message ?? 'Failed to fetch packet history');
|
||||||
|
} finally {
|
||||||
|
setStatusLoading(false);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// open websocket
|
||||||
|
const openWs = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
try {
|
||||||
|
wsRef.current.close();
|
||||||
|
} catch {}
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loc = window.location;
|
||||||
|
const protocol = loc.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const wsUrl = `ws://mitm.lan/api/packets/ws/packets?subscribe_recent=20`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data);
|
||||||
|
if (data && data.type === 'recent' && Array.isArray(data.packets)) {
|
||||||
|
if (paused) {
|
||||||
|
queuedDuringPause.current.unshift(...(data.packets as PacketRow[]));
|
||||||
|
} else {
|
||||||
|
pushNew(data.packets as PacketRow[]);
|
||||||
|
}
|
||||||
|
} else if (data && (data.iface || data.raw_b64 || data.id || data.timestamp)) {
|
||||||
|
if (paused) {
|
||||||
|
queuedDuringPause.current.unshift(data as PacketRow);
|
||||||
|
} else {
|
||||||
|
pushNew(data as PacketRow);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.debug('ws unknown message', data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('ws message parse error', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error('ws error', e);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [paused, pushNew]);
|
||||||
|
|
||||||
|
// pause handling: when unpausing, flush queuedDuringPause into list
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paused) {
|
||||||
|
const queued = queuedDuringPause.current;
|
||||||
|
if (queued.length > 0) {
|
||||||
|
pushNew(queued);
|
||||||
|
queuedDuringPause.current = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [paused, pushNew]);
|
||||||
|
|
||||||
|
// start up: fetch history and open ws
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetchHistory(limit).then(() => {
|
||||||
|
openWs();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
try {
|
||||||
|
wsRef.current.close();
|
||||||
|
} catch {}
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchHistory, limit, openWs]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await fetchHistory(limit);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePauseToggle = (val: boolean) => {
|
||||||
|
setPaused(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRawModal = (row: PacketRow) => {
|
||||||
|
if (!row.raw_b64) {
|
||||||
|
message.info('No raw bytes available for this packet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRawHex(base64ToHex(row.raw_b64));
|
||||||
|
setRawB64ForDownload(row.raw_b64 ?? null);
|
||||||
|
setSelectedPacket(row);
|
||||||
|
setHexModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadRaw = () => {
|
||||||
|
if (!rawB64ForDownload) return;
|
||||||
|
const blob = base64ToBlob(rawB64ForDownload);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `packet_${selectedPacket?.id ?? 'pkt'}.bin`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: 'Time',
|
||||||
|
dataIndex: 'timestamp',
|
||||||
|
key: 'timestamp',
|
||||||
|
width: 120,
|
||||||
|
render: (val: any) => <Text>{formatTimestamp(val)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ifaces',
|
||||||
|
dataIndex: 'iface',
|
||||||
|
key: 'iface',
|
||||||
|
width: 100,
|
||||||
|
render: (_: any, rec: PacketRow) => {
|
||||||
|
const ifaces = parseIfaces(rec.iface);
|
||||||
|
if (ifaces.length === 0) return <Text type="secondary">-</Text>;
|
||||||
|
return (
|
||||||
|
<Space wrap size={[6, 6]}>
|
||||||
|
{ifaces.map((name) => {
|
||||||
|
const { background, color } = colorForName(name);
|
||||||
|
// tag style: use background and computed text color for accessibility
|
||||||
|
return (
|
||||||
|
<Tooltip key={name} title={name}>
|
||||||
|
<Tag style={{ background, color, fontWeight: 600, borderRadius: 6, fontSize: 15 }}>{name}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'MAC (src → dst)',
|
||||||
|
key: 'macs',
|
||||||
|
width: 100,
|
||||||
|
render: (_: any, rec: PacketRow) => (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Text>{rec.src_mac ?? '—'}</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<Text>{rec.dst_mac ?? '—'}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Proto',
|
||||||
|
dataIndex: 'ip_proto',
|
||||||
|
key: 'ip_proto',
|
||||||
|
width: 100,
|
||||||
|
render: (v: any) => <Text>{v ?? '-'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Src IP:Port',
|
||||||
|
key: 'src',
|
||||||
|
width: 180,
|
||||||
|
render: (_: any, rec: PacketRow) =>
|
||||||
|
rec.src_ip ? (
|
||||||
|
<Text copyable={{ text: `${rec.src_ip}${rec.src_port ? `:${rec.src_port}` : ''}` }}>
|
||||||
|
{rec.src_ip}
|
||||||
|
{rec.src_port ? `:${rec.src_port}` : ''}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">-</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dst IP:Port',
|
||||||
|
key: 'dst',
|
||||||
|
width: 180,
|
||||||
|
render: (_: any, rec: PacketRow) =>
|
||||||
|
rec.dst_ip ? (
|
||||||
|
<Text copyable={{ text: `${rec.dst_ip}${rec.dst_port ? `:${rec.dst_port}` : ''}` }}>
|
||||||
|
{rec.dst_ip}
|
||||||
|
{rec.dst_port ? `:${rec.dst_port}` : ''}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">-</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Len',
|
||||||
|
dataIndex: 'length',
|
||||||
|
key: 'length',
|
||||||
|
width: 80,
|
||||||
|
render: (n: any) => (typeof n === 'number' ? n.toLocaleString('de-DE') : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
key: 'actions',
|
||||||
|
width: 180,
|
||||||
|
render: (_: any, rec: PacketRow) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => openRawModal(rec)}>
|
||||||
|
View raw
|
||||||
|
</Button>
|
||||||
|
{rec.raw_b64 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const blob = base64ToBlob(rec.raw_b64!);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `packet_${rec.id ?? 'pkt'}.bin`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||||
|
<Col>
|
||||||
|
<Text strong style={{ fontSize: 18 }}>
|
||||||
|
Packets
|
||||||
|
</Text>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">Live packet viewer — history + live stream</Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Text>History</Text>
|
||||||
|
<Select value={limit} onChange={(v) => setLimit(Number(v))} style={{ width: 120 }}>
|
||||||
|
<Option value={50}>50</Option>
|
||||||
|
<Option value={100}>100</Option>
|
||||||
|
<Option value={200}>200</Option>
|
||||||
|
<Option value={500}>500</Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Text>Live</Text>
|
||||||
|
<Switch checked={!paused} onChange={(checked) => handlePauseToggle(!checked ? true : false)} />
|
||||||
|
|
||||||
|
<Button icon={<SyncOutlined />} onClick={handleRefresh} loading={statusLoading} />
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : packets.length === 0 ? (
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
<Text type="secondary">No packets captured yet.</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey={(rec: PacketRow) =>
|
||||||
|
String(rec.id ?? `${rec.iface ?? 'if'}:${rec.timestamp ?? ''}:${Math.random()}`)
|
||||||
|
}
|
||||||
|
dataSource={packets}
|
||||||
|
columns={columns as any}
|
||||||
|
pagination={{ pageSize: 25 }}
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`Raw packet ${selectedPacket?.id ?? ''}`}
|
||||||
|
open={hexModalOpen}
|
||||||
|
onCancel={() => setHexModalOpen(false)}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setHexModalOpen(false)}>Close</Button>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={downloadRaw} type="primary">
|
||||||
|
Download raw
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
width={900}
|
||||||
|
>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{rawHex}</pre>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
frontend/src/components/SnifferManager.tsx
Normal file
276
frontend/src/components/SnifferManager.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
// src/components/Sniffing.tsx
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Card, Form, List, message, Modal, Radio, Select, Space, Spin, Tag, Tooltip, Typography } from 'antd';
|
||||||
|
import { ReactElement, useMemo, useState } from 'react';
|
||||||
|
import { startSniffer, stopSniffer, stopSnifferByInterface } from '../api/apiClient';
|
||||||
|
import { BridgeInfo, InterfaceInfo } from '../types/network';
|
||||||
|
import { InterfaceSnifferStatus } from '../types/sniffer';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface SnifferManagerProps {
|
||||||
|
interfaces: InterfaceInfo[];
|
||||||
|
bridges: BridgeInfo[];
|
||||||
|
statusMap: Record<string, InterfaceSnifferStatus>;
|
||||||
|
refreshAll: () => Promise<void>;
|
||||||
|
refreshStatus: () => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
statusLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SnifferManager(props: SnifferManagerProps): ReactElement {
|
||||||
|
// local UI state
|
||||||
|
|
||||||
|
// modal / form
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// derived entries
|
||||||
|
const statusEntries = useMemo(
|
||||||
|
() => Object.entries(props.statusMap) as [string, InterfaceSnifferStatus][],
|
||||||
|
[props.statusMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
// open/close modal
|
||||||
|
const onOpenStartModal = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setStartMode('interface');
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
const onCloseModal = () => setIsModalOpen(false);
|
||||||
|
|
||||||
|
// start submit
|
||||||
|
const handleStartSubmit = async (values: any) => {
|
||||||
|
const { target } = values;
|
||||||
|
if (!target) {
|
||||||
|
message.warning('Please select a target to start sniffing on.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = startMode === 'interface' ? { interface: target } : { bridge: target };
|
||||||
|
const res = await startSniffer(payload);
|
||||||
|
message.success(`Sniffer started on ${target} (session ${res.session_id})`);
|
||||||
|
await props.refreshAll();
|
||||||
|
setIsModalOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('startSniffer error', err);
|
||||||
|
message.error(err?.message ?? 'Failed to start sniffer');
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// stop all
|
||||||
|
const handleStopAll = async () => {
|
||||||
|
try {
|
||||||
|
await stopSniffer();
|
||||||
|
message.success('All sniffers stopped');
|
||||||
|
await props.refreshStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('stopSniffer error', err);
|
||||||
|
message.error(err?.message ?? 'Failed to stop sniffers');
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// stop per-interface
|
||||||
|
const handleStopFromList = async (ifaceName: string, session_id?: string | null) => {
|
||||||
|
try {
|
||||||
|
// prefer stop by interface
|
||||||
|
await stopSnifferByInterface(ifaceName);
|
||||||
|
message.success(`Stopped sniffing on ${ifaceName}`);
|
||||||
|
await props.refreshStatus();
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('stopSnifferByInterface error', err);
|
||||||
|
// try stop by session as fallback
|
||||||
|
if (session_id) {
|
||||||
|
try {
|
||||||
|
await stopSniffer({ session_id });
|
||||||
|
message.success(`Stopped session ${session_id}`);
|
||||||
|
await props.refreshStatus();
|
||||||
|
return;
|
||||||
|
} catch (err2: any) {
|
||||||
|
console.error('stopSniffer by session fallback failed', err2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.error(err?.message ?? 'Failed to stop sniffer');
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sniffing-manager">
|
||||||
|
<Card
|
||||||
|
title="Sniffer sessions"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Tooltip title="Start a new sniffer session">
|
||||||
|
<Button icon={<PlusOutlined />} onClick={onOpenStartModal} />
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Refresh status">
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => props.refreshStatus()} />
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Stop all sniffers">
|
||||||
|
<Button danger icon={<StopOutlined />} onClick={handleStopAll} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.statusLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : statusEntries.length === 0 ? (
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
<Text type="secondary">No status information available.</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={statusEntries}
|
||||||
|
renderItem={([name, st]: [string, InterfaceSnifferStatus]) => {
|
||||||
|
const running = st.running;
|
||||||
|
const exists = st.exists;
|
||||||
|
const up = st.up;
|
||||||
|
const session_id = st.session_id ?? null;
|
||||||
|
const session_label = st.session_label ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
running ? (
|
||||||
|
<Button
|
||||||
|
key="stop"
|
||||||
|
size="small"
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => handleStopFromList(name, session_id)}
|
||||||
|
disabled={props.loading}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key="start"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={async () => {
|
||||||
|
// start on this interface
|
||||||
|
try {
|
||||||
|
const res = await startSniffer({ interface: name });
|
||||||
|
message.success(`Started sniffer on ${name} (session ${res.session_id})`);
|
||||||
|
await props.refreshStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('startSniffer quick', err);
|
||||||
|
message.error(err?.message ?? 'Failed to start sniffer');
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Text strong>{name}</Text>
|
||||||
|
{running ? (
|
||||||
|
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||||
|
running
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag icon={<ExclamationCircleOutlined />} color="default">
|
||||||
|
stopped
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!exists && <Tag color="error">missing</Tag>}
|
||||||
|
{exists && !up && <Tag color="warning">down</Tag>}
|
||||||
|
{exists && up && <Tag color="processing">up</Tag>}
|
||||||
|
|
||||||
|
{session_id && (
|
||||||
|
<Tag>
|
||||||
|
{session_label ? `${session_label}` : 'session'}:{' '}
|
||||||
|
<Text code copyable={{ text: session_id }}>
|
||||||
|
{session_id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={<Text type="secondary">interface: {name}</Text>}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Start sniffer modal */}
|
||||||
|
<Modal
|
||||||
|
title="Start sniffer session"
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={onCloseModal}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={props.loading}
|
||||||
|
okText="Start"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleStartSubmit} initialValues={{ target: undefined }}>
|
||||||
|
<Form.Item label="Mode" name="mode">
|
||||||
|
<Radio.Group
|
||||||
|
value={startMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStartMode(e.target.value);
|
||||||
|
form.setFieldsValue({ target: undefined });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="interface">Interface</Radio>
|
||||||
|
<Radio value="bridge">Bridge</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={startMode === 'interface' ? 'Interface' : 'Bridge'}
|
||||||
|
name="target"
|
||||||
|
rules={[{ required: true, message: 'Please select a target' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder={startMode === 'interface' ? 'Select interface' : 'Select bridge'}
|
||||||
|
optionFilterProp="children"
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{startMode === 'interface'
|
||||||
|
? props.interfaces.map((i) => (
|
||||||
|
<Option key={`if:${i.name}`} value={i.name}>
|
||||||
|
{i.name}
|
||||||
|
</Option>
|
||||||
|
))
|
||||||
|
: props.bridges.map((b) => (
|
||||||
|
<Option key={`br:${b.ifname}`} value={b.ifname}>
|
||||||
|
{b.ifname} ({b.members.map((m) => m.ifname).join(', ')})
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,9 @@
|
|||||||
// src/components/Sniffing.tsx
|
// src/components/Sniffing.tsx
|
||||||
import {
|
import { Col, message, Row, Select, Typography } from 'antd';
|
||||||
CheckCircleOutlined,
|
import { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||||
ExclamationCircleOutlined,
|
import { fetchBridges, fetchInterfaces, fetchSnifferStatus } from '../api/apiClient';
|
||||||
PlayCircleOutlined,
|
import PacketViewer from '../components/PacketViewer';
|
||||||
PlusOutlined,
|
import SnifferManager from '../components/SnifferManager';
|
||||||
ReloadOutlined,
|
|
||||||
StopOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
Form,
|
|
||||||
List,
|
|
||||||
message,
|
|
||||||
Modal,
|
|
||||||
Radio,
|
|
||||||
Row,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
fetchBridges,
|
|
||||||
fetchInterfaces,
|
|
||||||
fetchSnifferStatus,
|
|
||||||
startSniffer,
|
|
||||||
stopSniffer,
|
|
||||||
stopSnifferByInterface,
|
|
||||||
} from '../api/apiClient';
|
|
||||||
import { BridgeInfo, InterfaceInfo } from '../types/network';
|
import { BridgeInfo, InterfaceInfo } from '../types/network';
|
||||||
import { InterfaceSnifferStatus } from '../types/sniffer';
|
import { InterfaceSnifferStatus } from '../types/sniffer';
|
||||||
|
|
||||||
@@ -40,18 +11,13 @@ const { Title, Text } = Typography;
|
|||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
export default function Sniffing(): ReactElement {
|
export default function Sniffing(): ReactElement {
|
||||||
// local UI state
|
|
||||||
const [interfaces, setInterfaces] = useState<InterfaceInfo[]>([]);
|
const [interfaces, setInterfaces] = useState<InterfaceInfo[]>([]);
|
||||||
const [bridges, setBridges] = useState<BridgeInfo[]>([]);
|
const [bridges, setBridges] = useState<BridgeInfo[]>([]);
|
||||||
const [statusMap, setStatusMap] = useState<Record<string, InterfaceSnifferStatus>>({});
|
const [statusMap, setStatusMap] = useState<Record<string, InterfaceSnifferStatus>>({});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [statusLoading, setStatusLoading] = useState(false);
|
const [statusLoading, setStatusLoading] = useState(false);
|
||||||
|
|
||||||
// modal / form
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
|
|
||||||
// initial load
|
// initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshAll().catch(() => {});
|
refreshAll().catch(() => {});
|
||||||
@@ -84,91 +50,8 @@ export default function Sniffing(): ReactElement {
|
|||||||
setStatusLoading(false);
|
setStatusLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// derived entries
|
|
||||||
const statusEntries = useMemo(() => Object.entries(statusMap) as [string, InterfaceSnifferStatus][], [statusMap]);
|
|
||||||
|
|
||||||
// open/close modal
|
|
||||||
const onOpenStartModal = () => {
|
|
||||||
form.resetFields();
|
|
||||||
setStartMode('interface');
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
const onCloseModal = () => setIsModalOpen(false);
|
|
||||||
|
|
||||||
// start submit
|
|
||||||
const handleStartSubmit = async (values: any) => {
|
|
||||||
const { target } = values;
|
|
||||||
if (!target) {
|
|
||||||
message.warning('Please select a target to start sniffing on.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const payload = startMode === 'interface' ? { interface: target } : { bridge: target };
|
|
||||||
const res = await startSniffer(payload);
|
|
||||||
message.success(`Sniffer started on ${target} (session ${res.session_id})`);
|
|
||||||
await refreshStatus();
|
|
||||||
// if interface/bridge lists may have changed, refresh them too
|
|
||||||
try {
|
|
||||||
const [ifs, brs] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
|
|
||||||
if (ifs.status === 'fulfilled') setInterfaces(ifs.value);
|
|
||||||
if (brs.status === 'fulfilled') setBridges(brs.value);
|
|
||||||
} catch {}
|
|
||||||
setIsModalOpen(false);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('startSniffer error', err);
|
|
||||||
message.error(err?.message ?? 'Failed to start sniffer');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// stop all
|
|
||||||
const handleStopAll = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await stopSniffer();
|
|
||||||
message.success('All sniffers stopped');
|
|
||||||
await refreshStatus();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('stopSniffer error', err);
|
|
||||||
message.error(err?.message ?? 'Failed to stop sniffers');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// stop per-interface
|
|
||||||
const handleStopFromList = async (ifaceName: string, session_id?: string | null) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// prefer stop by interface
|
|
||||||
await stopSnifferByInterface(ifaceName);
|
|
||||||
message.success(`Stopped sniffing on ${ifaceName}`);
|
|
||||||
await refreshStatus();
|
|
||||||
return;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('stopSnifferByInterface error', err);
|
|
||||||
// try stop by session as fallback
|
|
||||||
if (session_id) {
|
|
||||||
try {
|
|
||||||
await stopSniffer({ session_id });
|
|
||||||
message.success(`Stopped session ${session_id}`);
|
|
||||||
await refreshStatus();
|
|
||||||
return;
|
|
||||||
} catch (err2: any) {
|
|
||||||
console.error('stopSniffer by session fallback failed', err2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.error(err?.message ?? 'Failed to stop sniffer');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 16 }}>
|
<div className="sniffing-page" style={{ padding: 16 }}>
|
||||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Title level={2} style={{ margin: 0 }}>
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
@@ -177,172 +60,24 @@ export default function Sniffing(): ReactElement {
|
|||||||
<Text type="secondary">Start, stop and view AF_PACKET sniffer sessions</Text>
|
<Text type="secondary">Start, stop and view AF_PACKET sniffer sessions</Text>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row>
|
||||||
<Card
|
<Col span={24}>
|
||||||
title="Sniffer sessions"
|
<SnifferManager
|
||||||
style={{ marginBottom: 16 }}
|
interfaces={interfaces}
|
||||||
extra={
|
bridges={bridges}
|
||||||
<Space>
|
statusMap={statusMap}
|
||||||
<Tooltip title="Start a new sniffer session">
|
refreshAll={refreshAll}
|
||||||
<Button icon={<PlusOutlined />} onClick={onOpenStartModal} />
|
refreshStatus={refreshStatus}
|
||||||
</Tooltip>
|
loading={loading}
|
||||||
|
statusLoading={statusLoading}
|
||||||
<Tooltip title="Refresh status">
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => refreshStatus()} />
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="Stop all sniffers">
|
|
||||||
<Button danger icon={<StopOutlined />} onClick={handleStopAll} />
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{statusLoading ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
) : statusEntries.length === 0 ? (
|
|
||||||
<div style={{ padding: 12 }}>
|
|
||||||
<Text type="secondary">No status information available.</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<List
|
|
||||||
dataSource={statusEntries}
|
|
||||||
renderItem={([name, st]: [string, InterfaceSnifferStatus]) => {
|
|
||||||
const running = st.running;
|
|
||||||
const exists = st.exists;
|
|
||||||
const up = st.up;
|
|
||||||
const session_id = st.session_id ?? null;
|
|
||||||
const session_label = st.session_label ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List.Item
|
|
||||||
actions={[
|
|
||||||
running ? (
|
|
||||||
<Button
|
|
||||||
key="stop"
|
|
||||||
size="small"
|
|
||||||
icon={<StopOutlined />}
|
|
||||||
onClick={() => handleStopFromList(name, session_id)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
key="start"
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
icon={<PlayCircleOutlined />}
|
|
||||||
onClick={async () => {
|
|
||||||
// start on this interface
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await startSniffer({ interface: name });
|
|
||||||
message.success(`Started sniffer on ${name} (session ${res.session_id})`);
|
|
||||||
await refreshStatus();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('startSniffer quick', err);
|
|
||||||
message.error(err?.message ?? 'Failed to start sniffer');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<Text strong>{name}</Text>
|
|
||||||
{running ? (
|
|
||||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
|
||||||
running
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag icon={<ExclamationCircleOutlined />} color="default">
|
|
||||||
stopped
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!exists && <Tag color="error">missing</Tag>}
|
|
||||||
{exists && !up && <Tag color="warning">down</Tag>}
|
|
||||||
{exists && up && <Tag color="processing">up</Tag>}
|
|
||||||
|
|
||||||
{session_id && (
|
|
||||||
<Tag>
|
|
||||||
{session_label ? `${session_label}` : 'session'}:{' '}
|
|
||||||
<Text code copyable={{ text: session_id }}>
|
|
||||||
{session_id.slice(0, 8)}
|
|
||||||
</Text>
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
description={<Text type="secondary">interface: {name}</Text>}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</Col>
|
||||||
);
|
</Row>
|
||||||
}}
|
<Row>
|
||||||
/>
|
<Col span={24} style={{ marginTop: 24 }}>
|
||||||
)}
|
<PacketViewer interfaces={interfaces.map((i) => i.name)} />
|
||||||
</Card>
|
</Col>
|
||||||
|
</Row>
|
||||||
{/* Start sniffer modal */}
|
|
||||||
<Modal
|
|
||||||
title="Start sniffer session"
|
|
||||||
open={isModalOpen}
|
|
||||||
onCancel={onCloseModal}
|
|
||||||
onOk={() => form.submit()}
|
|
||||||
confirmLoading={loading}
|
|
||||||
okText="Start"
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical" onFinish={handleStartSubmit} initialValues={{ target: undefined }}>
|
|
||||||
<Form.Item label="Mode" name="mode">
|
|
||||||
<Radio.Group
|
|
||||||
value={startMode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStartMode(e.target.value);
|
|
||||||
form.setFieldsValue({ target: undefined });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Radio value="interface">Interface</Radio>
|
|
||||||
<Radio value="bridge">Bridge</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label={startMode === 'interface' ? 'Interface' : 'Bridge'}
|
|
||||||
name="target"
|
|
||||||
rules={[{ required: true, message: 'Please select a target' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
placeholder={startMode === 'interface' ? 'Select interface' : 'Select bridge'}
|
|
||||||
optionFilterProp="children"
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{startMode === 'interface'
|
|
||||||
? interfaces.map((i) => (
|
|
||||||
<Option key={`if:${i.name}`} value={i.name}>
|
|
||||||
{i.name}
|
|
||||||
</Option>
|
|
||||||
))
|
|
||||||
: bridges.map((b) => (
|
|
||||||
<Option key={`br:${b.ifname}`} value={b.ifname}>
|
|
||||||
{b.ifname} ({b.members.map((m) => m.ifname).join(', ')})
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user