speedpatrol 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/app.js CHANGED
@@ -1,24 +1,87 @@
1
1
  // Speedpatrol dashboard. Vanilla ES modules and no build step, so the UI is
2
2
  // served straight from source and works from a clone with only the CLI built.
3
+ //
4
+ // The server computes the numbers once (`/api/summary`). Each page shows them
5
+ // and does not count again, so two pages cannot disagree.
3
6
 
4
7
  const view = document.getElementById('view');
5
- const agentsEl = document.getElementById('agents');
6
- const metaEl = document.getElementById('meta');
8
+ const tabsEl = document.getElementById('tabs');
7
9
  const projectEl = document.getElementById('project-name');
8
10
  const liveEl = document.getElementById('live');
11
+ const patrolEl = document.getElementById('patrol');
12
+ const machineEl = document.getElementById('machine');
13
+ const themeEl = document.getElementById('theme');
9
14
 
10
15
  const POLL_MS = 3000;
11
16
  const ROLE_HINT = {
12
- scout: 'Writes benchmarks: speedpatrol scout',
13
- analyst: 'Explains confirmed regressions',
14
- optimizer: 'Proposes speed-ups in a worktree',
15
- runner: 'Measures commits: speedpatrol run',
17
+ scout: 'Writes the benchmarks',
18
+ analyst: 'Explains each confirmed regression',
19
+ optimizer: 'Tries a speed-up in a worktree',
20
+ runner: 'Measures each commit',
16
21
  patrol: 'Watches main for new commits',
17
22
  };
18
23
 
19
24
  /** Specs of the charts on the page, drawn after the HTML lands so they can measure their width. */
20
25
  let charts = new Map();
21
26
  let rendered = '';
27
+ /** The data of the current page, so a filter change renders again without a fetch. */
28
+ let current = null;
29
+
30
+ /** The filters of each page. They live here and not in the hash, so a link stays short. */
31
+ const ui = {
32
+ benchFilter: 'all',
33
+ benchQuery: '',
34
+ regFilter: 'open',
35
+ expFilter: 'all',
36
+ runFilter: 'all',
37
+ cmpFilter: 'changed',
38
+ };
39
+
40
+ // ---------------------------------------------------------------- icons
41
+
42
+ /** A 16×16 line icon from trusted path markup in this file. */
43
+ const icon = (paths) =>
44
+ `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
45
+ const ICONS = {
46
+ system: '<rect x="2" y="3" width="12" height="8" rx="1"/><path d="M6 14h4M8 11v3"/>',
47
+ light:
48
+ '<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/>',
49
+ dark: '<path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5Z"/>',
50
+ machine:
51
+ '<rect x="2.5" y="2.5" width="11" height="4.5" rx="1"/><rect x="2.5" y="9" width="11" height="4.5" rx="1"/><path d="M5 4.75h.01M5 11.25h.01"/>',
52
+ patrol: '<circle cx="8" cy="8" r="5.5"/><path d="M8 8l3-2.5"/><circle cx="8" cy="8" r="1"/>',
53
+ fail: '<path d="M8 1.8 14.2 8 8 14.2 1.8 8Z"/><path d="M8 5.2v3.3M8 10.8h.01"/>',
54
+ warn: '<path d="M8 2.2 14.3 13.3H1.7Z"/><path d="M8 6.5v3M8 11.6h.01"/>',
55
+ pass: '<circle cx="8" cy="8" r="6"/><path d="m5.4 8.2 1.8 1.8 3.5-3.6"/>',
56
+ info: '<circle cx="8" cy="8" r="6"/><path d="M8 7.5v3.6M8 5.2h.01"/>',
57
+ chevron: '<path d="m6 4 4 4-4 4"/>',
58
+ };
59
+
60
+ // ---------------------------------------------------------------- theme
61
+
62
+ // The choice cycles system → light → dark. "System" follows the OS setting, also when it changes.
63
+ const THEMES = ['system', 'light', 'dark'];
64
+ const THEME_WORDS = { system: 'System', light: 'Light', dark: 'Dark' };
65
+ const lightQuery = matchMedia('(prefers-color-scheme: light)');
66
+ let themeChoice = THEMES.includes(localStorage.getItem('speedpatrol-theme'))
67
+ ? localStorage.getItem('speedpatrol-theme')
68
+ : 'system';
69
+
70
+ function applyTheme() {
71
+ document.documentElement.dataset.theme =
72
+ themeChoice === 'system' ? (lightQuery.matches ? 'light' : 'dark') : themeChoice;
73
+ const next = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
74
+ themeEl.innerHTML = icon(ICONS[themeChoice]);
75
+ themeEl.setAttribute('aria-label', `Theme: ${THEME_WORDS[themeChoice]}. Change the theme to ${THEME_WORDS[next]}.`);
76
+ themeEl.title = `Theme: ${THEME_WORDS[themeChoice]}. Click for ${THEME_WORDS[next]}.`;
77
+ }
78
+ themeEl.addEventListener('click', () => {
79
+ themeChoice = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
80
+ if (themeChoice === 'system') localStorage.removeItem('speedpatrol-theme');
81
+ else localStorage.setItem('speedpatrol-theme', themeChoice);
82
+ applyTheme();
83
+ });
84
+ lightQuery.addEventListener('change', applyTheme);
22
85
 
23
86
  // ---------------------------------------------------------------- formatting
24
87
 
@@ -70,27 +133,46 @@ function pct(delta) {
70
133
  }
71
134
 
72
135
  const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
136
+ const plural = (count, word, many = `${word}s`) => `${count} ${count === 1 ? word : many}`;
137
+ const capital = (text) => (text ? text[0].toUpperCase() + text.slice(1) : '');
73
138
 
74
139
  function ago(iso) {
75
140
  if (!iso) return '';
76
141
  const seconds = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
77
142
  if (seconds < 60) return 'just now';
78
- if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
79
- if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
80
- return `${Math.floor(seconds / 86400)}d ago`;
143
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
144
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
145
+ return `${Math.floor(seconds / 86400)} d ago`;
81
146
  }
82
147
  const date = (iso) =>
83
148
  iso
84
149
  ? new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
85
150
  : '';
151
+ const clock = (iso) =>
152
+ new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
153
+
154
+ function duration(ms) {
155
+ if (ms === undefined || ms === null || !Number.isFinite(ms)) return '-';
156
+ const seconds = Math.round(ms / 1000);
157
+ if (seconds < 60) return `${seconds} s`;
158
+ if (seconds < 3600) return `${Math.floor(seconds / 60)} min ${seconds % 60} s`;
159
+ return `${Math.floor(seconds / 3600)} h ${Math.floor((seconds % 3600) / 60)} min`;
160
+ }
161
+ const money = (usd) => (usd ? `$${usd.toFixed(2)}` : '-');
86
162
 
87
163
  /** Only http(s) URLs become links: these strings come from agents. */
88
164
  const link = (url, label) =>
89
165
  url && /^https?:\/\//.test(url) ? `<a href="${esc(url)}" target="_blank" rel="noreferrer">${esc(label)}</a>` : '';
166
+ const issueLabel = (url) => {
167
+ const number = url?.match(/\/(?:issues|pull)\/(\d+)/)?.[1];
168
+ return number ? `#${number}` : 'link';
169
+ };
90
170
 
91
171
  const VERDICT_GLYPH = { regression: '◆', improvement: '●', noisy: '◐', unchanged: '○', missing: '–' };
92
172
  const verdict = (value) =>
93
- value ? `<span class="verdict ${esc(value)}"><i>${VERDICT_GLYPH[value] ?? '?'}</i>${esc(value)}</span>` : '';
173
+ value
174
+ ? `<span class="verdict ${esc(value)}"><i aria-hidden="true">${VERDICT_GLYPH[value] ?? '?'}</i>${esc(value)}</span>`
175
+ : '';
94
176
 
95
177
  /** Colour a change by its statistical verdict, never by the sign alone. */
96
178
  function delta(value, verdictValue) {
@@ -134,12 +216,70 @@ const markdown = (text) => {
134
216
  .replace(/(?<!<\/li>)<li>/g, '<ul><li>');
135
217
  };
136
218
 
137
- const empty = (html) => `<div class="empty">${html}</div>`;
219
+ // ---------------------------------------------------------------- shared parts
220
+
138
221
  const panel = (title, sub, body, right = '') =>
139
222
  `<section class="panel"><div class="panel-head"><h2>${title}</h2>${sub ? `<span class="sub">${sub}</span>` : ''}${
140
223
  right ? `<span class="right">${right}</span>` : ''
141
224
  }</div>${body}</section>`;
142
225
 
226
+ /** One command with a Copy button. */
227
+ const command = (text) =>
228
+ `<div class="command"><code>${esc(text)}</code><button type="button" class="copy" data-copy="${esc(
229
+ text,
230
+ )}" aria-label="Copy the command ${esc(text)}">Copy</button></div>`;
231
+
232
+ /**
233
+ * The one empty state of the dashboard: what the block shows, why it is
234
+ * empty now, and the one next step, as a command or a button.
235
+ */
236
+ function emptyState({ title, why, cmd, action, compact = false }) {
237
+ return `<div class="empty-state${compact ? ' compact' : ''}"><h3>${esc(title)}</h3>${why ? `<p>${esc(why)}</p>` : ''}${
238
+ cmd ? command(cmd) : ''
239
+ }${action ? `<button type="button" class="button" data-set="${esc(action.key)}" data-value="${esc(action.value)}">${esc(action.label)}</button>` : ''}</div>`;
240
+ }
241
+
242
+ /** The empty state of a project with no baseline yet: the summary owns the next step. */
243
+ const firstStep = (summary, title) => emptyState({ title, why: summary.next?.why, cmd: summary.next?.command });
244
+
245
+ /** A row of numbers. A stat with `href` is a link to its page. */
246
+ function band(stats, className = '') {
247
+ return `<div class="band ${className}">${stats
248
+ .filter(Boolean)
249
+ .map(({ label, value, note, tone, href, glyph }) => {
250
+ const tag = href ? 'a' : 'div';
251
+ return `<${tag} class="stat${tone ? ` tone-${tone}` : ''}"${href ? ` href="${esc(href)}"` : ''}><span class="stat-label">${esc(
252
+ label,
253
+ )}</span><strong class="stat-value">${glyph && tone ? icon(ICONS[tone]) : ''}${esc(value)}</strong>${
254
+ note ? `<span class="stat-note" title="${esc(note)}">${esc(note)}</span>` : ''
255
+ }</${tag}>`;
256
+ })
257
+ .join('')}</div>`;
258
+ }
259
+
260
+ /** Filter buttons with counts. `key` names the field of `ui` that they set. */
261
+ function chips(key, options) {
262
+ return `<div class="chips" role="group">${options
263
+ .map(
264
+ ({ value, label, count }) =>
265
+ `<button type="button" class="chip${ui[key] === value ? ' active' : ''}" aria-pressed="${ui[key] === value}" data-set="${key}" data-value="${esc(
266
+ value,
267
+ )}">${esc(label)}${count === undefined ? '' : ` <span class="count">${count}</span>`}</button>`,
268
+ )
269
+ .join('')}</div>`;
270
+ }
271
+
272
+ const toneIcon = (tone) => `<span class="tone-icon tone-${tone}">${icon(ICONS[tone])}</span>`;
273
+
274
+ /** A link that opens or closes one row of a list: the hash holds the open row, so a poll keeps it open. */
275
+ const rowToggle = (page, id, open, html) =>
276
+ `<a class="row-toggle" href="#/${page}${open ? '' : `/${encodeURIComponent(id)}`}" aria-expanded="${open}">${icon(
277
+ ICONS.chevron,
278
+ )}${html}</a>`;
279
+
280
+ const group = (label, count, extra = '') =>
281
+ `<h3 class="group-head">${esc(label)} <span class="count">${count}</span>${extra}</h3>`;
282
+
143
283
  // ---------------------------------------------------------------- data
144
284
 
145
285
  async function get(path) {
@@ -147,267 +287,488 @@ async function get(path) {
147
287
  if (!response.ok) throw new Error(`${path} -> ${response.status}`);
148
288
  return response.json();
149
289
  }
290
+ const maybe = (path) => get(path).catch(() => null);
291
+
292
+ const PAGES = ['overview', 'benchmarks', 'regressions', 'experiments', 'runs', 'activity'];
150
293
 
151
294
  function route() {
152
- const parts = location.hash.replace(/^#\/?/, '').split('/').map(decodeURIComponent);
153
- if (parts[0] === 'benchmark' && parts[1]) return { name: 'benchmark', id: parts[1] };
154
- if (parts[0] === 'comparison' && parts[1]) return { name: 'comparison', id: parts[1] };
155
- return { name: 'home' };
295
+ const [raw = '', id] = location.hash
296
+ .replace(/^#\/?/, '')
297
+ .split('/')
298
+ .map((part) => {
299
+ try {
300
+ return decodeURIComponent(part);
301
+ } catch {
302
+ return part;
303
+ }
304
+ });
305
+ // The old singular routes stay valid, so links in issues still open.
306
+ const page = { benchmark: 'benchmarks', comparison: 'comparisons' }[raw] ?? raw;
307
+ if (page === 'benchmarks' && id) return { name: 'benchmark', tab: 'benchmarks', id };
308
+ if (page === 'comparisons' && id) return { name: 'comparison', tab: 'runs', id };
309
+ if (PAGES.includes(page)) return { name: page, tab: page, id };
310
+ return { name: 'overview', tab: 'overview' };
156
311
  }
157
312
 
158
- async function load(current) {
159
- const overview = await get('/api/overview');
160
- if (current.name === 'benchmark') {
161
- const benchmark = await get(`/api/benchmarks/${encodeURIComponent(current.id)}`).catch(() => null);
162
- return { overview, benchmark };
163
- }
164
- if (current.name === 'comparison') {
165
- const comparison = await get(`/api/comparisons/${encodeURIComponent(current.id)}`).catch(() => null);
166
- return { overview, comparison };
167
- }
168
- const [benchmarks, comparisons, regressions, experiments, activity] = await Promise.all([
169
- get('/api/benchmarks'),
170
- get('/api/comparisons'),
171
- get('/api/regressions'),
172
- get('/api/experiments'),
173
- get('/api/activity?limit=40'),
313
+ async function load(at) {
314
+ const enc = encodeURIComponent;
315
+ const [summary, agents, page] = await Promise.all([
316
+ get('/api/summary'),
317
+ get('/api/agents'),
318
+ (async () => {
319
+ switch (at.name) {
320
+ case 'overview': {
321
+ const [benchmarks, runs] = await Promise.all([get('/api/benchmarks'), get('/api/runs')]);
322
+ return { benchmarks, runs };
323
+ }
324
+ case 'benchmarks':
325
+ return { benchmarks: await get('/api/benchmarks') };
326
+ case 'benchmark':
327
+ return { benchmark: await maybe(`/api/benchmarks/${enc(at.id)}`) };
328
+ case 'regressions':
329
+ return { regressions: await get('/api/regressions') };
330
+ case 'experiments':
331
+ return { experiments: await get('/api/experiments') };
332
+ case 'runs':
333
+ return { runs: await get('/api/runs') };
334
+ case 'comparison':
335
+ return { comparison: await maybe(`/api/comparisons/${enc(at.id)}`) };
336
+ case 'activity': {
337
+ const [sessions, events] = await Promise.all([
338
+ get('/api/sessions'),
339
+ at.id ? maybe(`/api/sessions/${enc(at.id)}/events`) : null,
340
+ ]);
341
+ return { sessions, events };
342
+ }
343
+ }
344
+ })(),
174
345
  ]);
175
- return { overview, benchmarks, comparisons, regressions, experiments, activity };
346
+ return { summary, ...agents, ...page };
176
347
  }
177
348
 
178
349
  async function refresh() {
179
- const current = route();
350
+ const at = route();
180
351
  let data;
181
352
  try {
182
- data = await load(current);
353
+ data = await load(at);
183
354
  liveEl.classList.remove('offline');
184
- liveEl.textContent = 'live';
355
+ liveEl.title = 'Live: the dashboard reads .speedpatrol every 3 seconds.';
356
+ liveEl.setAttribute('aria-label', 'Live');
185
357
  } catch {
186
358
  liveEl.classList.add('offline');
187
- liveEl.textContent = 'offline';
359
+ liveEl.title = 'Offline: the dashboard cannot reach its server. Start it again with npx speedpatrol dashboard.';
360
+ liveEl.setAttribute('aria-label', 'Offline');
188
361
  return;
189
362
  }
190
- // Re-rendering resets hover and scroll in the feed: skip it when nothing changed.
191
- const key = JSON.stringify([current, data]);
363
+ // Rendering again resets hover and focus: skip it when nothing changed.
364
+ const key = JSON.stringify([at, data]);
192
365
  if (key === rendered) return;
193
366
  rendered = key;
194
- renderHeader(data.overview);
367
+ current = { at, data };
368
+ render();
369
+ }
370
+
371
+ function render() {
372
+ if (!current) return;
373
+ const { at, data } = current;
374
+ renderHeader(data, at);
375
+ // Keep the focus and the caret of a search box across a render.
376
+ const focused = document.activeElement?.dataset?.query;
377
+ const caret = focused ? document.activeElement.selectionStart : 0;
195
378
  charts = new Map();
196
- if (current.name === 'benchmark') view.innerHTML = renderBenchmark(data.benchmark, current.id, data.overview);
197
- else if (current.name === 'comparison') view.innerHTML = renderComparison(data.comparison, current.id);
198
- else view.innerHTML = renderHome(data);
379
+ view.innerHTML = PAGE_RENDER[at.name](data, at);
199
380
  drawCharts();
381
+ if (focused) {
382
+ const input = view.querySelector(`[data-query="${focused}"]`);
383
+ input?.focus();
384
+ input?.setSelectionRange(caret, caret);
385
+ }
200
386
  }
201
387
 
202
388
  // ---------------------------------------------------------------- header
203
389
 
204
- function renderHeader(overview) {
205
- projectEl.textContent = overview.project.name;
206
- document.title = `${overview.project.name} · Speedpatrol`;
207
- const latest = overview.latest;
208
- metaEl.innerHTML = latest
209
- ? [
210
- `<span title="${esc(latest.machine.cpuModel)} · ${latest.machine.cpus} cpus · ${latest.machine.memoryGb} GB · node ${esc(
211
- latest.machine.node,
212
- )}"><span class="label">Machine</span><span class="mono">${esc(latest.machine.fingerprint)}</span> <span class="muted">${esc(
213
- latest.machine.hostname,
214
- )}</span></span>`,
215
- `<span title="${esc(latest.subject)}"><span class="label">Last measured</span><span class="mono">${short(
216
- latest.sha,
217
- )}</span> <span class="muted">${esc(latest.subject ?? '')} · ${ago(latest.startedAt)}</span></span>`,
218
- ].join('')
219
- : '<span class="muted">No runs yet</span>';
220
- agentsEl.innerHTML = overview.agents
221
- .map((agent) => {
222
- const label = agent.state === 'never' ? 'not run' : agent.state;
223
- const detail = agent.detail || ROLE_HINT[agent.role] || '';
224
- return `<div class="agent"><div class="agent-head"><span class="agent-role">${esc(agent.role)}</span><span class="state ${esc(
225
- agent.state,
226
- )}">${esc(label)}</span></div><div class="agent-detail" title="${esc(detail)}">${esc(detail)}</div><div class="agent-when">${
227
- agent.updatedAt ? ago(agent.updatedAt) : '&nbsp;'
228
- }</div></div>`;
229
- })
230
- .join('');
231
- }
390
+ function renderHeader(data, at) {
391
+ const { summary, agents, patrol } = data;
392
+ projectEl.textContent = summary.name;
393
+ document.title = `${summary.name} · Speedpatrol`;
394
+ const badges = {
395
+ regressions: summary.regressions.open ? { count: summary.regressions.open, tone: 'fail' } : null,
396
+ experiments: summary.experiments.waiting ? { count: summary.experiments.waiting, tone: 'warn' } : null,
397
+ };
398
+ for (const tab of tabsEl.querySelectorAll('a')) {
399
+ const page = tab.dataset.page;
400
+ const active = page === at.tab;
401
+ tab.classList.toggle('active', active);
402
+ if (active) tab.setAttribute('aria-current', 'page');
403
+ else tab.removeAttribute('aria-current');
404
+ const badge = badges[page];
405
+ tab.dataset.label ??= tab.textContent.trim();
406
+ const label = tab.dataset.label;
407
+ tab.innerHTML = `${esc(label)}${badge ? `<span class="tab-badge tone-${badge.tone}">${badge.count}</span>` : ''}`;
408
+ if (badge)
409
+ tab.setAttribute(
410
+ 'aria-label',
411
+ `${label}: ${badge.count} ${page === 'regressions' ? 'open' : 'wait to be published'}`,
412
+ );
413
+ else tab.removeAttribute('aria-label');
414
+ }
232
415
 
233
- // ---------------------------------------------------------------- home
416
+ const patrolAgent = agents.find((agent) => agent.role === 'patrol');
417
+ const patrolState = patrolAgent?.state ?? 'never';
418
+ const patrolText =
419
+ patrolState === 'never'
420
+ ? 'Patrol: not started. Start it with npx speedpatrol.'
421
+ : `Patrol: ${patrolState}. ${plural(patrol.cycles ?? 0, 'cycle')}${patrol.lastCycleAt ? `, the last ${ago(patrol.lastCycleAt)}` : ''}.${
422
+ patrolAgent?.detail ? ` ${patrolAgent.detail}.` : ''
423
+ }`;
424
+ patrolEl.className = `icon-chip state-${patrolState}`;
425
+ patrolEl.innerHTML = icon(ICONS.patrol);
426
+ patrolEl.title = patrolText;
427
+ patrolEl.setAttribute('aria-label', patrolText);
234
428
 
235
- function renderHome(data) {
236
- return `<div class="grid"><div class="stack">${benchmarksPanel(data.benchmarks)}${regressionsPanel(
237
- data.regressions,
238
- )}${experimentsPanel(data.experiments)}${comparisonsPanel(data.comparisons)}</div><div class="stack">${activityPanel(
239
- data.activity,
240
- )}</div></div>`;
429
+ const machine = summary.machine;
430
+ machineEl.hidden = !machine;
431
+ if (machine) {
432
+ const text = `Machine ${machine.fingerprint} (${machine.hostname}): ${machine.cpuModel}, ${machine.cpus} CPUs, ${machine.memoryGb} GB, Node ${machine.node}. Timings from other machines are not shown.`;
433
+ machineEl.innerHTML = icon(ICONS.machine);
434
+ machineEl.title = text;
435
+ machineEl.setAttribute('aria-label', text);
436
+ }
241
437
  }
242
438
 
243
- function benchmarksPanel({ benchmarks, machine }) {
244
- if (!benchmarks.length) {
245
- return panel(
246
- 'Benchmarks',
247
- '',
248
- empty(
249
- 'No baseline measurements yet. Run <code>speedpatrol scout</code> to write benchmarks for the critical paths, then <code>speedpatrol run</code> to measure this commit.',
250
- ),
251
- );
252
- }
253
- const rows = benchmarks
254
- .map((row, index) => {
255
- const spark = `spark-${index}`;
256
- charts.set(spark, { type: 'spark', points: row.series });
257
- return `<tr class="clickable" data-href="#/benchmark/${encodeURIComponent(row.id)}">
258
- <td><span class="bench-id">${esc(row.id)}</span>${row.critical ? '<span class="badge critical">critical</span>' : ''}</td>
259
- <td class="dim">${esc(row.kind)}</td>
260
- <td class="dim mono">${esc(row.primary)}</td>
261
- <td class="num">${fmt(row.latest?.median, row.unit)}</td>
262
- <td class="num">${row.deltaPct === undefined ? '<span class="muted">-</span>' : delta(row.deltaPct, row.verdict)}</td>
263
- <td>${verdict(row.verdict)}</td>
264
- <td><div class="chart" data-chart="${spark}" style="width:132px"></div></td>
265
- </tr>`;
266
- })
267
- .join('');
268
- const count = benchmarks[0]?.series.length ?? 0;
269
- return panel(
270
- 'Benchmarks',
271
- `primary metric median across ${count} baseline commit${count === 1 ? '' : 's'}`,
272
- `<table><thead><tr><th>Benchmark</th><th>Kind</th><th>Primary</th><th class="num">Median</th><th class="num">vs previous</th><th>Verdict</th><th>History</th></tr></thead><tbody>${rows}</tbody></table>`,
273
- machine ? `machine <span class="mono">${esc(machine)}</span>` : '',
439
+ // ---------------------------------------------------------------- overview
440
+
441
+ function confidence(summary) {
442
+ const { verdict: status, regressions, experiments, benchmarks, lastRun } = summary;
443
+ const filed = regressions.byStatus.filed;
444
+ const notFiled = regressions.byStatus.open;
445
+ const coverage = benchmarks.defined ?? benchmarks.measured;
446
+ return band(
447
+ [
448
+ { label: 'Status', value: status.text, note: status.detail, tone: status.tone, glyph: true },
449
+ {
450
+ label: 'Open regressions',
451
+ value: regressions.open,
452
+ note: regressions.open
453
+ ? [filed && `${filed} filed`, notFiled && `${notFiled} not filed`].filter(Boolean).join(', ')
454
+ : 'None',
455
+ tone: regressions.open ? 'fail' : undefined,
456
+ href: '#/regressions',
457
+ },
458
+ {
459
+ label: 'To publish',
460
+ value: experiments.waiting,
461
+ note: experiments.waiting
462
+ ? plural(experiments.waiting, 'verified speed-up')
463
+ : experiments.active
464
+ ? `None. ${plural(experiments.active, 'experiment')} in progress`
465
+ : 'None',
466
+ tone: experiments.waiting ? 'warn' : undefined,
467
+ href: '#/experiments',
468
+ },
469
+ {
470
+ label: 'Benchmarks measured',
471
+ value: coverage ? `${benchmarks.measured} of ${coverage}` : '0',
472
+ note: benchmarks.unmeasured.length
473
+ ? `${plural(benchmarks.unmeasured.length, 'benchmark has', 'benchmarks have')} no baseline`
474
+ : coverage
475
+ ? `${benchmarks.critical} critical`
476
+ : 'No benchmarks yet',
477
+ tone: benchmarks.unmeasured.length ? 'warn' : undefined,
478
+ href: '#/benchmarks',
479
+ },
480
+ {
481
+ label: 'Last baseline',
482
+ value: lastRun ? (lastRun.failed ? 'Failed' : ago(lastRun.startedAt)) : '-',
483
+ note: lastRun
484
+ ? `${short(lastRun.sha)} · ${
485
+ lastRun.failed ??
486
+ (lastRun.comparison
487
+ ? `${lastRun.comparison.verdict} against the previous`
488
+ : summary.runs.baselines <= 1
489
+ ? 'first baseline'
490
+ : 'not compared')
491
+ }`
492
+ : 'No run yet',
493
+ tone: lastRun?.failed ? 'fail' : undefined,
494
+ href: lastRun?.comparison ? `#/comparisons/${encodeURIComponent(lastRun.comparison.id)}` : '#/runs',
495
+ },
496
+ ],
497
+ 'confidence',
274
498
  );
275
499
  }
276
500
 
277
- function regressionsPanel(regressions) {
278
- if (!regressions.length) {
501
+ function attentionPanel(summary) {
502
+ const items = summary.attention;
503
+ if (!items.length) {
279
504
  return panel(
280
- 'Regressions',
505
+ 'Needs attention',
281
506
  '',
282
- empty('No regressions. <code>speedpatrol compare</code> checks a commit against its baseline.'),
507
+ `<p class="all-clear">${toneIcon('pass')}Nothing needs you now. The newest baseline has no open regression.</p>`,
283
508
  );
284
509
  }
285
- const rows = regressions
510
+ const rows = items
286
511
  .map(
287
- (item) => `<tr class="clickable" data-href="#/comparison/${encodeURIComponent(item.comparisonId)}">
288
- <td><span class="status ${esc(item.status)}">${esc(item.status)}</span></td>
289
- <td class="wrap" title="${esc(item.title)}">${esc(item.title)}<div class="muted mono">${esc(item.benchmark)} · ${esc(
290
- item.metric,
291
- )}</div></td>
292
- <td class="num"><span class="delta ${item.deltaPct > 0 ? 'worse' : 'better'}">${pct(item.deltaPct)}</span></td>
293
- <td class="wrap">${(item.suspects ?? [])
294
- .map(
295
- (suspect) =>
296
- `<span class="mono">${short(suspect.sha)}</span> <span class="dim">${esc(suspect.subject)}</span>`,
297
- )
298
- .join('<br>')}</td>
299
- <td>${link(item.issueUrl, issueLabel(item.issueUrl))}</td>
300
- </tr>`,
512
+ (item) => `<li class="attention-row tone-${item.tone}">${toneIcon(item.tone)}<div class="grow">
513
+ <div class="attention-title">${item.href ? `<a href="${esc(item.href)}">${esc(item.title)}</a>` : esc(item.title)}</div>
514
+ <div class="attention-detail">${esc(item.detail)}</div>
515
+ ${item.command ? command(item.command) : ''}
516
+ </div></li>`,
301
517
  )
302
518
  .join('');
303
- const open = regressions.filter((item) => item.status === 'open' || item.status === 'filed').length;
519
+ const fails = items.filter((item) => item.tone === 'fail').length;
304
520
  return panel(
305
- 'Regressions',
306
- `${open} open`,
307
- `<table><thead><tr><th>Status</th><th>Regression</th><th class="num">Delta</th><th>Suspects</th><th>Issue</th></tr></thead><tbody>${rows}</tbody></table>`,
521
+ 'Needs attention',
522
+ [fails && `${fails} to fix`, items.length - fails && `${items.length - fails} to check`]
523
+ .filter(Boolean)
524
+ .join(' · '),
525
+ `<ul class="attention">${rows}</ul>`,
308
526
  );
309
527
  }
310
528
 
311
- const issueLabel = (url) =>
312
- url ? (url.match(/\/(?:issues|pull)\/(\d+)/)?.[1] ? `#${url.match(/(\d+)$/)?.[1]}` : 'link') : '';
313
-
314
- function experimentsPanel(experiments) {
315
- if (!experiments.length) {
316
- return panel('Experiments', '', empty('No optimizer experiments yet.'));
317
- }
318
- const rows = experiments
319
- .map(
320
- (item) => `<tr class="${item.comparisonId ? 'clickable' : ''}" ${
321
- item.comparisonId ? `data-href="#/comparison/${encodeURIComponent(item.comparisonId)}"` : ''
322
- }>
323
- <td><span class="status ${esc(item.status)}">${esc(item.status)}</span></td>
324
- <td style="white-space:nowrap"><span class="bench-id">${esc(item.target.benchmark)}</span> <span class="muted mono">${esc(item.target.metric)}</span></td>
325
- <td class="num">${
326
- item.deltaPct === undefined
327
- ? '<span class="muted">-</span>'
328
- : `<span class="delta ${item.status === 'improved' || item.status === 'published' ? 'better' : ''}">${pct(item.deltaPct)}</span>`
329
- }</td>
330
- <td class="wrap mono dim" title="${esc(item.branch)}">${esc(item.branch)}</td>
331
- <td>${link(item.prUrl, issueLabel(item.prUrl))}</td>
332
- <td class="wrap dim" title="${esc(item.reason ?? item.summary ?? item.hypothesis ?? '')}">${esc(
333
- item.reason ?? item.summary ?? item.hypothesis ?? '',
334
- )}</td>
335
- </tr>`,
336
- )
529
+ function agentsPanel(agents, onActivity = false) {
530
+ const rows = agents
531
+ .map((agent) => {
532
+ const label = agent.state === 'never' ? 'not started' : agent.state;
533
+ const detail = agent.detail || ROLE_HINT[agent.role] || '';
534
+ return `<li class="agent-row"><span class="state ${esc(agent.state)}">${esc(capital(agent.role))}</span><span class="agent-detail" title="${esc(
535
+ detail,
536
+ )}">${esc(detail)}</span><span class="agent-when">${esc(label)}${agent.updatedAt ? ` · ${ago(agent.updatedAt)}` : ''}</span></li>`;
537
+ })
337
538
  .join('');
338
539
  return panel(
339
- 'Experiments',
540
+ 'Agents',
340
541
  '',
341
- `<table><thead><tr><th>Status</th><th>Target</th><th class="num">Delta</th><th>Branch</th><th>PR</th><th>Reason</th></tr></thead><tbody>${rows}</tbody></table>`,
542
+ `<ul class="agent-list">${rows}</ul>`,
543
+ onActivity ? '' : '<a href="#/activity">Activity</a>',
342
544
  );
343
545
  }
344
546
 
345
- function comparisonsPanel(comparisons) {
346
- if (!comparisons.length) {
347
- return panel(
348
- 'Comparisons',
349
- '',
350
- empty('No comparisons yet. Run <code>speedpatrol compare</code> to measure a change against the baseline.'),
351
- );
547
+ function runResult(run) {
548
+ if (run.failed) {
549
+ return `<span class="verdict regression" title="${esc(run.failed)}"><i aria-hidden="true">◆</i>failed</span>`;
352
550
  }
353
- const rows = comparisons
354
- .map((item) => {
355
- const counts = ['regression', 'improvement', 'noisy']
356
- .filter((key) => item.verdicts[key])
357
- .map((key) => `${item.verdicts[key]} ${key}${item.verdicts[key] === 1 ? '' : 's'}`)
358
- .join(' · ');
359
- return `<tr class="clickable" data-href="#/comparison/${encodeURIComponent(item.id)}">
360
- <td>${verdict(item.verdict)}</td>
361
- <td class="dim">${esc(item.mode === 'ab' ? 'A/B' : 'baseline')}</td>
362
- <td><span class="mono">${esc(item.head.label)}</span> <span class="muted">vs</span> <span class="mono">${esc(item.base.label)}</span></td>
363
- <td class="dim">${esc(counts || 'no significant change')}</td>
364
- <td class="num muted">${ago(item.createdAt)}</td>
365
- </tr>`;
366
- })
551
+ if (run.comparison) {
552
+ return `<a href="#/comparisons/${encodeURIComponent(run.comparison.id)}">${verdict(run.comparison.verdict)}</a>`;
553
+ }
554
+ return `<span class="muted">${run.first ? 'first baseline' : 'not compared'}</span>`;
555
+ }
556
+
557
+ const PURPOSE = { baseline: 'Baseline', compare: 'Compare', experiment: 'A/B', adhoc: 'Ad hoc' };
558
+
559
+ function recentRunsPanel(runs) {
560
+ if (!runs.length) return '';
561
+ const rows = runs
562
+ .slice(0, 6)
563
+ .map(
564
+ (
565
+ run,
566
+ ) => `<tr><td class="muted nowrap">${ago(run.startedAt)}</td><td class="dim">${PURPOSE[run.purpose] ?? esc(run.purpose)}</td>
567
+ <td class="mono" title="${esc(run.subject ?? '')}">${short(run.sha)}</td>
568
+ <td class="nowrap">${runResult(run)}</td></tr>`,
569
+ )
367
570
  .join('');
368
571
  return panel(
369
- 'Comparisons',
572
+ 'Recent runs',
370
573
  '',
371
- `<table><thead><tr><th>Verdict</th><th>Mode</th><th>Head vs base</th><th>Metrics</th><th class="num">When</th></tr></thead><tbody>${rows}</tbody></table>`,
574
+ `<table class="compact"><tbody>${rows}</tbody></table>`,
575
+ '<a href="#/runs">All runs</a>',
372
576
  );
373
577
  }
374
578
 
375
- function activityPanel(events) {
376
- if (!events.length) {
377
- return panel(
378
- 'Agent activity',
379
- '',
380
- empty('No agent sessions yet. <code>speedpatrol scout</code> starts the first one.'),
381
- );
382
- }
383
- const items = events
384
- .map(
385
- (event) =>
386
- `<li><span class="dot ${esc(event.kind)}"></span><div><div class="what">${esc(event.summary)}</div><div class="who"><b>${esc(
387
- event.role,
388
- )}</b> · ${esc(event.kind)}${event.tool ? ` · <span class="mono">${esc(event.tool)}</span>` : ''} · ${ago(event.at)}</div></div></li>`,
389
- )
390
- .join('');
391
- return panel('Agent activity', 'live', `<ul class="feed">${items}</ul>`);
579
+ function renderOverview(data) {
580
+ const { summary, agents, benchmarks, runs } = data;
581
+ const main = summary.lastRun
582
+ ? `${attentionPanel(summary)}${
583
+ benchmarks.benchmarks.length
584
+ ? panel(
585
+ 'Benchmarks',
586
+ 'The primary metric of each, at the newest baseline',
587
+ benchTable(benchmarks.benchmarks),
588
+ '<a href="#/benchmarks">All benchmarks</a>',
589
+ )
590
+ : ''
591
+ }`
592
+ : panel('Get started', '', firstStep(summary, 'Speedpatrol has not measured this project yet'));
593
+ return `${confidence(summary)}<div class="grid"><div class="stack">${main}</div><div class="stack">${recentRunsPanel(
594
+ runs,
595
+ )}${agentsPanel(agents)}</div></div>`;
596
+ }
597
+
598
+ // ---------------------------------------------------------------- benchmarks
599
+
600
+ const PROBLEM = {
601
+ regression: (row) =>
602
+ `<a class="problem tone-fail" href="#/regressions/${encodeURIComponent(row.regression.id)}"><i aria-hidden="true">◆</i>regressed ${pct(row.regression.deltaPct)} since ${short(row.regression.badSha)}, ${esc(row.regression.status)}</a>`,
603
+ errors: (row) =>
604
+ `<span class="problem tone-warn"><i aria-hidden="true">▲</i>${plural(row.errors, 'error')} in the last run</span>`,
605
+ unmeasured: () => '<span class="problem tone-warn"><i aria-hidden="true">▲</i>no baseline yet</span>',
606
+ stale: () => '<span class="problem tone-warn"><i aria-hidden="true">▲</i>not in the last baseline</span>',
607
+ };
608
+
609
+ function benchRow(row, index) {
610
+ const spark = `spark-${index}`;
611
+ charts.set(spark, { type: 'spark', points: row.series });
612
+ const href = `#/benchmarks/${encodeURIComponent(row.id)}`;
613
+ return `<tr class="clickable" data-href="${href}">
614
+ <td><a class="bench-id" href="${href}">${esc(row.id)}</a>${row.critical ? '<span class="badge critical">critical</span>' : ''}</td>
615
+ <td class="dim">${esc(row.kind)}</td>
616
+ <td class="dim mono">${esc(row.primary || '-')}</td>
617
+ <td class="num">${fmt(row.latest?.median, row.unit)}</td>
618
+ <td class="num">${row.deltaPct === undefined ? '<span class="muted">-</span>' : delta(row.deltaPct, row.verdict)}</td>
619
+ <td>${row.problem ? PROBLEM[row.problem](row) : verdict(row.verdict) || `<span class="muted">${row.series.length === 1 ? 'measured once' : '-'}</span>`}</td>
620
+ <td><div class="chart" data-chart="${spark}" style="width:132px"></div></td>
621
+ </tr>`;
622
+ }
623
+
624
+ /** The benchmarks, in two groups: the ones with a problem first, then the steady ones. */
625
+ function benchTable(rows) {
626
+ const problems = rows.filter((row) => row.problem);
627
+ // A benchmark with one baseline has nothing to compare with, so it is not "steady" yet.
628
+ const steady = rows.filter((row) => !row.problem && row.series.length > 1);
629
+ const once = rows.filter((row) => !row.problem && row.series.length <= 1);
630
+ let index = 0;
631
+ const section = (label, list) =>
632
+ list.length
633
+ ? `<tbody><tr class="group-row"><th colspan="7" scope="colgroup">${esc(label)} <span class="count">${list.length}</span></th></tr>${list
634
+ .map((row) => benchRow(row, index++))
635
+ .join('')}</tbody>`
636
+ : '';
637
+ return `<table><thead><tr><th>Benchmark</th><th>Kind</th><th>Primary metric</th><th class="num">Median</th><th class="num" title="The newest baseline against the one before it">Last step</th><th>Status</th><th>History</th></tr></thead>${section(
638
+ 'Needs a look',
639
+ problems,
640
+ )}${section(problems.length || once.length ? 'Steady' : 'All steady', steady)}${section('Measured once', once)}</table>`;
641
+ }
642
+
643
+ function renderBenchmarks(data) {
644
+ const { summary, benchmarks } = data;
645
+ const rows = benchmarks.benchmarks;
646
+ const commits = Math.max(0, ...rows.map((row) => row.series.length));
647
+ const head = band([
648
+ {
649
+ label: 'Measured',
650
+ value:
651
+ summary.benchmarks.defined === undefined
652
+ ? summary.benchmarks.measured
653
+ : `${summary.benchmarks.measured} of ${summary.benchmarks.defined}`,
654
+ note: summary.runs.baselines ? 'In the newest baseline' : 'No baseline yet',
655
+ },
656
+ { label: 'Critical', value: summary.benchmarks.critical, note: 'A regression here blocks CI' },
657
+ {
658
+ label: 'Regressed',
659
+ value: summary.benchmarks.regressed,
660
+ note: summary.benchmarks.regressed ? 'with an open regression' : 'None',
661
+ tone: summary.benchmarks.regressed ? 'fail' : undefined,
662
+ href: '#/regressions',
663
+ },
664
+ {
665
+ label: 'No baseline',
666
+ value: summary.benchmarks.unmeasured.length,
667
+ note: summary.benchmarks.unmeasured.join(', ') || 'None',
668
+ tone: summary.benchmarks.unmeasured.length ? 'warn' : undefined,
669
+ },
670
+ {
671
+ label: 'With errors',
672
+ value: summary.benchmarks.withErrors.length,
673
+ note: summary.benchmarks.withErrors.join(', ') || 'None',
674
+ tone: summary.benchmarks.withErrors.length ? 'warn' : undefined,
675
+ },
676
+ {
677
+ label: 'History',
678
+ value: plural(commits, 'commit'),
679
+ note: benchmarks.machine ? `on machine ${benchmarks.machine}` : 'No baseline',
680
+ },
681
+ ]);
682
+ if (!rows.length) return `${head}${panel('Benchmarks', '', firstStep(summary, 'No benchmarks yet'))}`;
683
+
684
+ const query = ui.benchQuery.trim().toLowerCase();
685
+ const filters = {
686
+ all: () => true,
687
+ problems: (row) => !!row.problem,
688
+ critical: (row) => row.critical,
689
+ };
690
+ const shown = rows.filter(filters[ui.benchFilter] ?? filters.all).filter((row) => !query || row.id.includes(query));
691
+ const bar = `<div class="filters">${chips('benchFilter', [
692
+ { value: 'all', label: 'All', count: rows.length },
693
+ { value: 'problems', label: 'Needs a look', count: rows.filter(filters.problems).length },
694
+ { value: 'critical', label: 'Critical', count: rows.filter(filters.critical).length },
695
+ ])}<input type="search" class="search" placeholder="Filter by id, such as &quot;api&quot;" aria-label="Filter the benchmarks by id" data-query="benchQuery" value="${esc(
696
+ ui.benchQuery,
697
+ )}"></div>`;
698
+ const body = shown.length
699
+ ? benchTable(shown)
700
+ : emptyState({
701
+ title: 'No benchmark matches the filter',
702
+ why: query ? `No benchmark id contains “${ui.benchQuery}”.` : 'No benchmark is in this group.',
703
+ action: { key: 'benchReset', value: '1', label: 'Show all benchmarks' },
704
+ compact: true,
705
+ });
706
+ return `${head}${panel('Benchmarks', 'Problems first, then the critical paths', `${bar}${body}`, benchmarks.machine ? `machine <span class="mono">${esc(benchmarks.machine)}</span>` : '')}`;
392
707
  }
393
708
 
394
709
  // ---------------------------------------------------------------- benchmark detail
395
710
 
396
- function renderBenchmark(benchmark, id, overview) {
397
- const crumbs = `<div class="crumbs"><a href="#/">Overview</a> / ${esc(id)}</div>`;
711
+ function renderBenchmark(data, at) {
712
+ const { benchmark, summary } = data;
713
+ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="#/benchmarks">Benchmarks</a> / ${esc(at.id)}</nav>`;
398
714
  if (!benchmark) {
399
- return `${crumbs}${panel('Benchmark', '', empty(`No baseline run measured <code>${esc(id)}</code>. Run <code>speedpatrol run</code>.`))}`;
715
+ return `${crumbs}${panel(
716
+ esc(at.id),
717
+ '',
718
+ emptyState({
719
+ title: `No baseline measured ${at.id} yet`,
720
+ why: 'The page shows the history of a benchmark from its baselines. A run on a clean commit records one.',
721
+ cmd: `npx speedpatrol run ${at.id}`,
722
+ }),
723
+ )}`;
400
724
  }
401
725
  const primary = benchmark.metrics.find((metric) => metric.primary) ?? benchmark.metrics[0];
402
726
  const others = benchmark.metrics.filter((metric) => metric !== primary);
403
727
  const markers = benchmark.regressions.map((item) => ({
404
728
  sha: item.badSha,
405
- label: `regression · ${pct(item.deltaPct)}`,
729
+ label: `regression ${pct(item.deltaPct)}`,
406
730
  }));
407
731
  charts.set('main', { type: 'line', metric: primary, height: 300, markers });
408
- const latest = primary.series.at(-1);
409
- const previous = primary.series.at(-2);
410
- const change = latest && previous ? latest.median / previous.median - 1 : undefined;
732
+ const series = primary.series;
733
+ const latest = series.at(-1);
734
+ const previous = series.at(-2);
735
+ const first = series[0];
736
+ const open = benchmark.regressions.filter((item) => item.status === 'open' || item.status === 'filed');
737
+
738
+ const callouts = open
739
+ .map(
740
+ (item) => `<div class="callout tone-fail">${toneIcon('fail')}<div class="grow"><strong>${esc(item.title)}</strong>
741
+ <div class="dim">${esc(item.metric)} ${pct(item.deltaPct)} since <span class="mono">${short(item.badSha)}</span>. ${capital(item.status)}${item.issueUrl ? ` as ${link(item.issueUrl, issueLabel(item.issueUrl))}` : ''}.</div></div>
742
+ <a href="#/regressions/${encodeURIComponent(item.id)}">Open the regression</a></div>`,
743
+ )
744
+ .join('');
745
+
746
+ const head = band([
747
+ { label: 'Median', value: fmt(latest?.median, primary.unit), note: `${primary.name} at ${short(latest?.sha)}` },
748
+ {
749
+ label: 'Against the previous',
750
+ value: previous ? pct(latest.median / previous.median - 1) : '-',
751
+ note: previous ? `${short(previous.sha)}: ${fmt(previous.median, primary.unit)}` : 'Only one baseline',
752
+ },
753
+ {
754
+ label: `Over ${plural(series.length, 'commit')}`,
755
+ value: series.length > 1 ? pct(latest.median / first.median - 1) : '-',
756
+ note: series.length > 1 ? `from ${fmt(first.median, primary.unit)} at ${short(first.sha)}` : 'Only one baseline',
757
+ tone: open.length ? 'fail' : undefined,
758
+ },
759
+ {
760
+ label: 'Spread',
761
+ value: latest ? `${fmt(latest.min, primary.unit)} to ${fmt(latest.p95, primary.unit)}` : '-',
762
+ note: 'min to p95 of the newest run',
763
+ },
764
+ { label: 'Iterations', value: benchmark.iterations, note: 'Timed samples in each run' },
765
+ {
766
+ label: 'Errors',
767
+ value: benchmark.errors.length,
768
+ note: benchmark.errors[0] ?? 'None in the newest baseline',
769
+ tone: benchmark.errors.length ? 'warn' : undefined,
770
+ },
771
+ ]);
411
772
 
412
773
  const multiples = others
413
774
  .map((metric, index) => {
@@ -425,8 +786,8 @@ function renderBenchmark(benchmark, id, overview) {
425
786
  const hotspots = benchmark.hotspots
426
787
  ? panel(
427
788
  'Hotspots',
428
- `self time, from the profile of <span class="mono">${short(benchmark.hotspots.sha)}</span> · ${date(benchmark.hotspots.at)}`,
429
- `<table><thead><tr><th>Function</th><th>Location</th><th class="num">Self</th><th class="num">Total</th><th class="num">Self %</th><th></th></tr></thead><tbody>${benchmark.hotspots.hotspots
789
+ `Self time, from the profile of <span class="mono">${short(benchmark.hotspots.sha)}</span> · ${date(benchmark.hotspots.at)}`,
790
+ `<table><thead><tr><th>Function</th><th>Location</th><th class="num">Self</th><th class="num">Total</th><th class="num">Self %</th><th><span class="sr-only">Share</span></th></tr></thead><tbody>${benchmark.hotspots.hotspots
430
791
  .map(
431
792
  (spot) =>
432
793
  `<tr><td class="mono">${esc(spot.function)}</td><td class="mono dim">${esc(spot.location)}</td><td class="num">${fmt(
@@ -442,47 +803,59 @@ function renderBenchmark(benchmark, id, overview) {
442
803
  : panel(
443
804
  'Hotspots',
444
805
  '',
445
- empty(
446
- 'No profile recorded for this benchmark yet. Profiles are taken on the next <code>speedpatrol run</code>.',
447
- ),
806
+ emptyState({
807
+ title: 'No profile yet',
808
+ why: 'A run records a CPU profile in one extra iteration, when the probe can profile this kind.',
809
+ cmd: `npx speedpatrol run ${benchmark.id}`,
810
+ compact: true,
811
+ }),
448
812
  );
449
813
 
450
- const regressions = benchmark.regressions.length
814
+ const history = benchmark.regressions.length
451
815
  ? panel(
452
- 'Regressions',
816
+ 'Regressions of this benchmark',
453
817
  '',
454
- benchmark.regressions
818
+ `<ul class="plain-list">${benchmark.regressions
455
819
  .map(
456
820
  (item) =>
457
- `<div class="panel-body"><span class="status ${esc(item.status)}">${esc(item.status)}</span> ${esc(item.title)} ${link(
458
- item.issueUrl,
459
- issueLabel(item.issueUrl),
460
- )}</div>${item.analysis ? `<div class="analysis">${markdown(item.analysis)}</div>` : ''}`,
821
+ `<li><span class="status ${esc(item.status)}">${esc(item.status)}</span> <a href="#/regressions/${encodeURIComponent(item.id)}">${esc(
822
+ item.title,
823
+ )}</a> <span class="muted">${date(item.createdAt)}</span></li>`,
461
824
  )
462
- .join(''),
825
+ .join('')}</ul>`,
826
+ )
827
+ : '';
828
+ const experiments = benchmark.experiments.length
829
+ ? panel(
830
+ 'Experiments on this benchmark',
831
+ '',
832
+ `<ul class="plain-list">${benchmark.experiments
833
+ .map(
834
+ (item) =>
835
+ `<li><span class="status ${esc(item.status)}">${esc(item.status)}</span> <a href="#/experiments/${encodeURIComponent(item.id)}">${esc(
836
+ item.summary ?? item.hypothesis ?? item.branch,
837
+ )}</a> ${item.deltaPct === undefined ? '' : `<span class="dim">${pct(item.deltaPct)}</span>`}</li>`,
838
+ )
839
+ .join('')}</ul>`,
463
840
  )
464
841
  : '';
465
842
 
466
843
  return `${crumbs}
467
844
  <div class="title-row"><h1>${esc(benchmark.id)}</h1>${benchmark.critical ? '<span class="badge critical">critical</span>' : ''}<span class="badge">${esc(
468
845
  benchmark.kind,
469
- )}</span>
470
- <div class="facts"><span>${benchmark.iterations} iterations</span><span>${primary.series.length} baseline commits</span>${
471
- benchmark.errors.length ? `<span>${benchmark.errors.length} errors</span>` : ''
472
- }<span>machine <span class="mono">${esc(overview.latest?.machine.fingerprint ?? '')}</span></span></div></div>
846
+ )}</span><span class="facts">machine <span class="mono">${esc(summary.machine?.fingerprint ?? '')}</span></span></div>
847
+ ${callouts}${head}
473
848
  <div class="stack">
474
849
  ${panel(
475
850
  `<span class="mono">${esc(primary.name)}</span>`,
476
- `primary metric · ${esc(primary.unit)} · ${primary.better} is better`,
477
- `<div class="hero"><span class="value">${fmt(latest?.median, primary.unit)}</span><span class="dim">median at <span class="mono">${short(
478
- latest?.sha,
479
- )}</span></span>${change === undefined ? '' : `<span>${delta(change, undefined)} <span class="muted">vs previous</span></span>`}</div>
480
- <div class="panel-body"><div class="chart" data-chart="main"></div></div>`,
481
- '<span class="key"><span class="line"></span>median</span><span class="key"><span class="band"></span>min to p95</span>',
851
+ `Primary metric · ${esc(primary.unit)} · ${primary.better} is better`,
852
+ `<div class="panel-body"><div class="chart" data-chart="main"></div></div>`,
853
+ '<span class="key"><span class="line"></span>median</span><span class="key"><span class="band-key"></span>min to p95</span>',
482
854
  )}
483
855
  ${others.length ? `<div class="multiples">${multiples}</div>` : ''}
484
856
  ${hotspots}
485
- ${regressions}
857
+ ${history}
858
+ ${experiments}
486
859
  </div>`;
487
860
  }
488
861
 
@@ -491,43 +864,466 @@ function bar(value, max) {
491
864
  return `<svg width="124" height="8" aria-hidden="true"><rect x="0" y="0" width="${width}" height="8" rx="2" fill="var(--series)"/></svg>`;
492
865
  }
493
866
 
867
+ // ---------------------------------------------------------------- regressions
868
+
869
+ const REG_GROUPS = [
870
+ { label: 'Open', statuses: ['open', 'filed'] },
871
+ { label: 'Fixed', statuses: ['fixed'] },
872
+ { label: 'Dismissed', statuses: ['dismissed'] },
873
+ ];
874
+
875
+ function regressionDetail(item) {
876
+ const next =
877
+ item.status === 'open' && !item.analysis
878
+ ? `<p class="dim">The analyst did not explain this regression yet.</p>${command(`npx speedpatrol analyze ${item.id}`)}`
879
+ : (item.status === 'open' || item.status === 'filed') && !item.issueUrl
880
+ ? `<p class="dim">No issue is open for it yet.</p>${command('npx speedpatrol publish')}`
881
+ : '';
882
+ return `<div class="row-detail">
883
+ <dl class="facts-list">
884
+ <div><dt>Change</dt><dd><span class="delta worse">${pct(item.deltaPct)}</span> on <span class="mono">${esc(item.metric)}</span></dd></div>
885
+ <div><dt>Last good</dt><dd class="mono">${short(item.goodSha)}</dd></div>
886
+ <div><dt>First bad</dt><dd class="mono">${short(item.badSha)}</dd></div>
887
+ <div><dt>Evidence</dt><dd><a href="#/comparisons/${encodeURIComponent(item.comparisonId)}">The comparison</a> · <a href="#/benchmarks/${encodeURIComponent(item.benchmark)}">The history</a></dd></div>
888
+ ${item.issueUrl ? `<div><dt>Issue</dt><dd>${link(item.issueUrl, issueLabel(item.issueUrl))}</dd></div>` : ''}
889
+ </dl>
890
+ ${item.reason ? `<p class="dim">${esc(item.reason)}</p>` : ''}
891
+ ${item.analysis ? `<h4>Analysis</h4><div class="analysis">${markdown(item.analysis)}</div>` : ''}
892
+ ${
893
+ item.suspects?.length
894
+ ? `<h4>Suspect commits</h4><ul class="plain-list">${item.suspects
895
+ .map(
896
+ (suspect) =>
897
+ `<li><span class="mono">${short(suspect.sha)}</span> ${esc(suspect.subject)}<div class="dim">${esc(suspect.reason)}</div></li>`,
898
+ )
899
+ .join('')}</ul>`
900
+ : ''
901
+ }
902
+ ${next}
903
+ </div>`;
904
+ }
905
+
906
+ function renderRegressions(data, at) {
907
+ const { summary, regressions } = data;
908
+ const counts = summary.regressions.byStatus;
909
+ const head = band([
910
+ {
911
+ label: 'Open',
912
+ value: summary.regressions.open,
913
+ note: summary.regressions.open
914
+ ? [counts.filed && `${counts.filed} filed`, counts.open && `${counts.open} not filed`]
915
+ .filter(Boolean)
916
+ .join(', ')
917
+ : 'None',
918
+ tone: summary.regressions.open ? 'fail' : undefined,
919
+ },
920
+ { label: 'Fixed', value: counts.fixed, note: 'Back within the thresholds, or closed on GitHub' },
921
+ { label: 'Dismissed', value: counts.dismissed, note: 'Closed as not planned' },
922
+ {
923
+ label: 'Comparisons',
924
+ value: summary.runs.comparisons,
925
+ note: 'Steps between baselines, and A/B runs',
926
+ href: '#/runs',
927
+ },
928
+ ]);
929
+ if (!regressions.length) {
930
+ const body = summary.runs.baselines
931
+ ? emptyState({
932
+ title: 'No regressions',
933
+ why: summary.runs.comparisons
934
+ ? `Speedpatrol made ${plural(summary.runs.comparisons, 'comparison')} and confirmed no slowdown. The patrol compares each new commit on main.`
935
+ : 'Speedpatrol has one baseline and nothing to compare with yet. Start the patrol: it compares each new commit on main with it.',
936
+ cmd: 'npx speedpatrol',
937
+ })
938
+ : firstStep(summary, 'No regressions yet');
939
+ return `${head}${panel('Regressions', '', body)}`;
940
+ }
941
+ const filter = {
942
+ open: (item) => item.status === 'open' || item.status === 'filed',
943
+ closed: (item) => item.status === 'fixed' || item.status === 'dismissed',
944
+ all: () => true,
945
+ };
946
+ const shown = regressions.filter(filter[ui.regFilter] ?? filter.all);
947
+ const bar = `<div class="filters">${chips('regFilter', [
948
+ { value: 'open', label: 'Open', count: regressions.filter(filter.open).length },
949
+ { value: 'closed', label: 'Closed', count: regressions.filter(filter.closed).length },
950
+ { value: 'all', label: 'All', count: regressions.length },
951
+ ])}</div>`;
952
+ const groups = REG_GROUPS.map(({ label, statuses }) => {
953
+ const items = shown.filter((item) => statuses.includes(item.status));
954
+ if (!items.length) return '';
955
+ const rows = items
956
+ .map((item) => {
957
+ const open = at.id === item.id;
958
+ return `<li class="row${open ? ' open' : ''}">${rowToggle(
959
+ 'regressions',
960
+ item.id,
961
+ open,
962
+ `<span class="row-status"><span class="status ${esc(item.status)}">${esc(item.status)}</span></span><span class="row-main"><span class="row-title">${esc(item.title)}</span><span class="row-meta mono">${esc(
963
+ item.benchmark,
964
+ )} · ${esc(item.metric)}</span></span><span class="delta worse num">${pct(item.deltaPct)}</span><span class="muted nowrap">${ago(item.createdAt)}</span>`,
965
+ )}${open ? regressionDetail(item) : ''}</li>`;
966
+ })
967
+ .join('');
968
+ return `${group(label, items.length)}<ul class="rows">${rows}</ul>`;
969
+ }).join('');
970
+ const body = shown.length
971
+ ? groups
972
+ : emptyState({
973
+ title: ui.regFilter === 'open' ? 'No open regressions' : 'No closed regressions',
974
+ why:
975
+ ui.regFilter === 'open'
976
+ ? 'Each regression that Speedpatrol confirmed is fixed or dismissed.'
977
+ : 'No regression is fixed or dismissed yet.',
978
+ action: { key: 'regFilter', value: 'all', label: 'Show all regressions' },
979
+ compact: true,
980
+ });
981
+ return `${head}${panel('Regressions', 'Open regressions first. Open a row to see the analysis.', `${bar}${body}`)}`;
982
+ }
983
+
984
+ // ---------------------------------------------------------------- experiments
985
+
986
+ const EXP_GROUPS = [
987
+ { key: 'waiting', label: 'Wait to be published', statuses: ['improved'] },
988
+ { key: 'active', label: 'In progress', statuses: ['proposed', 'measuring'] },
989
+ { key: 'published', label: 'Published', statuses: ['published'] },
990
+ { key: 'rejected', label: 'Rejected', statuses: ['rejected', 'failed'] },
991
+ ];
992
+
993
+ function experimentDetail(item) {
994
+ return `<div class="row-detail">
995
+ <dl class="facts-list">
996
+ <div><dt>Branch</dt><dd class="mono">${esc(item.branch)}</dd></div>
997
+ <div><dt>Base</dt><dd class="mono">${short(item.baseSha)}</dd></div>
998
+ ${item.headSha ? `<div><dt>Head</dt><dd class="mono">${short(item.headSha)}</dd></div>` : ''}
999
+ ${item.comparisonId ? `<div><dt>Evidence</dt><dd><a href="#/comparisons/${encodeURIComponent(item.comparisonId)}">The A/B comparison</a></dd></div>` : ''}
1000
+ ${item.prUrl ? `<div><dt>Pull request</dt><dd>${link(item.prUrl, issueLabel(item.prUrl))}</dd></div>` : ''}
1001
+ </dl>
1002
+ ${item.hypothesis ? `<h4>Hypothesis</h4><p>${inline(item.hypothesis)}</p>` : ''}
1003
+ ${item.summary ? `<h4>Change</h4><p>${inline(item.summary)}</p>` : ''}
1004
+ ${item.reason ? `<h4>Reason</h4><p>${inline(item.reason)}</p>` : ''}
1005
+ ${item.collateral?.length ? `<p class="dim">Slower as a side effect: <span class="mono">${item.collateral.map(esc).join(', ')}</span></p>` : ''}
1006
+ ${item.status === 'improved' && !item.prUrl ? `<p class="dim">The A/B run confirmed this speed-up. Publish it as a pull request.</p>${command('npx speedpatrol publish')}` : ''}
1007
+ </div>`;
1008
+ }
1009
+
1010
+ function renderExperiments(data, at) {
1011
+ const { summary, experiments } = data;
1012
+ const counts = summary.experiments;
1013
+ const head = band([
1014
+ {
1015
+ label: 'Wait to be published',
1016
+ value: counts.waiting,
1017
+ note: counts.waiting ? 'Verified speed-ups' : 'None',
1018
+ tone: counts.waiting ? 'warn' : undefined,
1019
+ },
1020
+ { label: 'In progress', value: counts.active, note: 'Proposed or measured now' },
1021
+ { label: 'Published', value: counts.published, note: 'Pull requests open' },
1022
+ { label: 'Rejected', value: counts.rejected, note: 'No speed-up, or slower elsewhere' },
1023
+ ]);
1024
+ if (!experiments.length) {
1025
+ const body = summary.runs.baselines
1026
+ ? emptyState({
1027
+ title: 'No experiments yet',
1028
+ why: 'The optimizer tries a speed-up in a worktree, and an A/B run checks it.',
1029
+ cmd: 'npx speedpatrol optimize',
1030
+ })
1031
+ : firstStep(summary, 'No experiments yet');
1032
+ return `${head}${panel('Experiments', '', body)}`;
1033
+ }
1034
+ const options = [{ value: 'all', label: 'All', count: experiments.length }];
1035
+ for (const { key, label, statuses } of EXP_GROUPS) {
1036
+ const count = experiments.filter((item) => statuses.includes(item.status)).length;
1037
+ if (count) options.push({ value: key, label, count });
1038
+ }
1039
+ const groups = EXP_GROUPS.filter(({ key }) => ui.expFilter === 'all' || ui.expFilter === key)
1040
+ .map(({ label, statuses }) => {
1041
+ const items = experiments.filter((item) => statuses.includes(item.status));
1042
+ if (!items.length) return '';
1043
+ const rows = items
1044
+ .map((item) => {
1045
+ const open = at.id === item.id;
1046
+ const better = item.status === 'improved' || item.status === 'published';
1047
+ return `<li class="row${open ? ' open' : ''}">${rowToggle(
1048
+ 'experiments',
1049
+ item.id,
1050
+ open,
1051
+ `<span class="row-status"><span class="status ${esc(item.status)}">${esc(item.status)}</span></span><span class="row-main"><span class="row-title">${esc(
1052
+ item.summary ?? item.hypothesis ?? item.branch,
1053
+ )}</span><span class="row-meta mono">${esc(item.target.benchmark)} · ${esc(item.target.metric)}</span></span><span class="num delta ${better ? 'better' : ''}">${pct(
1054
+ item.deltaPct,
1055
+ )}</span><span class="muted nowrap">${ago(item.updatedAt)}</span>`,
1056
+ )}${open ? experimentDetail(item) : ''}</li>`;
1057
+ })
1058
+ .join('');
1059
+ return `${group(label, items.length)}<ul class="rows">${rows}</ul>`;
1060
+ })
1061
+ .join('');
1062
+ return `${head}${panel(
1063
+ 'Experiments',
1064
+ 'Verified speed-ups first. Open a row to see the change.',
1065
+ `<div class="filters">${chips('expFilter', options)}</div>${groups || emptyState({ title: 'No experiment in this group', action: { key: 'expFilter', value: 'all', label: 'Show all experiments' }, compact: true })}`,
1066
+ )}`;
1067
+ }
1068
+
1069
+ // ---------------------------------------------------------------- runs
1070
+
1071
+ function renderRuns(data) {
1072
+ const { summary, runs } = data;
1073
+ const head = band([
1074
+ { label: 'Runs', value: summary.runs.total, note: 'All purposes' },
1075
+ {
1076
+ label: 'Baselines',
1077
+ value: summary.runs.baselines,
1078
+ note: summary.machine ? `on machine ${summary.machine.fingerprint}` : 'None yet',
1079
+ },
1080
+ {
1081
+ label: 'Failed',
1082
+ value: summary.runs.failed,
1083
+ note: summary.runs.failed ? 'The app did not start, or no benchmark ran' : 'None',
1084
+ tone: summary.runs.failed ? 'fail' : undefined,
1085
+ },
1086
+ { label: 'Comparisons', value: summary.runs.comparisons, note: 'Steps between baselines, and A/B runs' },
1087
+ ]);
1088
+ if (!runs.length) return `${head}${panel('Runs', '', firstStep(summary, 'No runs yet'))}`;
1089
+ const filter = {
1090
+ all: () => true,
1091
+ baseline: (run) => run.purpose === 'baseline',
1092
+ other: (run) => run.purpose !== 'baseline',
1093
+ failed: (run) => !!run.failed,
1094
+ };
1095
+ const options = [
1096
+ { value: 'all', label: 'All', count: runs.length },
1097
+ { value: 'baseline', label: 'Baselines', count: runs.filter(filter.baseline).length },
1098
+ { value: 'other', label: 'A/B and compare', count: runs.filter(filter.other).length },
1099
+ { value: 'failed', label: 'Failed', count: runs.filter(filter.failed).length },
1100
+ ];
1101
+ const shown = runs.filter(filter[ui.runFilter] ?? filter.all);
1102
+ // Failed runs first, then the last 7 days, then the rest, newest first.
1103
+ const failed = shown.filter(filter.failed);
1104
+ const week = Date.now() - 7 * 86_400_000;
1105
+ const days = new Map();
1106
+ for (const run of shown.filter((item) => !item.failed)) {
1107
+ const key = Date.parse(run.startedAt) >= week ? 'Last 7 days' : 'Earlier';
1108
+ days.set(key, [...(days.get(key) ?? []), run]);
1109
+ }
1110
+ const row = (run) => `<tr${run.failed ? ' class="failed-row"' : ''}>
1111
+ <td class="muted nowrap">${date(run.startedAt)}</td>
1112
+ <td class="dim">${PURPOSE[run.purpose] ?? esc(run.purpose)}</td>
1113
+ <td class="truncate"><span class="mono">${short(run.sha)}</span> ${esc(run.subject ?? '')}${run.branch ? ` <span class="muted mono">${esc(run.branch)}</span>` : ''}${
1114
+ run.failed ? `<div class="fail-text">${esc(run.failed)}</div>` : ''
1115
+ }</td>
1116
+ <td class="num">${run.failed ? '<span class="muted">-</span>' : run.benchmarks}${run.errors ? ` <span class="problem tone-warn" title="${plural(run.errors, 'benchmark')} with errors">▲ ${run.errors}</span>` : ''}</td>
1117
+ <td class="nowrap">${runResult(run)}</td>
1118
+ </tr>`;
1119
+ const section = (label, list) =>
1120
+ `<tbody><tr class="group-row"><th colspan="5" scope="colgroup">${esc(label)} <span class="count">${list.length}</span></th></tr>${list.map(row).join('')}</tbody>`;
1121
+ const table = shown.length
1122
+ ? `<table><thead><tr><th>Started</th><th>Purpose</th><th>Commit</th><th class="num">Benchmarks</th><th>Result</th></tr></thead>${
1123
+ failed.length ? section('Failed', failed) : ''
1124
+ }${[...days].map(([label, list]) => section(label, list)).join('')}</table>`
1125
+ : emptyState({
1126
+ title: 'No run in this group',
1127
+ action: { key: 'runFilter', value: 'all', label: 'Show all runs' },
1128
+ compact: true,
1129
+ });
1130
+ return `${head}${panel('Runs', 'Failed runs first. Open a result to see its comparison.', `<div class="filters">${chips('runFilter', options)}</div>${table}`)}`;
1131
+ }
1132
+
494
1133
  // ---------------------------------------------------------------- comparison detail
495
1134
 
496
- function renderComparison(comparison, id) {
497
- const crumbs = `<div class="crumbs"><a href="#/">Overview</a> / comparison ${esc(id)}</div>`;
498
- if (!comparison) return `${crumbs}${panel('Comparison', '', empty('No such comparison.'))}`;
499
- const rows = [...comparison.metrics]
500
- .sort((a, b) => Number(b.primary) - Number(a.primary) || a.benchmark.localeCompare(b.benchmark))
501
- .map(
502
- (metric) => `<tr${metric.primary ? '' : ' class="secondary"'}>
503
- <td>${verdict(metric.verdict)}</td>
504
- <td><span class="bench-id">${esc(metric.benchmark)}</span>${metric.critical && metric.primary ? '<span class="badge critical">critical</span>' : ''}</td>
1135
+ const VERDICT_ORDER = ['regression', 'noisy', 'missing', 'improvement', 'unchanged'];
1136
+
1137
+ function renderComparison(data, at) {
1138
+ const { comparison } = data;
1139
+ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="#/runs">Runs</a> / comparison ${esc(at.id)}</nav>`;
1140
+ if (!comparison) {
1141
+ return `${crumbs}${panel(
1142
+ 'Comparison',
1143
+ '',
1144
+ emptyState({
1145
+ title: 'No such comparison',
1146
+ why: 'The file of this comparison is not in .speedpatrol/runs/comparisons. It was removed, or the link is wrong.',
1147
+ cmd: 'npx speedpatrol compare',
1148
+ }),
1149
+ )}`;
1150
+ }
1151
+ const counts = {};
1152
+ for (const metric of comparison.metrics) counts[metric.verdict] = (counts[metric.verdict] ?? 0) + 1;
1153
+ const primaries = (value) => comparison.metrics.filter((metric) => metric.primary && metric.verdict === value).length;
1154
+ const stat = (value, label, tone) => ({
1155
+ label,
1156
+ value: counts[value] ?? 0,
1157
+ note: counts[value] ? `${primaries(value)} on a primary metric` : 'None',
1158
+ tone: counts[value] ? tone : undefined,
1159
+ });
1160
+ const head = band([
1161
+ {
1162
+ label: 'Verdict',
1163
+ value: comparison.verdict,
1164
+ note: 'The worst verdict of the primary metrics',
1165
+ tone: { regression: 'fail', noisy: 'warn', improvement: 'pass' }[comparison.verdict],
1166
+ },
1167
+ stat('regression', 'Regressions', 'fail'),
1168
+ stat('improvement', 'Improvements', 'pass'),
1169
+ stat('noisy', 'Noisy', 'warn'),
1170
+ stat('unchanged', 'Unchanged'),
1171
+ {
1172
+ label: 'Mode',
1173
+ value: comparison.mode === 'ab' ? 'A/B' : 'Baseline',
1174
+ note: comparison.mode === 'ab' ? 'Alternating rounds on one machine' : 'Two baselines',
1175
+ },
1176
+ ]);
1177
+ const warnings = comparison.warnings.length
1178
+ ? `<div class="callout tone-warn">${toneIcon('warn')}<div class="grow">${comparison.warnings.map(esc).join('<br>')}</div></div>`
1179
+ : '';
1180
+ const changed = (metric) => metric.verdict !== 'unchanged';
1181
+ const shown = comparison.metrics.filter((metric) => ui.cmpFilter === 'all' || changed(metric));
1182
+ const benchmarks = new Map();
1183
+ for (const metric of shown) benchmarks.set(metric.benchmark, [...(benchmarks.get(metric.benchmark) ?? []), metric]);
1184
+ const rank = (metric) => VERDICT_ORDER.indexOf(metric.verdict);
1185
+ const groups = [...benchmarks]
1186
+ .map(([id, metrics]) => ({
1187
+ id,
1188
+ metrics: metrics.sort((a, b) => Number(b.primary) - Number(a.primary) || rank(a) - rank(b)),
1189
+ }))
1190
+ .sort((a, b) => Math.min(...a.metrics.map(rank)) - Math.min(...b.metrics.map(rank)) || a.id.localeCompare(b.id));
1191
+ const rows = groups
1192
+ .map(({ id, metrics }) => {
1193
+ const worst = [...metrics].sort((a, b) => rank(a) - rank(b))[0];
1194
+ const critical = metrics.some((metric) => metric.critical);
1195
+ return `<tbody><tr class="group-row"><th colspan="7" scope="colgroup"><a class="bench-id" href="#/benchmarks/${encodeURIComponent(id)}">${esc(id)}</a>${
1196
+ critical ? '<span class="badge critical">critical</span>' : ''
1197
+ } <span class="count">${plural(metrics.length, 'metric')}</span> ${verdict(worst.verdict)}</th></tr>${metrics
1198
+ .map(
1199
+ (metric) => `<tr>
505
1200
  <td class="mono ${metric.primary ? '' : 'dim'}">${esc(metric.metric)}${metric.primary ? ' <span class="badge">primary</span>' : ''}</td>
1201
+ <td>${verdict(metric.verdict)}</td>
506
1202
  <td class="num">${fmt(metric.base?.median, metric.unit)}</td>
507
1203
  <td class="num">${fmt(metric.head?.median, metric.unit)}</td>
508
1204
  <td class="num">${delta(metric.deltaPct, metric.verdict)}</td>
509
1205
  <td class="num dim">${pct(metric.ci[0])} to ${pct(metric.ci[1])}</td>
510
1206
  <td class="num dim">${metric.pValue < 0.001 ? '&lt;0.001' : metric.pValue.toFixed(3)}</td>
511
1207
  </tr>`,
512
- )
1208
+ )
1209
+ .join('')}</tbody>`;
1210
+ })
513
1211
  .join('');
514
- const warnings = comparison.warnings.length
515
- ? `<div class="warnings">${comparison.warnings.map(esc).join('<br>')}</div>`
516
- : '';
1212
+ const unchanged = counts.unchanged ?? 0;
1213
+ const table = groups.length
1214
+ ? `<table><thead><tr><th>Metric</th><th>Verdict</th><th class="num">Base</th><th class="num">Head</th><th class="num">Change</th><th class="num">95% CI</th><th class="num">p</th></tr></thead>${rows}</table>`
1215
+ : emptyState({
1216
+ title: 'Each metric is unchanged',
1217
+ why: `The ${plural(unchanged, 'metric')} of this comparison are within the thresholds.`,
1218
+ action: { key: 'cmpFilter', value: 'all', label: 'Show all metrics' },
1219
+ compact: true,
1220
+ });
517
1221
  return `${crumbs}
518
- <div class="title-row"><h1>${esc(comparison.head.label)} <span class="muted">vs</span> ${esc(comparison.base.label)}</h1>${verdict(
519
- comparison.verdict,
520
- )}
521
- <div class="facts"><span>${comparison.mode === 'ab' ? 'A/B' : 'baseline'}</span><span>head <span class="mono">${short(
522
- comparison.head.sha,
523
- )}</span></span><span>base <span class="mono">${short(comparison.base.sha)}</span></span><span>${date(comparison.createdAt)}</span></div></div>
1222
+ <div class="title-row"><h1>${esc(comparison.head.label)} <span class="muted">vs</span> ${esc(comparison.base.label)}</h1>${verdict(comparison.verdict)}
1223
+ <span class="facts"><span>head <span class="mono">${short(comparison.head.sha)}</span></span><span>base <span class="mono">${short(comparison.base.sha)}</span></span><span>${date(comparison.createdAt)}</span></span></div>
1224
+ ${head}${warnings}
524
1225
  ${panel(
525
1226
  'Metrics',
526
- 'medians, change of head against base, bootstrap 95% CI, Mann-Whitney p',
527
- `${warnings}<table><thead><tr><th>Verdict</th><th>Benchmark</th><th>Metric</th><th class="num">Base</th><th class="num">Head</th><th class="num">Change</th><th class="num">95% CI</th><th class="num">p</th></tr></thead><tbody>${rows}</tbody></table>`,
1227
+ 'Medians, change of head against base, bootstrap 95% CI, Mann-Whitney p',
1228
+ `<div class="filters">${chips('cmpFilter', [
1229
+ { value: 'changed', label: 'Changed', count: comparison.metrics.filter(changed).length },
1230
+ { value: 'all', label: 'All metrics', count: comparison.metrics.length },
1231
+ ])}</div>${table}`,
528
1232
  )}`;
529
1233
  }
530
1234
 
1235
+ // ---------------------------------------------------------------- activity
1236
+
1237
+ const SESSION_GROUPS = [
1238
+ { label: 'Running sessions', states: ['running'] },
1239
+ { label: 'Stopped or failed', states: ['stopped', 'failed'] },
1240
+ { label: 'Done', states: ['done', 'empty'] },
1241
+ ];
1242
+
1243
+ function timeline(events) {
1244
+ if (!events) return '<p class="muted">The events of this session do not load.</p>';
1245
+ return `<ol class="timeline">${events
1246
+ .map(
1247
+ (event) =>
1248
+ `<li><span class="dot ${esc(event.kind)}" aria-hidden="true"></span><span class="t-time mono">${clock(event.at)}</span><div><div>${esc(
1249
+ event.summary,
1250
+ )}</div><div class="muted">${esc(event.kind)}${event.tool ? ` · <span class="mono">${esc(event.tool)}</span>` : ''}${
1251
+ event.durationMs ? ` · ${duration(event.durationMs)}` : ''
1252
+ }${event.costUsd ? ` · ${money(event.costUsd)}` : ''}</div></div></li>`,
1253
+ )
1254
+ .join('')}</ol>`;
1255
+ }
1256
+
1257
+ function renderActivity(data, at) {
1258
+ const { sessions, events, agents } = data;
1259
+ const count = (...states) => sessions.filter((session) => states.includes(session.state)).length;
1260
+ const cost = sessions.reduce((sum, session) => sum + (session.costUsd ?? 0), 0);
1261
+ const head = band([
1262
+ {
1263
+ label: 'Sessions',
1264
+ value: sessions.length,
1265
+ note: sessions[0] ? `Last event ${ago(sessions[0].lastAt)}` : 'None yet',
1266
+ },
1267
+ { label: 'Running sessions', value: count('running'), note: count('running') ? 'Their agent works now' : 'None' },
1268
+ {
1269
+ label: 'Stopped or failed',
1270
+ value: count('stopped', 'failed'),
1271
+ note: count('stopped', 'failed') ? 'No finish event' : 'None',
1272
+ tone: count('stopped', 'failed') ? 'warn' : undefined,
1273
+ },
1274
+ {
1275
+ label: 'Tool calls',
1276
+ value: sessions.reduce((sum, session) => sum + session.toolCalls, 0),
1277
+ note: 'In all sessions',
1278
+ },
1279
+ { label: 'Model cost', value: money(cost), note: cost ? 'As the runtime reported it' : 'No cost reported' },
1280
+ ]);
1281
+ const agentPanel = agentsPanel(agents, true);
1282
+ if (!sessions.length) {
1283
+ return `${head}<div class="grid"><div class="stack">${panel(
1284
+ 'Sessions',
1285
+ '',
1286
+ emptyState({
1287
+ title: 'No agent sessions yet',
1288
+ why: 'An agent writes a session when it works: the scout first, when it writes the benchmarks.',
1289
+ cmd: 'npx speedpatrol scout',
1290
+ }),
1291
+ )}</div><div class="stack">${agentPanel}</div></div>`;
1292
+ }
1293
+ const groups = SESSION_GROUPS.map(({ label, states }) => {
1294
+ const items = sessions.filter((session) => states.includes(session.state));
1295
+ if (!items.length) return '';
1296
+ const rows = items
1297
+ .map((session) => {
1298
+ const open = at.id === session.id;
1299
+ return `<li class="row${open ? ' open' : ''}">${rowToggle(
1300
+ 'activity',
1301
+ session.id,
1302
+ open,
1303
+ `<span class="row-status"><span class="state ${esc(session.state)}">${esc(capital(session.role ?? 'agent'))}</span></span><span class="row-main"><span class="row-title">${esc(
1304
+ session.goal ?? session.last ?? session.id,
1305
+ )}</span><span class="row-meta">${esc(session.last ?? '')}</span></span><span class="row-stats muted">${plural(session.toolCalls, 'tool call')} · ${duration(
1306
+ session.durationMs,
1307
+ )}${session.costUsd ? ` · ${money(session.costUsd)}` : ''}</span><span class="muted nowrap">${ago(session.lastAt)}</span>`,
1308
+ )}${open ? `<div class="row-detail">${timeline(events)}</div>` : ''}</li>`;
1309
+ })
1310
+ .join('');
1311
+ return `${group(label, items.length)}<ul class="rows">${rows}</ul>`;
1312
+ }).join('');
1313
+ return `${head}<div class="grid"><div class="stack">${panel('Sessions', 'Open a session to see its timeline.', groups)}</div><div class="stack">${agentPanel}</div></div>`;
1314
+ }
1315
+
1316
+ const PAGE_RENDER = {
1317
+ overview: renderOverview,
1318
+ benchmarks: renderBenchmarks,
1319
+ benchmark: renderBenchmark,
1320
+ regressions: renderRegressions,
1321
+ experiments: renderExperiments,
1322
+ runs: renderRuns,
1323
+ comparison: renderComparison,
1324
+ activity: renderActivity,
1325
+ };
1326
+
531
1327
  // ---------------------------------------------------------------- charts
532
1328
 
533
1329
  function niceTicks(lo, hi, count) {
@@ -580,7 +1376,7 @@ function drawLine(el, spec) {
580
1376
  el.innerHTML = '<span class="muted">No data</span>';
581
1377
  return;
582
1378
  }
583
- const m = { top: compact ? 10 : 18, right: compact ? 12 : 72, bottom: 24, left: compact ? 40 : 52 };
1379
+ const m = { top: compact ? 10 : 18, right: compact ? 32 : 72, bottom: 24, left: compact ? 40 : 52 };
584
1380
  const plotW = width - m.left - m.right;
585
1381
  const plotH = height - m.top - m.bottom;
586
1382
  const lows = points.map((p) => p.min);
@@ -610,7 +1406,7 @@ function drawLine(el, spec) {
610
1406
  .map((p, i) =>
611
1407
  i % every === 0 || i === points.length - 1
612
1408
  ? (i === points.length - 1 || points.length - 1 - i >= every * 0.6) &&
613
- `<text class="tick sha" x="${x(i)}" y="${height - 6}" text-anchor="middle">${esc(short(p.sha).slice(0, 7))}</text>`
1409
+ `<text class="tick sha" x="${x(i)}" y="${height - 6}" text-anchor="middle">${esc(short(p.sha))}</text>`
614
1410
  : '',
615
1411
  )
616
1412
  .filter(Boolean)
@@ -692,15 +1488,47 @@ function drawLine(el, spec) {
692
1488
 
693
1489
  // ---------------------------------------------------------------- wiring
694
1490
 
695
- view.addEventListener('click', (event) => {
696
- if (event.target.closest('a')) return;
1491
+ document.addEventListener('click', async (event) => {
1492
+ const copy = event.target.closest('[data-copy]');
1493
+ if (copy) {
1494
+ try {
1495
+ await navigator.clipboard.writeText(copy.dataset.copy);
1496
+ copy.textContent = 'Copied';
1497
+ } catch {
1498
+ copy.textContent = 'Copy failed';
1499
+ }
1500
+ setTimeout(() => {
1501
+ copy.textContent = 'Copy';
1502
+ }, 1500);
1503
+ return;
1504
+ }
1505
+ const setter = event.target.closest('[data-set]');
1506
+ if (setter) {
1507
+ if (setter.dataset.set === 'benchReset') Object.assign(ui, { benchFilter: 'all', benchQuery: '' });
1508
+ else ui[setter.dataset.set] = setter.dataset.value;
1509
+ render();
1510
+ return;
1511
+ }
1512
+ // A row is a pointer shortcut to the link in its first cell, which the keyboard reaches.
1513
+ if (event.target.closest('a, button, input')) return;
697
1514
  const row = event.target.closest('[data-href]');
698
- if (row) location.hash = row.dataset.href;
1515
+ if (row && !window.getSelection()?.toString()) location.hash = row.dataset.href;
1516
+ });
1517
+
1518
+ view.addEventListener('input', (event) => {
1519
+ const key = event.target.dataset?.query;
1520
+ if (!key) return;
1521
+ ui[key] = event.target.value;
1522
+ render();
699
1523
  });
700
1524
 
1525
+ let lastPage = route().tab;
701
1526
  window.addEventListener('hashchange', () => {
1527
+ const at = route();
1528
+ // Opening a row keeps the scroll position. A new page starts at the top.
1529
+ if (at.tab !== lastPage || !['regressions', 'experiments', 'activity'].includes(at.name)) window.scrollTo(0, 0);
1530
+ lastPage = at.tab;
702
1531
  rendered = '';
703
- window.scrollTo(0, 0);
704
1532
  refresh();
705
1533
  });
706
1534
 
@@ -710,5 +1538,6 @@ window.addEventListener('resize', () => {
710
1538
  resizeTimer = setTimeout(drawCharts, 120);
711
1539
  });
712
1540
 
1541
+ applyTheme();
713
1542
  refresh();
714
1543
  setInterval(refresh, POLL_MS);