test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m38s

This commit is contained in:
2026-03-01 17:29:39 +01:00
parent f8647ab3ed
commit 6689f01974
4 changed files with 783 additions and 290 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -1,38 +1,9 @@
// src/components/Sniffing.tsx
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
PlayCircleOutlined,
PlusOutlined,
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 { Col, message, Row, Select, Typography } from 'antd';
import { ReactElement, useCallback, useEffect, useState } from 'react';
import { fetchBridges, fetchInterfaces, fetchSnifferStatus } from '../api/apiClient';
import PacketViewer from '../components/PacketViewer';
import SnifferManager from '../components/SnifferManager';
import { BridgeInfo, InterfaceInfo } from '../types/network';
import { InterfaceSnifferStatus } from '../types/sniffer';
@@ -40,18 +11,13 @@ const { Title, Text } = Typography;
const { Option } = Select;
export default function Sniffing(): ReactElement {
// local UI state
const [interfaces, setInterfaces] = useState<InterfaceInfo[]>([]);
const [bridges, setBridges] = useState<BridgeInfo[]>([]);
const [statusMap, setStatusMap] = useState<Record<string, InterfaceSnifferStatus>>({});
const [loading, setLoading] = 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
useEffect(() => {
refreshAll().catch(() => {});
@@ -84,91 +50,8 @@ export default function Sniffing(): ReactElement {
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 (
<div style={{ padding: 16 }}>
<div className="sniffing-page" style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<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>
</Col>
</Row>
<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={() => 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>
);
}}
<Row>
<Col span={24}>
<SnifferManager
interfaces={interfaces}
bridges={bridges}
statusMap={statusMap}
refreshAll={refreshAll}
refreshStatus={refreshStatus}
loading={loading}
statusLoading={statusLoading}
/>
)}
</Card>
{/* 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>
</Col>
</Row>
<Row>
<Col span={24} style={{ marginTop: 24 }}>
<PacketViewer interfaces={interfaces.map((i) => i.name)} />
</Col>
</Row>
</div>
);
}