frontedn updates and backend test without unknown in packet parser
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-03-01 15:58:58 +01:00
parent 3fc5b6db48
commit f8647ab3ed
5 changed files with 364 additions and 365 deletions

View File

@@ -129,10 +129,10 @@ def parse_packet(pkt, bridge_label: str) -> None:
"src_mac": None, "src_mac": None,
"dst_mac": None, "dst_mac": None,
"eth_type_raw": None, "eth_type_raw": None,
"eth_type": EtherTypeEnum.UNKNOWN, "eth_type": None,
"vlan_id": None, "vlan_id": None,
"protocol_raw": None, "protocol_raw": None,
"protocol": IPProtocolEnum.UNKNOWN, "protocol": None,
"protocol_name": None, "protocol_name": None,
"src_ip": None, "src_ip": None,
"dst_ip": None, "dst_ip": None,
@@ -197,7 +197,7 @@ def parse_packet(pkt, bridge_label: str) -> None:
try: try:
pkt_info["protocol"] = protocol_from_number(proto_num) pkt_info["protocol"] = protocol_from_number(proto_num)
except Exception: except Exception:
pkt_info["protocol"] = IPProtocolEnum.UNKNOWN pkt_info["protocol"] = proto_num
if proto_num == 6 and TCP in pkt: if proto_num == 6 and TCP in pkt:
pkt_info["protocol_name"] = "TCP" pkt_info["protocol_name"] = "TCP"
@@ -229,7 +229,7 @@ def parse_packet(pkt, bridge_label: str) -> None:
try: try:
pkt_info["protocol"] = protocol_from_number(nh) pkt_info["protocol"] = protocol_from_number(nh)
except Exception: except Exception:
pkt_info["protocol"] = IPProtocolEnum.UNKNOWN pkt_info["protocol"] = nh
if nh == 6 and TCP in pkt: if nh == 6 and TCP in pkt:
pkt_info["protocol_name"] = "TCP" pkt_info["protocol_name"] = "TCP"

View File

@@ -1,5 +1,5 @@
import { Footer } from 'antd/es/layout/layout'; import { Footer } from 'antd/es/layout/layout';
export const AppFooter: React.FC = () => { export const AppFooter: React.FC = () => {
return <Footer>MitM Webserver App by Marcus Almert ©2025</Footer>; return <Footer>MitM Webserver App by Marcus Almert ©2025-2026</Footer>;
}; };

View File

@@ -24,6 +24,7 @@ import {
SnifferStartRequest, SnifferStartRequest,
SnifferStartResponse, SnifferStartResponse,
SnifferStatusResponse, SnifferStatusResponse,
SnifferStopRequest,
SnifferStopResponse, SnifferStopResponse,
} from '../types/sniffer'; } from '../types/sniffer';
@@ -104,21 +105,50 @@ export const removeBridge = async (req: BridgeRemoveRequest) => {
Sniffer Sniffer
------------------------- */ ------------------------- */
/**
* Start a sniffer session. Provide exactly one of { bridge, interface }.
* Returns a session_id that you can use to stop the session later.
*/
export const startSniffer = async (payload: SnifferStartRequest): Promise<SnifferStartResponse> => { export const startSniffer = async (payload: SnifferStartRequest): Promise<SnifferStartResponse> => {
const res = await api.post<SnifferStartResponse>('/sniffer/start', payload); const res = await api.post<SnifferStartResponse>('/sniffer/start', payload);
return res.data; return res.data;
}; };
export const stopSniffer = async (): Promise<SnifferStopResponse> => { /**
const res = await api.post<SnifferStopResponse>('/sniffer/stop'); * Stop sniffer(s).
* - If you pass a body with { session_id }, it will stop that specific session.
* - If you call without body and without query params, it will stop all sessions.
*/
export const stopSniffer = async (body?: SnifferStopRequest): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>('/sniffer/stop', body ?? {});
return res.data; return res.data;
}; };
/**
* Stop sniffing for a specific interface across sessions.
* Calls: POST /sniffer/stop?interface=eth0 (empty body)
*/
export const stopSnifferByInterface = async (iface: string): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?interface=${encodeURIComponent(iface)}`, {});
return res.data;
};
/**
* Stop sniffing for a specific bridge across sessions.
* Calls: POST /sniffer/stop?bridge=br0 (empty body)
*/
export const stopSnifferByBridge = async (bridge: string): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?bridge=${encodeURIComponent(bridge)}`, {});
return res.data;
};
/**
* Fetch the sniffer status (per-interface).
*/
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => { export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
const res = await api.get<SnifferStatusResponse>('/sniffer/status'); const res = await api.get<SnifferStatusResponse>('/sniffer/status');
return res.data; return res.data;
}; };
/* ------------------------- /* -------------------------
Packets Packets
------------------------- */ ------------------------- */

View File

@@ -3,261 +3,204 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined,
ReloadOutlined, ReloadOutlined,
StopOutlined, StopOutlined,
SyncOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Button, Card, Col, Divider, List, message, Row, Select, Space, Spin, Tag, Tooltip, Typography } from 'antd'; import {
import { ReactElement, useCallback, useEffect, useState } from 'react'; Button,
import { fetchSnifferStatus as fetchSnifferStatusApi, startSniffer, stopSniffer } from '../api/apiClient'; Card,
import { useBackendAPI } from '../hooks/useBackendAPI'; 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 { InterfaceSnifferStatus } from '../types/sniffer'; import { InterfaceSnifferStatus } from '../types/sniffer';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
/** Local type for the interface objects returned by your backend/hook.
* Adjust fields if your backend has different shape.
*/
type NetworkInterface = {
ifindex: number;
name: string;
state?: string;
mac?: string;
mtu?: number;
flags?: string[];
addresses?: string[];
};
export default function Sniffing(): ReactElement { export default function Sniffing(): ReactElement {
const {
interfacesQuery,
bridgesQuery,
fetchInterfaces,
fetchBridges,
refreshInterfaces,
refreshBridges,
snifferStatusQuery,
} = useBackendAPI();
// local UI state // local UI state
const [selectedInterface, setSelectedInterface] = useState<string | undefined>(undefined); const [interfaces, setInterfaces] = useState<InterfaceInfo[]>([]);
const [busy, setBusy] = useState(false); const [bridges, setBridges] = useState<BridgeInfo[]>([]);
const [statusMap, setStatusMap] = useState<Record<string, InterfaceSnifferStatus>>({});
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(false);
// ensure data is fetched on mount // modal / form
const [isModalOpen, setIsModalOpen] = useState(false);
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
const [form] = Form.useForm();
// initial load
useEffect(() => { useEffect(() => {
fetchInterfaces().catch(() => {}); refreshAll().catch(() => {});
fetchSnifferStatusApi().catch(() => {}); }, []);
if (fetchBridges) fetchBridges().catch(() => {});
}, [fetchInterfaces, fetchBridges]);
// Properly type interfaces as array of objects (not string[])
const interfaces = (interfacesQuery?.data ?? []) as NetworkInterface[];
const bridges = (bridgesQuery?.data ?? []) as Array<{ name: string }>;
const statusMap = (snifferStatusQuery?.data?.interfaces ?? {}) as Record<string, InterfaceSnifferStatus>;
// Explicitly type entries so renderItem knows the types
const statusEntries = Object.entries(statusMap) as [string, InterfaceSnifferStatus][];
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
setLoading(true);
try { try {
if (refreshInterfaces) await refreshInterfaces(); const [ifs, brs] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
if (snifferStatusQuery?.refetch) await snifferStatusQuery.refetch(); if (ifs.status === 'fulfilled') setInterfaces(ifs.value);
if (refreshBridges) await refreshBridges(); if (brs.status === 'fulfilled') setBridges(brs.value);
} catch {
// ignore — queries will surface errors
}
}, [refreshInterfaces, snifferStatusQuery, refreshBridges]);
// actions await refreshStatus();
const handleStart = async () => { } catch (err) {
if (!selectedInterface) { // ignore; errors handled in individual calls
message.warning('Please select an interface to start sniffing on.'); } finally {
setLoading(false);
}
}, []);
const refreshStatus = useCallback(async () => {
setStatusLoading(true);
try {
const st = await fetchSnifferStatus();
setStatusMap(st.interfaces ?? {});
} catch (err: any) {
console.error('fetchSnifferStatus error', err);
message.error(err?.message ?? 'Failed to fetch sniffer status');
} finally {
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; return;
} }
setBusy(true); setLoading(true);
try { try {
await startSniffer({ bridge: selectedInterface }); const payload = startMode === 'interface' ? { interface: target } : { bridge: target };
message.success(`Sniffer started on ${selectedInterface}`); const res = await startSniffer(payload);
await refreshAll(); 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) { } catch (err: any) {
console.error('startSniffer error', err); console.error('startSniffer error', err);
message.error(err?.message ?? 'Failed to start sniffer'); message.error(err?.message ?? 'Failed to start sniffer');
} finally { } finally {
setBusy(false); setLoading(false);
} }
}; };
const handleStop = async () => { // stop all
setBusy(true); const handleStopAll = async () => {
setLoading(true);
try { try {
await stopSniffer(); await stopSniffer();
message.success('Sniffer stopped'); message.success('All sniffers stopped');
await refreshAll(); await refreshStatus();
} catch (err: any) { } catch (err: any) {
console.error('stopSniffer error', err); console.error('stopSniffer error', err);
message.error(err?.message ?? 'Failed to stop sniffer'); message.error(err?.message ?? 'Failed to stop sniffers');
} finally { } finally {
setBusy(false); setLoading(false);
} }
}; };
// Quick action per-interface (start/stop) // stop per-interface
const handleStartOn = async (ifaceName: string) => { const handleStopFromList = async (ifaceName: string, session_id?: string | null) => {
setBusy(true); setLoading(true);
try { try {
await startSniffer({ bridge: ifaceName }); // prefer stop by interface
message.success(`Started sniffer on ${ifaceName}`); await stopSnifferByInterface(ifaceName);
await refreshAll(); message.success(`Stopped sniffing on ${ifaceName}`);
await refreshStatus();
return;
} catch (err: any) { } catch (err: any) {
console.error('handleStartOn error', err); console.error('stopSnifferByInterface error', err);
message.error(err?.message ?? `Failed to start on ${ifaceName}`); // try stop by session as fallback
} finally { if (session_id) {
setBusy(false); 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);
}
} }
};
const handleStopFromList = async (ifaceName?: string) => {
setBusy(true);
try {
await stopSniffer();
message.success(`Stopped sniffer${ifaceName ? ` (requested from ${ifaceName})` : ''}`);
await refreshAll();
} catch (err: any) {
console.error('handleStopFromList error', err);
message.error(err?.message ?? 'Failed to stop sniffer'); message.error(err?.message ?? 'Failed to stop sniffer');
} finally { } finally {
setBusy(false); setLoading(false);
} }
}; };
return ( return (
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
<Row gutter={[16, 16]}> <Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col span={16}>
<Card>
<Row justify="space-between" align="middle">
<Col> <Col>
<Title level={4} style={{ margin: 0 }}> <Title level={2} style={{ margin: 0 }}>
Sniffing Sniffing
</Title> </Title>
<Text type="secondary">Start, stop and view AF_PACKET sniffer status</Text> <Text type="secondary">Start, stop and view AF_PACKET sniffer sessions</Text>
</Col> </Col>
</Row>
<Col> <Card
title="Sniffer sessions"
style={{ marginBottom: 16 }}
extra={
<Space> <Space>
<Tooltip title="Refresh interfaces & status"> <Tooltip title="Start a new sniffer session">
<Button icon={<ReloadOutlined />} onClick={() => refreshAll()} disabled={busy} /> <Button icon={<PlusOutlined />} onClick={onOpenStartModal} />
</Tooltip> </Tooltip>
<Tooltip title="Refresh sniffer status"> <Tooltip title="Refresh status">
<Button <Button icon={<ReloadOutlined />} onClick={() => refreshStatus()} />
icon={<SyncOutlined spin={snifferStatusQuery?.isFetching} />} </Tooltip>
onClick={() => snifferStatusQuery?.refetch?.()}
disabled={busy} <Tooltip title="Stop all sniffers">
/> <Button danger icon={<StopOutlined />} onClick={handleStopAll} />
</Tooltip> </Tooltip>
</Space> </Space>
</Col>
</Row>
<Divider />
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle">
<Col>
<Text strong>Select interface / bridge</Text>
</Col>
<Col flex="1">
<Select
showSearch
placeholder="Choose interface or bridge"
style={{ width: '100%' }}
value={selectedInterface}
onChange={(val) => setSelectedInterface(val as string | undefined)}
loading={interfacesQuery?.isLoading}
allowClear
optionFilterProp="children"
filterOption={(input, option) =>
(option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
} }
> >
{/* interfaces from the system: use iface.name */} {statusLoading ? (
{interfaces.map((iface) => (
<Option key={`if:${iface.name}`} value={iface.name}>
{iface.name}
</Option>
))}
{/* optional bridges (if present) */}
{bridges && bridges.length > 0 && (
<>
<Option key="__divider__" value="__divider__" disabled>
────────── bridges ──────────
</Option>
{bridges.map((b) => (
<Option key={`br:${b.name}`} value={b.name}>
{b.name}
</Option>
))}
</>
)}
</Select>
</Col>
<Col>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleStart}
loading={busy && !snifferStatusQuery?.isFetching}
disabled={
!selectedInterface || busy || (selectedInterface && statusMap[selectedInterface]?.running)
}
>
Start
</Button>
<Button
danger
icon={<StopOutlined />}
onClick={handleStop}
loading={busy && !snifferStatusQuery?.isFetching}
disabled={busy}
>
Stop
</Button>
</Space>
</Col>
</Row>
<Row>
<Col span={24}>
<Text type="secondary">Tip: you can also start/stop directly from the list below.</Text>
</Col>
</Row>
</Space>
</Card>
<Divider />
<Card title="Sniffer status" bordered>
{snifferStatusQuery?.isLoading ? (
<div style={{ textAlign: 'center', padding: 24 }}> <div style={{ textAlign: 'center', padding: 24 }}>
<Spin /> <Spin />
</div> </div>
) : snifferStatusQuery?.isError ? (
<div style={{ padding: 12 }}>
<Space direction="vertical">
<Text type="danger">Failed to load sniffer status.</Text>
<Text type="secondary">
{(snifferStatusQuery?.error as any)?.message ?? String(snifferStatusQuery?.error)}
</Text>
</Space>
</div>
) : statusEntries.length === 0 ? ( ) : statusEntries.length === 0 ? (
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
<Text type="secondary">No status information available.</Text> <Text type="secondary">No status information available.</Text>
@@ -269,6 +212,9 @@ export default function Sniffing(): ReactElement {
const running = st.running; const running = st.running;
const exists = st.exists; const exists = st.exists;
const up = st.up; const up = st.up;
const session_id = st.session_id ?? null;
const session_label = st.session_label ?? null;
return ( return (
<List.Item <List.Item
actions={[ actions={[
@@ -277,8 +223,8 @@ export default function Sniffing(): ReactElement {
key="stop" key="stop"
size="small" size="small"
icon={<StopOutlined />} icon={<StopOutlined />}
onClick={() => handleStopFromList(name)} onClick={() => handleStopFromList(name, session_id)}
disabled={busy} disabled={loading}
> >
Stop Stop
</Button> </Button>
@@ -288,8 +234,21 @@ export default function Sniffing(): ReactElement {
size="small" size="small"
type="primary" type="primary"
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
onClick={() => handleStartOn(name)} onClick={async () => {
disabled={busy} // 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 Start
</Button> </Button>
@@ -313,13 +272,18 @@ export default function Sniffing(): ReactElement {
{!exists && <Tag color="error">missing</Tag>} {!exists && <Tag color="error">missing</Tag>}
{exists && !up && <Tag color="warning">down</Tag>} {exists && !up && <Tag color="warning">down</Tag>}
{exists && up && <Tag color="processing">up</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> </Space>
} }
description={ description={<Text type="secondary">interface: {name}</Text>}
<Space size="small">
<Text type="secondary">interface: {name}</Text>
</Space>
}
/> />
</List.Item> </List.Item>
); );
@@ -327,74 +291,58 @@ export default function Sniffing(): ReactElement {
/> />
)} )}
</Card> </Card>
</Col>
<Col span={8}> {/* Start sniffer modal */}
<Card title="Quick actions" size="small" bordered> <Modal
<Space direction="vertical" style={{ width: '100%' }}> title="Start sniffer session"
<Button icon={<ReloadOutlined />} onClick={() => refreshAll()} block disabled={busy}> open={isModalOpen}
Refresh all onCancel={onCloseModal}
</Button> onOk={() => form.submit()}
confirmLoading={loading}
<Button icon={<SyncOutlined />} onClick={() => snifferStatusQuery?.refetch?.()} block disabled={busy}> okText="Start"
Refresh status
</Button>
<Divider />
<Button
icon={<PlayCircleOutlined />}
onClick={() => {
// start on selected, fallback to first available interface
const target = selectedInterface ?? interfaces[0]?.name;
if (!target) {
message.warning('No interface selected / available');
return;
}
setSelectedInterface(target);
handleStartOn(target);
}}
block
disabled={busy || interfaces.length === 0}
> >
Start on selected / first <Form form={form} layout="vertical" onFinish={handleStartSubmit} initialValues={{ target: undefined }}>
</Button> <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>
<Button danger icon={<StopOutlined />} onClick={() => handleStop()} block disabled={busy}> <Form.Item
Stop sniffer label={startMode === 'interface' ? 'Interface' : 'Bridge'}
</Button> name="target"
</Space> rules={[{ required: true, message: 'Please select a target' }]}
</Card> >
<Select
<Card title="Available interfaces" size="small" style={{ marginTop: 12 }}> showSearch
{interfacesQuery?.isLoading ? ( placeholder={startMode === 'interface' ? 'Select interface' : 'Select bridge'}
<Spin /> optionFilterProp="children"
) : interfaces.length === 0 ? ( filterOption={(input, option) =>
<Text type="secondary">No interfaces found.</Text> (option?.children as unknown as string)?.toLowerCase().includes(input.toLowerCase())
) : ( }
<List >
size="small" {startMode === 'interface'
dataSource={interfaces} ? interfaces.map((i) => (
renderItem={(iface: NetworkInterface) => <List.Item>{iface.name}</List.Item>} <Option key={`if:${i.name}`} value={i.name}>
/> {i.name}
)} </Option>
</Card> ))
: bridges.map((b) => (
<Card title="Bridges" size="small" style={{ marginTop: 12 }}> <Option key={`br:${b.ifname}`} value={b.ifname}>
{bridgesQuery?.isLoading ? ( {b.ifname} ({b.members.map((m) => m.ifname).join(', ')})
<Spin /> </Option>
) : bridges.length === 0 ? ( ))}
<Text type="secondary">No bridges found.</Text> </Select>
) : ( </Form.Item>
<List </Form>
size="small" </Modal>
dataSource={bridges}
renderItem={(b: { name: string }) => <List.Item>{b.name}</List.Item>}
/>
)}
</Card>
</Col>
</Row>
</div> </div>
); );
} }

View File

@@ -1,20 +1,49 @@
/**
* Request to start a sniffer session.
* Exactly one of `bridge` or `interface` should be provided.
*/
export interface SnifferStartRequest { export interface SnifferStartRequest {
bridge: string; bridge?: string;
interface?: string;
} }
/**
* Response returned when starting a sniffer session.
*/
export interface SnifferStartResponse { export interface SnifferStartResponse {
started: boolean; started: boolean;
bridge: string; session_id: string;
target: string;
target_type: 'bridge' | 'interface';
} }
/**
* Optional body when stopping a sniffer session.
* Prefer providing session_id to stop a specific session.
*/
export interface SnifferStopRequest {
session_id?: string | null;
}
/**
* Response returned when stopping sniffer(s).
*/
export interface SnifferStopResponse { export interface SnifferStopResponse {
stopped: boolean; stopped: boolean;
session_id?: string | null;
target?: string | null;
target_type?: 'bridge' | 'interface' | null;
} }
/**
* Per-interface sniffer status (now includes owning session info).
*/
export interface InterfaceSnifferStatus { export interface InterfaceSnifferStatus {
running: boolean; running: boolean;
exists: boolean; exists: boolean;
up: boolean; up: boolean;
session_id?: string | null;
session_label?: string | null;
} }
/** /**
@@ -23,11 +52,3 @@ export interface InterfaceSnifferStatus {
export interface SnifferStatusResponse { export interface SnifferStatusResponse {
interfaces: Record<string, InterfaceSnifferStatus>; interfaces: Record<string, InterfaceSnifferStatus>;
} }
/**
* Standard API error shape we expect from FastAPI HTTPException(detail=...)
*/
export interface ApiError {
detail?: string | Record<string, any>;
// FastAPI sometimes returns {"detail": "message"} or other shapes
}