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/dist/bin.js +366 -80
- package/package.json +1 -1
- package/src/ui/app.js +1086 -257
- package/src/ui/index.html +24 -8
- package/src/ui/style.css +618 -158
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
|
|
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
|
|
13
|
-
analyst: 'Explains confirmed
|
|
14
|
-
optimizer: '
|
|
15
|
-
runner: 'Measures
|
|
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)}
|
|
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
|
|
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
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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(
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 {
|
|
346
|
+
return { summary, ...agents, ...page };
|
|
176
347
|
}
|
|
177
348
|
|
|
178
349
|
async function refresh() {
|
|
179
|
-
const
|
|
350
|
+
const at = route();
|
|
180
351
|
let data;
|
|
181
352
|
try {
|
|
182
|
-
data = await load(
|
|
353
|
+
data = await load(at);
|
|
183
354
|
liveEl.classList.remove('offline');
|
|
184
|
-
liveEl.
|
|
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.
|
|
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
|
-
//
|
|
191
|
-
const key = JSON.stringify([
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
?
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
278
|
-
|
|
501
|
+
function attentionPanel(summary) {
|
|
502
|
+
const items = summary.attention;
|
|
503
|
+
if (!items.length) {
|
|
279
504
|
return panel(
|
|
280
|
-
'
|
|
505
|
+
'Needs attention',
|
|
281
506
|
'',
|
|
282
|
-
|
|
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 =
|
|
510
|
+
const rows = items
|
|
286
511
|
.map(
|
|
287
|
-
(item) => `<
|
|
288
|
-
<
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
519
|
+
const fails = items.filter((item) => item.tone === 'fail').length;
|
|
304
520
|
return panel(
|
|
305
|
-
'
|
|
306
|
-
`${
|
|
307
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
'
|
|
540
|
+
'Agents',
|
|
340
541
|
'',
|
|
341
|
-
`<
|
|
542
|
+
`<ul class="agent-list">${rows}</ul>`,
|
|
543
|
+
onActivity ? '' : '<a href="#/activity">Activity</a>',
|
|
342
544
|
);
|
|
343
545
|
}
|
|
344
546
|
|
|
345
|
-
function
|
|
346
|
-
if (
|
|
347
|
-
return
|
|
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
|
-
|
|
354
|
-
.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
'
|
|
572
|
+
'Recent runs',
|
|
370
573
|
'',
|
|
371
|
-
`<table
|
|
574
|
+
`<table class="compact"><tbody>${rows}</tbody></table>`,
|
|
575
|
+
'<a href="#/runs">All runs</a>',
|
|
372
576
|
);
|
|
373
577
|
}
|
|
374
578
|
|
|
375
|
-
function
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 "api"" 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(
|
|
397
|
-
const
|
|
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(
|
|
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
|
|
729
|
+
label: `regression ${pct(item.deltaPct)}`,
|
|
406
730
|
}));
|
|
407
731
|
charts.set('main', { type: 'line', metric: primary, height: 300, markers });
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const
|
|
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
|
-
`
|
|
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
|
-
|
|
446
|
-
'No profile
|
|
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
|
|
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
|
-
`<
|
|
458
|
-
item.
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
477
|
-
`<div class="
|
|
478
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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 ? '<0.001' : metric.pValue.toFixed(3)}</td>
|
|
511
1207
|
</tr>`,
|
|
512
|
-
|
|
1208
|
+
)
|
|
1209
|
+
.join('')}</tbody>`;
|
|
1210
|
+
})
|
|
513
1211
|
.join('');
|
|
514
|
-
const
|
|
515
|
-
|
|
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.
|
|
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
|
-
'
|
|
527
|
-
|
|
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 ?
|
|
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)
|
|
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
|
-
|
|
696
|
-
|
|
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);
|