This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user