flow showing
This commit is contained in:
@@ -80,6 +80,7 @@ class PacketDBModel(BaseModel):
|
|||||||
length: Optional[int] = None
|
length: Optional[int] = None
|
||||||
raw_present: Optional[bool] = Field(None, description="Whether raw packet bytes were captured for this row.")
|
raw_present: Optional[bool] = Field(None, description="Whether raw packet bytes were captured for this row.")
|
||||||
capture_sources: Optional[list[str]] = Field(None, description="Capture sources that contributed to this row.")
|
capture_sources: Optional[list[str]] = Field(None, description="Capture sources that contributed to this row.")
|
||||||
|
flow_id: Optional[str] = Field(None, description="Derived flow identifier from tshark stream metadata, if available.")
|
||||||
raw_b64: Optional[str] = Field(None, description="Base64-encoded packet bytes.")
|
raw_b64: Optional[str] = Field(None, description="Base64-encoded packet bytes.")
|
||||||
app_protocol: Optional[str] = Field(None, description="Detected application protocol.")
|
app_protocol: Optional[str] = Field(None, description="Detected application protocol.")
|
||||||
app_master_protocol: Optional[str] = Field(None, description="Detected application master protocol.")
|
app_master_protocol: Optional[str] = Field(None, description="Detected application master protocol.")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def _db_text(value: Any) -> Any:
|
|||||||
def _serialize_row_for_broadcast(row: Dict[str, Any]) -> Dict[str, Any]:
|
def _serialize_row_for_broadcast(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
serialized = dict(row)
|
serialized = dict(row)
|
||||||
_normalize_json_fields(serialized)
|
_normalize_json_fields(serialized)
|
||||||
|
_attach_derived_fields(serialized)
|
||||||
|
|
||||||
raw_val = serialized.get("raw")
|
raw_val = serialized.get("raw")
|
||||||
if isinstance(raw_val, (bytes, bytearray)):
|
if isinstance(raw_val, (bytes, bytearray)):
|
||||||
@@ -54,6 +55,42 @@ def _normalize_json_fields(payload: Dict[str, Any]) -> None:
|
|||||||
payload[key] = parsed
|
payload[key] = parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_flow_id(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
dpi_metadata = payload.get("dpi_metadata")
|
||||||
|
if not isinstance(dpi_metadata, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
tcp_meta = dpi_metadata.get("tcp")
|
||||||
|
if isinstance(tcp_meta, dict):
|
||||||
|
stream = tcp_meta.get("stream")
|
||||||
|
if stream not in (None, "", []):
|
||||||
|
return f"tcp:{stream}"
|
||||||
|
|
||||||
|
udp_meta = dpi_metadata.get("udp")
|
||||||
|
if isinstance(udp_meta, dict):
|
||||||
|
stream = udp_meta.get("stream")
|
||||||
|
if stream not in (None, "", []):
|
||||||
|
return f"udp:{stream}"
|
||||||
|
|
||||||
|
tshark_meta = dpi_metadata.get("tshark")
|
||||||
|
if isinstance(tshark_meta, dict):
|
||||||
|
tcp_stream = tshark_meta.get("tcp_stream")
|
||||||
|
if tcp_stream not in (None, "", []):
|
||||||
|
return f"tcp:{tcp_stream}"
|
||||||
|
udp_stream = tshark_meta.get("udp_stream")
|
||||||
|
if udp_stream not in (None, "", []):
|
||||||
|
return f"udp:{udp_stream}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_derived_fields(payload: Dict[str, Any]) -> None:
|
||||||
|
if payload.get("flow_id") in (None, ""):
|
||||||
|
flow_id = _derive_flow_id(payload)
|
||||||
|
if flow_id is not None:
|
||||||
|
payload["flow_id"] = flow_id
|
||||||
|
|
||||||
|
|
||||||
class DatabasePool:
|
class DatabasePool:
|
||||||
"""Asyncpg connection pool wrapper used by the packet APIs."""
|
"""Asyncpg connection pool wrapper used by the packet APIs."""
|
||||||
|
|
||||||
@@ -509,7 +546,7 @@ class DatabasePool:
|
|||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM packets
|
FROM packets
|
||||||
ORDER BY updated_at DESC, id DESC
|
ORDER BY timestamp DESC, id DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
""",
|
""",
|
||||||
limit,
|
limit,
|
||||||
@@ -519,6 +556,7 @@ class DatabasePool:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
data = dict(row)
|
data = dict(row)
|
||||||
_normalize_json_fields(data)
|
_normalize_json_fields(data)
|
||||||
|
_attach_derived_fields(data)
|
||||||
|
|
||||||
raw_val = data.get("raw")
|
raw_val = data.get("raw")
|
||||||
if isinstance(raw_val, (bytes, bytearray)):
|
if isinstance(raw_val, (bytes, bytearray)):
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const { Option } = Select;
|
|||||||
const DEFAULT_LIMIT = 200;
|
const DEFAULT_LIMIT = 200;
|
||||||
const MAX_PACKETS = 2000; // in-memory cap
|
const MAX_PACKETS = 2000; // in-memory cap
|
||||||
|
|
||||||
|
type PacketTableRow = PacketRow & {
|
||||||
|
key: string;
|
||||||
|
__kind: 'packet' | 'flow';
|
||||||
|
flow_packet_count?: number;
|
||||||
|
children?: PacketTableRow[];
|
||||||
|
};
|
||||||
|
|
||||||
function base64ToHex(b64: string) {
|
function base64ToHex(b64: string) {
|
||||||
try {
|
try {
|
||||||
const bin = atob(b64);
|
const bin = atob(b64);
|
||||||
@@ -97,6 +104,39 @@ function packetKey(packet: PacketRow) {
|
|||||||
return String(packet.correlation_key ?? packet.packet_id ?? packet.packet_uid ?? packet.id ?? `${packet.ingress_if ?? 'if'}:${packet.timestamp ?? ''}`);
|
return String(packet.correlation_key ?? packet.packet_id ?? packet.packet_uid ?? packet.id ?? `${packet.ingress_if ?? 'if'}:${packet.timestamp ?? ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function packetTimestampMs(packet: PacketRow) {
|
||||||
|
const time = packet.timestamp ? new Date(packet.timestamp).getTime() : 0;
|
||||||
|
return Number.isNaN(time) ? 0 : time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPacketsByTimestampDesc(left: PacketRow, right: PacketRow) {
|
||||||
|
return packetTimestampMs(right) - packetTimestampMs(left);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPacketsByTimestampAsc(left: PacketRow, right: PacketRow) {
|
||||||
|
return packetTimestampMs(left) - packetTimestampMs(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDpiDict(packet: PacketRow, key: string): Record<string, unknown> | null {
|
||||||
|
const dpi = packet.dpi_metadata;
|
||||||
|
if (!dpi || typeof dpi !== 'object' || Array.isArray(dpi)) return null;
|
||||||
|
const value = dpi[key];
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlowId(packet: PacketRow) {
|
||||||
|
if (packet.flow_id) return String(packet.flow_id);
|
||||||
|
const tcp = getDpiDict(packet, 'tcp');
|
||||||
|
if (tcp?.stream != null) return `tcp:${String(tcp.stream)}`;
|
||||||
|
const udp = getDpiDict(packet, 'udp');
|
||||||
|
if (udp?.stream != null) return `udp:${String(udp.stream)}`;
|
||||||
|
const tshark = getDpiDict(packet, 'tshark');
|
||||||
|
if (tshark?.tcp_stream != null) return `tcp:${String(tshark.tcp_stream)}`;
|
||||||
|
if (tshark?.udp_stream != null) return `udp:${String(tshark.udp_stream)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function formatEthType(packet: PacketRow) {
|
function formatEthType(packet: PacketRow) {
|
||||||
if (packet.eth_type) return String(packet.eth_type);
|
if (packet.eth_type) return String(packet.eth_type);
|
||||||
if (typeof packet.eth_type_raw === 'number') return `0x${packet.eth_type_raw.toString(16)}`;
|
if (typeof packet.eth_type_raw === 'number') return `0x${packet.eth_type_raw.toString(16)}`;
|
||||||
@@ -109,6 +149,14 @@ function formatIpProto(packet: PacketRow) {
|
|||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProtocolLabel(packet: PacketRow) {
|
||||||
|
const base = formatIpProto(packet);
|
||||||
|
const tcp = getDpiDict(packet, 'tcp');
|
||||||
|
const tshark = getDpiDict(packet, 'tshark');
|
||||||
|
const packetType = tcp?.packet_type ?? tshark?.tcp_packet_type;
|
||||||
|
return packetType ? `${base} ${String(packetType)}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
/** Format timestamp to European locale with ms */
|
/** Format timestamp to European locale with ms */
|
||||||
function formatTimestamp(ts?: string) {
|
function formatTimestamp(ts?: string) {
|
||||||
if (!ts) return '-';
|
if (!ts) return '-';
|
||||||
@@ -163,12 +211,7 @@ export default function PacketViewer(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
next.sort((left, right) => {
|
next.sort((left, right) => {
|
||||||
const leftId = Number(left.id ?? 0);
|
return sortPacketsByTimestampDesc(left, right);
|
||||||
const rightId = Number(right.id ?? 0);
|
|
||||||
if (!Number.isNaN(leftId) && !Number.isNaN(rightId) && leftId !== rightId) {
|
|
||||||
return rightId - leftId;
|
|
||||||
}
|
|
||||||
return String(right.timestamp ?? '').localeCompare(String(left.timestamp ?? ''));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (next.length > MAX_PACKETS) return next.slice(0, MAX_PACKETS);
|
if (next.length > MAX_PACKETS) return next.slice(0, MAX_PACKETS);
|
||||||
@@ -182,7 +225,7 @@ export default function PacketViewer(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
const res = await fetchPackets(limitVal);
|
const res = await fetchPackets(limitVal);
|
||||||
const list: PacketRow[] = res.packets ?? [];
|
const list: PacketRow[] = res.packets ?? [];
|
||||||
setPackets(list);
|
setPackets([...list].sort(sortPacketsByTimestampDesc));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('fetchHistory error', err);
|
console.error('fetchHistory error', err);
|
||||||
message.error(err?.message ?? 'Failed to fetch packet history');
|
message.error(err?.message ?? 'Failed to fetch packet history');
|
||||||
@@ -304,6 +347,57 @@ export default function PacketViewer(): ReactElement {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tableRows = useMemo<PacketTableRow[]>(() => {
|
||||||
|
const grouped = new Map<string, PacketRow[]>();
|
||||||
|
const standalone: PacketTableRow[] = [];
|
||||||
|
|
||||||
|
for (const packet of packets) {
|
||||||
|
const flowId = getFlowId(packet);
|
||||||
|
if (!flowId) {
|
||||||
|
standalone.push({ ...packet, key: packetKey(packet), __kind: 'packet' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const items = grouped.get(flowId);
|
||||||
|
if (items) {
|
||||||
|
items.push(packet);
|
||||||
|
} else {
|
||||||
|
grouped.set(flowId, [packet]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowRows: PacketTableRow[] = [];
|
||||||
|
for (const [flowId, flowPackets] of grouped.entries()) {
|
||||||
|
const sortedPackets = [...flowPackets].sort(sortPacketsByTimestampAsc);
|
||||||
|
if (sortedPackets.length === 1) {
|
||||||
|
flowRows.push({
|
||||||
|
...sortedPackets[0],
|
||||||
|
flow_id: flowId,
|
||||||
|
key: packetKey(sortedPackets[0]),
|
||||||
|
__kind: 'packet',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestPacket = [...flowPackets].sort(sortPacketsByTimestampDesc)[0];
|
||||||
|
const children = sortedPackets.map((packet) => ({
|
||||||
|
...packet,
|
||||||
|
flow_id: flowId,
|
||||||
|
key: packetKey(packet),
|
||||||
|
__kind: 'packet' as const,
|
||||||
|
}));
|
||||||
|
flowRows.push({
|
||||||
|
...latestPacket,
|
||||||
|
flow_id: flowId,
|
||||||
|
key: `flow:${flowId}`,
|
||||||
|
__kind: 'flow',
|
||||||
|
flow_packet_count: sortedPackets.length,
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...flowRows, ...standalone].sort(sortPacketsByTimestampDesc);
|
||||||
|
}, [packets]);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -313,19 +407,6 @@ export default function PacketViewer(): ReactElement {
|
|||||||
width: 120,
|
width: 120,
|
||||||
render: (val: any) => <Text>{formatTimestamp(val)}</Text>,
|
render: (val: any) => <Text>{formatTimestamp(val)}</Text>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Corr',
|
|
||||||
key: 'correlation',
|
|
||||||
width: 140,
|
|
||||||
render: (_: any, rec: PacketRow) => (
|
|
||||||
<Space direction="vertical" size={0}>
|
|
||||||
<Text>{rec.packet_id ?? rec.packet_uid?.slice(0, 10) ?? '-'}</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{rec.correlation_source ?? '-'}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Path',
|
title: 'Path',
|
||||||
key: 'path',
|
key: 'path',
|
||||||
@@ -397,8 +478,14 @@ export default function PacketViewer(): ReactElement {
|
|||||||
title: 'Protocol',
|
title: 'Protocol',
|
||||||
dataIndex: 'ip_proto',
|
dataIndex: 'ip_proto',
|
||||||
key: 'ip_proto',
|
key: 'ip_proto',
|
||||||
width: 110,
|
width: 130,
|
||||||
render: (_: any, rec: PacketRow) => <Text>{formatIpProto(rec)}</Text>,
|
render: (_: any, rec: PacketTableRow) => <Text>{rec.__kind === 'flow' ? `${formatIpProto(rec)} flow` : formatProtocolLabel(rec)}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Flow',
|
||||||
|
key: 'flow_id',
|
||||||
|
width: 130,
|
||||||
|
render: (_: any, rec: PacketTableRow) => <Text>{rec.flow_id ?? '-'}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'App',
|
title: 'App',
|
||||||
@@ -452,24 +539,23 @@ export default function PacketViewer(): ReactElement {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Len',
|
title: 'Len/Pkts',
|
||||||
dataIndex: 'length',
|
dataIndex: 'length',
|
||||||
key: 'length',
|
key: 'length',
|
||||||
width: 80,
|
width: 90,
|
||||||
render: (n: any) => (typeof n === 'number' ? n.toLocaleString('de-DE') : '-'),
|
render: (_: any, rec: PacketTableRow) =>
|
||||||
},
|
rec.__kind === 'flow'
|
||||||
{
|
? `${rec.flow_packet_count ?? 0} pkts`
|
||||||
title: 'Risk',
|
: typeof rec.length === 'number'
|
||||||
dataIndex: 'app_risk_score',
|
? rec.length.toLocaleString('de-DE')
|
||||||
key: 'app_risk_score',
|
: '-',
|
||||||
width: 70,
|
|
||||||
render: (n: any) => (typeof n === 'number' ? n : '-'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 96,
|
width: 96,
|
||||||
render: (_: any, rec: PacketRow) => (
|
render: (_: any, rec: PacketTableRow) =>
|
||||||
|
rec.__kind === 'flow' ? null : (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title="View raw + tshark">
|
<Tooltip title="View raw + tshark">
|
||||||
<Button
|
<Button
|
||||||
@@ -551,12 +637,16 @@ export default function PacketViewer(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
rowKey={(rec: PacketRow) => packetKey(rec)}
|
rowKey={(rec: PacketTableRow) => rec.key}
|
||||||
dataSource={packets}
|
dataSource={tableRows}
|
||||||
columns={columns as any}
|
columns={columns as any}
|
||||||
pagination={{ pageSize: 25 }}
|
pagination={{ pageSize: 25 }}
|
||||||
size="small"
|
size="small"
|
||||||
bordered
|
bordered
|
||||||
|
expandable={{
|
||||||
|
defaultExpandAllRows: true,
|
||||||
|
rowExpandable: (record: PacketTableRow) => Array.isArray(record.children) && record.children.length > 0,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface PacketRow {
|
|||||||
length?: number | null;
|
length?: number | null;
|
||||||
raw_present?: boolean | null;
|
raw_present?: boolean | null;
|
||||||
capture_sources?: string[] | null;
|
capture_sources?: string[] | null;
|
||||||
|
flow_id?: string | null;
|
||||||
raw_b64?: string | null;
|
raw_b64?: string | null;
|
||||||
app_protocol?: string | null;
|
app_protocol?: string | null;
|
||||||
app_master_protocol?: string | null;
|
app_master_protocol?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user