bugpatrol 0.2.1 → 0.4.0
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/README.md +1 -1
- package/dist/bin.js +15519 -4094
- package/package.json +1 -1
- package/src/ui/app.js +1411 -237
- package/src/ui/index.html +11 -1
- package/src/ui/style.css +682 -16
package/src/ui/app.js
CHANGED
|
@@ -8,11 +8,47 @@ const view = document.getElementById('view');
|
|
|
8
8
|
const tabs = document.getElementById('tabs');
|
|
9
9
|
const liveEl = document.getElementById('live');
|
|
10
10
|
const projectEl = document.getElementById('project-name');
|
|
11
|
-
const
|
|
11
|
+
const surfacesEl = document.getElementById('surfaces');
|
|
12
12
|
const patrolEl = document.getElementById('patrol');
|
|
13
|
+
const themeEl = document.getElementById('theme');
|
|
14
|
+
|
|
15
|
+
// ---------- theme ----------
|
|
16
|
+
// The choice cycles system → light → dark. "System" follows the OS setting, also when it changes.
|
|
17
|
+
const THEMES = ['system', 'light', 'dark'];
|
|
18
|
+
const lightQuery = matchMedia('(prefers-color-scheme: light)');
|
|
19
|
+
let themeChoice = THEMES.includes(localStorage.getItem('bugpatrol-theme'))
|
|
20
|
+
? localStorage.getItem('bugpatrol-theme')
|
|
21
|
+
: 'system';
|
|
22
|
+
// Line icons for the top bar. The theme button and the surfaces show icons, and their labels hold the words.
|
|
23
|
+
const MONITOR_ICON = '<rect x="2" y="3" width="12" height="8" rx="1"/><path d="M6 14h4M8 11v3"/>';
|
|
24
|
+
const PHONE_ICON = '<rect x="4.5" y="1.5" width="7" height="13" rx="1.5"/><path d="M7 12.5h2"/>';
|
|
25
|
+
const themeIcons = {
|
|
26
|
+
system: MONITOR_ICON,
|
|
27
|
+
light:
|
|
28
|
+
'<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"/>',
|
|
29
|
+
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"/>',
|
|
30
|
+
};
|
|
31
|
+
function applyTheme() {
|
|
32
|
+
const theme = themeChoice === 'system' ? (lightQuery.matches ? 'light' : 'dark') : themeChoice;
|
|
33
|
+
document.documentElement.dataset.theme = theme;
|
|
34
|
+
const next = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
|
|
35
|
+
const label = { system: 'System', light: 'Light', dark: 'Dark' };
|
|
36
|
+
themeEl.innerHTML = iconSvg(themeIcons[themeChoice]);
|
|
37
|
+
themeEl.setAttribute('aria-label', `Theme: ${label[themeChoice]}. Change the theme to ${label[next]}`);
|
|
38
|
+
themeEl.title = `Theme: ${label[themeChoice]}. Click for ${label[next]}.`;
|
|
39
|
+
}
|
|
40
|
+
themeEl.addEventListener('click', () => {
|
|
41
|
+
themeChoice = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
|
|
42
|
+
if (themeChoice === 'system') localStorage.removeItem('bugpatrol-theme');
|
|
43
|
+
else localStorage.setItem('bugpatrol-theme', themeChoice);
|
|
44
|
+
applyTheme();
|
|
45
|
+
});
|
|
46
|
+
lightQuery.addEventListener('change', applyTheme);
|
|
47
|
+
applyTheme();
|
|
13
48
|
|
|
14
49
|
const state = {
|
|
15
50
|
view: 'overview',
|
|
51
|
+
summary: null,
|
|
16
52
|
overview: null,
|
|
17
53
|
issues: [],
|
|
18
54
|
issueFilter: 'open',
|
|
@@ -26,6 +62,9 @@ const state = {
|
|
|
26
62
|
selectedScreenId: null,
|
|
27
63
|
routines: [],
|
|
28
64
|
memory: { lessons: [] },
|
|
65
|
+
coverage: null,
|
|
66
|
+
coverageFilter: { failedOnly: false, hideSkipped: false, query: '' },
|
|
67
|
+
selectedCell: null,
|
|
29
68
|
runs: [],
|
|
30
69
|
selectedRunId: null,
|
|
31
70
|
reviews: [],
|
|
@@ -44,7 +83,8 @@ try {
|
|
|
44
83
|
}
|
|
45
84
|
const graphCamera = { box: null, key: '' };
|
|
46
85
|
let showBackLinks = false;
|
|
47
|
-
let
|
|
86
|
+
let renderedState = '';
|
|
87
|
+
let renderedPage = '';
|
|
48
88
|
|
|
49
89
|
// ---------------------------------------------------------------- data
|
|
50
90
|
|
|
@@ -62,14 +102,20 @@ async function getJson(url) {
|
|
|
62
102
|
|
|
63
103
|
const artifact = (path) => `/api/artifact?path=${encodeURIComponent(path)}`;
|
|
64
104
|
|
|
105
|
+
async function fetchText(path) {
|
|
106
|
+
const response = await fetch(artifact(path));
|
|
107
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
108
|
+
return response.text();
|
|
109
|
+
}
|
|
110
|
+
|
|
65
111
|
async function refresh({ keepSelection = true } = {}) {
|
|
66
112
|
const snapshot = await getJson('/api/state');
|
|
67
113
|
state.runs = snapshot.runs;
|
|
68
114
|
state.live = snapshot.live;
|
|
69
115
|
state.root = snapshot.root;
|
|
116
|
+
state.summary = await getJson('/api/summary');
|
|
70
117
|
state.overview = await getJson('/api/overview');
|
|
71
|
-
|
|
72
|
-
platformEl.textContent = state.overview.project.platform;
|
|
118
|
+
renderHeader();
|
|
73
119
|
const patrol = state.overview.patrol;
|
|
74
120
|
patrolEl.textContent = patrol.state === 'running' ? `Patrol running · cycle ${patrol.cycle}` : 'Patrol stopped';
|
|
75
121
|
patrolEl.classList.toggle('running', patrol.state === 'running');
|
|
@@ -98,7 +144,7 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
98
144
|
state.issueDetail = null;
|
|
99
145
|
}
|
|
100
146
|
}
|
|
101
|
-
if (state.view === 'activity') {
|
|
147
|
+
if (state.view === 'activity' || state.view === 'flow') {
|
|
102
148
|
state.sessions = await getJson('/api/sessions');
|
|
103
149
|
if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
|
|
104
150
|
state.selectedSessionId = state.sessions[0]?.id ?? null;
|
|
@@ -110,10 +156,6 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
110
156
|
}
|
|
111
157
|
}
|
|
112
158
|
if (state.view === 'flow') {
|
|
113
|
-
state.sessions = await getJson('/api/sessions');
|
|
114
|
-
if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
|
|
115
|
-
state.selectedSessionId = state.sessions[0]?.id ?? null;
|
|
116
|
-
}
|
|
117
159
|
state.flow = state.selectedSessionId
|
|
118
160
|
? await getJson(`/api/flow/${encodeURIComponent(state.selectedSessionId)}`).catch(() => null)
|
|
119
161
|
: null;
|
|
@@ -128,19 +170,27 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
128
170
|
}
|
|
129
171
|
}
|
|
130
172
|
if (state.view === 'memory') state.memory = await getJson('/api/memory');
|
|
173
|
+
if (state.view === 'coverage') {
|
|
174
|
+
state.coverage = state.summary.coverage.api ? await getJson('/api/coverage') : null;
|
|
175
|
+
if (!state.coverage?.cells[state.selectedCell]?.exchange) state.selectedCell = null;
|
|
176
|
+
if (state.summary.coverage.screens) {
|
|
177
|
+
state.appmap = await getJson('/api/appmap');
|
|
178
|
+
state.issues = await getJson('/api/issues');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
131
181
|
if (state.view === 'reviews') {
|
|
132
182
|
const reviews = await getJson('/api/reviews');
|
|
133
183
|
if (!reviews.some((review) => review.pr.number === state.selectedReview)) {
|
|
134
184
|
state.selectedReview = reviews[0]?.pr.number ?? null;
|
|
135
185
|
}
|
|
136
186
|
const detail = state.selectedReview ? await getJson(`/api/reviews/${state.selectedReview}`) : null;
|
|
137
|
-
// A rebuilt page restarts a playing video, so skip the render when the review did not change.
|
|
138
|
-
const key = JSON.stringify([reviews, detail]);
|
|
139
|
-
if (key === renderedReviews && view.querySelector('.review-page')) return;
|
|
140
|
-
renderedReviews = key;
|
|
141
187
|
state.reviews = reviews;
|
|
142
188
|
state.reviewDetail = detail;
|
|
143
189
|
}
|
|
190
|
+
// A rebuilt page closes what the reader opened and restarts a playing video: skip it when nothing changed.
|
|
191
|
+
const key = JSON.stringify(state);
|
|
192
|
+
if (key === renderedState && view.childElementCount) return;
|
|
193
|
+
renderedState = key;
|
|
144
194
|
render();
|
|
145
195
|
}
|
|
146
196
|
|
|
@@ -162,6 +212,24 @@ function render() {
|
|
|
162
212
|
button.classList.toggle('active', button.dataset.view === state.view);
|
|
163
213
|
if (button.dataset.view === 'checks') button.hidden = !hasChecks;
|
|
164
214
|
}
|
|
215
|
+
// The same page, drawn again with new data, keeps what the reader opened and where they were.
|
|
216
|
+
const page = JSON.stringify([
|
|
217
|
+
state.view,
|
|
218
|
+
state.selectedIssueId,
|
|
219
|
+
state.selectedSessionId,
|
|
220
|
+
state.selectedReview,
|
|
221
|
+
state.selectedRunId,
|
|
222
|
+
state.selectedScreenId,
|
|
223
|
+
state.selectedCell,
|
|
224
|
+
]);
|
|
225
|
+
const opened = page === renderedPage ? detailsOpen() : undefined;
|
|
226
|
+
const scroll = window.scrollY;
|
|
227
|
+
// A scroll box, such as the coverage matrix, keeps its place too.
|
|
228
|
+
const boxes = opened
|
|
229
|
+
? [...view.querySelectorAll('[data-keep-scroll]')].map((box) => [box.scrollLeft, box.scrollTop])
|
|
230
|
+
: [];
|
|
231
|
+
const typing = opened && document.activeElement?.matches('input[type="search"]');
|
|
232
|
+
renderedPage = page;
|
|
165
233
|
view.innerHTML = '';
|
|
166
234
|
if (state.view === 'checks') {
|
|
167
235
|
const banner = renderLiveBanner();
|
|
@@ -175,9 +243,32 @@ function render() {
|
|
|
175
243
|
screens: renderScreens,
|
|
176
244
|
reviews: renderReviews,
|
|
177
245
|
memory: renderMemory,
|
|
246
|
+
coverage: renderCoverage,
|
|
178
247
|
checks: renderRuns,
|
|
179
248
|
};
|
|
180
249
|
view.append(renderers[state.view]());
|
|
250
|
+
if (opened) {
|
|
251
|
+
for (const [key, details] of detailsByKey()) if (opened.has(key)) details.open = opened.get(key);
|
|
252
|
+
for (const [index, box] of [...view.querySelectorAll('[data-keep-scroll]')].entries())
|
|
253
|
+
if (boxes[index]) [box.scrollLeft, box.scrollTop] = boxes[index];
|
|
254
|
+
if (typing) view.querySelector('input[type="search"]')?.focus();
|
|
255
|
+
window.scrollTo(0, scroll);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Each <details> on the page by its summary and its place among the ones with the same summary. */
|
|
260
|
+
function detailsByKey() {
|
|
261
|
+
const seen = new Map();
|
|
262
|
+
return [...view.querySelectorAll('details')].map((details) => {
|
|
263
|
+
const summary = details.querySelector('summary')?.textContent ?? '';
|
|
264
|
+
const nth = (seen.get(summary) ?? 0) + 1;
|
|
265
|
+
seen.set(summary, nth);
|
|
266
|
+
return [`${summary}#${nth}`, details];
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function detailsOpen() {
|
|
271
|
+
return new Map(detailsByKey().map(([key, details]) => [key, details.open]));
|
|
181
272
|
}
|
|
182
273
|
|
|
183
274
|
function el(tag, props = {}, children = []) {
|
|
@@ -212,7 +303,7 @@ function renderLiveBanner() {
|
|
|
212
303
|
const total = live.plannedCaptures || done;
|
|
213
304
|
return el('div', { class: 'runbanner' }, [
|
|
214
305
|
el('span', { class: 'spinner' }),
|
|
215
|
-
el('span', { text: `Run in progress
|
|
306
|
+
el('span', { text: `Run in progress: ${done}/${total} captures` }),
|
|
216
307
|
live.currentStep ? el('span', { class: 'muted', text: live.currentStep }) : null,
|
|
217
308
|
]);
|
|
218
309
|
}
|
|
@@ -329,7 +420,7 @@ function renderDetail() {
|
|
|
329
420
|
...trace.suppressed.map((s) =>
|
|
330
421
|
el('div', { class: 'finding' }, [
|
|
331
422
|
el('div', { text: `${s.ruleId} on ${s.screenId}` }),
|
|
332
|
-
el('div', { class: 'why', text: `"${s.reason}"
|
|
423
|
+
el('div', { class: 'why', text: `"${s.reason}": ${s.decidedBy}` }),
|
|
333
424
|
]),
|
|
334
425
|
),
|
|
335
426
|
]),
|
|
@@ -462,7 +553,7 @@ function renderReasoning(screen) {
|
|
|
462
553
|
'Pixel diff',
|
|
463
554
|
d.identical
|
|
464
555
|
? `identical (${d.engine}, ${Math.round(d.durationMs)}ms)`
|
|
465
|
-
: `${d.changedPixels} px across ${d.regionCount} region(s)
|
|
556
|
+
: `${d.changedPixels} px across ${d.regionCount} region(s): ` +
|
|
466
557
|
`${(d.changedFraction * 100).toFixed(3)}% of compared area (${d.engine})`,
|
|
467
558
|
);
|
|
468
559
|
add('Masked', `${(d.maskedFraction * 100).toFixed(1)}% of the screen, ` + `${d.maskedRegionCount} region(s)`);
|
|
@@ -485,7 +576,7 @@ function renderReasoning(screen) {
|
|
|
485
576
|
const d = screen.decision;
|
|
486
577
|
add(
|
|
487
578
|
'Decision layer',
|
|
488
|
-
d.decider === 'none' ? `not consulted
|
|
579
|
+
d.decider === 'none' ? `not consulted: ${d.skippedReason}` : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`,
|
|
489
580
|
);
|
|
490
581
|
if (d.stateChars) add('State digest', `${d.stateChars} chars (hash ${String(d.stateHash ?? '').slice(0, 12)})`);
|
|
491
582
|
for (const [key, answer] of Object.entries(d.answers ?? {})) {
|
|
@@ -523,16 +614,21 @@ const roleIcons = {
|
|
|
523
614
|
decider: '<path d="m3 8 3 3 7-7"/>',
|
|
524
615
|
};
|
|
525
616
|
|
|
617
|
+
/** A 16×16 line icon from trusted path markup in this file. */
|
|
618
|
+
function iconSvg(paths) {
|
|
619
|
+
return (
|
|
620
|
+
`<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" ` +
|
|
621
|
+
`stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
526
625
|
function roleIcon(role) {
|
|
527
626
|
const icon = roleIcons[role] ?? roleIcons.system;
|
|
528
|
-
|
|
529
|
-
`<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" ` +
|
|
530
|
-
`stroke-width="1.4" aria-hidden="true">${icon}</svg>`;
|
|
531
|
-
return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: svg });
|
|
627
|
+
return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: iconSvg(icon) });
|
|
532
628
|
}
|
|
533
629
|
|
|
534
630
|
function relativeTime(value) {
|
|
535
|
-
if (!value) return '
|
|
631
|
+
if (!value) return '-';
|
|
536
632
|
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
|
|
537
633
|
if (!Number.isFinite(seconds)) return value;
|
|
538
634
|
if (seconds < 60) return 'just now';
|
|
@@ -590,10 +686,10 @@ function renderUsage(usage) {
|
|
|
590
686
|
el('td', { text: row.model }),
|
|
591
687
|
el('td', { text: String(row.sessions) }),
|
|
592
688
|
el('td', { text: count(row.tokens.input) }),
|
|
593
|
-
el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '
|
|
689
|
+
el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '-' }),
|
|
594
690
|
el('td', { text: count(row.tokens.output) }),
|
|
595
691
|
el('td', { text: count(Math.round((row.tokens.input + row.tokens.output) / Math.max(1, row.sessions))) }),
|
|
596
|
-
el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '
|
|
692
|
+
el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '-' }),
|
|
597
693
|
]),
|
|
598
694
|
),
|
|
599
695
|
])
|
|
@@ -604,8 +700,26 @@ function renderUsage(usage) {
|
|
|
604
700
|
]);
|
|
605
701
|
}
|
|
606
702
|
|
|
607
|
-
|
|
703
|
+
/** A capture file: a screenshot, or the exchange file of an API, which shows as text. */
|
|
704
|
+
function captureFile(path, label, className = '') {
|
|
608
705
|
if (!path) return el('div', { class: `image-empty ${className}`, text: 'No capture' });
|
|
706
|
+
if (path.endsWith('.exchange.json')) {
|
|
707
|
+
const small = isThumb(className);
|
|
708
|
+
const view = small
|
|
709
|
+
? httpThumb(undefined, label, className)
|
|
710
|
+
: el('pre', { class: `http ${className}`, title: label, text: 'Loading the exchange…' });
|
|
711
|
+
// The server formats the exchange, so the dashboard shows the same text as the models and issues.
|
|
712
|
+
fetch(`/api/exchange?path=${encodeURIComponent(path)}`)
|
|
713
|
+
.then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
|
|
714
|
+
.then((text) => {
|
|
715
|
+
if (small) view.textContent = text.match(/^HTTP \d{3}/m)?.[0] ?? 'HTTP';
|
|
716
|
+
else view.textContent = text;
|
|
717
|
+
})
|
|
718
|
+
.catch(() => {
|
|
719
|
+
if (!small) view.textContent = 'The exchange is not available.';
|
|
720
|
+
});
|
|
721
|
+
return view;
|
|
722
|
+
}
|
|
609
723
|
return el('img', {
|
|
610
724
|
class: className,
|
|
611
725
|
src: artifact(path),
|
|
@@ -615,6 +729,20 @@ function image(path, label, className = '') {
|
|
|
615
729
|
});
|
|
616
730
|
}
|
|
617
731
|
|
|
732
|
+
/** The capture of an issue: its screenshot, or the HTTP exchange of an API as the server formatted it. */
|
|
733
|
+
function captureView(capture, label, className = '') {
|
|
734
|
+
if (capture?.kind !== 'http') return captureFile(capture?.screenshot, label, className);
|
|
735
|
+
if (isThumb(className)) return httpThumb(capture.exchange?.response?.status, label, className);
|
|
736
|
+
return el('pre', { class: `http ${className}`, title: label, text: capture.text });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const isThumb = (className) => /\b(?:thumb|event-thumb)\b/.test(className);
|
|
740
|
+
|
|
741
|
+
/** A thumbnail is too small for the exchange text, so it shows the status only. */
|
|
742
|
+
function httpThumb(status, label, className) {
|
|
743
|
+
return el('div', { class: `http-thumb ${className}`, title: label, text: status ? `HTTP ${status}` : 'HTTP' });
|
|
744
|
+
}
|
|
745
|
+
|
|
618
746
|
function severityChip(value) {
|
|
619
747
|
return el('span', { class: `severity severity-${value}`, text: value });
|
|
620
748
|
}
|
|
@@ -626,6 +754,190 @@ function title(text, aside) {
|
|
|
626
754
|
]);
|
|
627
755
|
}
|
|
628
756
|
|
|
757
|
+
// ---------------------------------------------------------------- shared blocks
|
|
758
|
+
|
|
759
|
+
const DOCS = 'https://github.com/agent-labs-dev/bugpatrol/blob/main/docs';
|
|
760
|
+
|
|
761
|
+
const PLATFORM_WORDS = {
|
|
762
|
+
api: 'API',
|
|
763
|
+
web: 'Web app',
|
|
764
|
+
electron: 'Electron app',
|
|
765
|
+
desktop: 'Desktop app',
|
|
766
|
+
ios: 'iOS app',
|
|
767
|
+
android: 'Android app',
|
|
768
|
+
cli: 'CLI app',
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const PLATFORM_ICONS = {
|
|
772
|
+
api: '<path d="M5.5 3C4 3 4 4 4 5.5S3 8 2.5 8C3 8 4 8.5 4 10.5S4 13 5.5 13M10.5 3C12 3 12 4 12 5.5S13 8 13.5 8C13 8 12 8.5 12 10.5S12 13 10.5 13"/>',
|
|
773
|
+
web: '<circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12"/>',
|
|
774
|
+
electron: MONITOR_ICON,
|
|
775
|
+
desktop: MONITOR_ICON,
|
|
776
|
+
ios: PHONE_ICON,
|
|
777
|
+
android: PHONE_ICON,
|
|
778
|
+
cli: '<rect x="1.5" y="2.5" width="13" height="11" rx="1"/><path d="m4.5 6 2 2-2 2M8 10h3.5"/>',
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const surfaceName = (surface) => PLATFORM_WORDS[surface.platform] ?? surface.platform;
|
|
782
|
+
const fileName = (path) => path?.split('/').pop() ?? '';
|
|
783
|
+
|
|
784
|
+
function surfacesOf(kind) {
|
|
785
|
+
return (state.summary?.surfaces ?? []).filter((surface) => surface.kind === kind);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** The project's surfaces in the top bar. The surface of the config that the dashboard reads is marked. */
|
|
789
|
+
function renderHeader() {
|
|
790
|
+
const summary = state.summary;
|
|
791
|
+
projectEl.textContent = summary.name;
|
|
792
|
+
surfacesEl.replaceChildren(
|
|
793
|
+
...summary.surfaces.map((surface) => {
|
|
794
|
+
const configs = surface.configs.length
|
|
795
|
+
? `${surface.active ? 'The dashboard reads' : 'Config'}: ${surface.configs.join(', ')}`
|
|
796
|
+
: 'From the data. No config file tests it now.';
|
|
797
|
+
const name = surfaceName(surface);
|
|
798
|
+
return el('span', {
|
|
799
|
+
class: `surface${surface.active ? ' active' : ''}`,
|
|
800
|
+
role: 'img',
|
|
801
|
+
'aria-label': `${name}. ${configs}`,
|
|
802
|
+
title: `${name}. ${configs}`,
|
|
803
|
+
html: iconSvg(PLATFORM_ICONS[surface.platform] ?? PLATFORM_ICONS.web),
|
|
804
|
+
});
|
|
805
|
+
}),
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** A command for the config that the dashboard reads, or for the config of another surface. */
|
|
810
|
+
function cli(args, surface) {
|
|
811
|
+
const config =
|
|
812
|
+
surface && !surface.active && surface.configs[0]
|
|
813
|
+
? ` --config ${surface.configs[0]}`
|
|
814
|
+
: (state.summary?.configArg ?? '');
|
|
815
|
+
return `bugpatrol ${args}${config}`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** A command with a copy button. */
|
|
819
|
+
function commandBox(text) {
|
|
820
|
+
const button = el('button', {
|
|
821
|
+
type: 'button',
|
|
822
|
+
class: 'copy',
|
|
823
|
+
text: 'Copy',
|
|
824
|
+
'aria-label': `Copy the command ${text}`,
|
|
825
|
+
});
|
|
826
|
+
button.addEventListener('click', async () => {
|
|
827
|
+
try {
|
|
828
|
+
await navigator.clipboard.writeText(text);
|
|
829
|
+
button.textContent = 'Copied';
|
|
830
|
+
} catch {
|
|
831
|
+
button.textContent = 'Copy failed';
|
|
832
|
+
}
|
|
833
|
+
setTimeout(() => {
|
|
834
|
+
button.textContent = 'Copy';
|
|
835
|
+
}, 1500);
|
|
836
|
+
});
|
|
837
|
+
return el('div', { class: 'command' }, [el('code', { text }), button]);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The one empty state of the dashboard: what the block shows, why it is
|
|
842
|
+
* empty now, and the one next step, as a command or a button.
|
|
843
|
+
*/
|
|
844
|
+
function emptyState({ heading, why = [], command, action, docs, compact = false }) {
|
|
845
|
+
return el('div', { class: `empty-state${compact ? ' compact' : ''}` }, [
|
|
846
|
+
el('h3', { text: heading }),
|
|
847
|
+
...[].concat(why).map((text) => el('p', { text })),
|
|
848
|
+
command ? commandBox(command) : null,
|
|
849
|
+
action ? el('button', { type: 'button', class: 'primary', text: action.label, onclick: action.onclick }) : null,
|
|
850
|
+
docs
|
|
851
|
+
? el('a', { class: 'docs', href: docs, target: '_blank', rel: 'noopener noreferrer', text: 'Read the docs' })
|
|
852
|
+
: null,
|
|
853
|
+
]);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** A note with one command, for a block that has data but also a next step. */
|
|
857
|
+
function callout(text, command) {
|
|
858
|
+
return el('div', { class: 'callout' }, [el('p', { text }), commandBox(command)]);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const number = (value) => Number(value ?? 0).toLocaleString('en-US');
|
|
862
|
+
|
|
863
|
+
function percent(part, whole) {
|
|
864
|
+
if (!whole) return '0%';
|
|
865
|
+
const value = (part / whole) * 100;
|
|
866
|
+
return value > 0 && value < 1 ? '<1%' : `${Math.round(value)}%`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A row of numbers. A stat with an action is a button. */
|
|
870
|
+
function statBand(stats, className = '') {
|
|
871
|
+
return el(
|
|
872
|
+
'div',
|
|
873
|
+
{ class: `band ${className}` },
|
|
874
|
+
stats.filter(Boolean).map(({ label, value, note, tone, bar, go }) =>
|
|
875
|
+
el(
|
|
876
|
+
go ? 'button' : 'div',
|
|
877
|
+
{
|
|
878
|
+
class: `stat${tone ? ` tone-${tone}` : ''}`,
|
|
879
|
+
...(go ? { type: 'button', onclick: go } : {}),
|
|
880
|
+
},
|
|
881
|
+
[
|
|
882
|
+
el('span', { class: 'stat-label', text: label }),
|
|
883
|
+
el('strong', { class: 'stat-value', text: String(value) }),
|
|
884
|
+
bar ?? null,
|
|
885
|
+
note ? el('span', { class: 'stat-note', title: note, text: note }) : null,
|
|
886
|
+
],
|
|
887
|
+
),
|
|
888
|
+
),
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const CELL_STATES = ['pass', 'fail', 'blocked', 'skip', 'untested'];
|
|
893
|
+
|
|
894
|
+
/** The cases of each state as one stacked bar. */
|
|
895
|
+
function caseBar(cases) {
|
|
896
|
+
const states = CELL_STATES.filter((name) => cases[name]);
|
|
897
|
+
const total = states.reduce((sum, name) => sum + cases[name], 0);
|
|
898
|
+
return el(
|
|
899
|
+
'div',
|
|
900
|
+
{ class: 'bar', role: 'img', 'aria-label': states.map((name) => `${number(cases[name])} ${name}`).join(', ') },
|
|
901
|
+
states.map((name) =>
|
|
902
|
+
el('span', {
|
|
903
|
+
class: `bar-${name}`,
|
|
904
|
+
style: `flex-grow: ${cases[name] / total}`,
|
|
905
|
+
title: `${number(cases[name])} ${name}`,
|
|
906
|
+
}),
|
|
907
|
+
),
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function caseLegend(cases) {
|
|
912
|
+
return el(
|
|
913
|
+
'div',
|
|
914
|
+
{ class: 'bar-legend' },
|
|
915
|
+
CELL_STATES.filter((name) => cases[name] !== undefined).map((name) =>
|
|
916
|
+
el('span', {}, [el('i', { class: `bar-${name}` }), `${number(cases[name])} ${name}`]),
|
|
917
|
+
),
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function go(view) {
|
|
922
|
+
state.view = view;
|
|
923
|
+
if (view === 'screens') state.selectedScreenId = null;
|
|
924
|
+
await refresh();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function openSession(id) {
|
|
928
|
+
state.view = 'activity';
|
|
929
|
+
state.selectedSessionId = id;
|
|
930
|
+
await refresh();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function formatDuration(ms) {
|
|
934
|
+
if (ms === undefined) return '-';
|
|
935
|
+
const seconds = Math.round(ms / 1000);
|
|
936
|
+
if (seconds < 60) return `${seconds}s`;
|
|
937
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
938
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
939
|
+
}
|
|
940
|
+
|
|
629
941
|
const CI_WORDS = { pending: 'CI running', passed: 'CI green', failed: 'CI failed, fixing', 'gave-up': 'CI red' };
|
|
630
942
|
|
|
631
943
|
function githubChip({ kind, number, url, state, draft, ci }) {
|
|
@@ -651,7 +963,8 @@ function issueChips(issue) {
|
|
|
651
963
|
|
|
652
964
|
function renderOverview() {
|
|
653
965
|
const data = state.overview;
|
|
654
|
-
|
|
966
|
+
const summary = state.summary;
|
|
967
|
+
if (!data || !summary) return el('div', { class: 'empty', text: 'Loading overview…' });
|
|
655
968
|
const agents = el(
|
|
656
969
|
'div',
|
|
657
970
|
{ class: 'agent-grid' },
|
|
@@ -680,77 +993,271 @@ function renderOverview() {
|
|
|
680
993
|
]),
|
|
681
994
|
),
|
|
682
995
|
);
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
el('span', { class: 'grow' }, [
|
|
693
|
-
el('strong', { text: issue.title }),
|
|
694
|
-
el('span', { class: 'row-meta' }, [
|
|
695
|
-
el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ×${issue.occurrences}` }),
|
|
696
|
-
...issueChips(issue),
|
|
697
|
-
]),
|
|
698
|
-
]),
|
|
699
|
-
severityChip(issue.severity),
|
|
700
|
-
el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
|
|
701
|
-
]),
|
|
702
|
-
),
|
|
703
|
-
)
|
|
704
|
-
: el('p', { class: 'empty', text: 'No open issues. The agents found nothing wrong yet.' }),
|
|
996
|
+
return el('div', {}, [
|
|
997
|
+
renderConfidence(summary),
|
|
998
|
+
el('div', { class: 'overview-grid' }, [
|
|
999
|
+
renderAttention(data, summary),
|
|
1000
|
+
el('div', { class: 'stack-gap' }, [data.live ? renderLive(data.live) : null, renderRecentSessions(data)]),
|
|
1001
|
+
]),
|
|
1002
|
+
renderSurfaceCoverage(data, summary),
|
|
1003
|
+
agents,
|
|
1004
|
+
renderUsage(data.usage),
|
|
705
1005
|
]);
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
:
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** The numbers that say if the project works: the verdict, the open problems, the coverage, and the last session. */
|
|
1009
|
+
function renderConfidence(summary) {
|
|
1010
|
+
const { issues, waiting, coverage, lastSession, verdict } = summary;
|
|
1011
|
+
const severe = issues.bySeverity.critical + issues.bySeverity.major;
|
|
1012
|
+
const api = coverage.api;
|
|
1013
|
+
return statBand(
|
|
1014
|
+
[
|
|
1015
|
+
{ label: 'Status', value: verdict.text, tone: verdict.tone },
|
|
1016
|
+
{
|
|
1017
|
+
label: 'Open issues',
|
|
1018
|
+
value: number(issues.open),
|
|
1019
|
+
note: severe ? `${severe} critical or major` : issues.open ? 'None is critical or major' : 'None',
|
|
1020
|
+
tone: severe ? 'fail' : undefined,
|
|
1021
|
+
go: () => go('issues'),
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
label: 'Wait for the judge',
|
|
1025
|
+
value: number(waiting.total),
|
|
1026
|
+
note: waiting.repeat
|
|
1027
|
+
? `${number(waiting.repeat)} failed two times`
|
|
1028
|
+
: waiting.total
|
|
1029
|
+
? 'The judge did not read them yet'
|
|
1030
|
+
: 'None',
|
|
1031
|
+
tone: waiting.total ? 'warn' : undefined,
|
|
1032
|
+
go: () => go('activity'),
|
|
1033
|
+
},
|
|
1034
|
+
surfacesOf('api').length
|
|
1035
|
+
? {
|
|
1036
|
+
label: 'API coverage',
|
|
1037
|
+
value: api ? percent(api.tested, api.endpoints) : '-',
|
|
1038
|
+
note: api ? `${number(api.tested)} of ${number(api.endpoints)} endpoints tested` : 'No sweep yet',
|
|
1039
|
+
go: () => go('coverage'),
|
|
1040
|
+
}
|
|
1041
|
+
: null,
|
|
1042
|
+
surfacesOf('client').length
|
|
1043
|
+
? {
|
|
1044
|
+
label: 'Screens',
|
|
1045
|
+
value: number(coverage.screens?.found),
|
|
1046
|
+
note: coverage.screens?.found ? `${coverage.screens.withIssues} with open issues` : 'No screens yet',
|
|
1047
|
+
go: () => go('coverage'),
|
|
1048
|
+
}
|
|
1049
|
+
: null,
|
|
1050
|
+
{
|
|
1051
|
+
label: 'Last session',
|
|
1052
|
+
value: lastSession ? (lastSession.status === 'running' ? 'Running' : relativeTime(lastSession.startedAt)) : '-',
|
|
1053
|
+
note: lastSession
|
|
1054
|
+
? firstSentence(lastSession.summary) || `${capital(lastSession.role)} session`
|
|
1055
|
+
: 'No session yet',
|
|
1056
|
+
go: lastSession ? () => openSession(lastSession.id) : undefined,
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
'confidence',
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** The open issues, then the findings that wait for the judge. */
|
|
1064
|
+
function renderAttention(data, summary) {
|
|
1065
|
+
const { issues, waiting } = summary;
|
|
1066
|
+
const tested = summary.verdict.tone !== 'info';
|
|
1067
|
+
const issueRows = data.attention.map((issue) =>
|
|
1068
|
+
el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
|
|
1069
|
+
captureView(issue.evidence?.capture, issue.title, 'thumb'),
|
|
1070
|
+
el('span', { class: 'grow' }, [
|
|
1071
|
+
el('strong', { text: issue.title }),
|
|
1072
|
+
el('span', { class: 'row-meta' }, [
|
|
1073
|
+
el('small', { class: 'muted', text: `${issueWhere(issue)} · ×${issue.occurrences}` }),
|
|
1074
|
+
...issueChips(issue),
|
|
1075
|
+
]),
|
|
1076
|
+
]),
|
|
1077
|
+
severityChip(issue.severity),
|
|
1078
|
+
el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
|
|
1079
|
+
]),
|
|
1080
|
+
);
|
|
1081
|
+
const waitingRows = data.waiting.slice(0, 5).map((candidate) =>
|
|
1082
|
+
el('button', { class: 'attention-row', onclick: () => openSession(candidate.sessionId) }, [
|
|
1083
|
+
captureView(candidate.capture, candidate.summary, 'thumb'),
|
|
1084
|
+
el('span', { class: 'grow' }, [
|
|
1085
|
+
el('strong', { class: 'truncate', text: candidate.summary }),
|
|
1086
|
+
el('small', {
|
|
1087
|
+
class: 'muted',
|
|
1088
|
+
text: `${candidate.blocking ? 'Failed two times' : capital(candidate.source)} · ${relativeTime(candidate.createdAt)}`,
|
|
1089
|
+
}),
|
|
1090
|
+
]),
|
|
1091
|
+
severityChip(candidate.severity),
|
|
1092
|
+
]),
|
|
1093
|
+
);
|
|
1094
|
+
return el('section', { class: 'card panel' }, [
|
|
1095
|
+
title('Needs attention', `${number(issues.open)} open · ${number(waiting.total)} wait for the judge`),
|
|
1096
|
+
issueRows.length || waitingRows.length
|
|
1097
|
+
? null
|
|
1098
|
+
: emptyState({
|
|
1099
|
+
heading: tested ? 'Nothing needs attention' : 'Not tested yet',
|
|
1100
|
+
why: tested
|
|
1101
|
+
? 'No issue is open, and no finding waits for the judge.'
|
|
1102
|
+
: 'A patrol tests the project. The explorer reports findings, and the judge files the real problems as issues.',
|
|
1103
|
+
command: cli('patrol --once'),
|
|
1104
|
+
compact: true,
|
|
1105
|
+
}),
|
|
1106
|
+
issueRows.length ? el('div', { class: 'stack' }, issueRows) : null,
|
|
1107
|
+
waitingRows.length
|
|
1108
|
+
? el('div', { class: 'subsection' }, [
|
|
1109
|
+
el('h3', { text: `Findings that wait for the judge (${number(waiting.total)})` }),
|
|
1110
|
+
callout(
|
|
1111
|
+
'The judge reads each finding and files the real problems as issues. Run the judge now:',
|
|
1112
|
+
cli('patrol --once'),
|
|
1113
|
+
),
|
|
1114
|
+
el('div', { class: 'stack' }, waitingRows),
|
|
1115
|
+
waiting.total > waitingRows.length
|
|
1116
|
+
? el('button', {
|
|
1117
|
+
type: 'button',
|
|
1118
|
+
class: 'link',
|
|
1119
|
+
text: `Show all ${number(waiting.total)} findings in Activity`,
|
|
1120
|
+
onclick: () => go('activity'),
|
|
728
1121
|
})
|
|
729
1122
|
: null,
|
|
730
|
-
])
|
|
1123
|
+
])
|
|
1124
|
+
: null,
|
|
731
1125
|
]);
|
|
732
|
-
|
|
733
|
-
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function renderLive(live) {
|
|
1129
|
+
return el('section', { class: 'card panel' }, [
|
|
1130
|
+
title('Live', 'Session running'),
|
|
1131
|
+
captureFile(live.capture, 'Newest session capture', 'live-shot'),
|
|
1132
|
+
el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
|
|
1133
|
+
]);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** One coverage block for each surface: the endpoints of an API, the screens of a client app. */
|
|
1137
|
+
function renderSurfaceCoverage(data, summary) {
|
|
1138
|
+
const blocks = summary.surfaces.map((surface) =>
|
|
1139
|
+
el('div', { class: 'surface-block' }, [
|
|
1140
|
+
el('div', { class: 'surface-head' }, [
|
|
1141
|
+
el('h3', { text: surfaceName(surface) }),
|
|
1142
|
+
surface.configs[0] ? el('span', { class: 'muted mono', text: fileName(surface.configs[0]) }) : null,
|
|
1143
|
+
]),
|
|
1144
|
+
surface.kind === 'api'
|
|
1145
|
+
? apiCoverageBlock(summary.coverage.api, surface)
|
|
1146
|
+
: screenCoverageBlock(data, summary, surface),
|
|
1147
|
+
]),
|
|
1148
|
+
);
|
|
1149
|
+
return el('section', { class: 'card panel block' }, [
|
|
1150
|
+
title('Coverage', summary.surfaces.length > 1 ? 'For each surface' : ''),
|
|
734
1151
|
data.github ? el('div', { class: 'muted github-summary', text: `GitHub · ${data.github}` }) : null,
|
|
1152
|
+
blocks.length
|
|
1153
|
+
? el('div', { class: 'surface-list' }, blocks)
|
|
1154
|
+
: emptyState({
|
|
1155
|
+
heading: 'No config yet',
|
|
1156
|
+
why: 'Bugpatrol reads the config in .bugpatrol/ to know what to test.',
|
|
1157
|
+
command: 'bugpatrol init',
|
|
1158
|
+
compact: true,
|
|
1159
|
+
}),
|
|
1160
|
+
]);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function apiCoverageBlock(api, surface) {
|
|
1164
|
+
if (!api)
|
|
1165
|
+
return emptyState({
|
|
1166
|
+
heading: 'No sweep yet',
|
|
1167
|
+
why: 'The sweep sends each case of each endpoint and checks the answer against the spec.',
|
|
1168
|
+
command: cli('sweep', surface),
|
|
1169
|
+
compact: true,
|
|
1170
|
+
});
|
|
1171
|
+
return el('button', { type: 'button', class: 'surface-numbers', onclick: () => go('coverage') }, [
|
|
1172
|
+
el('div', { class: 'surface-line' }, [
|
|
1173
|
+
el('strong', { class: 'big', text: percent(api.tested, api.endpoints) }),
|
|
1174
|
+
el('span', { text: `of the endpoints tested: ${number(api.tested)} of ${number(api.endpoints)}` }),
|
|
1175
|
+
]),
|
|
1176
|
+
caseBar(api.cases),
|
|
1177
|
+
caseLegend(api.cases),
|
|
1178
|
+
api.remaining
|
|
1179
|
+
? el('small', { class: 'muted', text: `${number(api.remaining)} cases wait for the next sweep.` })
|
|
1180
|
+
: null,
|
|
1181
|
+
]);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function screenCoverageBlock(data, summary, surface) {
|
|
1185
|
+
const screens = summary.coverage.screens;
|
|
1186
|
+
if (!screens?.found)
|
|
1187
|
+
return emptyState({
|
|
1188
|
+
heading: 'No screens yet',
|
|
1189
|
+
why: surface.configs[0]
|
|
1190
|
+
? `${fileName(surface.configs[0])} tests the ${surfaceName(surface).toLowerCase()}. The explorer finds its screens when it runs.`
|
|
1191
|
+
: 'The explorer finds the screens when it runs.',
|
|
1192
|
+
command: cli('explore', surface),
|
|
1193
|
+
compact: true,
|
|
1194
|
+
});
|
|
1195
|
+
return el('div', {}, [
|
|
1196
|
+
el('div', { class: 'surface-line' }, [
|
|
1197
|
+
el('strong', { class: 'big', text: number(screens.found) }),
|
|
1198
|
+
el('span', {
|
|
1199
|
+
text: `screens found · ${number(screens.replayable)} with a routine · ${number(screens.withIssues)} with open issues`,
|
|
1200
|
+
}),
|
|
1201
|
+
]),
|
|
735
1202
|
el(
|
|
736
1203
|
'div',
|
|
737
1204
|
{ class: 'coverage-row' },
|
|
738
1205
|
data.screens.map((screen) =>
|
|
739
1206
|
el('button', { class: 'coverage-item', onclick: () => openScreen(screen.id) }, [
|
|
740
|
-
|
|
1207
|
+
captureFile(screen.lastCapture, screen.name),
|
|
741
1208
|
el('span', { text: screen.name }),
|
|
742
1209
|
]),
|
|
743
1210
|
),
|
|
744
1211
|
),
|
|
745
1212
|
]);
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function renderRecentSessions(data) {
|
|
1216
|
+
const next = data.patrol?.nextAt
|
|
1217
|
+
? `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
|
1218
|
+
: data.live
|
|
1219
|
+
? ''
|
|
1220
|
+
: 'Quiet now';
|
|
1221
|
+
return el('section', { class: 'card panel' }, [
|
|
1222
|
+
title('Recent sessions', next),
|
|
1223
|
+
data.recentSessions.length
|
|
1224
|
+
? el(
|
|
1225
|
+
'div',
|
|
1226
|
+
{ class: 'stack' },
|
|
1227
|
+
data.recentSessions.map((session) =>
|
|
1228
|
+
el('button', { class: 'list-row', onclick: () => openSession(session.id) }, [
|
|
1229
|
+
roleIcon(session.role),
|
|
1230
|
+
el('span', { class: 'grow' }, [
|
|
1231
|
+
el('strong', {
|
|
1232
|
+
class: 'truncate',
|
|
1233
|
+
text: firstSentence(session.summary) || `${capital(session.role)} session`,
|
|
1234
|
+
}),
|
|
1235
|
+
el('small', {
|
|
1236
|
+
class: 'muted',
|
|
1237
|
+
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
|
|
1238
|
+
}),
|
|
1239
|
+
]),
|
|
1240
|
+
el('span', { class: `badge ${SESSION_BADGE[session.status] ?? 'info'}`, text: session.status }),
|
|
1241
|
+
]),
|
|
1242
|
+
),
|
|
1243
|
+
)
|
|
1244
|
+
: emptyState({
|
|
1245
|
+
heading: 'No sessions yet',
|
|
1246
|
+
why: 'Each run of an agent is a session.',
|
|
1247
|
+
command: cli('patrol --once'),
|
|
1248
|
+
compact: true,
|
|
1249
|
+
}),
|
|
751
1250
|
]);
|
|
752
1251
|
}
|
|
753
1252
|
|
|
1253
|
+
const SESSION_BADGE = { running: 'warn', failed: 'fail', finished: 'info' };
|
|
1254
|
+
|
|
1255
|
+
/** Where an issue shows: its screen, or for an API issue, its request. */
|
|
1256
|
+
function issueWhere(issue) {
|
|
1257
|
+
const request = issue.evidence?.capture?.kind === 'http' ? issue.evidence.capture.exchange?.request : undefined;
|
|
1258
|
+
return !issue.screenId && request ? `${request.method} ${request.path}` : screenName(issue.screenId);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
754
1261
|
function screenName(id) {
|
|
755
1262
|
return (
|
|
756
1263
|
state.appmap?.screens.find((screen) => screen.id === id)?.name ||
|
|
@@ -771,6 +1278,25 @@ async function openIssue(id) {
|
|
|
771
1278
|
}
|
|
772
1279
|
|
|
773
1280
|
function renderIssues() {
|
|
1281
|
+
if (!state.issues.length) {
|
|
1282
|
+
const { waiting, verdict } = state.summary;
|
|
1283
|
+
return el('section', { class: 'card panel' }, [
|
|
1284
|
+
title('Issues'),
|
|
1285
|
+
emptyState({
|
|
1286
|
+
heading: 'No issues yet',
|
|
1287
|
+
why: [
|
|
1288
|
+
'An issue is a problem that the judge confirmed, with its evidence, its steps, and its fix.',
|
|
1289
|
+
waiting.total
|
|
1290
|
+
? `${number(waiting.total)} findings wait for the judge now. The judge files the real problems as issues.`
|
|
1291
|
+
: verdict.tone === 'info'
|
|
1292
|
+
? 'Bugpatrol did not test this project yet.'
|
|
1293
|
+
: 'The judge found no real problem yet.',
|
|
1294
|
+
],
|
|
1295
|
+
command: cli('patrol --once'),
|
|
1296
|
+
action: waiting.total ? { label: 'Show the findings', onclick: () => go('activity') } : undefined,
|
|
1297
|
+
}),
|
|
1298
|
+
]);
|
|
1299
|
+
}
|
|
774
1300
|
const filters = ['open', 'fixed', 'dismissed', 'all'];
|
|
775
1301
|
const filtered = visibleIssues();
|
|
776
1302
|
const list = el('div', { class: 'card panel' }, [
|
|
@@ -789,6 +1315,9 @@ function renderIssues() {
|
|
|
789
1315
|
}),
|
|
790
1316
|
),
|
|
791
1317
|
),
|
|
1318
|
+
filtered.length
|
|
1319
|
+
? null
|
|
1320
|
+
: el('p', { class: 'muted', text: `No issue is ${state.issueFilter}. Select a different filter.` }),
|
|
792
1321
|
...filtered.map((issue) =>
|
|
793
1322
|
el(
|
|
794
1323
|
'button',
|
|
@@ -800,7 +1329,7 @@ function renderIssues() {
|
|
|
800
1329
|
el('span', { class: 'row-meta' }, [
|
|
801
1330
|
el('small', {
|
|
802
1331
|
class: 'muted',
|
|
803
|
-
text: `${
|
|
1332
|
+
text: `${issueWhere(issue)} · ${relativeTime(issue.lastSeenAt)}`,
|
|
804
1333
|
}),
|
|
805
1334
|
...issueChips(issue),
|
|
806
1335
|
]),
|
|
@@ -826,15 +1355,24 @@ function visibleIssues() {
|
|
|
826
1355
|
function renderIssueDetail() {
|
|
827
1356
|
const detail = state.issueDetail;
|
|
828
1357
|
if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
|
|
829
|
-
const { issue, fix, candidates } = detail;
|
|
1358
|
+
const { issue, fix, candidates, gaveUpAfter, attemptDiffs = {} } = detail;
|
|
830
1359
|
const evidence = issue.evidence ?? {};
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1360
|
+
const capture = evidence.capture;
|
|
1361
|
+
const shots =
|
|
1362
|
+
capture?.kind === 'http'
|
|
1363
|
+
? [
|
|
1364
|
+
el('figure', { class: 'http-evidence' }, [
|
|
1365
|
+
captureView(capture, 'HTTP exchange'),
|
|
1366
|
+
el('figcaption', { text: 'HTTP exchange' }),
|
|
1367
|
+
]),
|
|
1368
|
+
]
|
|
1369
|
+
: [
|
|
1370
|
+
['Screenshot', capture?.screenshot],
|
|
1371
|
+
['Baseline', capture?.baseline],
|
|
1372
|
+
['Diff', capture?.diff],
|
|
1373
|
+
]
|
|
1374
|
+
.filter(([, path]) => path)
|
|
1375
|
+
.map(([label, path]) => el('figure', {}, [captureFile(path, label), el('figcaption', { text: label })]));
|
|
838
1376
|
const steps = evidence.steps ?? detail.routine?.steps ?? [];
|
|
839
1377
|
return el('article', { class: 'card panel issue-detail' }, [
|
|
840
1378
|
el('div', { class: 'detail-heading' }, [el('h1', { text: issue.title }), severityChip(issue.severity)]),
|
|
@@ -854,7 +1392,7 @@ function renderIssueDetail() {
|
|
|
854
1392
|
: null,
|
|
855
1393
|
el('div', { class: 'kv' }, [
|
|
856
1394
|
el('span', { text: issueStatus(issue, fix) }),
|
|
857
|
-
el('span', { text:
|
|
1395
|
+
el('span', { text: issueWhere(issue) }),
|
|
858
1396
|
el('span', { text: `×${issue.occurrences}` }),
|
|
859
1397
|
el('span', { text: `First ${relativeTime(issue.firstSeenAt)}` }),
|
|
860
1398
|
el('span', { text: `Last ${relativeTime(issue.lastSeenAt)}` }),
|
|
@@ -888,21 +1426,26 @@ function renderIssueDetail() {
|
|
|
888
1426
|
fix
|
|
889
1427
|
? el('section', { class: 'fix' }, [
|
|
890
1428
|
el('div', { class: 'section-title' }, [el('h2', { text: 'Proposed fix' }), fixBadge(fix)]),
|
|
1429
|
+
gaveUpAfter
|
|
1430
|
+
? el('p', { class: 'gave-up', text: `The fixer stopped after ${gaveUpAfter} fix attempts.` })
|
|
1431
|
+
: null,
|
|
891
1432
|
markdown(fix.summary || 'No summary yet.'),
|
|
892
|
-
...(fix.
|
|
893
|
-
? [
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
.
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1433
|
+
...(fix.attempts?.length
|
|
1434
|
+
? []
|
|
1435
|
+
: fix.retests?.length
|
|
1436
|
+
? [
|
|
1437
|
+
retestBlock(fix.retests.at(-1), issue),
|
|
1438
|
+
fix.retests.length > 1
|
|
1439
|
+
? el('details', { class: 'earlier-retests' }, [
|
|
1440
|
+
el('summary', { text: `Earlier retests (${fix.retests.length - 1})` }),
|
|
1441
|
+
...fix.retests
|
|
1442
|
+
.slice(0, -1)
|
|
1443
|
+
.reverse()
|
|
1444
|
+
.map((retest) => retestBlock(retest, issue)),
|
|
1445
|
+
])
|
|
1446
|
+
: null,
|
|
1447
|
+
]
|
|
1448
|
+
: []),
|
|
906
1449
|
title('Code change'),
|
|
907
1450
|
fix.diffStat ? el('pre', { text: fix.diffStat }) : null,
|
|
908
1451
|
// Collapsed: the verdict and the screenshots come first; the diff is
|
|
@@ -919,6 +1462,9 @@ function renderIssueDetail() {
|
|
|
919
1462
|
? el('span', { text: `Worktree removed ${new Date(fix.worktreeRemovedAt).toLocaleString()}` })
|
|
920
1463
|
: el('code', { text: fix.worktree }),
|
|
921
1464
|
]),
|
|
1465
|
+
...(fix.attempts?.length
|
|
1466
|
+
? [title('Fix attempts', 'oldest first'), attemptTimeline(fix, issue, attemptDiffs)]
|
|
1467
|
+
: []),
|
|
922
1468
|
])
|
|
923
1469
|
: null,
|
|
924
1470
|
]);
|
|
@@ -939,6 +1485,7 @@ function firstSentence(text) {
|
|
|
939
1485
|
function issueStatus(issue, fix) {
|
|
940
1486
|
if (issue.status === 'dismissed') return issue.closedBy ? `Dismissed by ${issue.closedBy.by}` : 'Dismissed';
|
|
941
1487
|
if (issue.status === 'fixed') return issue.closedBy ? `Fixed: ${issue.closedBy.reason}` : 'Fixed';
|
|
1488
|
+
if (issue.status === 'waits-for-deploy') return 'Merged, waits for a deploy';
|
|
942
1489
|
if (fix?.status === 'rejected' || issue.fixRejected) return 'Fix rejected by the team';
|
|
943
1490
|
if (issue.status === 'new' && issue.regression) return 'Regression';
|
|
944
1491
|
if (fix?.status === 'verified') return 'Fix verified in the app';
|
|
@@ -960,6 +1507,7 @@ function stepWords(step) {
|
|
|
960
1507
|
if (step.kind === 'open') return `Open ${step.url}`;
|
|
961
1508
|
if (step.kind === 'wait') return `Wait ${step.ms} ms`;
|
|
962
1509
|
if (step.kind === 'window') return `Switch to window "${step.match}"`;
|
|
1510
|
+
if (step.kind === 'upload') return `Upload a sample ${step.file} file to "${target}"`;
|
|
963
1511
|
return step.kind === 'back' ? 'Go back' : step.kind;
|
|
964
1512
|
}
|
|
965
1513
|
|
|
@@ -1015,17 +1563,93 @@ function fixBadge(fix) {
|
|
|
1015
1563
|
return el('span', { class: `badge ${kind}`, text: label });
|
|
1016
1564
|
}
|
|
1017
1565
|
|
|
1566
|
+
const ATTEMPT_KINDS = { first: 'First fix', rerun: 'Rerun', refix: 'Refix', ci: 'CI fix' };
|
|
1567
|
+
const ATTEMPT_OUTCOMES = {
|
|
1568
|
+
proposed: ['Proposed', 'pass'],
|
|
1569
|
+
'no-change': ['No change', 'warn'],
|
|
1570
|
+
declined: ['Declined', 'warn'],
|
|
1571
|
+
'verify-failed': ['Verify failed', 'fail'],
|
|
1572
|
+
error: ['Error', 'fail'],
|
|
1573
|
+
timeout: ['Timed out', 'fail'],
|
|
1574
|
+
abandoned: ['Abandoned', 'fail'],
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
/** Each fix attempt, oldest first, with the retests that judged it. */
|
|
1578
|
+
function attemptTimeline(fix, issue, attemptDiffs) {
|
|
1579
|
+
const last = fix.attempts.at(-1).n;
|
|
1580
|
+
const judged = (n) =>
|
|
1581
|
+
(fix.retests ?? []).filter((retest) =>
|
|
1582
|
+
// A retest from before attempts were recorded, or one naming an unknown attempt, judged the last one.
|
|
1583
|
+
fix.attempts.some((attempt) => attempt.n === retest.fixAttempt) ? retest.fixAttempt === n : n === last,
|
|
1584
|
+
);
|
|
1585
|
+
return el(
|
|
1586
|
+
'ol',
|
|
1587
|
+
{ class: 'attempt-timeline' },
|
|
1588
|
+
fix.attempts.map((attempt) => {
|
|
1589
|
+
const [outcome, kind] = attempt.outcome
|
|
1590
|
+
? (ATTEMPT_OUTCOMES[attempt.outcome] ?? [attempt.outcome, 'info'])
|
|
1591
|
+
: ['Running…', 'info'];
|
|
1592
|
+
return el('li', { class: 'attempt' }, [
|
|
1593
|
+
el('div', { class: 'detail-heading' }, [
|
|
1594
|
+
el('h3', { text: `Attempt ${attempt.n}: ${ATTEMPT_KINDS[attempt.kind] ?? attempt.kind}` }),
|
|
1595
|
+
el('span', { class: `badge ${kind}`, text: outcome }),
|
|
1596
|
+
]),
|
|
1597
|
+
el('div', { class: 'kv' }, [
|
|
1598
|
+
el('span', { text: relativeTime(attempt.startedAt) }),
|
|
1599
|
+
attempt.costUsd !== undefined ? el('span', { text: money(attempt.costUsd) }) : null,
|
|
1600
|
+
]),
|
|
1601
|
+
attempt.reason ? markdown(attempt.reason) : null,
|
|
1602
|
+
attempt.diffStat ? el('pre', { text: attempt.diffStat }) : null,
|
|
1603
|
+
attemptDiffs[attempt.n] ? lazyDiff(attemptDiffs[attempt.n]) : null,
|
|
1604
|
+
attempt.verifyOutput
|
|
1605
|
+
? el('details', { class: 'diff-details' }, [
|
|
1606
|
+
el('summary', { text: 'Show the verify output' }),
|
|
1607
|
+
el('pre', { text: attempt.verifyOutput }),
|
|
1608
|
+
])
|
|
1609
|
+
: null,
|
|
1610
|
+
...judged(attempt.n).map((retest) => retestBlock(retest, issue)),
|
|
1611
|
+
]);
|
|
1612
|
+
}),
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** A diff file that loads the first time the reader opens it. */
|
|
1617
|
+
function lazyDiff(path) {
|
|
1618
|
+
const body = el('div', { class: 'muted', text: 'Loading…' });
|
|
1619
|
+
let loaded = false;
|
|
1620
|
+
return el(
|
|
1621
|
+
'details',
|
|
1622
|
+
{
|
|
1623
|
+
class: 'diff-details',
|
|
1624
|
+
ontoggle: (event) => {
|
|
1625
|
+
if (!event.target.open || loaded) return;
|
|
1626
|
+
loaded = true;
|
|
1627
|
+
fetchText(path)
|
|
1628
|
+
.then((diff) => body.replaceWith(diffBlock(diff)))
|
|
1629
|
+
.catch((error) => {
|
|
1630
|
+
body.textContent = `Could not read the diff: ${error.message}`;
|
|
1631
|
+
});
|
|
1632
|
+
},
|
|
1633
|
+
},
|
|
1634
|
+
[el('summary', { text: 'Show the diff' }), body],
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1018
1638
|
function retestBlock(retest, issue) {
|
|
1019
|
-
const
|
|
1639
|
+
const capture = issue.evidence?.capture;
|
|
1640
|
+
const before = retest.before ?? capture?.screenshot;
|
|
1641
|
+
// An API issue has no before file: the reported exchange is the before.
|
|
1642
|
+
const beforeView = (path) =>
|
|
1643
|
+
!path && capture?.kind === 'http' ? captureView(capture, 'Before') : captureFile(path, 'Before');
|
|
1020
1644
|
const pair = (shot) =>
|
|
1021
1645
|
el('div', { class: 'retest-shot-pair' }, [
|
|
1022
|
-
retest.
|
|
1646
|
+
retest.captures?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
|
|
1023
1647
|
shot.reached === false ? el('span', { class: 'muted', text: 'Not reached' }) : null,
|
|
1024
1648
|
shot.note ? el('p', { class: 'muted', text: shot.note }) : null,
|
|
1025
1649
|
el('div', { class: 'retest-shots' }, [
|
|
1026
|
-
el('figure', {}, [
|
|
1650
|
+
el('figure', {}, [beforeView(shot.before), el('figcaption', { text: 'Before' })]),
|
|
1027
1651
|
el('figure', {}, [
|
|
1028
|
-
|
|
1652
|
+
captureFile(shot.after, 'After (with the fix)'),
|
|
1029
1653
|
el('figcaption', { text: 'After (with the fix)' }),
|
|
1030
1654
|
]),
|
|
1031
1655
|
]),
|
|
@@ -1033,8 +1657,8 @@ function retestBlock(retest, issue) {
|
|
|
1033
1657
|
return el('div', { class: 'retest-block' }, [
|
|
1034
1658
|
el('h3', { text: `Retest ${retest.attempt}: ${retest.outcome}` }),
|
|
1035
1659
|
el('p', { text: retest.reason }),
|
|
1036
|
-
retest.
|
|
1037
|
-
? retest.
|
|
1660
|
+
retest.captures?.length
|
|
1661
|
+
? retest.captures.map((shot) => pair(shot))
|
|
1038
1662
|
: pair({ before, after: retest.after, note: retest.note }),
|
|
1039
1663
|
]);
|
|
1040
1664
|
}
|
|
@@ -1087,9 +1711,9 @@ function inlineMarkdown(line) {
|
|
|
1087
1711
|
return parts;
|
|
1088
1712
|
}
|
|
1089
1713
|
|
|
1090
|
-
function
|
|
1091
|
-
|
|
1092
|
-
title(
|
|
1714
|
+
function sessionList(heading) {
|
|
1715
|
+
return el('section', { class: 'card panel' }, [
|
|
1716
|
+
title(heading, `${state.sessions.length}`),
|
|
1093
1717
|
...state.sessions.map((session) =>
|
|
1094
1718
|
el(
|
|
1095
1719
|
'button',
|
|
@@ -1106,59 +1730,228 @@ function renderActivity() {
|
|
|
1106
1730
|
el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
|
|
1107
1731
|
el('small', {
|
|
1108
1732
|
class: 'muted',
|
|
1109
|
-
text:
|
|
1110
|
-
`${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)} · ${session.steps} steps · ` +
|
|
1111
|
-
`${session.tokens ? `${tokens(session.tokens)} · ` : ''}API ${money(session.costUsd)} · ` +
|
|
1112
|
-
`${session.screensFound.length} screens · ${session.issues.length} issues`,
|
|
1733
|
+
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
|
|
1113
1734
|
}),
|
|
1114
1735
|
]),
|
|
1736
|
+
session.status === 'finished'
|
|
1737
|
+
? null
|
|
1738
|
+
: el('span', { class: `badge ${SESSION_BADGE[session.status]}`, text: session.status }),
|
|
1115
1739
|
],
|
|
1116
1740
|
),
|
|
1117
1741
|
),
|
|
1118
1742
|
]);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function noSessions(page) {
|
|
1746
|
+
return el('section', { class: 'card panel' }, [
|
|
1747
|
+
title(page),
|
|
1748
|
+
emptyState({
|
|
1749
|
+
heading: 'No sessions yet',
|
|
1750
|
+
why: [
|
|
1751
|
+
page === 'Flow'
|
|
1752
|
+
? 'Flow puts the actions of a session, the requests that failed, and the backend logs in one timeline.'
|
|
1753
|
+
: 'Activity shows each agent session: what it tested, what it found, and what the judge decided.',
|
|
1754
|
+
'No agent ran in this project yet.',
|
|
1755
|
+
],
|
|
1756
|
+
command: cli('patrol --once'),
|
|
1757
|
+
}),
|
|
1758
|
+
]);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function renderActivity() {
|
|
1762
|
+
if (!state.sessions.length) return noSessions('Activity');
|
|
1119
1763
|
const detail = state.sessionDetail;
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1764
|
+
return el('div', { class: 'master-detail' }, [
|
|
1765
|
+
sessionList('Sessions'),
|
|
1766
|
+
detail ? renderSessionDetail(detail) : el('section', { class: 'card panel empty', text: 'Select a session.' }),
|
|
1767
|
+
]);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Most findings of a sweep wait for the judge, so that state is quiet text and the others are badges.
|
|
1771
|
+
const CANDIDATE_STATES = {
|
|
1772
|
+
waiting: ['', 'Waits for the judge'],
|
|
1773
|
+
filed: ['fail', 'Filed as an issue'],
|
|
1774
|
+
dismissed: ['info', 'Dismissed'],
|
|
1775
|
+
ignored: ['info', 'Not sent to the judge'],
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
/** One session: its numbers first, then its findings by failure, then its events. */
|
|
1779
|
+
function renderSessionDetail(detail) {
|
|
1780
|
+
const { session, report, candidates, events } = detail;
|
|
1781
|
+
const { findings, sweep } = report;
|
|
1782
|
+
const cases = sweep ? sweep.pass + sweep.fail + sweep.blocked : 0;
|
|
1783
|
+
const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
1784
|
+
const agentEvents = events.some((event) => event.kind !== 'candidate');
|
|
1785
|
+
const timeline = el(
|
|
1786
|
+
'div',
|
|
1787
|
+
{ class: 'event-list' },
|
|
1788
|
+
events.filter((event) => state.showThoughts || isFeedEvent(event)).map(renderEventRow),
|
|
1789
|
+
);
|
|
1790
|
+
return el('section', { class: 'card panel session-detail' }, [
|
|
1791
|
+
title(
|
|
1792
|
+
firstSentence(session.summary) || `${capital(session.role)} session`,
|
|
1793
|
+
`${capital(session.role)} · ${formatTime(session.startedAt)}`,
|
|
1794
|
+
),
|
|
1795
|
+
statBand([
|
|
1796
|
+
{ label: 'Duration', value: session.status === 'running' ? 'Running' : formatDuration(report.durationMs) },
|
|
1797
|
+
sweep
|
|
1798
|
+
? {
|
|
1799
|
+
label: 'Cases in this session',
|
|
1800
|
+
value: number(cases),
|
|
1801
|
+
note: `${number(sweep.endpoints)} endpoints tested · ${number(sweep.blocked)} blocked`,
|
|
1802
|
+
}
|
|
1803
|
+
: null,
|
|
1804
|
+
sweep
|
|
1805
|
+
? {
|
|
1806
|
+
label: 'Pass · fail · blocked',
|
|
1807
|
+
value: `${number(sweep.pass)} · ${number(sweep.fail)} · ${number(sweep.blocked)}`,
|
|
1808
|
+
bar: caseBar(sweep),
|
|
1809
|
+
}
|
|
1810
|
+
: null,
|
|
1811
|
+
report.steps || !sweep
|
|
1812
|
+
? { label: 'Steps', value: number(report.steps), note: `${report.screens.length} screens found` }
|
|
1813
|
+
: null,
|
|
1814
|
+
{
|
|
1815
|
+
label: 'Findings',
|
|
1816
|
+
value: number(findings.total),
|
|
1817
|
+
note: sweep
|
|
1818
|
+
? `${number(findings.repeat)} failed two times`
|
|
1819
|
+
: `${number(findings.filed)} filed as ${findings.filed === 1 ? 'an issue' : 'issues'}`,
|
|
1820
|
+
},
|
|
1821
|
+
{
|
|
1822
|
+
label: 'Wait for the judge',
|
|
1823
|
+
value: number(findings.waiting),
|
|
1824
|
+
note: `${number(findings.filed)} filed · ${number(findings.dismissed)} dismissed`,
|
|
1825
|
+
tone: findings.waiting ? 'warn' : undefined,
|
|
1826
|
+
},
|
|
1827
|
+
session.tokens
|
|
1828
|
+
? {
|
|
1829
|
+
label: 'Tokens',
|
|
1830
|
+
value: count(session.tokens.input + session.tokens.output),
|
|
1831
|
+
note: `API ${money(session.costUsd)}`,
|
|
1832
|
+
}
|
|
1833
|
+
: null,
|
|
1834
|
+
]),
|
|
1835
|
+
findings.waiting
|
|
1836
|
+
? callout(
|
|
1837
|
+
`The judge did not read ${number(findings.waiting)} findings of this session yet. Run the judge:`,
|
|
1838
|
+
cli('patrol --once'),
|
|
1839
|
+
)
|
|
1840
|
+
: null,
|
|
1841
|
+
report.groups.length
|
|
1842
|
+
? el('section', { class: 'subsection' }, [
|
|
1843
|
+
title(
|
|
1844
|
+
'Findings',
|
|
1845
|
+
`${number(findings.total)} in ${report.groups.length} ${report.groups.length === 1 ? 'group' : 'groups'}`,
|
|
1846
|
+
),
|
|
1847
|
+
...report.groups.map((group) => findingGroup(group, byId)),
|
|
1848
|
+
])
|
|
1849
|
+
: el('p', { class: 'muted', text: 'This session reported no finding.' }),
|
|
1850
|
+
countChips('Findings by case', report.byCase),
|
|
1851
|
+
countChips('Findings by check', report.byCheck),
|
|
1852
|
+
countChips(
|
|
1853
|
+
'Findings by screen',
|
|
1854
|
+
report.byScreen.map(({ label, count: n }) => ({ label: screenName(label), count: n })),
|
|
1855
|
+
),
|
|
1856
|
+
// A sweep uses no model, so it has no usage to show.
|
|
1857
|
+
session.tokensByModel
|
|
1858
|
+
? el('section', { class: 'subsection' }, [
|
|
1859
|
+
title('Usage'),
|
|
1860
|
+
el(
|
|
1861
|
+
'div',
|
|
1862
|
+
{ class: 'muted session-usage' },
|
|
1863
|
+
Object.entries(session.tokensByModel).map(([model, usage]) =>
|
|
1864
|
+
el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }),
|
|
1865
|
+
),
|
|
1866
|
+
),
|
|
1867
|
+
])
|
|
1868
|
+
: null,
|
|
1869
|
+
// A sweep has no actions: its events repeat its findings, so they stay one click away.
|
|
1870
|
+
agentEvents
|
|
1871
|
+
? el('section', { class: 'subsection' }, [
|
|
1872
|
+
title(
|
|
1873
|
+
'Timeline',
|
|
1874
|
+
state.showThoughts
|
|
1875
|
+
? `${events.length} events`
|
|
1876
|
+
: `${events.filter(isFeedEvent).length} of ${events.length} events`,
|
|
1877
|
+
),
|
|
1878
|
+
// The heading shows the first sentence. A longer summary shows here in full.
|
|
1879
|
+
session.summary && firstSentence(session.summary) !== session.summary.trim()
|
|
1880
|
+
? el('div', { class: 'session-summary' }, [markdown(session.summary)])
|
|
1881
|
+
: null,
|
|
1882
|
+
el('label', { class: 'toggle' }, [
|
|
1883
|
+
el('input', {
|
|
1884
|
+
type: 'checkbox',
|
|
1885
|
+
...(state.showThoughts ? { checked: '' } : {}),
|
|
1886
|
+
onchange: (event) => {
|
|
1887
|
+
state.showThoughts = event.target.checked;
|
|
1888
|
+
render();
|
|
1889
|
+
},
|
|
1156
1890
|
}),
|
|
1891
|
+
el('span', { text: 'Show agent reasoning' }),
|
|
1157
1892
|
]),
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1893
|
+
timeline,
|
|
1894
|
+
])
|
|
1895
|
+
: el('details', { class: 'subsection raw-events' }, [
|
|
1896
|
+
el('summary', { text: `All events (${events.length})` }),
|
|
1897
|
+
timeline,
|
|
1898
|
+
]),
|
|
1899
|
+
]);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function countChips(heading, counts) {
|
|
1903
|
+
if (!counts.length) return null;
|
|
1904
|
+
return el('section', { class: 'subsection' }, [
|
|
1905
|
+
title(heading),
|
|
1906
|
+
el(
|
|
1907
|
+
'div',
|
|
1908
|
+
{ class: 'chips' },
|
|
1909
|
+
counts.map(({ label, count: n }) =>
|
|
1910
|
+
el('span', { class: 'chip' }, [el('span', { text: label }), el('strong', { text: number(n) })]),
|
|
1911
|
+
),
|
|
1912
|
+
),
|
|
1913
|
+
]);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/** A group of findings with the same failure. Each finding opens to its evidence. */
|
|
1917
|
+
function findingGroup(group, byId) {
|
|
1918
|
+
const members = group.candidateIds.map((id) => byId.get(id)).filter(Boolean);
|
|
1919
|
+
return el('details', { class: 'finding-group' }, [
|
|
1920
|
+
el('summary', {}, [
|
|
1921
|
+
el('span', { class: 'group-label', text: capital(group.label) }),
|
|
1922
|
+
...group.assertions.map((id) => el('code', { class: 'assertion', text: id })),
|
|
1923
|
+
el('span', {
|
|
1924
|
+
class: 'muted group-repeat',
|
|
1925
|
+
text: group.repeat && group.repeat < group.count ? `${group.repeat} of ${group.count} failed two times` : '',
|
|
1926
|
+
}),
|
|
1927
|
+
el('strong', { class: 'group-count', text: number(group.count) }),
|
|
1928
|
+
]),
|
|
1929
|
+
...members.map((candidate) => {
|
|
1930
|
+
const [badge, words] = CANDIDATE_STATES[candidate.state] ?? ['info', candidate.state];
|
|
1931
|
+
const at = candidate.source === 'sweep' ? candidate.summary.indexOf(': ') : -1;
|
|
1932
|
+
const capture = candidate.evidence?.capture;
|
|
1933
|
+
return el('details', { class: 'candidate-row' }, [
|
|
1934
|
+
el('summary', {}, [
|
|
1935
|
+
el('span', { class: 'grow truncate', text: at > 0 ? candidate.summary.slice(0, at) : candidate.summary }),
|
|
1936
|
+
candidate.state === 'filed' && candidate.issueId
|
|
1937
|
+
? el('button', {
|
|
1938
|
+
type: 'button',
|
|
1939
|
+
class: `badge ${badge}`,
|
|
1940
|
+
text: words,
|
|
1941
|
+
onclick: (event) => {
|
|
1942
|
+
event.preventDefault();
|
|
1943
|
+
openIssue(candidate.issueId);
|
|
1944
|
+
},
|
|
1945
|
+
})
|
|
1946
|
+
: el('span', { class: badge ? `badge ${badge}` : 'muted state-words', text: words }),
|
|
1947
|
+
]),
|
|
1948
|
+
candidate.route?.reason ? el('p', { class: 'muted', text: candidate.route.reason }) : null,
|
|
1949
|
+
capture?.kind === 'http'
|
|
1950
|
+
? el('pre', { class: 'http', text: candidate.text })
|
|
1951
|
+
: captureFile(capture?.screenshot, candidate.summary, 'candidate-shot'),
|
|
1952
|
+
]);
|
|
1953
|
+
}),
|
|
1954
|
+
]);
|
|
1162
1955
|
}
|
|
1163
1956
|
|
|
1164
1957
|
function duration(session) {
|
|
@@ -1179,7 +1972,7 @@ function renderEventRow(event) {
|
|
|
1179
1972
|
text: tokens(event.tokens, ''),
|
|
1180
1973
|
})
|
|
1181
1974
|
: null,
|
|
1182
|
-
event.
|
|
1975
|
+
event.capture ? captureFile(event.capture, event.summary, 'event-thumb') : null,
|
|
1183
1976
|
event.input !== undefined || event.output !== undefined
|
|
1184
1977
|
? el('details', {}, [
|
|
1185
1978
|
el('summary', { text: 'Tool data' }),
|
|
@@ -1193,52 +1986,17 @@ function renderEventRow(event) {
|
|
|
1193
1986
|
// failed requests the app reported, and the backend logs that followed, merged
|
|
1194
1987
|
// in time order. A log close behind a failure is marked correlated.
|
|
1195
1988
|
function renderFlow() {
|
|
1196
|
-
|
|
1197
|
-
title('Flow'),
|
|
1198
|
-
...state.sessions.map((session) =>
|
|
1199
|
-
el(
|
|
1200
|
-
'button',
|
|
1201
|
-
{
|
|
1202
|
-
class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
|
|
1203
|
-
onclick: async () => {
|
|
1204
|
-
state.selectedSessionId = session.id;
|
|
1205
|
-
await refresh();
|
|
1206
|
-
},
|
|
1207
|
-
},
|
|
1208
|
-
[
|
|
1209
|
-
roleIcon(session.role),
|
|
1210
|
-
el('span', { class: 'grow' }, [
|
|
1211
|
-
el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
|
|
1212
|
-
el('small', {
|
|
1213
|
-
class: 'muted',
|
|
1214
|
-
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
|
|
1215
|
-
}),
|
|
1216
|
-
]),
|
|
1217
|
-
],
|
|
1218
|
-
),
|
|
1219
|
-
),
|
|
1220
|
-
]);
|
|
1989
|
+
if (!state.sessions.length) return noSessions('Flow');
|
|
1221
1990
|
const flow = state.flow;
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
list,
|
|
1225
|
-
el('section', {
|
|
1226
|
-
class: 'card panel empty',
|
|
1227
|
-
text: 'No flow for this session. Add a `logs:` source to bugpatrol.yml and run a patrol.',
|
|
1228
|
-
}),
|
|
1229
|
-
]);
|
|
1230
|
-
}
|
|
1991
|
+
const list = sessionList('Sessions');
|
|
1992
|
+
if (!flow) return el('div', { class: 'master-detail' }, [list, el('section', { class: 'card panel' }, [noFlow()])]);
|
|
1231
1993
|
const sources = flow.sources.length
|
|
1232
1994
|
? flow.sources.map((source) =>
|
|
1233
1995
|
el('div', {
|
|
1234
1996
|
text: `${source.name} (${source.kind}): ${source.collected} line${source.collected === 1 ? '' : 's'}`,
|
|
1235
1997
|
}),
|
|
1236
1998
|
)
|
|
1237
|
-
: [
|
|
1238
|
-
el('div', {
|
|
1239
|
-
text: 'No log sources configured. Add `logs:` to bugpatrol.yml to see backend logs beside each action.',
|
|
1240
|
-
}),
|
|
1241
|
-
];
|
|
1999
|
+
: [el('div', { text: 'The config had no log source for this session, so the flow has no backend logs.' })];
|
|
1242
2000
|
return el('div', { class: 'master-detail' }, [
|
|
1243
2001
|
list,
|
|
1244
2002
|
el('section', { class: 'card panel' }, [
|
|
@@ -1251,13 +2009,56 @@ function renderFlow() {
|
|
|
1251
2009
|
]);
|
|
1252
2010
|
}
|
|
1253
2011
|
|
|
2012
|
+
const LOGS_EXAMPLE = `logs:
|
|
2013
|
+
- name: api
|
|
2014
|
+
kind: file
|
|
2015
|
+
path: logs/api.log`;
|
|
2016
|
+
|
|
2017
|
+
/** Why the selected session has no flow, and the next step. */
|
|
2018
|
+
function noFlow() {
|
|
2019
|
+
const summary = state.summary;
|
|
2020
|
+
const session = state.sessionDetail?.session;
|
|
2021
|
+
const report = state.sessionDetail?.report;
|
|
2022
|
+
const what =
|
|
2023
|
+
'Flow puts the actions of a session, the requests that failed, and the backend logs in one timeline, so you can see which log line follows which request.';
|
|
2024
|
+
if (session?.status === 'running')
|
|
2025
|
+
return emptyState({
|
|
2026
|
+
heading: 'The flow shows when the session ends',
|
|
2027
|
+
why: [what, 'This session runs now. Bugpatrol writes its flow at the end.'],
|
|
2028
|
+
});
|
|
2029
|
+
if (!summary.logs.length)
|
|
2030
|
+
return emptyState({
|
|
2031
|
+
heading: 'No backend logs',
|
|
2032
|
+
why: [
|
|
2033
|
+
what,
|
|
2034
|
+
`${summary.config ? fileName(summary.config) : 'The config'} has no logs: source, so Bugpatrol reads no backend logs. Add one, then run a patrol:`,
|
|
2035
|
+
],
|
|
2036
|
+
command: LOGS_EXAMPLE,
|
|
2037
|
+
docs: `${DOCS}/dashboard.md#flow`,
|
|
2038
|
+
});
|
|
2039
|
+
if (report?.sweep && !report.steps)
|
|
2040
|
+
return emptyState({
|
|
2041
|
+
heading: 'A sweep has no flow',
|
|
2042
|
+
why: [
|
|
2043
|
+
what,
|
|
2044
|
+
'This session is a sweep from bugpatrol sweep, which reads no logs. A patrol runs the sweep and reads the logs: sources too.',
|
|
2045
|
+
],
|
|
2046
|
+
command: cli('patrol --once'),
|
|
2047
|
+
});
|
|
2048
|
+
return emptyState({
|
|
2049
|
+
heading: 'No flow for this session',
|
|
2050
|
+
why: [what, 'This session ran before the config had a logs: source. The next patrol writes a flow.'],
|
|
2051
|
+
command: cli('patrol --once'),
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
|
|
1254
2055
|
function renderFlowRow(entry) {
|
|
1255
2056
|
return el('div', { class: `event-row flow-row flow-${entry.kind}${entry.correlated ? ' correlated' : ''}` }, [
|
|
1256
2057
|
el('time', { text: new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
|
|
1257
2058
|
el('span', { class: 'flow-kind', text: entry.kind }),
|
|
1258
2059
|
el('span', { class: 'grow truncate', text: entry.summary }),
|
|
1259
2060
|
entry.level ? el('small', { class: 'muted', text: entry.level }) : null,
|
|
1260
|
-
entry.
|
|
2061
|
+
entry.capture ? captureFile(entry.capture, entry.summary, 'event-thumb') : null,
|
|
1261
2062
|
]);
|
|
1262
2063
|
}
|
|
1263
2064
|
|
|
@@ -1267,9 +2068,37 @@ async function openScreen(id) {
|
|
|
1267
2068
|
await refresh();
|
|
1268
2069
|
}
|
|
1269
2070
|
|
|
2071
|
+
/** Screens belong to a client app. For an API, the page says so and points to Coverage. */
|
|
2072
|
+
function noScreens() {
|
|
2073
|
+
const clients = surfacesOf('client');
|
|
2074
|
+
const apis = surfacesOf('api');
|
|
2075
|
+
const coverage = { label: 'Open Coverage', onclick: () => go('coverage') };
|
|
2076
|
+
if (!clients.length)
|
|
2077
|
+
return emptyState({
|
|
2078
|
+
heading: 'This project tests an API',
|
|
2079
|
+
why: [
|
|
2080
|
+
'Screens shows the screens that the explorer finds in a client app, and how they connect.',
|
|
2081
|
+
`${apis[0]?.configs[0] ? fileName(apis[0].configs[0]) : 'The config'} tests an API, which has no screens. Coverage shows its endpoints and cases.`,
|
|
2082
|
+
'A monorepo can test a client app too: add a config for it in .bugpatrol/.',
|
|
2083
|
+
],
|
|
2084
|
+
action: coverage,
|
|
2085
|
+
});
|
|
2086
|
+
const client = clients[0];
|
|
2087
|
+
return emptyState({
|
|
2088
|
+
heading: 'No screens yet',
|
|
2089
|
+
why: [
|
|
2090
|
+
'Screens shows the screens that the explorer finds in a client app, and how they connect.',
|
|
2091
|
+
`${client.configs[0] ? fileName(client.configs[0]) : 'This project'} tests the ${surfaceName(client).toLowerCase()}. The explorer did not find a screen yet.`,
|
|
2092
|
+
apis.length ? 'This project also tests an API. Coverage shows its endpoints.' : '',
|
|
2093
|
+
].filter(Boolean),
|
|
2094
|
+
command: cli('explore', client),
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
|
|
1270
2098
|
function renderScreens() {
|
|
1271
2099
|
const graphScreens = state.appmap?.screens ?? [];
|
|
1272
2100
|
const screens = graphScreens.filter((screen) => !screen.virtual);
|
|
2101
|
+
if (!screens.length) return el('section', { class: 'card panel' }, [title('Screens'), noScreens()]);
|
|
1273
2102
|
const edges = state.appmap?.edges ?? [];
|
|
1274
2103
|
const selected = screens.find((screen) => screen.id === state.selectedScreenId);
|
|
1275
2104
|
if (selected) {
|
|
@@ -1287,7 +2116,7 @@ function renderScreens() {
|
|
|
1287
2116
|
},
|
|
1288
2117
|
}),
|
|
1289
2118
|
title(selected.name, `${selected.visits} visits · ${relativeTime(selected.lastSeenAt)}`),
|
|
1290
|
-
|
|
2119
|
+
captureFile(selected.lastCapture, selected.name, 'screen-large'),
|
|
1291
2120
|
el('p', { text: selected.description }),
|
|
1292
2121
|
title('Routine'),
|
|
1293
2122
|
el('p', {
|
|
@@ -1338,38 +2167,48 @@ function renderScreens() {
|
|
|
1338
2167
|
}),
|
|
1339
2168
|
),
|
|
1340
2169
|
);
|
|
2170
|
+
const apis = surfacesOf('api');
|
|
1341
2171
|
return el('div', {}, [
|
|
1342
2172
|
title('Screens', `${screens.length} found`),
|
|
2173
|
+
apis.length
|
|
2174
|
+
? el('p', { class: 'muted surface-note' }, [
|
|
2175
|
+
`This project also tests an API${apis[0].configs[0] ? ` (${fileName(apis[0].configs[0])})` : ''}. `,
|
|
2176
|
+
el('button', {
|
|
2177
|
+
type: 'button',
|
|
2178
|
+
class: 'link',
|
|
2179
|
+
text: 'Coverage shows its endpoints.',
|
|
2180
|
+
onclick: () => go('coverage'),
|
|
2181
|
+
}),
|
|
2182
|
+
])
|
|
2183
|
+
: null,
|
|
1343
2184
|
toggle,
|
|
1344
|
-
|
|
1345
|
-
?
|
|
1346
|
-
:
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
'
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
[
|
|
1359
|
-
|
|
1360
|
-
el('
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
]),
|
|
1369
|
-
],
|
|
1370
|
-
),
|
|
2185
|
+
screenMode === 'graph'
|
|
2186
|
+
? renderScreenGraph(graphScreens, edges)
|
|
2187
|
+
: el(
|
|
2188
|
+
'div',
|
|
2189
|
+
{ class: 'screen-grid' },
|
|
2190
|
+
screens.map((screen) =>
|
|
2191
|
+
el(
|
|
2192
|
+
'button',
|
|
2193
|
+
{
|
|
2194
|
+
class: 'card screen-card',
|
|
2195
|
+
onclick: () => openScreen(screen.id),
|
|
2196
|
+
},
|
|
2197
|
+
[
|
|
2198
|
+
captureFile(screen.lastCapture, screen.name),
|
|
2199
|
+
el('div', { class: 'screen-copy' }, [
|
|
2200
|
+
el('strong', { text: screen.name }),
|
|
2201
|
+
el('p', { class: 'truncate muted', text: screen.description }),
|
|
2202
|
+
el('small', {
|
|
2203
|
+
class: 'muted',
|
|
2204
|
+
text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits`,
|
|
2205
|
+
}),
|
|
2206
|
+
screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
|
|
2207
|
+
]),
|
|
2208
|
+
],
|
|
1371
2209
|
),
|
|
1372
2210
|
),
|
|
2211
|
+
),
|
|
1373
2212
|
]);
|
|
1374
2213
|
}
|
|
1375
2214
|
|
|
@@ -1467,14 +2306,14 @@ function renderScreenGraph(screens, allEdges) {
|
|
|
1467
2306
|
},
|
|
1468
2307
|
});
|
|
1469
2308
|
card.append(svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: 9, class: 'graph-card' }));
|
|
1470
|
-
if (screen.
|
|
2309
|
+
if (screen.lastCapture)
|
|
1471
2310
|
card.append(
|
|
1472
2311
|
svgEl('image', {
|
|
1473
2312
|
x: node.x + 5,
|
|
1474
2313
|
y: node.y + 5,
|
|
1475
2314
|
width: node.w - 10,
|
|
1476
2315
|
height: 104,
|
|
1477
|
-
href: artifact(screen.
|
|
2316
|
+
href: artifact(screen.lastCapture),
|
|
1478
2317
|
preserveAspectRatio: 'xMidYMin slice',
|
|
1479
2318
|
}),
|
|
1480
2319
|
);
|
|
@@ -1646,11 +2485,21 @@ async function openReview(number) {
|
|
|
1646
2485
|
}
|
|
1647
2486
|
|
|
1648
2487
|
function renderReviews() {
|
|
2488
|
+
if (!state.reviews.length)
|
|
2489
|
+
return el('section', { class: 'card panel' }, [
|
|
2490
|
+
title('Pull request reviews'),
|
|
2491
|
+
emptyState({
|
|
2492
|
+
heading: 'No pull request reviews yet',
|
|
2493
|
+
why: [
|
|
2494
|
+
'A review tests one pull request in the running app. It checks each claim of the pull request and lists the problems that the pull request adds.',
|
|
2495
|
+
'Bugpatrol did not review a pull request in this project yet. Give the number of a pull request:',
|
|
2496
|
+
],
|
|
2497
|
+
command: cli('review <pr>'),
|
|
2498
|
+
docs: `${DOCS}/github.md`,
|
|
2499
|
+
}),
|
|
2500
|
+
]);
|
|
1649
2501
|
const list = el('div', { class: 'card panel' }, [
|
|
1650
2502
|
title('Pull request reviews', `${state.reviews.length}`),
|
|
1651
|
-
state.reviews.length
|
|
1652
|
-
? null
|
|
1653
|
-
: el('p', { class: 'empty', text: 'No reviews yet. Run bugpatrol review <pr> to review a pull request.' }),
|
|
1654
2503
|
...state.reviews.map((review) =>
|
|
1655
2504
|
el(
|
|
1656
2505
|
'button',
|
|
@@ -1841,7 +2690,9 @@ function buildEvidence(label, replay, recordings) {
|
|
|
1841
2690
|
el(
|
|
1842
2691
|
'div',
|
|
1843
2692
|
{ class: 'shot-strip' },
|
|
1844
|
-
shots.map((path, index) =>
|
|
2693
|
+
shots.map((path, index) =>
|
|
2694
|
+
captureFile(path, `${label}: ${index ? `after step ${index}` : 'before the first step'}`),
|
|
2695
|
+
),
|
|
1845
2696
|
);
|
|
1846
2697
|
return el('figure', { class: 'build-evidence' }, [
|
|
1847
2698
|
pick ? recordingView(pick) : shots.length ? strip() : el('div', { class: 'image-empty', text: 'No capture' }),
|
|
@@ -1860,7 +2711,10 @@ function recordingView(rec) {
|
|
|
1860
2711
|
el('small', { class: 'muted', text: name }),
|
|
1861
2712
|
]);
|
|
1862
2713
|
if (rec.kind === 'image')
|
|
1863
|
-
return el('div', { class: 'recording' }, [
|
|
2714
|
+
return el('div', { class: 'recording' }, [
|
|
2715
|
+
captureFile(rec.path, name),
|
|
2716
|
+
el('small', { class: 'muted', text: name }),
|
|
2717
|
+
]);
|
|
1864
2718
|
return castPlayer(rec.path);
|
|
1865
2719
|
}
|
|
1866
2720
|
|
|
@@ -1884,8 +2738,7 @@ function castPlayer(path) {
|
|
|
1884
2738
|
}, at * 1000);
|
|
1885
2739
|
});
|
|
1886
2740
|
});
|
|
1887
|
-
|
|
1888
|
-
.then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
|
|
2741
|
+
fetchText(path)
|
|
1889
2742
|
.then((text) => {
|
|
1890
2743
|
cast = parseCast(text);
|
|
1891
2744
|
if (!cast) throw new Error('not an asciicast file');
|
|
@@ -1908,8 +2761,11 @@ function findingBlock(finding) {
|
|
|
1908
2761
|
el('p', { text: finding.reason }),
|
|
1909
2762
|
finding.head || finding.base
|
|
1910
2763
|
? el('div', { class: 'retest-shots' }, [
|
|
1911
|
-
el('figure', {}, [
|
|
1912
|
-
el('figure', {}, [
|
|
2764
|
+
el('figure', {}, [captureFile(finding.base, 'Base'), el('figcaption', { text: 'Base' })]),
|
|
2765
|
+
el('figure', {}, [
|
|
2766
|
+
captureFile(finding.head, 'This pull request'),
|
|
2767
|
+
el('figcaption', { text: 'This pull request' }),
|
|
2768
|
+
]),
|
|
1913
2769
|
])
|
|
1914
2770
|
: null,
|
|
1915
2771
|
finding.baseNote ? el('p', { class: 'muted', text: finding.baseNote }) : null,
|
|
@@ -1934,6 +2790,18 @@ function renderMemory() {
|
|
|
1934
2790
|
el('span', { class: 'badge info', text: lesson.scope ?? 'app' }),
|
|
1935
2791
|
el('span', { class: 'muted', text: `${lesson.source} · ×${lesson.hits} · ${relativeTime(lesson.lastSeenAt)}` }),
|
|
1936
2792
|
]);
|
|
2793
|
+
if (!lessons.length)
|
|
2794
|
+
return el('section', { class: 'card panel' }, [
|
|
2795
|
+
title('Memory'),
|
|
2796
|
+
emptyState({
|
|
2797
|
+
heading: 'No lessons yet',
|
|
2798
|
+
why: [
|
|
2799
|
+
'A lesson is a fact about the app that an agent keeps for its next sessions, for example how to sign in.',
|
|
2800
|
+
'The agents save lessons when they learn something. You can also add one:',
|
|
2801
|
+
],
|
|
2802
|
+
command: cli('memory add --role explorer "text"'),
|
|
2803
|
+
}),
|
|
2804
|
+
]);
|
|
1937
2805
|
return el('section', { class: 'card panel' }, [
|
|
1938
2806
|
title('Memory'),
|
|
1939
2807
|
el('p', { class: 'muted', text: 'Use bugpatrol memory to edit lessons.' }),
|
|
@@ -1950,6 +2818,311 @@ function renderMemory() {
|
|
|
1950
2818
|
]);
|
|
1951
2819
|
}
|
|
1952
2820
|
|
|
2821
|
+
const CASE_WORDS = {
|
|
2822
|
+
ok: ['2xx', 'The request as the spec describes it'],
|
|
2823
|
+
'no-auth': ['no-auth', 'The request without credentials'],
|
|
2824
|
+
'invalid-input': ['invalid-input', 'A body or a parameter that the schema rejects'],
|
|
2825
|
+
'missing-id': ['missing-id', 'An id that does not exist'],
|
|
2826
|
+
'other-user': ['other-user', 'The data of one user, read by a second user'],
|
|
2827
|
+
'repeat-delete': ['repeat-delete', 'The same DELETE two times'],
|
|
2828
|
+
};
|
|
2829
|
+
const CELL_WORDS = {
|
|
2830
|
+
pass: 'Pass',
|
|
2831
|
+
fail: 'Fail',
|
|
2832
|
+
blocked: 'Blocked: the sweep could not run the case',
|
|
2833
|
+
skip: 'Skip: the request policy refuses the case',
|
|
2834
|
+
untested: 'Untested: the case waits in the sweep cursor',
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
function caseHeader(id) {
|
|
2838
|
+
if (id.startsWith('declared:')) {
|
|
2839
|
+
const code = id.slice('declared:'.length);
|
|
2840
|
+
return [code, `A declared error status. The cell shows the case that got ${code}.`];
|
|
2841
|
+
}
|
|
2842
|
+
return CASE_WORDS[id] ?? [id, id];
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
const LEGEND = { pass: '200', fail: '500', blocked: 'blocked', skip: 'skip', untested: 'untested' };
|
|
2846
|
+
const CELL_BADGE = { pass: 'pass', fail: 'fail', blocked: 'warn' };
|
|
2847
|
+
|
|
2848
|
+
/** The exchange behind one cell, as the server formats it, in a panel at the side of the matrix. */
|
|
2849
|
+
function renderCellExchange(key, cell) {
|
|
2850
|
+
const space = key.lastIndexOf(' ');
|
|
2851
|
+
const label = `${key.slice(0, space)} · ${caseHeader(key.slice(space + 1))[0]}`;
|
|
2852
|
+
const reason = cell.state === 'fail' ? `Failed checks: ${cell.reason}` : cell.reason;
|
|
2853
|
+
return el('aside', { class: 'drawer card', 'aria-label': 'The exchange of the cell' }, [
|
|
2854
|
+
el('div', { class: 'section-title' }, [
|
|
2855
|
+
el('h2', { class: 'mono', text: label }),
|
|
2856
|
+
el('span', { class: `badge ${CELL_BADGE[cell.state] ?? 'info'}`, text: cell.state }),
|
|
2857
|
+
el('button', {
|
|
2858
|
+
type: 'button',
|
|
2859
|
+
class: 'close',
|
|
2860
|
+
'aria-label': 'Close the exchange',
|
|
2861
|
+
text: '×',
|
|
2862
|
+
onclick: () => {
|
|
2863
|
+
state.selectedCell = null;
|
|
2864
|
+
render();
|
|
2865
|
+
},
|
|
2866
|
+
}),
|
|
2867
|
+
]),
|
|
2868
|
+
reason ? el('p', { class: 'muted', text: reason }) : null,
|
|
2869
|
+
captureFile(cell.exchange, label),
|
|
2870
|
+
]);
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
/** Coverage for each surface of the project: the endpoints of an API, the screens of a client app. */
|
|
2874
|
+
function renderCoverage() {
|
|
2875
|
+
const summary = state.summary;
|
|
2876
|
+
if (!summary.surfaces.length)
|
|
2877
|
+
return el('section', { class: 'card panel' }, [
|
|
2878
|
+
title('Coverage'),
|
|
2879
|
+
emptyState({
|
|
2880
|
+
heading: 'No config yet',
|
|
2881
|
+
why: 'Coverage shows what Bugpatrol tested: the screens of a client app and the endpoints of an API.',
|
|
2882
|
+
command: 'bugpatrol init',
|
|
2883
|
+
}),
|
|
2884
|
+
]);
|
|
2885
|
+
return el(
|
|
2886
|
+
'div',
|
|
2887
|
+
{ class: 'stack-gap' },
|
|
2888
|
+
summary.surfaces.map((surface) =>
|
|
2889
|
+
surface.kind === 'api' ? renderApiCoverage(surface) : renderScreenCoverage(surface),
|
|
2890
|
+
),
|
|
2891
|
+
);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function surfaceTitle(surface, aside) {
|
|
2895
|
+
return el('div', { class: 'section-title' }, [
|
|
2896
|
+
el('h2', {}, [
|
|
2897
|
+
surfaceName(surface),
|
|
2898
|
+
surface.configs[0]
|
|
2899
|
+
? el('span', { class: 'muted mono config-name', text: ` ${fileName(surface.configs[0])}` })
|
|
2900
|
+
: null,
|
|
2901
|
+
]),
|
|
2902
|
+
aside ? el('span', { class: 'muted', text: aside }) : null,
|
|
2903
|
+
]);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function renderScreenCoverage(surface) {
|
|
2907
|
+
const totals = state.summary.coverage.screens;
|
|
2908
|
+
const screens = (state.appmap?.screens ?? []).filter((screen) => !screen.virtual);
|
|
2909
|
+
if (!totals?.found)
|
|
2910
|
+
return el('section', { class: 'card panel' }, [
|
|
2911
|
+
surfaceTitle(surface),
|
|
2912
|
+
screenCoverageBlock(null, state.summary, surface),
|
|
2913
|
+
]);
|
|
2914
|
+
const open = state.issues.filter((issue) => !['fixed', 'dismissed'].includes(issue.status));
|
|
2915
|
+
const rows = screens
|
|
2916
|
+
.map((screen) => ({ screen, issues: open.filter((issue) => issue.screenId === screen.id).length }))
|
|
2917
|
+
.sort((a, b) => b.issues - a.issues || a.screen.name.localeCompare(b.screen.name));
|
|
2918
|
+
return el('section', { class: 'card panel' }, [
|
|
2919
|
+
surfaceTitle(surface, 'Screens that the explorer found'),
|
|
2920
|
+
statBand([
|
|
2921
|
+
{ label: 'Screens found', value: number(totals.found) },
|
|
2922
|
+
{ label: 'With a routine', value: number(totals.replayable), note: 'Bugpatrol can reach them again' },
|
|
2923
|
+
{
|
|
2924
|
+
label: 'With open issues',
|
|
2925
|
+
value: number(totals.withIssues),
|
|
2926
|
+
tone: totals.withIssues ? 'fail' : undefined,
|
|
2927
|
+
note: `${number(open.length)} open issues in all`,
|
|
2928
|
+
},
|
|
2929
|
+
]),
|
|
2930
|
+
el('table', { class: 'usage-table screen-table' }, [
|
|
2931
|
+
el(
|
|
2932
|
+
'tr',
|
|
2933
|
+
{},
|
|
2934
|
+
['Screen', 'Visits', 'Routine', 'Open issues', 'Last seen'].map((text) => el('th', { text })),
|
|
2935
|
+
),
|
|
2936
|
+
...rows.map(({ screen, issues }) =>
|
|
2937
|
+
el('tr', {}, [
|
|
2938
|
+
el('td', {}, [
|
|
2939
|
+
el('button', { type: 'button', class: 'link', text: screen.name, onclick: () => openScreen(screen.id) }),
|
|
2940
|
+
]),
|
|
2941
|
+
el('td', { text: number(screen.visits) }),
|
|
2942
|
+
el('td', { text: screen.routineId ? 'Yes' : 'No' }),
|
|
2943
|
+
el('td', {}, [
|
|
2944
|
+
issues
|
|
2945
|
+
? el('span', { class: 'issue-count', text: `${issues} open` })
|
|
2946
|
+
: el('span', { class: 'muted', text: '0' }),
|
|
2947
|
+
]),
|
|
2948
|
+
el('td', { text: relativeTime(screen.lastSeenAt) }),
|
|
2949
|
+
]),
|
|
2950
|
+
),
|
|
2951
|
+
]),
|
|
2952
|
+
]);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
/**
|
|
2956
|
+
* The rank of an endpoint: 0 with a failed case, 1 with a passed case, 2 with
|
|
2957
|
+
* a case that waits in the cursor, and 3 when the sweep skipped or blocked all of its cases.
|
|
2958
|
+
*/
|
|
2959
|
+
function endpointRank(endpoint, cases, cells) {
|
|
2960
|
+
let rank = 3;
|
|
2961
|
+
for (const id of cases) {
|
|
2962
|
+
const cell = cells[`${endpoint} ${id}`]?.state;
|
|
2963
|
+
if (cell === 'fail') return 0;
|
|
2964
|
+
if (cell === 'pass') rank = 1;
|
|
2965
|
+
if (cell === 'untested') rank = Math.min(rank, 2);
|
|
2966
|
+
}
|
|
2967
|
+
return rank;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
function renderApiCoverage(surface) {
|
|
2971
|
+
const totals = state.summary.coverage.api;
|
|
2972
|
+
const data = state.coverage;
|
|
2973
|
+
if (!totals || !data)
|
|
2974
|
+
return el('section', { class: 'card panel' }, [surfaceTitle(surface), apiCoverageBlock(undefined, surface)]);
|
|
2975
|
+
const { cases, cells } = data;
|
|
2976
|
+
const own = CELL_STATES.reduce((sum, name) => sum + totals.cases[name], 0);
|
|
2977
|
+
const ranks = new Map(data.endpoints.map((endpoint) => [endpoint, endpointRank(endpoint, cases, cells)]));
|
|
2978
|
+
const endpoints = [...data.endpoints].sort((a, b) => ranks.get(a) - ranks.get(b));
|
|
2979
|
+
const filter = state.coverageFilter;
|
|
2980
|
+
const tbody = el('tbody', {
|
|
2981
|
+
onclick: (event) => {
|
|
2982
|
+
const button = event.target.closest('button[data-key]');
|
|
2983
|
+
if (!button) return;
|
|
2984
|
+
const key = button.dataset.key;
|
|
2985
|
+
state.selectedCell = state.selectedCell === key ? null : key;
|
|
2986
|
+
render();
|
|
2987
|
+
},
|
|
2988
|
+
});
|
|
2989
|
+
const shown = el('span', { class: 'muted' });
|
|
2990
|
+
const fill = () => {
|
|
2991
|
+
const query = filter.query.trim().toLowerCase();
|
|
2992
|
+
const rows = endpoints.filter(
|
|
2993
|
+
(endpoint) =>
|
|
2994
|
+
(!filter.failedOnly || ranks.get(endpoint) === 0) &&
|
|
2995
|
+
(!filter.hideSkipped || ranks.get(endpoint) < 3) &&
|
|
2996
|
+
(!query || endpoint.toLowerCase().includes(query)),
|
|
2997
|
+
);
|
|
2998
|
+
tbody.replaceChildren(...rows.map((endpoint) => matrixRow(endpoint, cases, cells)));
|
|
2999
|
+
shown.textContent = `${number(rows.length)} of ${number(endpoints.length)} endpoints`;
|
|
3000
|
+
};
|
|
3001
|
+
fill();
|
|
3002
|
+
const declared = cases.filter((id) => id.startsWith('declared:')).length;
|
|
3003
|
+
const table = el('table', { class: `matrix${filter.hideSkipped ? ' hide-skipped' : ''}` }, [
|
|
3004
|
+
el('thead', {}, [
|
|
3005
|
+
// The declared columns repeat a case that got that status, so they get their own group.
|
|
3006
|
+
declared
|
|
3007
|
+
? el('tr', { class: 'matrix-groups' }, [
|
|
3008
|
+
el('th', {}),
|
|
3009
|
+
el('th', {}),
|
|
3010
|
+
el('th', { colspan: declared, class: 'declared', text: 'Declared error statuses' }),
|
|
3011
|
+
cases.length > declared + 1 ? el('th', { colspan: cases.length - declared - 1, text: 'Cases' }) : null,
|
|
3012
|
+
])
|
|
3013
|
+
: null,
|
|
3014
|
+
el('tr', {}, [
|
|
3015
|
+
el('th', { text: 'Endpoint' }),
|
|
3016
|
+
...cases.map((id) => {
|
|
3017
|
+
const [label, tip] = caseHeader(id);
|
|
3018
|
+
return el('th', { class: id.startsWith('declared:') ? 'declared' : '', title: tip, text: label });
|
|
3019
|
+
}),
|
|
3020
|
+
]),
|
|
3021
|
+
]),
|
|
3022
|
+
tbody,
|
|
3023
|
+
]);
|
|
3024
|
+
const toggle = (key, label) =>
|
|
3025
|
+
el('label', { class: 'toggle' }, [
|
|
3026
|
+
el('input', {
|
|
3027
|
+
type: 'checkbox',
|
|
3028
|
+
...(filter[key] ? { checked: '' } : {}),
|
|
3029
|
+
onchange: (event) => {
|
|
3030
|
+
filter[key] = event.target.checked;
|
|
3031
|
+
table.classList.toggle('hide-skipped', filter.hideSkipped);
|
|
3032
|
+
fill();
|
|
3033
|
+
},
|
|
3034
|
+
}),
|
|
3035
|
+
el('span', { text: label }),
|
|
3036
|
+
]);
|
|
3037
|
+
const selected = state.selectedCell && cells[state.selectedCell];
|
|
3038
|
+
return el('section', { class: 'card panel' }, [
|
|
3039
|
+
surfaceTitle(surface, 'Endpoint × case, from the sweep'),
|
|
3040
|
+
statBand([
|
|
3041
|
+
{
|
|
3042
|
+
label: 'Endpoints tested',
|
|
3043
|
+
value: percent(totals.tested, totals.endpoints),
|
|
3044
|
+
note: `${number(totals.tested)} of ${number(totals.endpoints)}`,
|
|
3045
|
+
},
|
|
3046
|
+
{
|
|
3047
|
+
label: 'All cases',
|
|
3048
|
+
value: number(own),
|
|
3049
|
+
bar: caseBar(totals.cases),
|
|
3050
|
+
note: `${number(totals.cases.pass)} pass · ${number(totals.cases.fail)} fail`,
|
|
3051
|
+
},
|
|
3052
|
+
{
|
|
3053
|
+
label: 'Failed',
|
|
3054
|
+
value: number(totals.cases.fail),
|
|
3055
|
+
tone: totals.cases.fail ? 'fail' : undefined,
|
|
3056
|
+
note: `${percent(totals.cases.fail, totals.cases.pass + totals.cases.fail)} of the cases that ran`,
|
|
3057
|
+
},
|
|
3058
|
+
{
|
|
3059
|
+
label: 'Blocked · skipped',
|
|
3060
|
+
value: `${number(totals.cases.blocked)} · ${number(totals.cases.skip)}`,
|
|
3061
|
+
note: 'The sweep could not run them, or the policy refuses them',
|
|
3062
|
+
},
|
|
3063
|
+
{
|
|
3064
|
+
label: 'Wait in the cursor',
|
|
3065
|
+
value: number(totals.remaining),
|
|
3066
|
+
note: totals.remaining ? 'The next sweep runs them first' : 'This pass is complete',
|
|
3067
|
+
},
|
|
3068
|
+
]),
|
|
3069
|
+
el('div', { class: 'matrix-tools' }, [
|
|
3070
|
+
el('input', {
|
|
3071
|
+
type: 'search',
|
|
3072
|
+
class: 'search',
|
|
3073
|
+
placeholder: 'Search the paths',
|
|
3074
|
+
'aria-label': 'Search the endpoint paths',
|
|
3075
|
+
value: filter.query,
|
|
3076
|
+
oninput: (event) => {
|
|
3077
|
+
filter.query = event.target.value;
|
|
3078
|
+
fill();
|
|
3079
|
+
},
|
|
3080
|
+
}),
|
|
3081
|
+
toggle('failedOnly', 'Failed only'),
|
|
3082
|
+
toggle('hideSkipped', 'Hide skipped and blocked'),
|
|
3083
|
+
shown,
|
|
3084
|
+
el('div', { class: 'matrix-legend' }, [
|
|
3085
|
+
...Object.entries(LEGEND).map(([name, sample]) =>
|
|
3086
|
+
el('span', {}, [el('span', { class: `matrix-cell ${name}`, text: sample }), name]),
|
|
3087
|
+
),
|
|
3088
|
+
el('span', { text: 'Blank: the sweep makes no such case.' }),
|
|
3089
|
+
]),
|
|
3090
|
+
]),
|
|
3091
|
+
el('div', { class: 'matrix-scroll', 'data-keep-scroll': '' }, [table]),
|
|
3092
|
+
selected ? renderCellExchange(state.selectedCell, selected) : null,
|
|
3093
|
+
]);
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
function matrixRow(endpoint, cases, cells) {
|
|
3097
|
+
const space = endpoint.indexOf(' ');
|
|
3098
|
+
return el('tr', {}, [
|
|
3099
|
+
el('th', { scope: 'row' }, [
|
|
3100
|
+
el('span', { class: 'method', text: endpoint.slice(0, space) }),
|
|
3101
|
+
el('code', { text: endpoint.slice(space + 1) }),
|
|
3102
|
+
]),
|
|
3103
|
+
...cases.map((id) => {
|
|
3104
|
+
const key = `${endpoint} ${id}`;
|
|
3105
|
+
const cell = cells[key];
|
|
3106
|
+
if (!cell) return el('td', { class: 'matrix-none', title: 'The sweep makes no such case for this endpoint' });
|
|
3107
|
+
const tip = [CELL_WORDS[cell.state], cell.reason, cell.at ? relativeTime(cell.at) : ''].filter(Boolean);
|
|
3108
|
+
const props = {
|
|
3109
|
+
class: `matrix-cell ${cell.state}${state.selectedCell === key ? ' active' : ''}`,
|
|
3110
|
+
text: cell.status ?? cell.state,
|
|
3111
|
+
};
|
|
3112
|
+
return el('td', { class: `cell-${cell.state}` }, [
|
|
3113
|
+
cell.exchange
|
|
3114
|
+
? el('button', {
|
|
3115
|
+
...props,
|
|
3116
|
+
type: 'button',
|
|
3117
|
+
'data-key': key,
|
|
3118
|
+
title: [...tip, 'Click to open the exchange'].join('\n'),
|
|
3119
|
+
})
|
|
3120
|
+
: el('span', { ...props, title: tip.join('\n') }),
|
|
3121
|
+
]);
|
|
3122
|
+
}),
|
|
3123
|
+
]);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
1953
3126
|
// ---------------------------------------------------------------- lightbox
|
|
1954
3127
|
|
|
1955
3128
|
const lightbox = document.getElementById('lightbox');
|
|
@@ -1958,7 +3131,7 @@ const lightboxCaption = document.getElementById('lightbox-caption');
|
|
|
1958
3131
|
|
|
1959
3132
|
function openLightbox(path, caption) {
|
|
1960
3133
|
lightboxImg.src = artifact(path);
|
|
1961
|
-
lightboxCaption.textContent = `${caption}
|
|
3134
|
+
lightboxCaption.textContent = `${caption}: ${path}`;
|
|
1962
3135
|
lightbox.hidden = false;
|
|
1963
3136
|
}
|
|
1964
3137
|
lightbox.addEventListener('click', () => {
|
|
@@ -1975,6 +3148,7 @@ tabs.addEventListener('click', async (event) => {
|
|
|
1975
3148
|
if (!target) return;
|
|
1976
3149
|
state.view = target.dataset.view;
|
|
1977
3150
|
if (state.view === 'screens') state.selectedScreenId = null;
|
|
3151
|
+
if (state.view === 'coverage') state.selectedCell = null;
|
|
1978
3152
|
await refresh();
|
|
1979
3153
|
});
|
|
1980
3154
|
|