From 08b7a7a6838ab4f750b87f4fd431885d125d50e4 Mon Sep 17 00:00:00 2001 From: Julian Goldstein Date: Tue, 23 Jun 2026 19:43:15 -0500 Subject: [PATCH 01/17] Capture HTTP across ECS awsvpc task network namespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host-netns TCX attach (wildcard by default; --iface for an explicit list that keeps lo) only covers the host namespace, so HTTP served from ECS awsvpc tasks — whose ENI lives in the task's own netns — was invisible. Discover running tasks by their /ecs/ cgroup (cgroupfs and systemd layouts), attach the probe into each task's netns, and reconcile on a timer as tasks start and stop. Each netns is its own BpfObject/ring buffer (one attach spec per program), so probe.js owns the subscriptions and fans every ring buffer into one consumer that httptop.js registers via subscribe(). Attach order: before so the observer sits at the front of each TCX chain. Keep the last few raw payloads per endpoint for the inspect pane. Flags: --no-tasks, --task-loopback, --reconcile-ms, --selftest (the self-test is gated on the flag, not import.meta.main, which is unreliable once esbuild bundles the app into one src/index.jsx). Co-Authored-By: Claude Opus 4.8 --- README.md | 24 +++- src/probes/httptop.js | 24 +++- src/probes/probe.js | 283 +++++++++++++++++++++++++++++++++++------- 3 files changed, 273 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index fc51f79..f4f281d 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,28 @@ yeet run . # watch every up interface, including loopback With any plaintext HTTP flowing on the box, that's it — `httpinspect` enumerates the up interfaces, attaches at the TC layer, and starts ranking endpoints. Flags tune what it watches and how it groups (pass them after `--`, so the runtime routes them to the script): -| flag | default | meaning | -| ---------------- | ------------- | -------------------------------------------------------------------- | -| `--iface=` | all up ifaces | comma-separated interface names to watch, e.g. `--iface=lo,eth0` | -| `--keep-query` | off | keep query strings distinct — `/x?id=1` and `/x?id=2` stay separate rows instead of collapsing into one | +| flag | default | meaning | +| ------------------ | -------------- | -------------------------------------------------------------------- | +| `--iface=` | all (wildcard) | comma-separated interface names to watch, e.g. `--iface=lo,eth0`. Unset → the daemon wildcard-attaches every host interface (this skips `lo`); naming interfaces keeps `lo`. | +| `--keep-query` | off | keep query strings distinct — `/x?id=1` and `/x?id=2` stay separate rows instead of collapsing into one | +| `--no-tasks` | off | don't attach into ECS task network namespaces (host netns only) | +| `--task-loopback` | off | also hook the in-task `lo` (the 127.0.0.1 leg); best-effort, TCX-on-lo EINVALs on some kernels | +| `--reconcile-ms=N` | 5000 | how often to re-scan for ECS tasks that started or stopped | +| `--selftest` | off | headless capture check — aggregate for ~4s, print counts, exit (no TUI) | ```sh -yeet run . -- --iface lo,eth0 # only these interfaces +yeet run . -- --iface lo,eth0 # only these interfaces (explicit list keeps lo) yeet run . -- --keep-query # /x?id=1 and /x?id=2 stay separate rows +yeet run . -- --task-loopback # also capture same-task 127.0.0.1 upstreams ``` +On a host daemon, requests served from **ECS awsvpc tasks** live in each task's +own network namespace, which a host-netns attach can't reach. `httpinspect` +discovers running tasks (by their `/ecs/` cgroup) and attaches into each +task's netns, re-scanning every `--reconcile-ms` as tasks come and go. Running +it as a sidecar inside the task instead captures that task's `eth0` + `lo` +directly. + Runs until `Ctrl-C`. Resize the terminal and the table reflows; needs a real terminal (it's a TUI — don't pipe or redirect the output). ## A 30-second primer on HTTP-on-the-wire @@ -218,7 +230,7 @@ Because it's encrypted before it hits the wire. At the TC layer the payload is c That's the `Host:` header the client sent. Services addressed by name show their name; those addressed by IP show the IP. **Can I get a quick check without the full TUI?** -Yes. `yeet run src/probes/probe.js` attaches the probe, aggregates for ~4s, and prints the counts before exiting — a headless sanity check of the capture + parse pipeline. +Yes. `yeet run . -- --selftest` attaches the probe (host + each ECS task netns), aggregates for ~4s, and prints the counts before exiting — a headless sanity check of the capture + parse pipeline. (It's a flag, not a separate entry: `yeet run` bundles the whole app into one `src/index.jsx`, so `import.meta.main` can't distinguish the probe from the app.) ## License diff --git a/src/probes/httptop.js b/src/probes/httptop.js index 2ca472c..11e6699 100644 --- a/src/probes/httptop.js +++ b/src/probes/httptop.js @@ -10,8 +10,7 @@ // `rows`, so a from() over `rows` would tear the ring buffer down whenever // detail is open. A daemon-style always-on feed is the right shape here. import { signal } from "yeet:tui"; -import { RingBuf } from "yeet:bpf"; -import { control } from "@/probes/probe.js"; +import { subscribe } from "@/probes/probe.js"; import { fmtCount } from "@/lib/format.js"; export const TICK_MS = 400; /* redraw cadence between per-second rate samples */ @@ -36,6 +35,7 @@ export const tick = signal(0); export const HIST_LEN = 60; /* req/s samples kept per endpoint (≈1 min) */ export const LAT_LEN = 200; /* recent response latencies kept (ms) */ +export const SAMPLE_MAX = 8; /* recent raw payloads kept per endpoint (inspect pane) */ /* ---- parsing ------------------------------------------------------ */ function bytesToLatin1(bytes, max) { @@ -115,6 +115,15 @@ function isDuplicate(ev, now) { const pending = new Map(); // flowKey -> [entry, …] const flowKey = (ev) => `${ev.family}:${Math.min(ev.sport, ev.dport)}-${Math.max(ev.sport, ev.dport)}`; +/* Keep the last few raw captured payloads (request/response line + headers + + * whatever body fit in the first segment) per endpoint, newest first, so the + * inspect pane can scroll through them. */ +function pushSample(row, ev, data, now) { + const text = bytesToLatin1(data.subarray(0, Number(ev.captured)), Number(ev.captured)); + row.samples.unshift({ ts: now, kind: ev.kind, dir: ev.dir, text }); + if (row.samples.length > SAMPLE_MAX) row.samples.pop(); +} + /* one ring-buffer event (an `http_event`, wrapped under its btf_struct name) */ function onEvent(raw) { const ev = raw.http_event ?? raw; @@ -137,7 +146,7 @@ function onRequest(ev, data, now) { let row = stats.get(key); if (!row) { row = { ...req, count: 0, prev: 0, rate: 0, peak: 0, bytes: 0, - first: now, last: now, hist: [], lat: [], status: {}, lastMs: null }; + first: now, last: now, hist: [], lat: [], status: {}, lastMs: null, samples: [] }; stats.set(key, row); } const len = Number(ev.total_len); @@ -146,6 +155,7 @@ function onRequest(ev, data, now) { row.bytes += len; totals.reqs++; totals.bytes += len; + pushSample(row, ev, data, now); // Queue this request so the matching response can measure its latency. const f = flowKey(ev); @@ -163,6 +173,7 @@ function onResponse(ev, data, now) { const row = stats.get(key); if (!row) return; + pushSample(row, ev, data, now); const ms = Math.max(0, (Number(ev.ts) - reqTs) / 1e6); // monotonic ns → ms row.lat.push(ms); @@ -210,9 +221,10 @@ function sampleRates() { } } -// Start the feed. The ring buffer is single-consumer and ingestion is -// always-on (see the module header), so wire it up at load time. -new RingBuf(control, "events").subscribe( +// Start the feed. probe.js fans every netns ring buffer (host + each ECS task) +// into one consumer, and ingestion is always-on (see the module header), so +// register at load time. +subscribe( onEvent, (err) => console.error("[httptop] ringbuf error:", err.message), ); diff --git a/src/probes/probe.js b/src/probes/probe.js index 93fd19f..400dc87 100644 --- a/src/probes/probe.js +++ b/src/probes/probe.js @@ -1,63 +1,223 @@ -// Shared BPF object. The single src/bpf/httptop.bpf.c unit is compiled and -// linked into bin/probe.bpf.o and loaded once here; the feature probe -// (httptop.js) imports this `control` and reads the `events` ring buffer. -// All binds + attaches happen before the single start(), so they live here. +// Shared BPF attach layer. The single src/bpf/httptop.bpf.c unit is compiled to +// bin/probe.bpf.o and loaded here; the feature probe (httptop.js) registers a +// `subscribe(onEvent)` consumer and this module fans every captured event into +// it. All attaches live here. // -// httptop attaches at the TC layer (TCX, ingress + egress) on every up -// interface. The TCX wildcard skips loopback, so we enumerate explicitly -// (incl. `lo`, where most local HTTP lives); `--iface a,b` narrows to named -// interfaces. This module imports only yeet:bpf — no `@/` aliases — so it -// stays runnable on its own for the import.meta.main self-test below. +// httptop attaches at the TC layer (TCX, ingress + egress). The loader allows +// only one attach spec per program name per BpfObject, so each network +// namespace is its own BpfObject (hence its own `events` ring buffer) and this +// module owns every ring-buffer subscription, fanning them into the one +// consumer httptop registers. +// +// Coverage, by namespace: +// • host netns — wildcard by default (the daemon enumerates + attaches every +// supported interface itself); `--iface a,b` switches to an explicit +// ifindex list narrowed to those names (which, unlike wildcard, keeps `lo`). +// • ECS awsvpc task netns — a host daemon's wildcard never reaches these (the +// task ENI lives inside the task's netns), so we discover running tasks and +// attach into each task's netns explicitly, reconciling on a timer as tasks +// start and stop. `--no-tasks` disables this; `--task-loopback` also hooks +// the in-task `lo` (the 127.0.0.1 leg), best-effort since TCX-on-lo EINVALs +// on some kernels. +// +// This module imports only yeet:bpf — no `@/` aliases — so it stays runnable on +// its own for the --selftest correctness probe below. import { BpfObject, RingBuf } from "yeet:bpf"; +// ---- args --------------------------------------------------------------- const wanted = yeet.args.iface ? new Set(String(yeet.args.iface).split(",").map((s) => s.trim()).filter(Boolean)) : null; +const RECONCILE_MS = Math.max(1000, Number(yeet.args.reconcile_ms) || 5000); +const taskLoopback = !!yeet.args.task_loopback; +const noTasks = !!yeet.args.no_tasks; -let ifaces = []; -try { - const { data, errors } = await yeet.graph.query( - `{ network_interfaces { index name is_up } }`, +// ---- low-level helpers -------------------------------------------------- +const EXE = { exe: "../bin/probe.bpf.o", base: import.meta.dirname }; +const RINGBUF = { kind: "ringbuf", btf_struct: "http_event" }; + +// Race a graph query against a deadline — a pathological query can otherwise +// wedge the daemon for every run until it restarts. +function withTimeout(p, ms, what) { + return Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out after ${ms}ms`)), ms)), + ]); +} + +// Build + start one attachment: both directions of the program against `spec`. +// `order: "before"` (BPF_F_BEFORE) places us at the front of the interface's +// TCX chain — both so a passive observer sees packets before anything can +// drop/redirect them, and because on awsvpc task ENIs a datapath TCX egress +// program otherwise runs ahead of ours and our egress hook never sees the +// response traffic. +function startAttach(spec) { + const ordered = { order: "before", ...spec }; + return new BpfObject(EXE) + .bind("events", RINGBUF) + .attach("on_ingress", ordered) + .attach("on_egress", ordered) + .start(); +} + +// ---- host-netns attach spec -------------------------------------------- +// No --iface → wildcard (omit `ifindex`); --iface a,b → explicit ifindex list +// resolved from the graph and narrowed to those names. +async function hostSpec() { + if (!wanted) return { spec: { kind: "tcx" }, label: "all (wildcard)" }; + const { data, errors } = await withTimeout( + yeet.graph.query(`{ network_interfaces { index name is_up } }`), + 2000, "interface query", ); if (errors) throw new Error(errors[0].message); - ifaces = (data.network_interfaces || []).filter((i) => i.is_up && (!wanted || wanted.has(i.name))); -} catch (err) { - console.error(`[httptop] could not list interfaces: ${err.message}`); - yeet.exit(); + const ifaces = (data.network_interfaces || []).filter((i) => i.is_up && wanted.has(i.name)); + const ifindex = ifaces.map((i) => i.index); + if (ifindex.length === 0) throw new Error("no matching up interfaces to watch"); + return { spec: { kind: "tcx", ifindex }, label: ifaces.map((i) => i.name).join(",") }; } -const ifindexes = ifaces.map((i) => i.index); -if (ifindexes.length === 0) { - console.error("[httptop] no matching up interfaces to watch"); - yeet.exit(); +// ---- ECS task discovery (host daemon → task netns) ---------------------- +// Every container in an awsvpc task shares one netns, and ECS nests each task +// under a per-task cgroup carrying its 32-hex task id, so one representative pid +// per task id is enough to enter that netns. The id shows up two ways depending +// on the cgroup driver: +// systemd: /ecstasks.slice/ecstasks-.slice/docker-.scope +// cgroupfs: /ecs// +// Match both. (Plain `/ecs.service` — the agent — has no id and is ignored.) +const TASK_RE = /ecs(?:tasks-|\/)([0-9a-f]{32})/i; +// Diagnostics from the most recent scan, surfaced by the self-test so a 0-task +// result is debuggable (wrong cgroup layout vs. nothing running) without a code +// round-trip. +let lastScan = { procs: 0, ecsPaths: [], samplePaths: [] }; +async function discoverTasks() { + const { data, errors } = await withTimeout( + yeet.graph.query(`{ procs { pid cgroups { pathname } } }`), + 3000, "task discovery", + ); + if (errors) throw new Error(errors[0].message); + const procs = data.procs || []; + const byTask = new Map(); // taskId -> first live pid seen in it + const ecsPaths = new Set(); // cgroup paths mentioning "ecs" (regex debugging) + const samplePaths = new Set(); + for (const p of procs) { + for (const c of p.cgroups || []) { + const path = c.pathname || ""; + if (samplePaths.size < 6) samplePaths.add(path); + if (/ecs/i.test(path)) ecsPaths.add(path); + const m = TASK_RE.exec(path); + if (m && !byTask.has(m[1])) byTask.set(m[1], p.pid); + } + } + lastScan = { procs: procs.length, ecsPaths: [...ecsPaths].slice(0, 6), samplePaths: [...samplePaths] }; + return byTask; } -// What the status bar shows for the watched interfaces. -export const ifaceLabel = wanted ? ifaces.map((i) => i.name).join(",") : `all (${ifaces.length})`; +// ---- attachment registry + event fan-in --------------------------------- +const active = new Map(); // key -> { control, sub: Promise | null } +let consumer = null; // { onEvent, onError } — registered by httptop.js + +function subscribeRing(control) { + return new RingBuf(control, "events").subscribe( + (raw) => { if (consumer) consumer.onEvent(raw); }, + (err) => { if (consumer && consumer.onError) consumer.onError(err); }, + ); +} + +// The one ingestion entry point. httptop.js calls this once; we wire its +// callback to every current ring buffer and to any added later. +export function subscribe(onEvent, onError) { + consumer = { onEvent, onError }; + for (const entry of active.values()) { + if (!entry.sub) entry.sub = subscribeRing(entry.control); + } +} + +async function add(key, spec) { + if (active.has(key)) return; + const control = await startAttach(spec); + const entry = { control, sub: null }; + if (consumer) entry.sub = subscribeRing(control); + active.set(key, entry); +} -// `base: import.meta.dirname` resolves the object path against the running bundle. -const tcx = { kind: "tcx", ifindex: ifindexes }; -const probe = new BpfObject({ exe: "../bin/probe.bpf.o", base: import.meta.dirname }); +async function remove(key) { + const entry = active.get(key); + if (!entry) return; + active.delete(key); + try { if (entry.sub) (await entry.sub).unsubscribe(); } catch { /* already gone */ } + try { await entry.control.stop(); } catch { /* already gone */ } +} -export const control = await (async () => { +// ---- reconcile: keep `active` in sync with the live ECS task set --------- +// Each task is its own BpfObject, so we add new tasks and drop departed ones +// individually — existing attachments are never torn down, so task churn costs +// no events on flows already being watched. +async function reconcile() { + let tasks; try { - return await probe - .bind("events", { kind: "ringbuf", btf_struct: "http_event" }) - .attach("on_ingress", tcx) - .attach("on_egress", tcx) - .start(); + tasks = await discoverTasks(); } catch (err) { - console.error(`[httptop] failed to load eBPF: ${err.message}`); - console.error("[httptop] need CAP_BPF/root and a compiled bin/probe.bpf.o (run `make`)."); - yeet.exit(); + return { error: err.message, total: 0, added: [], removed: [], failed: [] }; + } + + const desired = new Map(); // key -> spec + for (const [taskId, pid] of tasks) { + desired.set(`task:${taskId}`, { kind: "tcx", ns: { pid } }); // wildcard inside the task netns + if (taskLoopback) { + desired.set(`task:${taskId}:lo`, { kind: "tcx", ns: { pid }, ifindex: [1] }); + } + } + + const added = [], failed = [], removed = []; + for (const [key, spec] of desired) { + if (!active.has(key)) { + try { await add(key, spec); added.push(key); } + catch (err) { failed.push({ key, msg: err.message }); } + } + } + for (const key of [...active.keys()]) { + if (key !== "host" && !desired.has(key)) { await remove(key); removed.push(key); } } -})(); + return { total: tasks.size, added, removed, failed }; +} -// Standalone correctness probe — `yeet run src/probes/probe.js` dumps the -// endpoints it aggregates over a few seconds, so you can eyeball that the -// kernel filter, the btf_struct envelope, and the loopback dedup all behave -// before any UI exists. Dormant once httptop.js imports `control`. -if (import.meta.main) { +// ---- bring up the host attach (fatal on failure), then tasks ------------ +let hostLabel = "?"; +try { + const { spec, label } = await hostSpec(); + hostLabel = label; + await add("host", spec); +} catch (err) { + console.error(`[httptop] failed to load eBPF: ${err.message}`); + console.error("[httptop] need CAP_BPF/root and a compiled bin/probe.bpf.o (run `make`)."); + yeet.exit(); +} + +// The host BpfControl, exported for the --selftest correctness probe below. +export const control = active.get("host").control; + +// Discovery + per-task attach runs OFF the critical path: a fire-and-forget +// initial scan plus a timer, so the UI mounts immediately and tasks attach a +// beat later as scans complete. Skipped under --selftest, which drives its own +// scan (below) so it can report what it found. +if (!noTasks && !yeet.args.selftest) { + reconcile().catch(() => {}); + setInterval(() => reconcile().catch(() => {}), RECONCILE_MS); +} + +// What the status bar shows for the watched namespaces. +export const ifaceLabel = hostLabel; + +// Standalone correctness probe — `yeet run . -- --selftest` aggregates the +// endpoints it sees across the host netns *and* every ECS task netns for a few +// seconds, then prints the counts before exiting. A headless check that the +// kernel filter, the btf_struct envelope, and the task-netns attach all behave. +// +// Gated on an explicit flag, NOT import.meta.main: esbuild bundles every module +// into one src/index.jsx whose import.meta.main is true for the whole file, so +// import.meta.main can't tell "run the probe" from "run the app" — relying on it +// would fire this block (and its yeet.exit) during a normal `yeet run .`. +if (yeet.args.selftest) { const REQ = /^([A-Z]+) +(\S+) +HTTP\/\d\.\d$/; const parse = (bytes) => { let t = ""; @@ -76,24 +236,55 @@ if (import.meta.main) { const stats = new Map(); const seen = new Set(); - let dupes = 0; - await new RingBuf(control, "events").subscribe((raw) => { + const statusTally = {}; + const STATUS = /^HTTP\/\d\.\d (\d{3})/; + let dupes = 0, reqs = 0, resps = 0; + subscribe((raw) => { const ev = raw.http_event ?? raw; const k = `${ev.family}:${ev.sport}>${ev.dport}#${ev.seq}`; if (seen.has(k)) { dupes++; return; } seen.add(k); const d = ev.data instanceof Uint8Array ? ev.data : Uint8Array.from(Object.values(ev.data)); + let t = ""; for (let i = 0; i < Number(ev.captured); i++) { const c = d[i]; if (c === 0) break; t += String.fromCharCode(c); } + if (ev.kind === 1) { // response + resps++; + const m = STATUS.exec(t); + if (m) statusTally[m[1]] = (statusTally[m[1]] || 0) + 1; + return; + } + reqs++; const r = parse(d.subarray(0, Number(ev.captured))); if (!r) return; const key = `${r.method} ${r.host} ${r.path}`; stats.set(key, (stats.get(key) || 0) + 1); }); + if (!noTasks) { + const t0 = Date.now(); + const sum = await reconcile(); + const dt = Date.now() - t0; + if (sum.error) { + console.log(`[verify] task discovery FAILED after ${dt}ms: ${sum.error}`); + } else { + console.log(`[verify] scanned ${lastScan.procs} procs in ${dt}ms — matched ${sum.total} task netns, attached ${sum.added.length}, failed ${sum.failed.length}`); + sum.failed.forEach((f) => console.log(`[verify] attach ${f.key} failed: ${f.msg}`)); + if (sum.total === 0) { + console.log("[verify] no /ecs/ cgroups matched. cgroup paths mentioning ecs:"); + (lastScan.ecsPaths.length ? lastScan.ecsPaths : ["(none)"]).forEach((p) => console.log(`[verify] ${p}`)); + console.log("[verify] sample cgroup paths:"); + lastScan.samplePaths.forEach((p) => console.log(`[verify] ${p}`)); + } + } + } + await new Promise((r) => setTimeout(r, 4500)); - console.log(`[verify] watching ifindexes ${ifindexes.join(",")}`); - console.log(`[verify] deduped ${dupes} loopback double-sightings`); + console.log(`[verify] watching ${ifaceLabel} (${active.size} attachment${active.size === 1 ? "" : "s"})`); + console.log(`[verify] deduped ${dupes} duplicate sightings`); + console.log(`[verify] events: ${reqs} requests, ${resps} responses`); + const st = Object.entries(statusTally).sort((a, b) => b[1] - a[1]).map(([c, n]) => `${c}×${n}`).join(" "); + console.log(`[verify] response status codes: ${st || "(none captured)"}`); console.log("[verify] aggregated endpoints (count desc):"); [...stats.entries()].sort((a, b) => b[1] - a[1]).forEach(([k, c]) => console.log(` ${String(c).padStart(3)} ${k}`)); - await control.stop(); + for (const key of [...active.keys()]) await remove(key); yeet.exit(); } From ca24e3e1ac343dd0252777391b0314eb3e01e3ad Mon Sep 17 00:00:00 2001 From: Julian Goldstein Date: Tue, 23 Jun 2026 19:43:15 -0500 Subject: [PATCH 02/17] TUI table: status / RPS / P99 / flexible host columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 2xx/3xx/4xx/5xx response-class columns (color-coded), a P99 latency column with a white→red heat ramp, and rename REQ/S → RPS shown to one decimal as a ~10s smoothed rate. Drop the COUNT column (rows are still count-sorted). Let the HOST column grow (clamp(20, 30%, 64)) so long FQDN:port hosts aren't clipped. Co-Authored-By: Claude Opus 4.8 --- src/components/list.jsx | 40 ++++++++++++++++++++++++++++++++-------- src/lib/format.js | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/components/list.jsx b/src/components/list.jsx index e622c12..25728b5 100644 --- a/src/components/list.jsx +++ b/src/components/list.jsx @@ -3,11 +3,24 @@ // highlighted index); `size` reflows the visible window on resize. import { Box, Text, bold, dim, fg } from "yeet:tui"; import { - methodColor, accent, rateOn, grid, selBg, - W_RANK, W_METHOD, W_COUNT, W_RATE, W_HOST, W_LAST, - pad, padEnd, fmtCount, fmtAgo, + methodColor, accent, rateOn, grid, selBg, statusColor, statusClasses, latColor, percentile, + W_RANK, W_METHOD, W_RATE, W_HOST, W_LAST, W_STATUS, W_LAT, + pad, padEnd, fmtCount, fmtRate, fmtAgo, fmtMs, } from "@/lib/format.js"; +/* Smooth the instantaneous per-second rate over the last ~10s of history so the + * RPS column shows a meaningful fractional value instead of a jumpy integer. */ +function smoothRate(row) { + const h = row.hist; + if (!h?.length) return row.rate; + const n = Math.min(10, h.length); + let sum = 0; + for (let i = h.length - n; i < h.length; i++) sum += h[i]; + return sum / n; +} + +const STATUS_CLASSES = [2, 3, 4, 5]; + function HeaderRow() { return ( @@ -15,23 +28,34 @@ function HeaderRow() { {bold("METHOD")} {bold("HOST")} {bold("PATH")} - {bold(pad("COUNT", W_COUNT))} - {bold(pad("REQ/S", W_RATE))} + {STATUS_CLASSES.map((c) => ( + {bold(fg(statusColor(c * 100))(pad(`${c}xx`, W_STATUS)))} + ))} + {bold(pad("RPS", W_RATE))} + {bold(pad("P99", W_LAT))} {bold(pad("LAST", W_LAST))} ); } function Row({ row, rank, selected }) { - const rateStr = row.rate > 0 ? pad(fmtCount(row.rate), W_RATE) : dim(pad("·", W_RATE)); + const rps = smoothRate(row); + const rateStr = rps > 0 ? pad(fmtRate(rps), W_RATE) : dim(pad("·", W_RATE)); + const status = statusClasses(row.status); + const p99 = row.lat?.length ? percentile(row.lat, 99) : null; return ( {selected ? fg(accent)("› " + pad(rank, 2).slice(1)) : dim(pad(rank, 2) + " ")} {fg(methodColor(row.method))(padEnd(row.method, W_METHOD))} {dim(row.host)} {row.path} - {bold(fg(accent)(pad(fmtCount(row.count), W_COUNT)))} - {row.rate > 0 ? fg(rateOn)(rateStr) : rateStr} + {STATUS_CLASSES.map((c) => ( + + {status[c] > 0 ? fg(statusColor(c * 100))(pad(fmtCount(status[c]), W_STATUS)) : dim(pad("·", W_STATUS))} + + ))} + {rps > 0 ? fg(rateOn)(rateStr) : rateStr} + {p99 != null ? fg(latColor(p99))(pad(fmtMs(p99), W_LAT)) : dim(pad("·", W_LAT))} {dim(pad(fmtAgo(Date.now() - row.last), W_LAST))} ); diff --git a/src/lib/format.js b/src/lib/format.js index 562b406..3b371f4 100644 --- a/src/lib/format.js +++ b/src/lib/format.js @@ -18,8 +18,12 @@ export const grid = idx(8); /* table border */ export const selBg = idx(236); /* highlighted row in the list */ export const label = idx(244); /* detail-screen field labels */ -/* Fixed column widths (cells); PATH takes the remaining 1fr. */ -export const W_RANK = 4, W_METHOD = 8, W_COUNT = 8, W_RATE = 8, W_HOST = 22, W_LAST = 6; +/* Fixed column widths (cells); PATH takes the remaining 1fr. HOST is flexible: + * at least 20 cells, ~30% of the row, capped at 64 — so long FQDN:port hosts + * (e.g. `auth.yeet.plumbing:8081`) show in full on a wide terminal and fall + * back to ellipsis only when genuinely cramped. */ +export const W_RANK = 4, W_METHOD = 8, W_COUNT = 8, W_RATE = 8, W_LAST = 6, W_STATUS = 5, W_LAT = 7; +export const W_HOST = "clamp(20, 30%, 64)"; export const pad = (s, w) => String(s).padStart(w); export const padEnd = (s, w) => String(s).padEnd(w); @@ -32,6 +36,11 @@ export function fmtCount(n) { return String(n); } +/* requests/sec to one decimal for readable low rates; k/M for high ones. */ +export function fmtRate(n) { + return n >= 1000 ? fmtCount(n) : n.toFixed(1); +} + export function fmtBytes(n) { const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0; @@ -71,6 +80,25 @@ export function statusColor(code) { return METHOD_FALLBACK; } +/* Sum a row's { code: count } status map into { 2,3,4,5 } class buckets + * (1xx and unparsed 0 are dropped). */ +export function statusClasses(status) { + const b = { 2: 0, 3: 0, 4: 0, 5: 0 }; + for (const code in status) { + const c = Math.floor(Number(code) / 100); + if (b[c] !== undefined) b[c] += status[code]; + } + return b; +} + +/* Latency heat: white (fast) → red (slow), saturating at ~500ms. */ +const lerp = (a, b, t) => Math.round(a + (b - a) * t); +export function latColor(ms) { + const t = Math.max(0, Math.min(1, ms / 500)); + const white = [0xff, 0xff, 0xff], red = [0xf4, 0x87, 0x71]; + return rgb(lerp(white[0], red[0], t), lerp(white[1], red[1], t), lerp(white[2], red[2], t)); +} + /* p-th percentile (0..100) of an unsorted numeric array; 0 if empty. */ export function percentile(values, p) { if (!values.length) return 0; From 458a17ea18be251b48ca1fab1937f36408fcf8ba Mon Sep 17 00:00:00 2001 From: Julian Goldstein Date: Tue, 23 Jun 2026 19:43:15 -0500 Subject: [PATCH 03/17] Inspect pane: 4 KB capture + scrollable, syntax-colored JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture 4 KiB per message in both directions (was 511B req / 32B resp), ring buffer 8→16 MiB, so headers + bodies come through. The detail pane shows the last few payloads in a scrollable view (j/k, PageUp/Down): request/response line bold, headers greyed, and the body pretty-printed and syntax-colored when it is JSON (tolerant of truncation). Also fix the footer never updating — it now reads the tick signal so reqs/endpoints/bytes/uptime advance. Co-Authored-By: Claude Opus 4.8 --- src/bpf/httptop.bpf.c | 16 ++-- src/components/detail.jsx | 149 ++++++++++++++++++++++++++++++-------- src/components/footer.jsx | 20 +++-- src/main.jsx | 17 +++-- 4 files changed, 148 insertions(+), 54 deletions(-) diff --git a/src/bpf/httptop.bpf.c b/src/bpf/httptop.bpf.c index 25033e3..56ec363 100644 --- a/src/bpf/httptop.bpf.c +++ b/src/bpf/httptop.bpf.c @@ -30,7 +30,7 @@ #define TCX_NEXT (-1) /* passive observer: run next prog / default-pass */ -#define DATA_MAX 512 /* must be a power of two (see mask below) */ +#define DATA_MAX 4096 /* must be a power of two (see mask below) */ #define MIN_REQ 16 /* "GET / HTTP/1.1\r\n" is already 16 bytes */ #define DIR_EGRESS 0 @@ -39,8 +39,6 @@ #define KIND_REQUEST 0 #define KIND_RESPONSE 1 -#define RESP_CAP 32 /* responses: only the status line is parsed */ - struct http_event { __u64 ts; /* bpf_ktime_get_ns() at capture (monotonic) */ __u16 sport; @@ -59,7 +57,7 @@ __attribute__((used)) static const struct http_event __http_event_anchor; struct { __uint(type, BPF_MAP_TYPE_RINGBUF); - __uint(max_entries, 8 << 20); + __uint(max_entries, 16 << 20); /* larger events (4 KiB) → more headroom */ } events SEC(".maps"); /* Does the 8-byte prefix begin with an HTTP method token (method + space)? */ @@ -157,12 +155,12 @@ static __always_inline int handle(struct __sk_buff *skb, __u8 dir) else if (is_http_response(m)) kind = KIND_RESPONSE; else return TCX_NEXT; - /* Requests carry the line + Host header (parsed in JS); responses only need - the status line, so cap them short to spare ringbuf bandwidth. */ + /* Copy the captured prefix of this first segment — request/response line, + headers, and as much body as fits — so JS can parse the line and the + inspect pane can show the (JSON) body. */ __u32 cap = plen; - __u32 limit = kind == KIND_RESPONSE ? RESP_CAP : (DATA_MAX - 1); - if (cap > limit) - cap = limit; + if (cap > DATA_MAX - 1) + cap = DATA_MAX - 1; cap &= (DATA_MAX - 1); /* make the bound explicit for the verifier */ if (cap == 0) return TCX_NEXT; diff --git a/src/components/detail.jsx b/src/components/detail.jsx index 14c6702..40a9b79 100644 --- a/src/components/detail.jsx +++ b/src/components/detail.jsx @@ -1,13 +1,58 @@ // Detail screen: a per-endpoint breakdown for the endpoint the user pressed -// Enter on. Reads `focusKey` (which endpoint) and `tick` — the endpoint's -// fields mutate in place, so reading `tick` is what re-renders this panel as -// they change. `endpoint()` looks the row up; `totals` gives the share. -import { Box, Text, bold, dim, fg } from "yeet:tui"; +// Enter on, plus a scrollable view of the last few raw payloads captured for +// it. Reads `focusKey` (which endpoint), `tick` (the endpoint's fields mutate +// in place, so reading `tick` re-renders), and `bodyScroll` (the payload-view +// scroll offset, driven by j/k in main.jsx). `endpoint()` looks the row up. +import { Box, Text, bold, dim, fg, rgb, idx } from "yeet:tui"; import { methodColor, accent, rateOn, grid, label, W_METHOD, - fmtCount, fmtBytes, fmtAgo, fmtMs, percentile, statusColor, sparkline, + fmtCount, fmtBytes, fmtAgo, fmtMs, percentile, statusColor, } from "@/lib/format.js"; +// JSON syntax palette + a header-name grey. +const J_KEY = rgb(0x9cdcfe), J_STR = rgb(0xce9178), J_NUM = rgb(0xb5cea8), + J_LIT = rgb(0x569cd6), J_PUNCT = idx(244), HDR = idx(244); + +// Split one JSON line into colored spans (keys, strings, numbers, literals, +// punctuation). Tolerant: runs on raw text too, so truncated bodies still color. +const JSON_TOK = /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)|(true|false|null)|([{}\[\],])/g; +function colorJsonLine(line) { + const spans = []; + let last = 0, m; + while ((m = JSON_TOK.exec(line)) !== null) { + if (m.index > last) spans.push(line.slice(last, m.index)); + if (m[1]) spans.push(fg(J_KEY)(m[1])); + else if (m[2]) spans.push(fg(J_STR)(m[2])); + else if (m[3]) spans.push(fg(J_NUM)(m[3])); + else if (m[4]) spans.push(fg(J_LIT)(m[4])); + else spans.push(fg(J_PUNCT)(m[5])); + last = JSON_TOK.lastIndex; + } + if (last < line.length) spans.push(line.slice(last)); + return spans.length ? spans : [line]; +} + +// A body → display lines. JSON gets reformatted (when it parses) and colored; +// anything else is shown raw. +function bodyLines(body) { + const t = body.trim(); + if (!(t.startsWith("{") || t.startsWith("["))) return body.split(/\r?\n/).map((l) => [l]); + let pretty = body; + try { pretty = JSON.stringify(JSON.parse(t), null, 2); } catch { /* truncated/invalid: color raw */ } + return pretty.split(/\r?\n/).map(colorJsonLine); +} + +// One captured header line → "Name:" greyed, value plain. +function headerLine(l) { + const i = l.indexOf(":"); + return i < 0 ? [dim(l)] : [fg(HDR)(l.slice(0, i + 1)), l.slice(i + 1)]; +} + +// Largest scroll offset the payload view currently allows. The render writes it +// each frame (plain assignment — not a signal) so main.jsx's key handler can +// clamp bodyScroll without re-deriving the line count. +export const scrollState = { max: 0 }; + // Components are called `(opts, ...children)` by the JSX runtime, so read the // value pieces from the rest args — not a `children` prop. function Field(opts, ...children) { @@ -27,42 +72,82 @@ function statusSpans(status) { [i ? " " : "", fg(statusColor(Number(code)))(code), dim(`×${n}`)]); } -export default function DetailPanel({ focusKey, tick, endpoint, totals, size }) { +const kindTag = (k) => (k === 1 ? "RESP" : "REQ "); +const dirTag = (d) => (d === 1 ? "in" : "out"); + +/* Flatten the captured payloads into display lines, each an array of colored + * spans: a separator, the request/response line (bold) + headers (greyed), a + * blank, then the JSON-formatted, syntax-colored body. One logical line per + * Text (no hard-wrap), so the scroll offset is line-exact and long lines clip. */ +function buildBodyLines(samples, now, width) { + const out = []; + for (const s of samples) { + const head = `── ${kindTag(s.kind)} ${dirTag(s.dir)} · ${fmtAgo(now - s.ts)} ago `; + out.push([fg(label)(head + "─".repeat(Math.max(0, width - head.length)))]); + const sep = s.text.indexOf("\r\n\r\n"); + const headText = sep >= 0 ? s.text.slice(0, sep) : s.text; + const body = sep >= 0 ? s.text.slice(sep + 4) : ""; + headText.split(/\r?\n/).forEach((l, i) => out.push(i === 0 ? [bold(l)] : headerLine(l))); + if (body.trim()) { + out.push([" "]); + for (const bl of bodyLines(body)) out.push(bl); + } + out.push([" "]); + } + return out; +} + +export default function DetailPanel({ focusKey, tick, endpoint, totals, size, bodyScroll }) { return ( {() => { - tick.get(); // re-render on each state tick (fields below mutate in place) + tick.get(); // re-render on each state tick (fields mutate in place) + bodyScroll.get(); // and on scroll const r = endpoint(focusKey.get()); - if (!r) return {dim("endpoint no longer tracked — press esc to go back")}; + if (!r) { scrollState.max = 0; return {dim("endpoint no longer tracked — press esc to go back")}; } const now = Date.now(); const share = totals.reqs ? (r.count / totals.reqs) * 100 : 0; - const sparkW = Math.max(10, Math.min(r.hist.length || 1, size.get().cols - 18)); const lat = r.lat.length - ? `p50 ${fmtMs(percentile(r.lat, 50))} · p95 ${fmtMs(percentile(r.lat, 95))} · ` + - `max ${fmtMs(Math.max(...r.lat))} ${"·"} ${r.lat.length} samples` + ? `p50 ${fmtMs(percentile(r.lat, 50))} · p95 ${fmtMs(percentile(r.lat, 95))} · ` + + `p99 ${fmtMs(percentile(r.lat, 99))} · max ${fmtMs(Math.max(...r.lat))}` : dim("no responses paired yet"); - return [ - - {bold(fg(methodColor(r.method))(r.method))} - {bold(`${r.host}${r.path}`)} - , - , - {bold(fg(accent)(fmtCount(r.count)))}{dim(` (${r.count})`)}, - {`${share.toFixed(1)}% of all requests`}, - {r.rate > 0 ? fg(rateOn)(String(r.rate)) : dim("0")}{dim(` peak ${r.peak}/s`)}, - {lat}, - {statusSpans(r.status)}, - {fmtBytes(r.bytes)}{dim(" on the wire")}, - {`${fmtAgo(now - r.first)} ago`}, - {`${fmtAgo(now - r.last)} ago`}, - , - {fg(label)("Req/s, last minute")}, - {fg(rateOn)(sparkline(r.hist, sparkW, r.peak))}, - , - {fg(label)("Latency, recent responses")}, - {fg(accent)(sparkline(r.lat, sparkW))}, - ]; + + const { cols, rows } = size.get(); + const width = Math.max(20, cols - 4); + const lines = buildBodyLines(r.samples, now, width); + const vis = Math.max(3, rows - 15); // leave room for the stats block + chrome + scrollState.max = Math.max(0, lines.length - vis); + const off = Math.min(Math.max(0, bodyScroll.get()), scrollState.max); + const view = lines.slice(off, off + vis); + + return ( + + + {bold(fg(methodColor(r.method))(r.method))} + {bold(`${r.host}${r.path}`)} + + + {bold(fg(accent)(fmtCount(r.count)))} + {dim(` ${share.toFixed(1)}% · `)} + {r.rate > 0 ? fg(rateOn)(`${r.rate}/s`) : dim("0/s")} + {dim(` peak ${r.peak}/s`)} + + {lat} + {statusSpans(r.status)} + {fmtBytes(r.bytes)}{dim(` · first ${fmtAgo(now - r.first)} ago · last ${fmtAgo(now - r.last)} ago`)} + + + {fg(label)(`Recent payloads (${r.samples.length}) · j/k scroll`)} + {scrollState.max > 0 ? dim(` · ${off}/${scrollState.max}`) : ""} + + + {r.samples.length === 0 + ? {dim("no payloads captured yet")} + : view.map((l) => {l})} + + + ); }} ); diff --git a/src/components/footer.jsx b/src/components/footer.jsx index 755cffd..9e0c915 100644 --- a/src/components/footer.jsx +++ b/src/components/footer.jsx @@ -1,14 +1,18 @@ -// Running totals pinned to the bottom. Reads the plain `totals` object every -// render; the uptime ticks because the surrounding tree re-renders on state -// ticks (the list via `rows`, the detail screen via `tick`). +// Running totals pinned to the bottom. `totals` is a plain object and +// `endpointCount()` reads a plain Map, so neither makes this reactive on its +// own — the thunk reads the `tick` signal (bumped every redraw in httptop.js) +// to re-render, which is what advances reqs/bytes/uptime. import { Text, dim } from "yeet:tui"; import { fmtCount, fmtBytes, fmtUptime } from "@/lib/format.js"; -export default function Footer({ totals, endpointCount }) { +export default function Footer({ totals, endpointCount, tick }) { return ( - {() => dim( - `${fmtCount(totals.reqs)} reqs · ${endpointCount()} endpoints · ` + - `${fmtBytes(totals.bytes)} seen · up ${fmtUptime(Date.now() - totals.startMs)}` - )} + {() => { + tick.get(); // dependency: re-render on each sample/redraw tick + return dim( + `${fmtCount(totals.reqs)} reqs · ${endpointCount()} endpoints · ` + + `${fmtBytes(totals.bytes)} seen · up ${fmtUptime(Date.now() - totals.startMs)}` + ); + }} ); } diff --git a/src/main.jsx b/src/main.jsx index 7534b48..5265a49 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -18,7 +18,7 @@ import { ifaceLabel } from "@/probes/probe.js"; import { rows, totals, tick, endpoint, endpointCount, keyOf } from "@/probes/httptop.js"; import StatusBar from "@/components/statusbar.jsx"; import ListPanel from "@/components/list.jsx"; -import DetailPanel from "@/components/detail.jsx"; +import DetailPanel, { scrollState } from "@/components/detail.jsx"; import Footer from "@/components/footer.jsx"; import Legend from "@/components/legend.jsx"; @@ -37,6 +37,7 @@ if (typeof tty === "undefined") { // per-endpoint detail screen is open. Both are signals so the view reacts. const sel = signal(0); const focusKey = signal(null); +const bodyScroll = signal(0); // payload-view scroll offset on the detail screen function moveSel(delta) { const n = rows.get().length; @@ -49,7 +50,7 @@ function enterDetail() { const data = rows.get(); if (data.length === 0) return; const row = data[Math.max(0, Math.min(data.length - 1, sel.get()))]; - if (row) focusKey.set(keyOf(row)); + if (row) { bodyScroll.set(0); focusKey.set(keyOf(row)); } } const exitDetail = () => focusKey.set(null); @@ -62,9 +63,9 @@ const Root = (size) => ( {() => focusKey.get() - ? + ? : } -