Files
pilens/web/src/pages/Statistics.tsx
T
Andy Dixon 6605a1d467 Phase 3: conversations, Follow TCP Stream, DNS explorer, TLS metadata explorer, statistics
Conversations and statistics are computed from the SQLite summary index (across
segments); stream payload via tshark follow; DNS/TLS fields via tshark. TLS is
metadata-only and labelled 'encrypted pass-through' throughout.
2026-10-09 18:39:39 +01:00

114 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { fmtBytes, type Endpoint, type Session, type Stats, type TimeBucket } from "../api";
import { usePoll } from "../ui";
import { SessionPicker, Bar, PacketsLink } from "../components/chrome";
export default function Statistics({ sessionId }: { sessionId: string }) {
const [sessions] = usePoll<Session[]>("/api/captures", 5000);
const id = sessionId || sessions?.[0]?.id || "";
const [stats, err] = usePoll<Stats>(id ? `/api/captures/${id}/statistics?buckets=300` : null, 0);
const [eps] = usePoll<Endpoint[]>(id ? `/api/captures/${id}/endpoints?limit=15` : null);
const [metric, setMetric] = useState<"bytes" | "packets">("bytes");
const dur = stats ? stats.end - stats.start : 0;
const bucketS = stats?.counters?.bucket_seconds || 1;
const maxProto = stats?.protocols[0]?.[metric] ?? 1;
const maxSize = useMemo(() => Math.max(1, ...(stats?.size_buckets ?? []).map((s) => s[metric])), [stats, metric]);
const maxEp = eps?.[0]?.bytes ?? 1;
return (
<div className="page">
<div className="page-head">
<div><h1>Statistics</h1><p>Computed from the packet index. Every value is a count of captured packets or bytes, not an estimate.</p></div>
<SessionPicker sessions={sessions} id={id} base="/statistics">
<div className="row" style={{ gap: ".25rem" }}>
<button className={metric === "bytes" ? "on" : ""} onClick={() => setMetric("bytes")}>Bytes</button>
<button className={metric === "packets" ? "on" : ""} onClick={() => setMetric("packets")}>Packets</button>
</div>
</SessionPicker>
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
{!stats ? <div className="empty">Loading…</div> : stats.packets === 0 ? <div className="empty">No packets captured yet.</div> : (
<>
<div className="grid g4" style={{ marginBottom: ".9rem" }}>
<div className="card"><h3>Packets</h3><div className="metric">{stats.packets.toLocaleString()}</div></div>
<div className="card"><h3>Bytes</h3><div className="metric" style={{ fontSize: "1.4rem" }}>{fmtBytes(stats.bytes)}</div></div>
<div className="card"><h3>Duration</h3><div className="metric" style={{ fontSize: "1.4rem" }}>{dur > 0 ? dur.toFixed(1) + " s" : "–"}</div><div className="hint">{dur > 0 ? `${Math.round(stats.packets / dur)} pkt/s avg · ${fmtBytes(stats.bytes / dur)}/s` : ""}</div></div>
<div className="card"><h3>TCP streams</h3><div className="metric">{stats.tcp_streams.toLocaleString()}</div></div>
</div>
<div className="card" style={{ marginBottom: ".9rem" }}>
<div className="row spread"><h3>Traffic over time</h3><span className="hint">{metric} per {bucketS >= 1 ? bucketS + " s" : "bucket"}</span></div>
<Timeline buckets={stats.timeline} metric={metric} t0={stats.start} />
</div>
<div className="grid g2">
<div className="card"><h3>Protocol hierarchy</h3>
<table className="data"><tbody>
{stats.protocols.slice(0, 15).map((p) => (
<tr key={p.name}><td className="mono">{p.name}</td>
<td style={{ width: "55%" }}><Bar value={p[metric]} max={maxProto} label={metric === "bytes" ? fmtBytes(p.bytes) : p.packets.toLocaleString()} /></td>
<td className="hint mono">{((p[metric] / (metric === "bytes" ? stats.bytes : stats.packets)) * 100).toFixed(1)}%</td></tr>
))}
</tbody></table>
</div>
<div className="card"><h3>Top talkers</h3>
<table className="data"><thead><tr><th>Address</th><th>Traffic</th><th>Tx / Rx</th></tr></thead><tbody>
{eps?.map((e) => (
<tr key={e.addr}><td className="mono"><PacketsLink id={id} filter={`${e.addr.includes(":") ? "ipv6" : "ip"}.addr==${e.addr}`}>{e.addr}</PacketsLink></td>
<td style={{ width: "45%" }}><Bar value={e.bytes} max={maxEp} /></td>
<td className="hint mono">{fmtBytes(e.tx_bytes)} / {fmtBytes(e.rx_bytes)}</td></tr>
))}
</tbody></table>
</div>
<div className="card"><h3>Packet size distribution</h3>
<table className="data"><tbody>
{stats.size_buckets.map((b) => (
<tr key={b.name}><td className="mono">{b.name}</td>
<td><Bar value={b[metric]} max={maxSize} label={metric === "bytes" ? fmtBytes(b.bytes) : b.packets.toLocaleString()} kind="var(--accent)" /></td></tr>
))}
</tbody></table>
</div>
<div className="card"><h3>Definitions</h3>
<dl className="kv" style={{ fontSize: ".85rem" }}>
<dt>Protocol</dt><dd>The highest-layer protocol TShark assigned to each packet (one packet counts once).</dd>
<dt>TCP streams</dt><dd>Distinct tshark stream indices, counted per segment, so a connection crossing a segment boundary counts more than once.</dd>
<dt>Tx / Rx</dt><dd>Bytes with this address as source / destination. A packet contributes to both its endpoints.</dd>
<dt>Bytes</dt><dd>On-the-wire frame length as captured.</dd>
</dl>
</div>
</div>
</>
)}
</div>
);
}
function Timeline({ buckets, metric, t0 }: { buckets: TimeBucket[]; metric: "bytes" | "packets"; t0: number }) {
const [hover, setHover] = useState<number | null>(null);
const W = 1000, H = 160, pad = 4;
if (!buckets.length) return <div className="empty">No data</div>;
const max = Math.max(1, ...buckets.map((b) => b[metric]));
const n = buckets.length;
const bw = (W - pad * 2) / n;
const bars = buckets.map((b, i) => {
const h = (b[metric] / max) * (H - 20);
return <rect key={i} x={pad + i * bw} y={H - h} width={Math.max(0.5, bw - 0.5)} height={h} fill="var(--cyan)" opacity={hover === i ? 1 : .5}
onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)} />;
});
const hb = hover != null ? buckets[hover] : null;
return (
<div style={{ position: "relative" }}>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} preserveAspectRatio="none" style={{ display: "block" }} role="img" aria-label="Traffic over time">
{bars}
<line x1={pad} y1={H - 0.5} x2={W - pad} y2={H - 0.5} stroke="var(--line-2)" strokeWidth={0.5} />
</svg>
<div className="hint" style={{ display: "flex", justifyContent: "space-between" }}>
<span>0 s</span>
{hb && <span className="mono">+{(hb.t - t0).toFixed(1)} s: {metric === "bytes" ? fmtBytes(hb.bytes) : hb.packets + " pkts"}</span>}
<span>{(buckets[n - 1].t - t0).toFixed(0)} s</span>
</div>
</div>
);
}