diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index 328ace8..1143d09 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -139,7 +139,7 @@ class DatabasePool: """, limit, ) - + logger.exception("Fetched %d packets from DB", len(rows)) out: List[Dict[str, Any]] = [] for r in rows: d = dict(r) diff --git a/frontend/src/components/PacketViewer.tsx b/frontend/src/components/PacketViewer.tsx new file mode 100644 index 0000000..0b272f7 --- /dev/null +++ b/frontend/src/components/PacketViewer.tsx @@ -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(); +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([]); + const [loading, setLoading] = useState(true); + const [statusLoading, setStatusLoading] = useState(false); + const [limit, setLimit] = useState(DEFAULT_LIMIT); + const [paused, setPaused] = useState(false); + const wsRef = useRef(null); + const [hexModalOpen, setHexModalOpen] = useState(false); + const [rawHex, setRawHex] = useState(null); + const [rawB64ForDownload, setRawB64ForDownload] = useState(null); + const [selectedPacket, setSelectedPacket] = useState(null); + const queuedDuringPause = useRef([]); + + // 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) => {formatTimestamp(val)}, + }, + { + title: 'Ifaces', + dataIndex: 'iface', + key: 'iface', + width: 100, + render: (_: any, rec: PacketRow) => { + const ifaces = parseIfaces(rec.iface); + if (ifaces.length === 0) return -; + return ( + + {ifaces.map((name) => { + const { background, color } = colorForName(name); + // tag style: use background and computed text color for accessibility + return ( + + {name} + + ); + })} + + ); + }, + }, + { + title: 'MAC (src → dst)', + key: 'macs', + width: 100, + render: (_: any, rec: PacketRow) => ( +
+
+ {rec.src_mac ?? '—'} +
+
+ {rec.dst_mac ?? '—'} +
+
+ ), + }, + { + title: 'Proto', + dataIndex: 'ip_proto', + key: 'ip_proto', + width: 100, + render: (v: any) => {v ?? '-'}, + }, + { + title: 'Src IP:Port', + key: 'src', + width: 180, + render: (_: any, rec: PacketRow) => + rec.src_ip ? ( + + {rec.src_ip} + {rec.src_port ? `:${rec.src_port}` : ''} + + ) : ( + - + ), + }, + { + title: 'Dst IP:Port', + key: 'dst', + width: 180, + render: (_: any, rec: PacketRow) => + rec.dst_ip ? ( + + {rec.dst_ip} + {rec.dst_port ? `:${rec.dst_port}` : ''} + + ) : ( + - + ), + }, + { + 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) => ( + + + {rec.raw_b64 && ( + + )} + + ), + }, + ], + [], + ); + + return ( +
+ + + + Packets + +
+ Live packet viewer — history + live stream +
+ + + + + History + + + Live + handlePauseToggle(!checked ? true : false)} /> + +