Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/components/detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 [<Text>{dim("— no clients seen yet")}</Text>];
return list.map((c) => (
<Box direction="row" height="fit">
<Text width="1fr" overflow="ellipsis">{c.errs > 0 ? c.id : dim(c.id)}</Text>
<Text width={W_COUNT}>{pad(fmtCount(c.count), W_COUNT)}</Text>
<Text width={W_ERR}>{fg(errColor(c.er))(pad(fmtErrPct(c.er), W_ERR))}</Text>
</Box>
));
}

export default function DetailPanel({ focusKey, tick, endpoint, totals, size }) {
return (
<Box border={{ line: "round", fg: grid }} padding={1} direction="column"
Expand All @@ -53,6 +72,10 @@ export default function DetailPanel({ focusKey, tick, endpoint, totals, size })
<Field name="Req/s now">{r.rate > 0 ? fg(rateOn)(String(r.rate)) : dim("0")}{dim(` peak ${r.peak}/s`)}</Field>,
<Field name="Latency">{lat}</Field>,
<Field name="Status">{statusSpans(r.status)}</Field>,
<Field name="Errors">{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")}</Field>,
<Field name="Bytes">{fmtBytes(r.bytes)}{dim(" on the wire")}</Field>,
<Field name="First seen">{`${fmtAgo(now - r.first)} ago`}</Field>,
<Field name="Last seen">{`${fmtAgo(now - r.last)} ago`}</Field>,
Expand All @@ -62,6 +85,16 @@ export default function DetailPanel({ focusKey, tick, endpoint, totals, size })
<Text> </Text>,
<Text>{fg(label)("Latency, recent responses")}</Text>,
<Text overflow="hidden">{fg(accent)(sparkline(r.lat, sparkW))}</Text>,
<Text> </Text>,
<Text>{fg(label)("Errors/s, last minute")}</Text>,
<Text overflow="hidden">{fg(errColor(errRate(r)))(sparkline(r.ehist, sparkW))}</Text>,
<Text> </Text>,
<Box direction="row" height="fit">
<Text width="1fr">{fg(label)("By client (worst first)")}</Text>
<Text width={W_COUNT}>{fg(label)(pad("REQS", W_COUNT))}</Text>
<Text width={W_ERR}>{fg(label)(pad("ERR%", W_ERR))}</Text>
</Box>,
...clientRows(r.clients),
];
}}
</Box>
Expand Down
8 changes: 5 additions & 3 deletions src/components/legend.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text>{() => {
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)]);
}}</Text>
);
Expand Down
28 changes: 19 additions & 9 deletions src/components/list.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,44 @@
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 (
<Box direction="row" height="fit">
<Text width={W_RANK}>{dim("#")}</Text>
<Text width={W_METHOD}>{bold("METHOD")}</Text>
<Text width={W_HOST}>{bold("HOST")}</Text>
<Text width="1fr">{bold("PATH")}</Text>
<Text width={W_RANK}>{dim(cell("#", W_RANK))}</Text>
<Text width={W_METHOD}>{bold(padEnd("METHOD", W_METHOD))}</Text>
<Text width={W_HOST}>{bold(padEnd("HOST", W_HOST))}</Text>
<Text width={W_PATH}>{bold(padEnd("PATH", W_PATH))}</Text>
<Text width={W_COUNT}>{bold(pad("COUNT", W_COUNT))}</Text>
<Text width={W_RATE}>{bold(pad("REQ/S", W_RATE))}</Text>
<Text width={W_ERR}>{bold(pad("ERR%", W_ERR))}</Text>
<Text width={W_LAST}>{bold(pad("LAST", W_LAST))}</Text>
</Box>
);
}

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 (
<Box direction="row" height="fit" bg={selected ? selBg : undefined}>
<Text width={W_RANK}>{selected ? fg(accent)("› " + pad(rank, 2).slice(1)) : dim(pad(rank, 2) + " ")}</Text>
<Text width={W_RANK}>{selected ? fg(accent)(rankCell) : dim(rankCell)}</Text>
<Text width={W_METHOD}>{fg(methodColor(row.method))(padEnd(row.method, W_METHOD))}</Text>
<Text width={W_HOST} overflow="ellipsis">{dim(row.host)}</Text>
<Text width="1fr" overflow="ellipsis">{row.path}</Text>
<Text width={W_HOST}>{dim(cell(row.host, W_HOST))}</Text>
<Text width={W_PATH}>{cell(row.path, W_PATH)}</Text>
<Text width={W_COUNT}>{bold(fg(accent)(pad(fmtCount(row.count), W_COUNT)))}</Text>
<Text width={W_RATE}>{row.rate > 0 ? fg(rateOn)(rateStr) : rateStr}</Text>
<Text width={W_ERR}>{errSpan}</Text>
<Text width={W_LAST}>{dim(pad(fmtAgo(Date.now() - row.last), W_LAST))}</Text>
</Box>
);
Expand Down
19 changes: 14 additions & 5 deletions src/components/statusbar.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box direction="row" height="fit">
<Text>{bold(fg(accent)("httpinspect"))}</Text>
<Text width="1fr">{dim(` iface: ${ifaceLabel} · plaintext HTTP only`)}</Text>
<Text width="1fr">{() => {
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}`));
}}</Text>
</Box>
);
}
29 changes: 27 additions & 2 deletions src/lib/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => (
<Box direction="column" width="1fr" height="1fr" padding={[0, 1]}>
<StatusBar ifaceLabel={ifaceLabel} />
<StatusBar ifaceLabel={ifaceLabel} tick={tick} topAlert={topAlert} />
{() => focusKey.get()
? <DetailPanel focusKey={focusKey} tick={tick} endpoint={endpoint} totals={totals} size={size} />
: <ListPanel rows={rows} sel={sel} size={size} />}
<Footer totals={totals} endpointCount={endpointCount} />
<Legend focusKey={focusKey} />
<Legend focusKey={focusKey} sortMode={sortMode} />
</Box>
);

Expand Down Expand Up @@ -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();
}
});
Expand Down
Loading
Loading