phase: 04_llm_chart_readability

**Phase 04 final verification pass — all tasks already in `complete/`; verified + fixed 3 defects found in verification.**

- Verified `/llm` live (both engines, gufo-only instance on :8099, `llm_monitor` on :8095) in a real browser; dev server left running/healthy.
- Completed task-04's truncated layout fix: `#engines` now carries `.items-start` (the CSS rule existed but nothing used it) — gufo 544 px vs vLLM 715 px, no blank card; pinned by `test_llm_engine_panels_are_not_stretched_to_their_tallest_sibling`.
- Fixed: event rows said the subject twice ("gufo gufo is answering again"), and `/ups` mangled mid-message names ("sent to apc: OK" → "sent to  OK") → shared `Dash.eventText()` (strips `UpstreamError: ` + a *leading* name only, mirroring `alerts._clean_error`) used by `/system`, `/llm`, `/ups`; new test.
- Fixed: count sparklines autoscaled rollup averages (queue max 0.1379) into full-height spikes → `sparkline({minSpan})`, `minSpan: 1` on sessions/queued (spike 30 px → 4 px); assertions added.
- Tests: `bash .agents/validate.sh` → **EXIT=0** (`uv run pytest --cov --cov-fail-under=94`: **485 passed, 7 skipped**, coverage **96.87%**; `ruff check src tests agent`: All checks passed; `pyright`: 0 errors; `node --check` all static JS clean; repo hygiene clean; `uv pip list --outdated` empty). `uv run pytest -m live --live` → **7 passed**.
- Completion criteria: heroes largest per engine ✔ (38.4 px vs 16.8 px row values); sparkline per cell + selected window ✔ (5/5 gufo, 7/7 vLLM; 6 h chip moved KV max 53 %→58 %); mean+p95 without hover ✔ (243 / 48.8 / 285 ms feet); provenance tag ✔ (`Δ10.0s`/`Δ8.0s` + `title`=exact source); single-engine ✔ (1 panel, totals hidden, zero red); screenshots ✔ `data/logs/llm-verify-{both-engines,charts-and-slots,single-engine,monitor-reference}.png` + `NOTES-chart-set.md`; pytest/cov/ruff/pyright ✔; earlier phases unchanged-behaviour ✔ (all 01–03 tests pass).
- Deviation (documented, not code): throughput heroes stay **neutral ink** — `severity.py` has no throughput row and 0 tok/s idle is healthy; colour comes from rated cells (KV) + reachability. Kept per `NOTES-chart-set`.
- Deviation: could not append the task-04 written verdict to `NOTES-chart-set.md` (it lives in `todo/`, which I must not edit). Verdict: ours reads better than `:8095` — bigger throughput numbers, per-cell shape over the selected window, percentiles inline; `llm_monitor` only sparklines decode+KV and has no window control.
- Docs: AGENTS.md updated (485 test count, `sparkline` `minSpan` rule, `Dash.eventText` event-row rule, `/llm` panel `items-start`); README reviewed in full — commands, config table (all `config.py` vars present), architecture bullets, page descriptions verified accurate; no other inconsistencies found.
- Note for the harness: `.agents/phases/todo/03…/NOTES-verify.md` and `todo/04…/NOTES-chart-set.md` are untracked notes left behind by their phases (task files moved, notes did not).
- Next pending phase: `05_ups_hero_battery_glyph`.
This commit is contained in:
2026-10-17 07:03:58 -04:00
parent 9757e3c947
commit f44a617ef9
42 changed files with 1428 additions and 194 deletions
+117 -16
View File
@@ -344,30 +344,131 @@ function renderLegend(container, series, format, showPeak) {
}).join('');
}
/** Small non-interactive sparkline for cards where a full chart would shout. */
/**
* Small non-interactive sparkline for cards where a full chart would shout.
*
* Same honesty rules as `LineChart`, at 160×34: fewer than `minPoints` points say
* "no history" (never a flat zero line, never a confident line through one point), and
* labels quote the data, not the padded axis — a flat window reports the one real value.
*
* opts (all optional; omitting them keeps the autoscaled, full-width line this function
* has always drawn):
* - height: plot height in px (default 34; the box is the point of a sparkline)
* - color: explicit stroke/fill colour; wins over `severity`
* - severity: payload verdict ('ok'|'warn'|'fail', or a `{severity}` cell) → stroke from
* that theme token (`cssVar('--ok')` etc.). Anything else — null, '', an
* unknown word — draws **no colour**: a faint grey stroke and no fill,
* because an unrated series is a shape, not a verdict (an unknown number
* must never look green)
* - min / max: explicit y bounds (today's behaviour)
* - minSpan: smallest y span an autoscaled axis may have, in the series' own units.
* For counts (sessions, queued) pass 1: a 1-minute rollup averages 8 seconds
* of one waiting request down to 0.14, and an axis that shrinks to fit turns
* that into a full-height spike — the twin of the rescale fixedMax fixes. An
* all-zero window still pads to the mid-line, so "nothing queued" reads flat
* - fixedMax: known ceiling for percent-like metrics — plot against [min ?? 0, fixedMax]
* instead of rescaling to the data, so an 8 % KV window that once held a
* 92 % spike reads the same as one that did not. Adds top/bottom scale marks
* so "the line is at a third" says a third *of what*; values clamp to the
* axis rather than drawing off-box
* - align: 'right' pins the newest sample to the right edge and grows the line
* leftwards as history fills in (llm_monitor's drawSpark). With `span` a
* partly-filled window occupies only the right of the plot — and x stays
* time, not sample index, so the shape keeps meaning *when*. Default 'left'
* spans the data domain (today's behaviour)
* - span: x window in seconds for align:'right' (default: the oldest sample)
* - labels: `{current, max}` → the small `now / max` foot drawSpark prints, formatted
* with `format`; `max` is the window maximum of the data, never the fixed
* axis
* - format: value → string for the labels and scale marks (default DEFAULT_FORMAT)
* - minPoints: finite points required before a line is drawn (default 2)
* - ariaLabel: names the spark for assistive tech; the generated fallback quotes values
*
* No animation and no CSS transition at all — `prefers-reduced-motion` is honoured by
* construction, and a moving squiggle competes with the number it annotates.
*/
function sparkline(container, points, opts) {
const o = Object.assign({ height: 34, color: cssVar('--series-1'), min: null, max: null }, opts || {});
const pts = (points || []).filter((p) => p && Number.isFinite(p.value));
const o = Object.assign({
height: 34, color: null, severity: null, min: null, max: null, fixedMax: null,
minSpan: null, align: 'left', span: null, labels: null, format: DEFAULT_FORMAT,
minPoints: 2, ariaLabel: null,
}, opts || {});
const fmt = typeof o.format === 'function' ? o.format : DEFAULT_FORMAT;
const pts = (points || [])
.map((p, i) => (p && Number.isFinite(p.value)
? { ts: Number.isFinite(p.ts) ? p.ts : i, value: p.value }
: null))
.filter(Boolean)
.sort((a, b) => a.ts - b.ts);
const w = Math.max(60, container.clientWidth || 160);
const h = o.height;
container.innerHTML = '';
if (pts.length < 2) {
if (pts.length < Math.max(1, o.minPoints)) {
container.innerHTML = '<div class="faint mono" style="font-size:11px">no history</div>';
return;
}
const xs = pts.map((p) => p.ts), ys = pts.map((p) => p.value);
const x0 = Math.min(...xs), x1 = Math.max(...xs);
let lo = o.min !== null ? o.min : Math.min(...ys);
let hi = o.max !== null ? o.max : Math.max(...ys);
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; }
const sx = (ts) => ((ts - x0) / (x1 - x0 || 1)) * (w - 2) + 1;
const sy = (v) => h - 2 - ((v - lo) / (hi - lo)) * (h - 4);
const ys = pts.map((p) => p.value);
const dataMax = Math.max(...ys);
let lo, hi;
if (o.fixedMax != null) {
lo = o.min != null ? o.min : 0;
hi = Math.max(o.fixedMax, lo); // never an inverted axis
} else {
lo = o.min !== null ? o.min : Math.min(...ys);
hi = o.max !== null ? o.max : Math.max(...ys);
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; }
// the axis never gets finer than one thing (see `minSpan` above)
if (o.minSpan > 0 && hi - lo < o.minSpan) hi = lo + o.minSpan;
}
const first = pts[0];
const last = pts[pts.length - 1];
let x0 = first.ts;
const x1 = last.ts;
if (o.align === 'right' && o.span > 0) x0 = x1 - o.span;
// Marks live in a left gutter so they never sit on the line; a 160px box has the pixels.
const marksOn = o.fixedMax != null;
const padL = marksOn ? 24 : 1;
const inner = { l: padL, r: 1, t: 2, b: 2 };
const plotW = Math.max(10, w - inner.l - inner.r);
const plotH = Math.max(6, h - inner.t - inner.b);
const sx = (ts) => {
const span = (x1 - x0) || 1;
const clamped = Math.min(Math.max(ts, x0), x1);
return inner.l + ((clamped - x0) / span) * plotW;
};
const sy = (v) => {
const c = o.fixedMax != null ? Math.min(Math.max(v, lo), hi) : v;
return inner.t + plotH - ((c - lo) / ((hi - lo) || 1)) * plotH;
};
let sev = o.severity;
if (sev && typeof sev === 'object') sev = sev.severity;
const sevColor = sev === 'ok' ? cssVar('--ok')
: sev === 'warn' ? cssVar('--warn')
: sev === 'fail' ? cssVar('--fail') : null;
const stroke = o.color || sevColor || cssVar('--text-faint');
const filled = Boolean(o.color || sevColor);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${sx(p.ts).toFixed(1)} ${sy(p.value).toFixed(1)}`).join(' ');
const svg = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none"
role="img" aria-label="trend from ${lo.toFixed(2)} to ${hi.toFixed(2)}">
<path d="${d} L${sx(x1).toFixed(1)} ${h} L${sx(x0).toFixed(1)} ${h} Z" fill="${o.color}" opacity=".14"/>
<path d="${d}" fill="none" stroke="${o.color}" stroke-width="1.4"/></svg>`;
container.innerHTML = svg;
const baseY = (inner.t + plotH).toFixed(1);
const area = `<path d="${d} L${sx(last.ts).toFixed(1)} ${baseY} L${sx(first.ts).toFixed(1)} ${baseY} Z" fill="${stroke}" opacity=".14"/>`;
const baseline = `<line x1="${inner.l}" x2="${(inner.l + plotW).toFixed(1)}" y1="${baseY}" y2="${baseY}" stroke="${stroke}" opacity=".22"/>`;
let marks = '';
if (marksOn) {
// top/bottom of the *axis* only — enough to read the line as a fraction of the ceiling
const mk = (v, y) => `<text x="2" y="${y}" font-size="8" fill="${cssVar('--text-faint')}" dominant-baseline="${y < h / 2 ? 'hanging' : 'alphabetic'}">${escapeHtml(fmt(v))}</text>`;
marks = mk(hi, inner.t + 7) + mk(lo, h - inner.b);
}
const aria = o.ariaLabel ||
`trend from ${fmt(first.value)} to ${fmt(last.value)}, max ${fmt(dataMax)}`;
let foot = '';
if (o.labels && (o.labels.current || o.labels.max)) {
const bits = [];
if (o.labels.current) bits.push(`now ${fmt(last.value)}`);
if (o.labels.max) bits.push(`max ${fmt(dataMax)}`);
foot = `<div class="faint mono" style="font-size:10px;line-height:1.25">${escapeHtml(bits.join(' / '))}</div>`;
}
container.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none"
role="img" aria-label="${escapeHtml(aria)}">${filled ? area : ''}${baseline}
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"/>${marks}</svg>${foot}`;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, cssVar,