From bb177d42b70ff7845f8cfaefe03ec56de94ee487 Mon Sep 17 00:00:00 2001 From: Jason E Date: Sat, 27 Jun 2026 14:17:14 -0400 Subject: [PATCH] Add error-rate tracking and per-client incident surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer live error-rate and incident detection onto the endpoint dashboard. - probes/httptop.js: tally respTotal/errs/err4/err5 per endpoint with an errors/sec ring buffer; export errRate(); parse a configurable client header (--client-header, default x-api-key) for a per-endpoint "by client" breakdown; add a sortMode signal (count|errors) and topAlert() to find the worst over-threshold endpoint and the client driving it. - lib/format.js: fmtErrPct(), errColor() heat scale, W_ERR/W_PATH widths, and a cell() helper that fits a string to an exact column width — a Text's width is only a max, so a fixed column must carry its own padding. - list.jsx: new ERR% column; pad HOST/PATH/rank to fixed widths so every column lines up under its header and row-to-row. - detail.jsx: Errors field (4xx/5xx split), errors/sec sparkline, and a "By client (worst first)" table. - statusbar.jsx: red incident banner when topAlert() fires. - main.jsx / legend.jsx: wire the `e` sort toggle and its legend hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/detail.jsx | 35 ++++++++++- src/components/legend.jsx | 8 ++- src/components/list.jsx | 28 ++++++--- src/components/statusbar.jsx | 19 ++++-- src/lib/format.js | 29 +++++++++- src/main.jsx | 14 ++++- src/probes/httptop.js | 109 +++++++++++++++++++++++++++++++---- 7 files changed, 207 insertions(+), 35 deletions(-) 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() ? : }