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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user