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/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..b4f94c9 100644 --- a/src/components/detail.jsx +++ b/src/components/detail.jsx @@ -1,68 +1,204 @@ -// 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"; +// Detail: a two-screen drill for one endpoint. +// • requests table — a Wireshark-style list of the endpoint's recent calls +// (one row each: #, age, status, latency, size). ↑/↓ select, ⏎ to open. +// • body view — the opened request's headers + body, scrollable, with `<`/`>` +// flipping the in (request) / out (response). esc steps back out. +// Reads `focusKey`, `tick`, and the nav signals (`txnSel`, `open`, `txnDir`, +// `scroll`). `open` distinguishes the two screens. +import { Box, Text, bold, fg, bg, rgb } from "yeet:tui"; import { - methodColor, accent, rateOn, grid, label, W_METHOD, - fmtCount, fmtBytes, fmtAgo, fmtMs, percentile, statusColor, sparkline, + methodColor, accent, rateOn, grid, label, muted, selBg, W_METHOD, + fmtCount, fmtBytes, fmtAgo, fmtMs, statusColor, latColor, } from "@/lib/format.js"; -// 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) { - return ( - - {fg(label)(opts.name)} - {children.flat(Infinity)} - - ); +// Vibrant JSON syntax palette. +const J_KEY = rgb(0x8be9fd), J_STR = rgb(0xf1fa8c), J_NUM = rgb(0xbd93f9), J_LIT = rgb(0xff79c6); + +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(muted)(m[5])); + last = JSON_TOK.lastIndex; + } + if (last < line.length) spans.push(line.slice(last)); + return spans.length ? spans : [line]; +} + +function wrapTo(line, width) { + if (line.length <= width) return [line]; + const out = []; + for (let i = 0; i < line.length; i += width) out.push(line.slice(i, i + width)); + return out; +} + +function headerLine(l) { + const i = l.indexOf(":"); + return i < 0 ? [fg(muted)(l)] : [fg(label)(l.slice(0, i + 1)), l.slice(i + 1)]; +} + +// A captured message → wrapped+colored display lines. Headers and body are +// collapsible sections (▾ open / ▸ collapsed), driven by hOpen/bOpen. +function msgLines(text, width, hOpen, bOpen) { + const sep = text.indexOf("\r\n\r\n"); + const headText = sep >= 0 ? text.slice(0, sep) : text; + const body = sep >= 0 ? text.slice(sep + 4) : ""; + const rule = (lbl) => [fg(label)(`${lbl} ` + "─".repeat(Math.max(0, width - lbl.length - 1)))]; + const headerCount = headText.split(/\r?\n/).length; + + const out = [rule(`${hOpen ? "▾" : "▸"} headers (${headerCount})`)]; + if (hOpen) { + headText.split(/\r?\n/).forEach((l, j) => + wrapTo(l, width).forEach((c, k) => out.push(j === 0 ? [bold(c)] : (k === 0 ? headerLine(c) : [c])))); + } + + out.push([" "], rule(`${bOpen ? "▾" : "▸"} body`)); + if (bOpen) { + const t = body.trim(); + if (!t) { + out.push([fg(muted)("(no body)")]); + } else { + let src = body; + if (t.startsWith("{") || t.startsWith("[")) { + try { src = JSON.stringify(JSON.parse(t), null, 2); } catch { /* truncated: color raw */ } + } + for (const line of src.split(/\r?\n/)) for (const c of wrapTo(line, width)) out.push(colorJsonLine(c)); + } + } + return out; } -/* Status-code tallies as colored "200×120 404×3" spans, busiest first. */ +// Max scroll offset of the body view, published each render for main.jsx. +export const detailView = { max: 0 }; +// Top row of the requests-table window, kept across renders. +let tableTop = 0; + function statusSpans(status) { const codes = Object.entries(status).sort((a, b) => b[1] - a[1]).slice(0, 6); - if (codes.length === 0) return dim("— no responses paired yet"); + if (codes.length === 0) return fg(muted)("none paired"); return codes.flatMap(([code, n], i) => - [i ? " " : "", fg(statusColor(Number(code)))(code), dim(`×${n}`)]); + [i ? " " : "", bold(fg(statusColor(Number(code)))(code)), fg(muted)(`×${n}`)]); } -export default function DetailPanel({ focusKey, tick, endpoint, totals, size }) { +// in / out as clickable-looking tabs; the active one is loud — a bright filled +// bg (cyan for in, pink for out) with dark ink for contrast. +const TAB_INK = rgb(0x14142b); +const tab = (lbl, active, hue) => active + ? bg(hue)(bold(fg(TAB_INK)(` ${lbl} `))) + : fg(muted)(` ${lbl} `); + +// Endpoint header shown on both screens. +function endpointHead(r, totals) { + const share = totals.reqs ? (r.count / totals.reqs) * 100 : 0; + return [ + + {bold(fg(methodColor(r.method))(r.method))} + {bold(fg(accent)(`${r.host}${r.path}`))} + , + {[ + bold(fg(accent)(fmtCount(r.count))), fg(muted)(" reqs · "), + fg(muted)(`${share.toFixed(1)}% · `), + r.rate > 0 ? bold(fg(rateOn)(`${r.rate}/s`)) : fg(muted)("0/s"), + fg(muted)(" · "), ...[].concat(statusSpans(r.status)), + ]}, + ]; +} + +// ---- requests table (Wireshark-style packet list) ---- +// Width-based columns (a single padded Text gets its spaces trimmed, which +// collapses the columns) — one Text per cell, like the endpoint list. +const C_TIME = 9, C_CODE = 6, C_LAT = 9, C_SIZE = 8; + +function tableHeader() { + return ( + + {" "} + {bold(fg(accent)("Time"))} + {bold(fg(accent)("Code"))} + {bold(fg(accent)("Latency"))} + {bold(fg(accent)("Size"))} + {bold(fg(accent)("Info"))} + + ); +} + +function tableRow(t, on, now) { + const info = t.in.split(/\r?\n/)[0] || ""; + const method = info.split(" ")[0] || ""; + return ( + + {on ? fg(accent)("▸") : " "} + {fg(on ? accent : muted)(`${fmtAgo(now - t.ts)} ago`)} + {t.status ? bold(fg(statusColor(t.status))(String(t.status))) : fg(muted)(t.out === null ? "·" : "—")} + {t.ms != null ? fg(latColor(t.ms))(fmtMs(t.ms)) : fg(muted)("·")} + {fg(muted)(fmtBytes((t.in?.length || 0) + (t.out?.length || 0)))} + {fg(methodColor(method))(method)}{fg(on ? accent : muted)(info.slice(method.length))} + + ); +} + +export default function DetailPanel({ focusKey, tick, endpoint, totals, size, txnSel, open, txnDir, scroll, hOpen, bOpen }) { return ( {() => { - tick.get(); // re-render on each state tick (fields below mutate in place) + tick.get(); txnSel.get(); open.get(); txnDir.get(); scroll.get(); hOpen.get(); bOpen.get(); const r = endpoint(focusKey.get()); - if (!r) return {dim("endpoint no longer tracked — press esc to go back")}; + if (!r) return {fg(muted)("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` - : 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 { rows, cols } = size.get(); + const txns = r.txns; + const sel = Math.min(Math.max(0, txnSel.get()), Math.max(0, txns.length - 1)); + + if (!open.get()) { + // ── requests table ── + const vis = Math.max(3, rows - 9); + if (sel < tableTop) tableTop = sel; + else if (sel >= tableTop + vis) tableTop = sel - vis + 1; + tableTop = Math.max(0, Math.min(tableTop, Math.max(0, txns.length - vis))); + return ( + + {endpointHead(r, totals)} + {fg(label)(`requests (${txns.length})`)}{fg(muted)(" ↑/↓ select · ⏎ open · esc back")} + {txns.length === 0 + ? {fg(muted)("no requests captured yet")} + : [tableHeader(), ...txns.slice(tableTop, tableTop + vis).map((t, i) => tableRow(t, tableTop + i === sel, now))]} + + ); + } + + // ── body view ── + const txn = txns[sel]; + const dir = txnDir.get(); // 0 = in (request), 1 = out (response) + const msg = txn ? (dir === 1 ? txn.out : txn.in) : null; + const W = Math.max(24, cols - 6); + const lines = msg ? msgLines(msg, W, hOpen.get(), bOpen.get()) + : [[fg(muted)(dir === 1 ? "no response captured (egress not seen)" : "no request captured")]]; + const vis = Math.max(3, rows - 8); + detailView.max = Math.max(0, lines.length - vis); + const off = Math.min(Math.max(0, scroll.get()), detailView.max); + return ( + + {endpointHead(r, totals)} + {[ + fg(label)(`req ${txns.length ? sel + 1 : 0}/${txns.length}`), + txn && txn.status ? fg(muted)(" · ") : "", + txn && txn.status ? bold(fg(statusColor(txn.status))(String(txn.status))) : "", + txn && txn.ms != null ? fg(muted)(` · ${fmtMs(txn.ms)}`) : "", + " ", tab("in", dir === 0, accent), " ", tab("out", dir === 1, rgb(0xff79c6)), + fg(muted)(" tab · h/b collapse · ↑/↓ scroll · ←/→ back"), + ]} + + {lines.slice(off, off + vis).map((l) => {l})} + + + ); }} ); diff --git a/src/components/footer.jsx b/src/components/footer.jsx index 755cffd..33795b7 100644 --- a/src/components/footer.jsx +++ b/src/components/footer.jsx @@ -1,14 +1,21 @@ -// 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`). -import { Text, dim } from "yeet:tui"; -import { fmtCount, fmtBytes, fmtUptime } from "@/lib/format.js"; +// 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 the counters. +import { Text, bold, fg } from "yeet:tui"; +import { accent, rateOn, muted, label, 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 + const sep = fg(muted)(" · "); + return [ + bold(fg(accent)(fmtCount(totals.reqs))), fg(muted)(" reqs"), sep, + bold(fg(rateOn)(String(endpointCount()))), fg(muted)(" endpoints"), sep, + bold(fg(label)(fmtBytes(totals.bytes))), fg(muted)(" seen"), sep, + fg(muted)("up "), bold(fg(accent)(fmtUptime(Date.now() - totals.startMs))), + ]; + }} ); } diff --git a/src/components/legend.jsx b/src/components/legend.jsx index c578897..c28b527 100644 --- a/src/components/legend.jsx +++ b/src/components/legend.jsx @@ -1,15 +1,17 @@ -// Mode-aware key legend: keys in accent, labels dimmed. Reads `focusKey` so it -// swaps between the list-screen and detail-screen bindings reactively. -import { Text, dim, fg } from "yeet:tui"; -import { accent } from "@/lib/format.js"; +// Mode-aware key legend: keys in accent, labels in muted indigo. Reads +// `focusKey` so it swaps between the list-screen and detail-screen bindings. +import { Text, bold, fg } from "yeet:tui"; +import { accent, muted } from "@/lib/format.js"; -export default function Legend({ focusKey }) { +export default function Legend({ focusKey, open }) { return ( {() => { - const keys = focusKey.get() - ? [["esc / ←", "back"], ["q", "list"], ["Ctrl-C", "quit"]] - : [["↑/↓", "move"], ["PgUp/Dn", "page"], ["⏎", "details"], ["q / Ctrl-C", "quit"]]; - return keys.flatMap(([k, d], i) => [i ? dim(" ") : "", fg(accent)(k), dim(" " + d)]); + const keys = !focusKey.get() + ? [["↑/↓", "move"], ["⏎/→", "requests"], ["PgUp/Dn", "page"], ["q / Ctrl-C", "quit"]] + : open.get() + ? [["tab", "in/out"], ["h/b", "collapse"], ["↑/↓", "scroll"], ["←/→", "back"]] + : [["↑/↓", "request"], ["⏎", "open body"], ["esc", "back"]]; + return keys.flatMap(([k, d], i) => [i ? fg(muted)(" ") : "", bold(fg(accent)(k)), fg(muted)(" " + d)]); }} ); } diff --git a/src/components/list.jsx b/src/components/list.jsx index e622c12..0b991b9 100644 --- a/src/components/list.jsx +++ b/src/components/list.jsx @@ -1,38 +1,62 @@ // List screen: a bordered, flex-filling endpoint table sorted by request // count. Reads `rows` (the sorted endpoint snapshot) and `sel` (the // highlighted index); `size` reflows the visible window on resize. -import { Box, Text, bold, dim, fg } from "yeet:tui"; +import { Box, Text, bold, 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, muted, 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 ( - {dim("#")} - {bold("METHOD")} - {bold("HOST")} - {bold("PATH")} - {bold(pad("COUNT", W_COUNT))} - {bold(pad("REQ/S", W_RATE))} - {bold(pad("LAST", W_LAST))} + {fg(muted)("#")} + {bold(fg(accent)("METHOD"))} + {bold(fg(accent)("HOST"))} + {bold(fg(accent)("PATH"))} + {STATUS_CLASSES.map((c) => ( + {bold(fg(statusColor(c * 100))(pad(`${c}xx`, W_STATUS)))} + ))} + {bold(fg(accent)(pad("RPS", W_RATE)))} + {bold(fg(accent)(pad("P99", W_LAT)))} + {bold(fg(accent)(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) : fg(muted)(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} - {dim(pad(fmtAgo(Date.now() - row.last), W_LAST))} + {fg(selected ? accent : muted)(pad(rank, 2) + " ")} + {bold(fg(methodColor(row.method))(padEnd(row.method, W_METHOD)))} + {fg(muted)(row.host)} + {bold(row.path)} + {STATUS_CLASSES.map((c) => ( + + {status[c] > 0 ? fg(statusColor(c * 100))(pad(fmtCount(status[c]), W_STATUS)) : fg(muted)(pad("·", W_STATUS))} + + ))} + {rps > 0 ? fg(rateOn)(rateStr) : rateStr} + {p99 != null ? fg(latColor(p99))(pad(fmtMs(p99), W_LAT)) : fg(muted)(pad("·", W_LAT))} + {fg(muted)(pad(fmtAgo(Date.now() - row.last), W_LAST))} ); } @@ -46,15 +70,14 @@ export default function ListPanel({ rows, sel, size }) { - {dim("─".repeat(400))} + {fg(grid)("─".repeat(400))} {() => { const data = rows.get(); const vis = Math.max(3, size.get().rows - 8); if (data.length === 0) { - return {dim("waiting for HTTP requests… (try: curl http://localhost:PORT/path)")}; + return {fg(muted)("waiting for HTTP requests… (try: curl http://localhost:PORT/path)")}; } const cur = Math.max(0, Math.min(data.length - 1, sel.get())); - // Keep the selection inside the visible window [listTop, listTop+vis). if (cur < listTop) listTop = cur; else if (cur >= listTop + vis) listTop = cur - vis + 1; listTop = Math.max(0, Math.min(listTop, Math.max(0, data.length - vis))); diff --git a/src/components/statusbar.jsx b/src/components/statusbar.jsx index 66bb722..82d7432 100644 --- a/src/components/statusbar.jsx +++ b/src/components/statusbar.jsx @@ -1,13 +1,15 @@ // Top status bar: the brand on the left, the watched-interface label on the // right. Pure UI — `ifaceLabel` is the static string the probe resolved. -import { Box, Text, bold, dim, fg } from "yeet:tui"; -import { accent } from "@/lib/format.js"; +import { Box, Text, bold, fg } from "yeet:tui"; +import { accent, label, muted } from "@/lib/format.js"; export default function StatusBar({ ifaceLabel }) { return ( {bold(fg(accent)("httpinspect"))} - {dim(` iface: ${ifaceLabel} · plaintext HTTP only`)} + + {fg(muted)(" iface: ")}{fg(label)(ifaceLabel)}{fg(muted)(" · plaintext HTTP only")} + ); } diff --git a/src/lib/format.js b/src/lib/format.js index 562b406..23c2e00 100644 --- a/src/lib/format.js +++ b/src/lib/format.js @@ -1,25 +1,30 @@ // Pure presentation helpers — strings, color, and the table's column widths. // No signals or BPF, so it's safe to import anywhere; the components reach it // through the `@/` alias (resolved at bundle time). -import { rgb, idx } from "yeet:tui"; +import { rgb } from "yeet:tui"; -/* Per-method accent colors; unknown methods fall back to plain grey. */ +/* Per-method accent colors — all vibrant, no greys. */ export const METHOD_COLORS = { - GET: rgb(0x4ec9b0), POST: rgb(0xdcdcaa), PUT: rgb(0x9cdcfe), - PATCH: rgb(0xc586c0), DELETE: rgb(0xf48771), HEAD: rgb(0x808080), - OPTIONS: rgb(0x808080), CONNECT: rgb(0x808080), TRACE: rgb(0x808080), + GET: rgb(0x50fa7b), POST: rgb(0xf1fa8c), PUT: rgb(0x8be9fd), + PATCH: rgb(0xbd93f9), DELETE: rgb(0xff5555), HEAD: rgb(0xff79c6), + OPTIONS: rgb(0xffb86c), CONNECT: rgb(0x80ffea), TRACE: rgb(0xd6acff), }; -export const METHOD_FALLBACK = idx(7); +export const METHOD_FALLBACK = rgb(0xbd93f9); export const methodColor = (m) => METHOD_COLORS[m] || METHOD_FALLBACK; -export const accent = rgb(0x4fc1ff); /* httptop brand + count column */ -export const rateOn = rgb(0x4ec9b0); /* a live (>0) req/s value */ -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 */ +export const accent = rgb(0x8be9fd); /* cyan: brand, counts, selection */ +export const rateOn = rgb(0x50fa7b); /* green: a live (>0) req/s value */ +export const muted = rgb(0x8b9bf5); /* soft indigo: secondary text (replaces dim) */ +export const grid = rgb(0x6d5dfc); /* indigo: table border + dividers */ +export const selBg = rgb(0x3b3168); /* indigo-violet: highlighted/selected row */ +export const label = rgb(0xff79c6); /* pink: field labels / header names */ -/* 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 +37,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; @@ -64,13 +74,32 @@ export function fmtMs(ms) { /* HTTP status code -> color by class (2xx green, 3xx blue, 4xx yellow, 5xx red). */ export function statusColor(code) { - if (code >= 500) return rgb(0xf48771); - if (code >= 400) return rgb(0xdcdcaa); - if (code >= 300) return rgb(0x9cdcfe); - if (code >= 200) return rgb(0x4ec9b0); + if (code >= 500) return rgb(0xff5555); + if (code >= 400) return rgb(0xf1fa8c); + if (code >= 300) return rgb(0x8be9fd); + if (code >= 200) return rgb(0x50fa7b); 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 = [0xff, 0x55, 0x55]; + 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; diff --git a/src/main.jsx b/src/main.jsx index 7534b48..a31df45 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, { detailView } from "@/components/detail.jsx"; import Footer from "@/components/footer.jsx"; import Legend from "@/components/legend.jsx"; @@ -37,6 +37,12 @@ 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 txnSel = signal(0); // selected request in the detail requests table +const open = signal(false); // false = requests table, true = body view +const txnDir = signal(0); // 0 = in (request), 1 = out (response) +const scroll = signal(0); // line offset of the body view +const hOpen = signal(true); // headers section expanded in the body view +const bOpen = signal(true); // body section expanded in the body view function moveSel(delta) { const n = rows.get().length; @@ -49,7 +55,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) { txnSel.set(0); txnDir.set(0); open.set(false); scroll.set(0); focusKey.set(keyOf(row)); } } const exitDetail = () => focusKey.set(null); @@ -62,10 +68,10 @@ const Root = (size) => ( {() => focusKey.get() - ? + ? : } -