diff --git a/src/components/detail.jsx b/src/components/detail.jsx
index 14c6702..dc1c09d 100644
--- a/src/components/detail.jsx
+++ b/src/components/detail.jsx
@@ -4,9 +4,11 @@
// they change. `endpoint()` looks the row up; `totals` gives the share.
import { Box, Text, bold, dim, fg } from "yeet:tui";
import {
- methodColor, accent, rateOn, grid, label, W_METHOD,
+ methodColor, accent, rateOn, grid, label, W_METHOD, W_COUNT, W_ERR,
fmtCount, fmtBytes, fmtAgo, fmtMs, percentile, statusColor, sparkline,
+ errColor, fmtErrPct, pad,
} from "@/lib/format.js";
+import { errRate } from "@/probes/httptop.js";
// Components are called `(opts, ...children)` by the JSX runtime, so read the
// value pieces from the rest args — not a `children` prop.
@@ -27,6 +29,23 @@ function statusSpans(status) {
[i ? " " : "", fg(statusColor(Number(code)))(code), dim(`×${n}`)]);
}
+/* The "by client" abuse breakdown: which callers drive this endpoint's traffic
+ * and its errors. Worst offender first (errors, then volume), top 6. */
+function clientRows(clients) {
+ const list = [...clients.entries()]
+ .map(([id, c]) => ({ id, count: c.count, errs: c.errs, er: c.count ? c.errs / c.count : 0 }))
+ .sort((a, b) => b.errs - a.errs || b.count - a.count)
+ .slice(0, 6);
+ if (!list.length) return [{dim("— no clients seen yet")}];
+ return list.map((c) => (
+
+ {c.errs > 0 ? c.id : dim(c.id)}
+ {pad(fmtCount(c.count), W_COUNT)}
+ {fg(errColor(c.er))(pad(fmtErrPct(c.er), W_ERR))}
+
+ ));
+}
+
export default function DetailPanel({ focusKey, tick, endpoint, totals, size }) {
return (
{r.rate > 0 ? fg(rateOn)(String(r.rate)) : dim("0")}{dim(` peak ${r.peak}/s`)},
{lat},
{statusSpans(r.status)},
+ {r.respTotal ? [
+ bold(fg(errColor(errRate(r)))(fmtErrPct(errRate(r)))),
+ dim(` ${r.err4} 4xx · ${r.err5} 5xx of ${r.respTotal} paired`),
+ ] : dim("no responses paired yet")},
{fmtBytes(r.bytes)}{dim(" on the wire")},
{`${fmtAgo(now - r.first)} ago`},
{`${fmtAgo(now - r.last)} ago`},
@@ -62,6 +85,16 @@ export default function DetailPanel({ focusKey, tick, endpoint, totals, size })
,
{fg(label)("Latency, recent responses")},
{fg(accent)(sparkline(r.lat, sparkW))},
+ ,
+ {fg(label)("Errors/s, last minute")},
+ {fg(errColor(errRate(r)))(sparkline(r.ehist, sparkW))},
+ ,
+
+ {fg(label)("By client (worst first)")}
+ {fg(label)(pad("REQS", W_COUNT))}
+ {fg(label)(pad("ERR%", W_ERR))}
+ ,
+ ...clientRows(r.clients),
];
}}
diff --git a/src/components/legend.jsx b/src/components/legend.jsx
index c578897..ea87f74 100644
--- a/src/components/legend.jsx
+++ b/src/components/legend.jsx
@@ -1,14 +1,16 @@
// Mode-aware key legend: keys in accent, labels dimmed. Reads `focusKey` so it
-// swaps between the list-screen and detail-screen bindings reactively.
+// swaps between the list-screen and detail-screen bindings reactively, and
+// `sortMode` so the `e` hint shows the order it will switch the list into.
import { Text, dim, fg } from "yeet:tui";
import { accent } from "@/lib/format.js";
-export default function Legend({ focusKey }) {
+export default function Legend({ focusKey, sortMode }) {
return (
{() => {
const keys = focusKey.get()
? [["esc / ←", "back"], ["q", "list"], ["Ctrl-C", "quit"]]
- : [["↑/↓", "move"], ["PgUp/Dn", "page"], ["⏎", "details"], ["q / Ctrl-C", "quit"]];
+ : [["↑/↓", "move"], ["PgUp/Dn", "page"], ["⏎", "details"],
+ ["e", `sort: ${sortMode.get()}`], ["q / Ctrl-C", "quit"]];
return keys.flatMap(([k, d], i) => [i ? dim(" ") : "", fg(accent)(k), dim(" " + d)]);
}}
);
diff --git a/src/components/list.jsx b/src/components/list.jsx
index e622c12..496013d 100644
--- a/src/components/list.jsx
+++ b/src/components/list.jsx
@@ -4,19 +4,21 @@
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,
+ W_RANK, W_METHOD, W_COUNT, W_RATE, W_HOST, W_LAST, W_ERR, W_PATH,
+ pad, padEnd, cell, fmtCount, fmtAgo, fmtErrPct, errColor,
} from "@/lib/format.js";
+import { errRate } from "@/probes/httptop.js";
function HeaderRow() {
return (
- {dim("#")}
- {bold("METHOD")}
- {bold("HOST")}
- {bold("PATH")}
+ {dim(cell("#", W_RANK))}
+ {bold(padEnd("METHOD", W_METHOD))}
+ {bold(padEnd("HOST", W_HOST))}
+ {bold(padEnd("PATH", W_PATH))}
{bold(pad("COUNT", W_COUNT))}
{bold(pad("REQ/S", W_RATE))}
+ {bold(pad("ERR%", W_ERR))}
{bold(pad("LAST", W_LAST))}
);
@@ -24,14 +26,22 @@ function HeaderRow() {
function Row({ row, rank, selected }) {
const rateStr = row.rate > 0 ? pad(fmtCount(row.rate), W_RATE) : dim(pad("·", W_RATE));
+ const er = errRate(row);
+ const errCell = pad(fmtErrPct(er), W_ERR);
+ // Highlight a real incident: red + bold once the error rate clears the noise band.
+ const errSpan = er <= 0 ? dim(errCell)
+ : er >= 0.15 ? bold(fg(errColor(er))(errCell))
+ : fg(errColor(er))(errCell);
+ const rankCell = cell((selected ? "› " : " ") + rank, W_RANK);
return (
- {selected ? fg(accent)("› " + pad(rank, 2).slice(1)) : dim(pad(rank, 2) + " ")}
+ {selected ? fg(accent)(rankCell) : dim(rankCell)}
{fg(methodColor(row.method))(padEnd(row.method, W_METHOD))}
- {dim(row.host)}
- {row.path}
+ {dim(cell(row.host, W_HOST))}
+ {cell(row.path, W_PATH)}
{bold(fg(accent)(pad(fmtCount(row.count), W_COUNT)))}
{row.rate > 0 ? fg(rateOn)(rateStr) : rateStr}
+ {errSpan}
{dim(pad(fmtAgo(Date.now() - row.last), W_LAST))}
);
diff --git a/src/components/statusbar.jsx b/src/components/statusbar.jsx
index 66bb722..01dd9ef 100644
--- a/src/components/statusbar.jsx
+++ b/src/components/statusbar.jsx
@@ -1,13 +1,22 @@
-// 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.
+// Top status bar: the brand on the left; the right side normally shows the
+// watched-interface label, but yields to a red incident banner when an endpoint
+// trips the error-rate alert. `topAlert()` reads endpoint stats that mutate in
+// place, so the banner re-evaluates off `tick`.
import { Box, Text, bold, dim, fg } from "yeet:tui";
-import { accent } from "@/lib/format.js";
+import { accent, errColor, fmtErrPct } from "@/lib/format.js";
-export default function StatusBar({ ifaceLabel }) {
+export default function StatusBar({ ifaceLabel, tick, topAlert }) {
return (
{bold(fg(accent)("httpinspect"))}
- {dim(` iface: ${ifaceLabel} · plaintext HTTP only`)}
+ {() => {
+ tick.get(); // re-evaluate the alert as endpoint stats mutate in place
+ const a = topAlert();
+ if (!a) return dim(` iface: ${ifaceLabel} · plaintext HTTP only`);
+ const who = a.client ? ` · ${a.client}` : "";
+ return " " + bold(fg(errColor(a.rate))(
+ `⚠ ${a.method} ${a.path} ${fmtErrPct(a.rate)} errors${who}`));
+ }}
);
}
diff --git a/src/lib/format.js b/src/lib/format.js
index 562b406..6627a94 100644
--- a/src/lib/format.js
+++ b/src/lib/format.js
@@ -18,12 +18,22 @@ 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 is capped so the numeric columns land in
+ * the same screen position on every row; a trailing 1fr spacer absorbs any
+ * leftover width on wide terminals. */
+export const W_RANK = 4, W_METHOD = 8, W_COUNT = 8, W_RATE = 8, W_HOST = 22, W_LAST = 6, W_ERR = 7, W_PATH = 50;
export const pad = (s, w) => String(s).padStart(w);
export const padEnd = (s, w) => String(s).padEnd(w);
+/* Fit a left-aligned string to exactly `w` columns: elide with `…` past the
+ * width, pad out below it. A Text's `width` is only a max — it won't pad short
+ * content for us — so a constant-width column has to carry its own padding. */
+export const cell = (s, w) => {
+ s = String(s);
+ return s.length > w ? s.slice(0, Math.max(0, w - 1)) + "…" : s.padEnd(w);
+};
+
/* 1234 -> "1.2k", 12345 -> "12k", 1_200_000 -> "1.2M" */
export function fmtCount(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
@@ -62,6 +72,21 @@ export function fmtMs(ms) {
return ms.toFixed(2) + "ms";
}
+/* error fraction (0..1) -> "·" when none, else "0.4%" / "12%" / "73%". */
+export function fmtErrPct(rate) {
+ if (rate <= 0) return "·";
+ const p = rate * 100;
+ if (p >= 10) return Math.round(p) + "%";
+ return p.toFixed(1) + "%";
+}
+
+/* error fraction (0..1) -> heat color: grey none, yellow some, red bad. */
+export function errColor(rate) {
+ if (rate <= 0) return label;
+ if (rate < 0.15) return rgb(0xdcdcaa); // yellow — baseline noise
+ return rgb(0xf48771); // red — a real incident
+}
+
/* HTTP status code -> color by class (2xx green, 3xx blue, 4xx yellow, 5xx red). */
export function statusColor(code) {
if (code >= 500) return rgb(0xf48771);
diff --git a/src/main.jsx b/src/main.jsx
index 7534b48..80b15a0 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -15,7 +15,7 @@
// importing probes/probe.js (the shared object) and probes/httptop.js (ingest).
import { Box, mount, signal } from "yeet:tui";
import { ifaceLabel } from "@/probes/probe.js";
-import { rows, totals, tick, endpoint, endpointCount, keyOf } from "@/probes/httptop.js";
+import { rows, totals, tick, endpoint, endpointCount, keyOf, sortMode, topAlert } from "@/probes/httptop.js";
import StatusBar from "@/components/statusbar.jsx";
import ListPanel from "@/components/list.jsx";
import DetailPanel from "@/components/detail.jsx";
@@ -54,18 +54,25 @@ function enterDetail() {
const exitDetail = () => focusKey.set(null);
+/* Flip the list between busiest-first and worst-error-rate-first. Reset the
+ * selection because the rows reorder under it on the next tick. */
+function toggleSort() {
+ sortMode.set(sortMode.get() === "count" ? "errors" : "count");
+ sel.set(0);
+}
+
// ── root ─────────────────────────────────────────────────────────────────────
// `view(size)` hands us the terminal's reactive size signal; reading it inside
// the body thunk reflows the active panel on resize. The body switches screens
// on `focusKey`; the data layer feeds it through the props.
const Root = (size) => (
-
+
{() => focusKey.get()
?
: }
-
+
);
@@ -93,6 +100,7 @@ tty.on("keydown", (e) => {
default:
if (e.key === "j") moveSel(1);
else if (e.key === "k") moveSel(-1);
+ else if (e.key === "e") toggleSort();
else if (e.key === "q") yeet.exit();
}
});
diff --git a/src/probes/httptop.js b/src/probes/httptop.js
index 2ca472c..ee5b9fd 100644
--- a/src/probes/httptop.js
+++ b/src/probes/httptop.js
@@ -20,8 +20,23 @@ export const TICK_MS = 400; /* redraw cadence between per-second rate samples */
* `--keep-query` keeps them distinct. */
const keepQuery = !!yeet.args.keep_query;
+/* Request header used to identify the calling client (a merchant API key, a
+ * tenant id, …). Lower-cased for a case-insensitive header match. `--client-header
+ * x-tenant-id` overrides; default is the card-auth merchant key. The per-endpoint
+ * "by client" breakdown keys on whatever this header carries, falling back to the
+ * Host and then "anon". */
+const clientHeader = String(yeet.args.client_header || "x-api-key").toLowerCase();
+
+/* Error-rate alert threshold for the status-bar banner: an endpoint trips the
+ * banner once enough responses are in and the 4xx+5xx share clears this. */
+const ALERT_MIN_RESP = 20;
+const ALERT_RATE = 0.15;
+
/* endpoint key -> { method, host, path, count, prev, rate, peak, bytes,
- * first, last, hist, lat, status, lastMs } */
+ * first, last, hist, lat, status, lastMs,
+ * respTotal, // responses paired (denominator for the error rate)
+ * errs, err4, err5, errPrev, ehist, // 4xx+5xx accounting, errors/sec history
+ * clients } // clientId -> { count, errs, status:{} } (the abuse breakdown) */
const stats = new Map();
export const rows = signal([]);
export const totals = { reqs: 0, bytes: 0, startMs: Date.now() };
@@ -29,6 +44,33 @@ export const endpointCount = () => stats.size;
export const endpoint = (key) => stats.get(key) ?? null;
export const keyOf = (r) => `${r.method} ${r.host} ${r.path}`;
+/* 4xx+5xx share of an endpoint's paired responses, 0..1 (0 before any land). */
+export const errRate = (r) => (r.respTotal ? r.errs / r.respTotal : 0);
+
+/* List sort order. "count" (default, busiest first) or "errors" (worst error
+ * rate first) — main.jsx flips it on `e` so the incident floats to the top. */
+export const sortMode = signal("count");
+
+/* The worst-offending endpoint for the status-bar banner: the highest error
+ * rate above ALERT_RATE once it has enough responses, plus the client driving
+ * most of its errors. null when nothing is alarming. */
+export function topAlert() {
+ let worst = null;
+ for (const r of stats.values()) {
+ if (r.respTotal < ALERT_MIN_RESP) continue;
+ const rate = errRate(r);
+ if (rate < ALERT_RATE) continue;
+ if (!worst || rate > worst.rate) worst = { row: r, rate };
+ }
+ if (!worst) return null;
+ // Name the client contributing the most errors on that endpoint.
+ let topClient = null;
+ for (const [id, c] of worst.row.clients) {
+ if (c.errs > 0 && (!topClient || c.errs > topClient.errs)) topClient = { id, errs: c.errs };
+ }
+ return { method: worst.row.method, path: worst.row.path, rate: worst.rate, client: topClient?.id ?? null };
+}
+
/* Bumped every redraw tick. The detail screen reads it so it re-renders as an
* endpoint's in-place fields (rate, latency, …) change — those mutations don't
* touch a signal on their own. The list re-renders via `rows` instead. */
@@ -71,14 +113,19 @@ function parseRequest(bytes) {
const method = m[1];
let target = m[2];
+ // One pass over the headers: pull Host (for the endpoint key) and the
+ // configured client header (for the per-client breakdown). Both are
+ // case-insensitive; we keep scanning until we have what we need.
let host = null;
+ let client = null;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const c = line.indexOf(":");
- if (c > 0 && line.slice(0, c).toLowerCase() === "host") {
- host = line.slice(c + 1).trim();
- break;
- }
+ if (c <= 0) continue;
+ const name = line.slice(0, c).toLowerCase();
+ if (host === null && name === "host") host = line.slice(c + 1).trim();
+ else if (client === null && name === clientHeader) client = line.slice(c + 1).trim();
+ if (host !== null && client !== null) break;
}
// CONNECT / absolute-form targets carry the authority in the target itself.
@@ -94,7 +141,7 @@ function parseRequest(bytes) {
const q = path.indexOf("?");
if (q >= 0) path = path.slice(0, q);
}
- return { method, host: host || "-", path };
+ return { method, host: host || "-", path, client: client || host || "anon" };
}
/* ---- ingest ------------------------------------------------------- */
@@ -137,7 +184,9 @@ 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,
+ respTotal: 0, errs: 0, err4: 0, err5: 0, errPrev: 0, ehist: [],
+ clients: new Map() };
stats.set(key, row);
}
const len = Number(ev.total_len);
@@ -147,18 +196,25 @@ function onRequest(ev, data, now) {
totals.reqs++;
totals.bytes += len;
- // Queue this request so the matching response can measure its latency.
+ // Tally this request against its client now (volume shows immediately); the
+ // response's status is attributed to the same client when it's paired below.
+ let c = row.clients.get(req.client);
+ if (!c) { c = { count: 0, errs: 0, status: {} }; row.clients.set(req.client, c); }
+ c.count++;
+
+ // Queue this request so the matching response can measure its latency and so
+ // the response's status code lands on the right endpoint and client.
const f = flowKey(ev);
let q = pending.get(f);
if (!q) { q = []; pending.set(f, q); }
- q.push({ ts: Number(ev.ts), key, at: now });
+ q.push({ ts: Number(ev.ts), key, client: req.client, at: now });
if (q.length > 64) q.shift(); // cap a flow whose responses we never see
}
function onResponse(ev, data, now) {
const q = pending.get(flowKey(ev));
if (!q || q.length === 0) return; // no request seen for this flow
- const { ts: reqTs, key } = q.shift();
+ const { ts: reqTs, key, client } = q.shift();
if (q.length === 0) pending.delete(flowKey(ev));
const row = stats.get(key);
@@ -170,14 +226,37 @@ function onResponse(ev, data, now) {
row.lastMs = ms;
const code = parseStatus(data.subarray(0, Number(ev.captured)));
- if (code) row.status[code] = (row.status[code] || 0) + 1;
+ if (!code) return;
+ row.status[code] = (row.status[code] || 0) + 1;
+ row.respTotal++;
+
+ // Error accounting: a 4xx/5xx counts against the endpoint and the client that
+ // sent the paired request, so the "by client" breakdown pins who's failing.
+ const isErr = code >= 400;
+ if (isErr) {
+ row.errs++;
+ if (code >= 500) row.err5++; else row.err4++;
+ }
+ const c = row.clients.get(client);
+ if (c) {
+ c.status[code] = (c.status[code] || 0) + 1;
+ if (isErr) c.errs++;
+ }
}
/* ---- ticking ------------------------------------------------------ */
/* Re-sort endpoints by count and push to the `rows` signal (the view reads it
* reactively). Called on every redraw tick. */
function refresh() {
- rows.set([...stats.values()].sort((a, b) => b.count - a.count));
+ const data = [...stats.values()];
+ if (sortMode.get() === "errors") {
+ // Worst error rate first, count as the tiebreaker so quiet endpoints with a
+ // lone error don't outrank a busy one that's genuinely on fire.
+ data.sort((a, b) => errRate(b) - errRate(a) || b.count - a.count);
+ } else {
+ data.sort((a, b) => b.count - a.count);
+ }
+ rows.set(data);
}
/* Per-second: turn the count delta since the last sample into a req/s rate,
@@ -190,6 +269,12 @@ function sampleRates() {
if (row.rate > row.peak) row.peak = row.rate;
row.hist.push(row.rate);
if (row.hist.length > HIST_LEN) row.hist.shift();
+
+ // Errors/sec this window, kept as a sibling history for the detail sparkline.
+ const erate = row.errs - row.errPrev;
+ row.errPrev = row.errs;
+ row.ehist.push(erate);
+ if (row.ehist.length > HIST_LEN) row.ehist.shift();
}
for (const [k, t] of seen) if (now - t > 4000) seen.delete(k);