sloppatrol 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -160
- package/dist/census/index.js +4 -2
- package/dist/census/index.js.map +1 -1
- package/dist/census/lib/guardrails.js +38 -9
- package/dist/census/lib/guardrails.js.map +1 -1
- package/dist/census/lib/stack.d.ts +2 -1
- package/dist/census/lib/stack.js +8 -4
- package/dist/census/lib/stack.js.map +1 -1
- package/dist/cli/index.js +20 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/lib/browser.d.ts +2 -0
- package/dist/cli/lib/browser.js +13 -0
- package/dist/cli/lib/browser.js.map +1 -0
- package/dist/cli/lib/usage.d.ts +1 -1
- package/dist/cli/lib/usage.js +4 -3
- package/dist/cli/lib/usage.js.map +1 -1
- package/dist/config/lib/schema.d.ts +30 -0
- package/dist/config/lib/schema.js +16 -0
- package/dist/config/lib/schema.js.map +1 -1
- package/dist/dashboard/index.d.ts +4 -0
- package/dist/dashboard/index.js +6 -2
- package/dist/dashboard/index.js.map +1 -1
- package/dist/dashboard/lib/snapshot.d.ts +168 -30
- package/dist/dashboard/lib/snapshot.js +104 -37
- package/dist/dashboard/lib/snapshot.js.map +1 -1
- package/dist/dashboard/lib/summary.d.ts +230 -0
- package/dist/dashboard/lib/summary.js +398 -0
- package/dist/dashboard/lib/summary.js.map +1 -0
- package/dist/domain/index.d.ts +2 -0
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/domain/lib/cycle.d.ts +17 -0
- package/dist/domain/lib/cycle.js +2 -0
- package/dist/domain/lib/cycle.js.map +1 -0
- package/dist/{patrol → domain}/lib/pick.d.ts +2 -1
- package/dist/{patrol → domain}/lib/pick.js.map +1 -1
- package/dist/domain/lib/refactor.d.ts +2 -0
- package/dist/gate/index.d.ts +7 -0
- package/dist/gate/index.js +15 -0
- package/dist/gate/index.js.map +1 -1
- package/dist/patrol/lib/attempt.js +10 -2
- package/dist/patrol/lib/attempt.js.map +1 -1
- package/dist/patrol/lib/base.d.ts +10 -3
- package/dist/patrol/lib/base.js +14 -8
- package/dist/patrol/lib/base.js.map +1 -1
- package/dist/patrol/lib/cycle.d.ts +1 -1
- package/dist/patrol/lib/cycle.js +32 -1
- package/dist/patrol/lib/cycle.js.map +1 -1
- package/dist/patrol/lib/recover.d.ts +2 -1
- package/dist/patrol/lib/recover.js +16 -1
- package/dist/patrol/lib/recover.js.map +1 -1
- package/dist/patrol/lib/scorecard.js +12 -4
- package/dist/patrol/lib/scorecard.js.map +1 -1
- package/dist/patrol/lib/steps.d.ts +5 -2
- package/dist/patrol/lib/steps.js +9 -6
- package/dist/patrol/lib/steps.js.map +1 -1
- package/dist/playbook/index.js +3 -3
- package/dist/roles/lib/prompts.d.ts +1 -0
- package/dist/roles/lib/prompts.js +6 -0
- package/dist/roles/lib/prompts.js.map +1 -1
- package/dist/roles/lib/refactorer.d.ts +1 -0
- package/dist/roles/lib/refactorer.js +24 -6
- package/dist/roles/lib/refactorer.js.map +1 -1
- package/dist/roles/lib/scorecard-text.js +1 -1
- package/dist/roles/lib/scorecard-text.js.map +1 -1
- package/dist/store/index.d.ts +4 -1
- package/dist/store/index.js +7 -0
- package/dist/store/index.js.map +1 -1
- package/package.json +1 -1
- package/ui/app.js +1236 -324
- package/ui/index.html +35 -13
- package/ui/style.css +1063 -340
- /package/dist/{patrol → domain}/lib/pick.js +0 -0
package/ui/app.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// The Sloppatrol dashboard: reads /api/state every two seconds and renders
|
|
2
|
-
// the
|
|
3
|
-
// the agent sessions. Read-only.
|
|
2
|
+
// the overview, the refactors with their gate, the scorecard, the check
|
|
3
|
+
// runs, the backlog and the agent sessions. Read-only. The server computes
|
|
4
|
+
// every number once (`state.summary`); the pages only show them.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
/** The pages. `share` has no tab: it is the scorecard as a card to post. */
|
|
7
|
+
const TABS = ['overview', 'refactors', 'scorecard', 'checks', 'backlog', 'sessions', 'share'];
|
|
8
|
+
const ROLES = [
|
|
6
9
|
['patrol', 'Patrol'],
|
|
7
10
|
['surveyor', 'Surveyor'],
|
|
8
11
|
['refactorer', 'Refactorer'],
|
|
@@ -10,212 +13,559 @@ const LANES = [
|
|
|
10
13
|
['gate', 'Gate'],
|
|
11
14
|
['reviewer', 'Reviewer'],
|
|
12
15
|
];
|
|
13
|
-
const OPPORTUNITY_FILTERS = ['open', 'verified', 'proposed', 'done', 'exhausted', 'dismissed', 'all'];
|
|
14
16
|
|
|
15
17
|
let state;
|
|
16
|
-
let selection; // { type: 'refactor' | 'check' | 'opportunity' | 'session', id }
|
|
17
|
-
|
|
18
|
+
let selection; // { type: 'refactor' | 'check' | 'opportunity' | 'session' | 'unit', id }
|
|
19
|
+
const filters = { refactors: 'all', scorecard: 'all', checks: 'all', backlog: 'open', sessions: 'all' };
|
|
20
|
+
/** The groups a person closed, by id, so a refresh keeps them closed. */
|
|
21
|
+
const closed = new Set(['refactors:merged', 'refactors:closed']);
|
|
18
22
|
const fetched = new Map(); // full check runs and session events, by key
|
|
19
23
|
|
|
20
24
|
const $ = (id) => document.getElementById(id);
|
|
21
25
|
|
|
26
|
+
// ---------- theme ----------
|
|
27
|
+
// The choice cycles system → light → dark. "System" follows the OS setting, also when it changes.
|
|
28
|
+
|
|
29
|
+
const THEMES = ['system', 'light', 'dark'];
|
|
30
|
+
const THEME_WORDS = { system: 'System', light: 'Light', dark: 'Dark' };
|
|
31
|
+
const darkQuery = matchMedia('(prefers-color-scheme: dark)');
|
|
32
|
+
const ICONS = {
|
|
33
|
+
system: '<rect x="2" y="3" width="12" height="8" rx="1"/><path d="M6 14h4M8 11v3"/>',
|
|
34
|
+
light:
|
|
35
|
+
'<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"/>',
|
|
36
|
+
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"/>',
|
|
37
|
+
external: '<path d="M9 3h4v4M13 3 7.5 8.5M11 9.5V13H3V5h3.5"/>',
|
|
38
|
+
};
|
|
39
|
+
let themeChoice = THEMES.includes(localStorage.getItem('sloppatrol-theme'))
|
|
40
|
+
? localStorage.getItem('sloppatrol-theme')
|
|
41
|
+
: 'system';
|
|
42
|
+
|
|
43
|
+
function icon(name) {
|
|
44
|
+
return `<svg viewBox="0 0 16 16" aria-hidden="true">${ICONS[name]}</svg>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyTheme() {
|
|
48
|
+
const theme = themeChoice === 'system' ? (darkQuery.matches ? 'dark' : 'light') : themeChoice;
|
|
49
|
+
document.documentElement.dataset.theme = theme;
|
|
50
|
+
const next = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
|
|
51
|
+
const button = $('theme');
|
|
52
|
+
button.innerHTML = icon(themeChoice);
|
|
53
|
+
button.setAttribute('aria-label', `Theme: ${THEME_WORDS[themeChoice]}. Change the theme to ${THEME_WORDS[next]}.`);
|
|
54
|
+
button.title = `Theme: ${THEME_WORDS[themeChoice]}. Click for ${THEME_WORDS[next]}.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
$('theme').addEventListener('click', () => {
|
|
58
|
+
themeChoice = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
|
|
59
|
+
if (themeChoice === 'system') localStorage.removeItem('sloppatrol-theme');
|
|
60
|
+
else localStorage.setItem('sloppatrol-theme', themeChoice);
|
|
61
|
+
applyTheme();
|
|
62
|
+
});
|
|
63
|
+
darkQuery.addEventListener('change', applyTheme);
|
|
64
|
+
applyTheme();
|
|
65
|
+
|
|
66
|
+
// ---------- words and numbers ----------
|
|
67
|
+
|
|
22
68
|
function esc(value) {
|
|
23
69
|
return String(value ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
24
70
|
}
|
|
25
71
|
|
|
72
|
+
const number = (n) => Number(n ?? 0).toLocaleString('en-US');
|
|
73
|
+
|
|
74
|
+
function plural(n, word, many = `${word}s`) {
|
|
75
|
+
return `${number(n)} ${n === 1 ? word : many}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function capital(text) {
|
|
79
|
+
return text ? text.charAt(0).toUpperCase() + text.slice(1) : '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A coarse age, so the page does not change each second. */
|
|
26
83
|
function ago(iso) {
|
|
27
84
|
if (!iso) return '';
|
|
28
85
|
const s = Math.max(0, (Date.parse(state?.now ?? Date.now()) - Date.parse(iso)) / 1000);
|
|
29
|
-
if (s < 60) return
|
|
86
|
+
if (s < 60) return 'now';
|
|
30
87
|
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
31
88
|
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
|
32
89
|
return `${Math.round(s / 86400)} d ago`;
|
|
33
90
|
}
|
|
34
91
|
|
|
35
|
-
function
|
|
36
|
-
|
|
92
|
+
function duration(ms) {
|
|
93
|
+
if (ms === undefined || Number.isNaN(ms)) return '';
|
|
94
|
+
if (ms < 1000) return `${ms} ms`;
|
|
95
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
|
96
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ${Math.round((ms % 60_000) / 1000)} s`;
|
|
97
|
+
return `${Math.floor(ms / 3_600_000)} h ${Math.round((ms % 3_600_000) / 60_000)} min`;
|
|
37
98
|
}
|
|
38
99
|
|
|
39
100
|
function clock(iso) {
|
|
40
101
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
41
102
|
}
|
|
42
103
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
104
|
+
const money = (usd) => `$${Number(usd ?? 0).toFixed(2)}`;
|
|
105
|
+
const short = (sha) => (sha ? sha.slice(0, 10) : '');
|
|
106
|
+
|
|
107
|
+
// ---------- shared blocks ----------
|
|
108
|
+
|
|
109
|
+
/** A status chip. The word carries the meaning; the color repeats it. */
|
|
110
|
+
function chip(text, tone = '') {
|
|
111
|
+
return `<span class="chip ${tone}"><span class="dot" aria-hidden="true"></span>${esc(text)}</span>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function tag(text) {
|
|
115
|
+
return `<span class="chip kind">${esc(text)}</span>`;
|
|
49
116
|
}
|
|
50
117
|
|
|
51
|
-
|
|
52
|
-
|
|
118
|
+
/** A row of numbers. A stat with a target is a button that opens its page. */
|
|
119
|
+
function statBand(stats) {
|
|
120
|
+
return `<div class="band">${stats
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.map(({ label, value, note, tone, go }) => {
|
|
123
|
+
const body = `<span class="stat-label">${esc(label)}</span>
|
|
124
|
+
<strong class="stat-value">${value}</strong>
|
|
125
|
+
${note ? `<span class="stat-note" title="${esc(note)}">${esc(note)}</span>` : ''}`;
|
|
126
|
+
const cls = `stat${tone ? ` tone-${tone}` : ''}`;
|
|
127
|
+
return go
|
|
128
|
+
? `<button type="button" class="${cls}" data-go="${esc(go)}">${body}</button>`
|
|
129
|
+
: `<div class="${cls}">${body}</div>`;
|
|
130
|
+
})
|
|
131
|
+
.join('')}</div>`;
|
|
53
132
|
}
|
|
54
133
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
134
|
+
function commandBox(command) {
|
|
135
|
+
return `<div class="command"><code>${esc(command)}</code><button type="button" class="copy" data-copy="${esc(command)}" aria-label="Copy the command ${esc(command)}">Copy</button></div>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The one empty state of the dashboard: what the block shows, why it is
|
|
140
|
+
* empty now, and the one next step as a command.
|
|
141
|
+
*/
|
|
142
|
+
function emptyState({ title, why, command, compact = false }) {
|
|
143
|
+
return `<div class="empty-state${compact ? ' compact' : ''}">
|
|
144
|
+
<h3>${esc(title)}</h3>
|
|
145
|
+
<p>${esc(why)}</p>
|
|
146
|
+
${command ? commandBox(command) : ''}
|
|
147
|
+
</div>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function card(title, aside, body, cls = '') {
|
|
151
|
+
return `<section class="card ${cls}">
|
|
152
|
+
<div class="card-head"><h2>${esc(title)}</h2>${aside ? `<span class="muted">${aside}</span>` : ''}</div>
|
|
153
|
+
${body}
|
|
154
|
+
</section>`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A group of rows that a person can close. */
|
|
158
|
+
function group(id, title, count, body, tone = '') {
|
|
159
|
+
const open = !closed.has(id);
|
|
160
|
+
return `<details class="group" data-group="${esc(id)}" ${open ? 'open' : ''}>
|
|
161
|
+
<summary><span class="group-title ${tone}">${esc(title)}</span><span class="count">${number(count)}</span></summary>
|
|
162
|
+
<div class="list">${body}</div>
|
|
163
|
+
</details>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Filter buttons with counts. A button with no item is still there, so the set stays the same. */
|
|
167
|
+
function filterBar(page, options) {
|
|
168
|
+
return `<div class="filters" role="group" aria-label="Filter">${options
|
|
169
|
+
.map(
|
|
170
|
+
({ id, label, count }) =>
|
|
171
|
+
`<button type="button" data-filter="${page}:${id}" aria-pressed="${filters[page] === id}" class="${filters[page] === id ? 'active' : ''}">${esc(label)}${count === undefined ? '' : ` <span class="count">${number(count)}</span>`}</button>`,
|
|
172
|
+
)
|
|
173
|
+
.join('')}</div>`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sparkline(values, max = 100) {
|
|
177
|
+
if (values.length < 2) return '';
|
|
178
|
+
const w = 96;
|
|
179
|
+
const h = 24;
|
|
180
|
+
const points = values
|
|
181
|
+
.map((v, i) => `${((i / (values.length - 1)) * w).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`)
|
|
182
|
+
.join(' ');
|
|
183
|
+
return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${points}" /></svg>`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function delta(n, unit = '') {
|
|
187
|
+
if (n === undefined) return '';
|
|
188
|
+
const cls = n > 0 ? 'up' : n < 0 ? 'down' : '';
|
|
189
|
+
const word = n > 0 ? 'up' : n < 0 ? 'down' : 'no change';
|
|
190
|
+
return `<span class="delta ${cls}" title="${word} since the scorecard before">${n > 0 ? '▲' : n < 0 ? '▼' : '±'}${Math.abs(n)}${unit}</span>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The colour band of a 0-100 score. */
|
|
194
|
+
function band(pct) {
|
|
195
|
+
return pct < 40 ? 'low' : pct < 70 ? 'mid' : 'high';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const pct = (score) => Math.round((score / 4) * 100);
|
|
199
|
+
|
|
200
|
+
function bar(parts) {
|
|
201
|
+
const total = parts.reduce((sum, p) => sum + p.value, 0);
|
|
202
|
+
if (!total) return '';
|
|
203
|
+
return `<div class="bar" role="img" aria-label="${esc(parts.map((p) => `${p.value} ${p.label}`).join(', '))}">${parts
|
|
204
|
+
.filter((p) => p.value)
|
|
205
|
+
.map(
|
|
206
|
+
(p) => `<span class="bar-${p.tone}" style="flex-grow:${p.value}" title="${esc(`${p.value} ${p.label}`)}"></span>`,
|
|
207
|
+
)
|
|
208
|
+
.join('')}</div>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------- lookups ----------
|
|
212
|
+
|
|
213
|
+
const opportunityById = (id) => state.opportunities.find((o) => o.id === id);
|
|
214
|
+
const refactorById = (id) => state.refactors.find((r) => r.id === id);
|
|
215
|
+
|
|
216
|
+
/** What a session works on, in words. */
|
|
217
|
+
function subjectWords(subject) {
|
|
218
|
+
if (!subject) return '';
|
|
219
|
+
const [kind, id] = subject.split(':');
|
|
220
|
+
if (kind === 'refactor') return refactorById(id)?.title ?? `Refactor ${id}`;
|
|
221
|
+
if (kind === 'survey') return `Survey of ${short(id)}`;
|
|
222
|
+
if (kind === 'score') return `Scores of ${short(id)}`;
|
|
223
|
+
return subject;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const OUTCOME = {
|
|
227
|
+
working: ['in progress', 'run'],
|
|
228
|
+
waits: ['waits for you', 'warn'],
|
|
229
|
+
rejected: ['rejected', 'bad'],
|
|
230
|
+
error: ['error', 'bad'],
|
|
231
|
+
interrupted: ['interrupted', 'stop'],
|
|
232
|
+
merged: ['merged', 'ok'],
|
|
233
|
+
closed: ['closed', ''],
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
function outcomeChip(r) {
|
|
237
|
+
if (r.status === 'verified') return chip('to publish', 'warn');
|
|
238
|
+
if (r.status === 'published') return chip('pull request open', 'warn');
|
|
239
|
+
const [text, tone] = OUTCOME[r.outcome];
|
|
240
|
+
return chip(text, tone);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function stepper(r) {
|
|
244
|
+
const marks = { ok: '✓', bad: '✗', run: '•', wait: '…', '': '' };
|
|
245
|
+
const words = { ok: 'passed', bad: 'failed', run: 'running', wait: 'waits', '': 'not reached' };
|
|
246
|
+
return `<ol class="steps" aria-label="Pipeline">${r.steps
|
|
247
|
+
.map(
|
|
248
|
+
(s) =>
|
|
249
|
+
`<li class="step ${s.state}"><span class="mark" aria-hidden="true">${marks[s.state]}</span>${esc(s.label)}<span class="sr-only">: ${words[s.state]}</span></li>`,
|
|
250
|
+
)
|
|
251
|
+
.join('')}</ol>`;
|
|
60
252
|
}
|
|
61
253
|
|
|
62
|
-
// ----------
|
|
254
|
+
// ---------- top bar ----------
|
|
63
255
|
|
|
64
256
|
function renderTop() {
|
|
65
257
|
$('project').textContent = state.project;
|
|
66
|
-
const
|
|
258
|
+
const a = state.summary.activity;
|
|
259
|
+
const tone = { running: 'run', waiting: 'ok', stopped: '' }[a.state];
|
|
260
|
+
const title = a.state === 'stopped' ? 'No Sloppatrol process runs now. Start a patrol with: sloppatrol' : a.detail;
|
|
67
261
|
setHtml(
|
|
68
|
-
$('
|
|
69
|
-
|
|
70
|
-
p.lastSha ? `<span>main <code>${esc(p.lastSha.slice(0, 10))}</code></span>` : '',
|
|
71
|
-
`<span>${p.cycles} cycle${p.cycles === 1 ? '' : 's'}</span>`,
|
|
72
|
-
p.lastCycleAt ? `<span>last cycle ${ago(p.lastCycleAt)}</span>` : '<span>no cycle yet</span>',
|
|
73
|
-
].join(''),
|
|
262
|
+
$('activity'),
|
|
263
|
+
`<span class="chip ${tone}" title="${esc(title)}"><span class="dot" aria-hidden="true"></span>${esc(a.text)}</span>`,
|
|
74
264
|
);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------- overview ----------
|
|
268
|
+
|
|
269
|
+
function confidenceBand() {
|
|
270
|
+
const s = state.summary;
|
|
271
|
+
const main = s.main;
|
|
272
|
+
const waits = s.decisions.toPublish + s.decisions.toReview;
|
|
273
|
+
const q = s.quality;
|
|
274
|
+
const last = s.cycles.last;
|
|
275
|
+
return statBand([
|
|
276
|
+
{ label: 'Status', value: esc(s.verdict.text), note: s.verdict.detail, tone: s.verdict.tone },
|
|
277
|
+
{
|
|
278
|
+
label: 'Checks on main',
|
|
279
|
+
value: main ? (main.state === 'passed' ? 'Pass' : main.state === 'failed' ? 'Fail' : 'Running') : '–',
|
|
280
|
+
note: main
|
|
281
|
+
? main.failed
|
|
282
|
+
? `${main.failed} fails on ${short(main.sha)}`
|
|
283
|
+
: `On ${short(main.sha)}, ${ago(main.at)}`
|
|
284
|
+
: 'No run yet',
|
|
285
|
+
tone: main?.state === 'failed' ? 'fail' : main?.state === 'passed' ? 'pass' : undefined,
|
|
286
|
+
go: 'checks:main',
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
label: 'Waits for you',
|
|
290
|
+
value: number(waits),
|
|
291
|
+
note: waits
|
|
292
|
+
? [
|
|
293
|
+
s.decisions.toPublish && `${s.decisions.toPublish} to publish`,
|
|
294
|
+
s.decisions.toReview && `${plural(s.decisions.toReview, 'pull request')} to review`,
|
|
295
|
+
]
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
.join(' · ')
|
|
298
|
+
: 'No decision waits',
|
|
299
|
+
tone: waits ? 'warn' : undefined,
|
|
300
|
+
go: 'refactors:waits',
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
label: 'Backlog',
|
|
304
|
+
value: number(s.backlog.open),
|
|
305
|
+
note: s.backlog.open
|
|
306
|
+
? main?.state === 'failed'
|
|
307
|
+
? 'None can start while main fails'
|
|
308
|
+
: `${s.backlog.ready.length} ready · ${s.backlog.byStrength.strong} strong`
|
|
309
|
+
: 'No open opportunity',
|
|
310
|
+
go: 'backlog:open',
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
label: 'Code quality',
|
|
314
|
+
value: q?.overall !== undefined ? `${q.overall}% ${delta(q.delta)}` : '–',
|
|
315
|
+
note: q ? `${number(q.coverage.files)} of ${plural(q.coverage.totalFiles, 'file')} scored` : 'No scorecard yet',
|
|
316
|
+
go: 'scorecard',
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
label: 'Last cycle',
|
|
320
|
+
value: last ? esc(ago(last.at)) : '–',
|
|
321
|
+
note: last ? last.text : `${plural(s.cycles.count, 'cycle')}`,
|
|
322
|
+
tone: last?.tone === 'fail' ? 'fail' : undefined,
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function attentionItem(item) {
|
|
328
|
+
const links = (item.links ?? [])
|
|
329
|
+
.map(
|
|
330
|
+
(l) =>
|
|
331
|
+
`<li><button type="button" class="link" data-select="${esc(l.select)}">${esc(l.label)}</button>${l.href ? ` <a class="external" href="${esc(l.href)}" target="_blank" rel="noreferrer" aria-label="Open the pull request on GitHub" title="Open the pull request on GitHub">${icon('external')}</a>` : ''}</li>`,
|
|
332
|
+
)
|
|
333
|
+
.join('');
|
|
334
|
+
const target = item.target ? `${item.target.tab}${item.target.filter ? `:${item.target.filter}` : ''}` : '';
|
|
335
|
+
return `<li class="attention ${item.tone}">
|
|
336
|
+
<div class="attention-head">
|
|
337
|
+
<strong>${esc(item.title)}</strong>
|
|
338
|
+
${target && !item.target.select ? `<button type="button" class="link" data-go="${esc(target)}">Show</button>` : ''}
|
|
339
|
+
${item.target?.select ? `<button type="button" class="link" data-select="${esc(item.target.select)}">Show</button>` : ''}
|
|
340
|
+
</div>
|
|
341
|
+
<p>${esc(item.detail)}</p>
|
|
342
|
+
${links ? `<ul class="links">${links}</ul>` : ''}
|
|
343
|
+
${item.command ? commandBox(item.command) : ''}
|
|
344
|
+
</li>`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function agentsCard() {
|
|
348
|
+
const byRole = Object.fromEntries(state.statuses.map((s) => [s.role, s]));
|
|
349
|
+
const rows = ROLES.map(([role, label]) => {
|
|
350
|
+
const s = byRole[role];
|
|
351
|
+
const word = !s ? 'not started' : s.stale ? 'stopped' : s.state;
|
|
352
|
+
const tone = { running: 'run', done: 'ok', failed: 'bad', stopped: 'stop' }[word] ?? '';
|
|
353
|
+
const body = `<span class="agent-name">${esc(label)}</span>${chip(word, tone)}
|
|
354
|
+
<span class="agent-detail">${esc(s?.stale ? 'Stopped without an end. The next patrol cleans up.' : (s?.detail ?? 'No work yet.'))}</span>
|
|
355
|
+
<span class="agent-time">${s ? ago(s.updatedAt) : ''}</span>`;
|
|
356
|
+
return s?.sessionId
|
|
357
|
+
? `<button type="button" class="agent" data-select="session:${esc(s.sessionId)}" aria-label="${esc(`${label}: ${word}. Open its session.`)}">${body}</button>`
|
|
358
|
+
: `<div class="agent">${body}</div>`;
|
|
359
|
+
}).join('');
|
|
360
|
+
return card('Agents', '', `<div class="agents">${rows}</div>`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function qualityCard() {
|
|
364
|
+
const q = state.summary.quality;
|
|
365
|
+
if (!q)
|
|
366
|
+
return card(
|
|
367
|
+
'Code quality',
|
|
368
|
+
'',
|
|
369
|
+
emptyState({
|
|
370
|
+
title: 'No scorecard yet',
|
|
371
|
+
why: 'The assessor scores the files of main in each cycle.',
|
|
372
|
+
command: 'sloppatrol score',
|
|
373
|
+
compact: true,
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
const rows = q.metrics
|
|
377
|
+
.map(
|
|
378
|
+
(m) => `<li><span>${esc(m.name)}</span>
|
|
379
|
+
<span class="meter" aria-hidden="true"><span class="${band(m.value)}" style="width:${m.value}%"></span></span>
|
|
380
|
+
<span class="num">${m.value}%</span>${delta(m.delta) || '<span></span>'}</li>`,
|
|
381
|
+
)
|
|
382
|
+
.join('');
|
|
383
|
+
return card(
|
|
384
|
+
'Code quality',
|
|
385
|
+
`<button type="button" class="link" data-go="scorecard">Scorecard</button>`,
|
|
386
|
+
`<ul class="meters">${rows}</ul>
|
|
387
|
+
<p class="muted small">${number(q.coverage.files)} of ${plural(q.coverage.totalFiles, 'file')} scored (${q.coverage.share}% of the lines) on ${short(q.sha)}.</p>`,
|
|
87
388
|
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function outcomesCard() {
|
|
392
|
+
const r = state.summary.refactors;
|
|
393
|
+
if (!r.total) return card('Refactor results', '', emptyState({ ...refactorsEmpty(), compact: true }));
|
|
394
|
+
const parts = [
|
|
395
|
+
{ label: 'merged', value: r.merged, tone: 'ok' },
|
|
396
|
+
{ label: 'wait for you', value: r.waits, tone: 'warn' },
|
|
397
|
+
{ label: 'closed', value: r.closed, tone: 'idle' },
|
|
398
|
+
{ label: 'rejected by the gate', value: r.rejected, tone: 'bad' },
|
|
399
|
+
{ label: 'stopped with an error', value: r.error, tone: 'stop' },
|
|
400
|
+
];
|
|
401
|
+
const steps = r.rejectedBy
|
|
402
|
+
.map((s) => `<li><span>Rejected at ${esc(s.label)}</span><span class="num">${number(s.count)}</span></li>`)
|
|
403
|
+
.join('');
|
|
404
|
+
return card(
|
|
405
|
+
'Refactor results',
|
|
406
|
+
`<button type="button" class="link" data-go="refactors">Refactors</button>`,
|
|
407
|
+
`<p class="big">${r.keptRate === undefined ? '–' : `${r.keptRate}%`} <span class="muted">of judged refactors kept (${number(r.kept)} of ${number(r.judged)})</span></p>
|
|
408
|
+
${bar(parts)}
|
|
409
|
+
<div class="legend">${parts.map((p) => `<span><i class="bar-${p.tone}" aria-hidden="true"></i>${number(p.value)} ${esc(p.label)}</span>`).join('')}</div>
|
|
410
|
+
${steps ? `<ul class="tally">${steps}</ul>` : ''}`,
|
|
104
411
|
);
|
|
105
412
|
}
|
|
106
413
|
|
|
414
|
+
function renderOverview() {
|
|
415
|
+
const s = state.summary;
|
|
416
|
+
if (s.verdict.text === 'Not started')
|
|
417
|
+
return `${confidenceBand()}<div class="card">${emptyState({
|
|
418
|
+
title: 'Sloppatrol has not run on this repo yet',
|
|
419
|
+
why: 'Run one cycle. It runs your checks on main, scores the code, records the first opportunities, and tries the best one. If you are not sure that the setup is complete, run sloppatrol doctor first.',
|
|
420
|
+
command: 'sloppatrol --once',
|
|
421
|
+
})}</div>`;
|
|
422
|
+
const attention = s.attention.length
|
|
423
|
+
? `<ul class="attention-list">${s.attention.map(attentionItem).join('')}</ul>`
|
|
424
|
+
: emptyState({
|
|
425
|
+
title: 'Nothing needs attention',
|
|
426
|
+
why: 'The checks pass on main, no refactor waits for you, and no agent failed in the last 7 days.',
|
|
427
|
+
command: 'sloppatrol --once',
|
|
428
|
+
compact: true,
|
|
429
|
+
});
|
|
430
|
+
const recent = state.refactors.slice(0, 5);
|
|
431
|
+
return `${confidenceBand()}
|
|
432
|
+
<div class="grid two">
|
|
433
|
+
${card('Needs attention', s.attention.length ? plural(s.attention.length, 'item') : '', attention, 'attention-card')}
|
|
434
|
+
${agentsCard()}
|
|
435
|
+
</div>
|
|
436
|
+
<div class="grid two">
|
|
437
|
+
${qualityCard()}
|
|
438
|
+
${outcomesCard()}
|
|
439
|
+
</div>
|
|
440
|
+
${
|
|
441
|
+
recent.length
|
|
442
|
+
? card(
|
|
443
|
+
'Recent refactors',
|
|
444
|
+
`<button type="button" class="link" data-go="refactors">All ${number(s.refactors.total)}</button>`,
|
|
445
|
+
`<div class="list">${recent.map(refactorRowHtml).join('')}</div>`,
|
|
446
|
+
)
|
|
447
|
+
: ''
|
|
448
|
+
}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
107
451
|
// ---------- refactors ----------
|
|
108
452
|
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const mutation = proof?.behaviour?.mutation
|
|
129
|
-
? proof.behaviour.mutation.caught
|
|
130
|
-
? 'ok'
|
|
131
|
-
: 'bad'
|
|
132
|
-
: mutationRun?.status === 'running'
|
|
133
|
-
? 'run'
|
|
134
|
-
: '';
|
|
135
|
-
const metricsDone = proof?.improvement ? (proof.improvement.improved ? 'ok' : 'bad') : '';
|
|
136
|
-
const ready = checks === 'ok' && (!tests || mutation === 'ok');
|
|
137
|
-
const metrics = metricsDone || (working && ready ? 'run' : '');
|
|
138
|
-
const review = v?.review ? (v.review.approve ? 'ok' : 'bad') : working && metrics === 'ok' ? 'run' : '';
|
|
139
|
-
const pr = refactor.prUrl ? (refactor.status === 'closed' ? 'bad' : 'ok') : '';
|
|
140
|
-
const checkLabel = run?.status === 'running' && run.current ? `Checks: ${run.current}` : 'Checks';
|
|
141
|
-
return [
|
|
142
|
-
['Refactorer', agent],
|
|
143
|
-
['Guards', guards],
|
|
144
|
-
[checkLabel, checks],
|
|
145
|
-
...(tests ? [['Mutation', mutation]] : []),
|
|
146
|
-
['Metrics', metrics],
|
|
147
|
-
['Review', review],
|
|
148
|
-
[refactor.status === 'merged' ? 'Merged' : 'Pull request', pr],
|
|
149
|
-
];
|
|
453
|
+
function refactorsEmpty() {
|
|
454
|
+
const s = state.summary;
|
|
455
|
+
if (s.main?.state === 'failed')
|
|
456
|
+
return {
|
|
457
|
+
title: 'No refactor can start',
|
|
458
|
+
why: `The check ${s.main.failed} fails on main. The gate needs green checks on main to verify a refactor. Fix main, then run the checks again.`,
|
|
459
|
+
command: 'sloppatrol check',
|
|
460
|
+
};
|
|
461
|
+
if (!s.backlog.total)
|
|
462
|
+
return {
|
|
463
|
+
title: 'No refactor yet',
|
|
464
|
+
why: 'The refactorer works on the opportunities that the surveyor records. The backlog is empty.',
|
|
465
|
+
command: 'sloppatrol survey',
|
|
466
|
+
};
|
|
467
|
+
return {
|
|
468
|
+
title: 'No refactor yet',
|
|
469
|
+
why: `${plural(s.backlog.open, 'opportunity', 'opportunities')} ${s.backlog.open === 1 ? 'waits' : 'wait'} for the refactorer. Try the best one now.`,
|
|
470
|
+
command: 'sloppatrol refactor',
|
|
471
|
+
};
|
|
150
472
|
}
|
|
151
473
|
|
|
152
|
-
function
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
.
|
|
156
|
-
|
|
474
|
+
function refactorRowHtml(r) {
|
|
475
|
+
const stat = r.diffStat ? `+${r.diffStat.added} −${r.diffStat.removed} in ${plural(r.diffStat.files, 'file')}` : '';
|
|
476
|
+
const why =
|
|
477
|
+
r.rejectedAt && r.reason
|
|
478
|
+
? `Rejected at ${r.steps.find((s) => s.id === r.rejectedAt)?.label ?? r.rejectedAt}: ${r.reason.split('\n')[0]}`
|
|
479
|
+
: ['error', 'interrupted'].includes(r.outcome) && r.reason
|
|
480
|
+
? r.reason.split('\n')[0]
|
|
481
|
+
: '';
|
|
482
|
+
return `<button type="button" class="row ${selected('refactor', r.id)}" data-select="refactor:${esc(r.id)}">
|
|
483
|
+
<span class="row-head">${outcomeChip(r)}<span class="row-title">${esc(r.title)}</span>${r.kind ? tag(r.kind) : ''}</span>
|
|
484
|
+
${stepper(r)}
|
|
485
|
+
<span class="row-sub"><span>${ago(r.updatedAt)}</span>${stat ? `<span>${stat}</span>` : ''}${why ? `<span class="why">${esc(why.slice(0, 160))}</span>` : ''}</span>
|
|
486
|
+
</button>`;
|
|
157
487
|
}
|
|
158
488
|
|
|
489
|
+
const REFACTOR_GROUPS = [
|
|
490
|
+
['working', 'In progress', (r) => r.outcome === 'working', 'run'],
|
|
491
|
+
['waits', 'Waits for you', (r) => r.outcome === 'waits', 'warn'],
|
|
492
|
+
['error', 'Stopped with an error', (r) => r.outcome === 'error' || r.outcome === 'interrupted', 'bad'],
|
|
493
|
+
['rejected', 'Rejected by the gate', (r) => r.outcome === 'rejected', 'bad'],
|
|
494
|
+
['merged', 'Merged', (r) => r.outcome === 'merged', 'ok'],
|
|
495
|
+
['closed', 'Closed without a merge', (r) => r.outcome === 'closed', ''],
|
|
496
|
+
];
|
|
497
|
+
|
|
159
498
|
function renderRefactors() {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
499
|
+
const r = state.summary.refactors;
|
|
500
|
+
const numbers = statBand([
|
|
501
|
+
{ label: 'Attempts', value: number(r.total), note: `${plural(r.judged, 'refactor')} judged by the gate` },
|
|
502
|
+
{
|
|
503
|
+
label: 'Kept',
|
|
504
|
+
value: r.keptRate === undefined ? '–' : `${r.keptRate}%`,
|
|
505
|
+
note: `${number(r.kept)} of ${number(r.judged)} passed the gate`,
|
|
506
|
+
tone: r.keptRate === undefined ? undefined : r.keptRate >= 50 ? 'pass' : 'warn',
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
label: 'Waits for you',
|
|
510
|
+
value: number(r.waits),
|
|
511
|
+
note: `${state.summary.decisions.toPublish} to publish · ${state.summary.decisions.toReview} to review`,
|
|
512
|
+
tone: r.waits ? 'warn' : undefined,
|
|
513
|
+
go: 'refactors:waits',
|
|
514
|
+
},
|
|
515
|
+
{ label: 'Merged', value: number(r.merged), go: 'refactors:merged' },
|
|
516
|
+
{
|
|
517
|
+
label: 'Rejected',
|
|
518
|
+
value: number(r.rejected),
|
|
519
|
+
note: r.rejectedBy[0] ? `Most at ${r.rejectedBy[0].label} (${r.rejectedBy[0].count})` : 'None',
|
|
520
|
+
go: 'refactors:rejected',
|
|
521
|
+
},
|
|
522
|
+
{ label: 'Errors', value: number(r.error), tone: r.error ? 'fail' : undefined, go: 'refactors:error' },
|
|
523
|
+
]);
|
|
524
|
+
if (!state.refactors.length) return `${numbers}<div class="card">${emptyState(refactorsEmpty())}</div>`;
|
|
525
|
+
const counts = Object.fromEntries(REFACTOR_GROUPS.map(([id, , test]) => [id, state.refactors.filter(test).length]));
|
|
526
|
+
const bar = filterBar('refactors', [
|
|
527
|
+
{ id: 'all', label: 'All', count: state.refactors.length },
|
|
528
|
+
...REFACTOR_GROUPS.map(([id, label]) => ({ id, label, count: counts[id] })),
|
|
529
|
+
]);
|
|
530
|
+
const shown = REFACTOR_GROUPS.filter(([id]) => filters.refactors === 'all' || filters.refactors === id);
|
|
531
|
+
const groups = shown
|
|
532
|
+
.map(([id, label, test, tone]) => {
|
|
533
|
+
const list = state.refactors.filter(test);
|
|
534
|
+
if (!list.length) return '';
|
|
535
|
+
return group(`refactors:${id}`, label, list.length, list.map(refactorRowHtml).join(''), tone);
|
|
173
536
|
})
|
|
174
|
-
.join('')
|
|
537
|
+
.join('');
|
|
538
|
+
return `${numbers}${bar}${
|
|
539
|
+
groups ||
|
|
540
|
+
emptyState({
|
|
541
|
+
title: 'No refactor in this group',
|
|
542
|
+
why: 'No refactor has this result now. Select All to see each refactor.',
|
|
543
|
+
compact: true,
|
|
544
|
+
})
|
|
545
|
+
}`;
|
|
175
546
|
}
|
|
176
547
|
|
|
177
548
|
function refactorDetail(r) {
|
|
178
|
-
|
|
549
|
+
if (!r) return '<p>This refactor is not in the data.</p>';
|
|
550
|
+
const o = opportunityById(r.opportunityId);
|
|
179
551
|
const v = r.verdict;
|
|
180
|
-
const
|
|
552
|
+
const gate = v?.checkRunId ?? state.checkRuns.find((c) => c.refactorId === r.id && c.purpose === 'gate')?.id;
|
|
181
553
|
const sessions = state.sessions.filter((s) => s.subject === `refactor:${r.id}`);
|
|
182
554
|
return `
|
|
183
|
-
<h2>${esc(
|
|
184
|
-
<div class="pills">${
|
|
555
|
+
<h2>${esc(r.title)}</h2>
|
|
556
|
+
<div class="pills">${outcomeChip(r)}${o ? `${tag(o.kind)}${tag(`${o.side} only`)}` : ''}</div>
|
|
185
557
|
${stepper(r)}
|
|
558
|
+
${r.status === 'verified' ? `<h3>Next step</h3><p>The gate kept this refactor. Publish it to open its pull request.</p>${commandBox('sloppatrol publish')}` : ''}
|
|
186
559
|
${r.prUrl ? `<h3>Pull request</h3><p><a href="${esc(r.prUrl)}" target="_blank" rel="noreferrer">${esc(r.prUrl)}</a></p>` : ''}
|
|
187
560
|
${r.reason ? `<h3>Reason</h3><p>${esc(r.reason)}</p>` : ''}
|
|
188
561
|
${r.summary ? `<h3>What changed</h3><p>${esc(r.summary)}</p>` : ''}
|
|
189
562
|
${o ? `<h3>Files</h3><div class="files">${o.files.map((f) => `<code>${esc(f)}</code>`).join('')}</div>` : ''}
|
|
190
563
|
${v?.guards?.length ? `<h3>Guard findings</h3>${v.guards.map((g) => `<p>✗ <b>${esc(g.guard)}</b> ${g.file ? `<code>${esc(g.file)}</code> ` : ''}${esc(g.detail)}</p>`).join('')}` : ''}
|
|
191
|
-
${
|
|
564
|
+
${gate ? `<h3>Gate checks</h3><p><button type="button" class="link" data-select="check:${esc(gate)}">Open the check run</button></p>` : ''}
|
|
192
565
|
${r.proof ? proofHtml(r.proof) : ''}
|
|
193
566
|
${v?.review ? `<h3>Review: ${v.review.approve ? 'approved' : 'rejected'}</h3><p>${esc(v.review.reasons)}</p>` : ''}
|
|
194
|
-
<h3>Branch</h3><p><code>${esc(r.branch)}</code> from <code>${esc(r.baseSha
|
|
195
|
-
${sessions.length ? `<h3>Agent sessions</h3>${
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ---------- proof ----------
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* A score for people, 0-100. The assessor picks an anchored level 0-4 (which
|
|
202
|
-
* keeps judges consistent); a level of 3 shows as 75.
|
|
203
|
-
*/
|
|
204
|
-
function pct(score) {
|
|
205
|
-
return Math.round((score / 4) * 100);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** The colour band of a 0-100 score. */
|
|
209
|
-
function band(score) {
|
|
210
|
-
const p = pct(score);
|
|
211
|
-
return p < 40 ? 'low' : p < 70 ? 'mid' : 'high';
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function delta(before, after) {
|
|
215
|
-
if (before === undefined || after === undefined) return '';
|
|
216
|
-
const d = pct(after) - pct(before);
|
|
217
|
-
const cls = d > 0 ? 'up' : d < 0 ? 'down' : '';
|
|
218
|
-
return `<span class="delta ${cls}">${d > 0 ? '+' : ''}${d}</span>`;
|
|
567
|
+
<h3>Branch</h3><p><code>${esc(r.branch)}</code> from <code>${esc(short(r.baseSha))}</code></p>
|
|
568
|
+
${sessions.length ? `<h3>Agent sessions</h3><div class="list">${sessions.map(sessionRowHtml).join('')}</div>` : ''}`;
|
|
219
569
|
}
|
|
220
570
|
|
|
221
571
|
function fmt(n) {
|
|
@@ -243,15 +593,19 @@ function proofHtml(proof) {
|
|
|
243
593
|
if (imp) {
|
|
244
594
|
const rows = state.metrics
|
|
245
595
|
.filter((m) => imp.before[m.id] !== undefined || imp.after[m.id] !== undefined)
|
|
246
|
-
.map(
|
|
247
|
-
(m)
|
|
248
|
-
|
|
249
|
-
|
|
596
|
+
.map((m) => {
|
|
597
|
+
const target = imp.targets.includes(m.id);
|
|
598
|
+
const change =
|
|
599
|
+
imp.before[m.id] !== undefined && imp.after[m.id] !== undefined
|
|
600
|
+
? delta(pct(imp.after[m.id]) - pct(imp.before[m.id]))
|
|
601
|
+
: '';
|
|
602
|
+
return `<tr class="${target ? 'target' : ''}"><td>${esc(m.name)}${target ? ` ${tag('target')}` : ''}</td><td>${fmt(imp.before[m.id])}</td><td>${fmt(imp.after[m.id])}</td><td>${change}</td></tr>`;
|
|
603
|
+
})
|
|
250
604
|
.join('');
|
|
251
605
|
const s = imp.signals;
|
|
252
606
|
html += `<h3>Proof: it makes the code better</h3>
|
|
253
|
-
<p class="muted"
|
|
254
|
-
<table class="scores"><thead><tr><th>Metric</th><th>Before</th><th>After</th><th
|
|
607
|
+
<p class="muted">${plural(imp.judges ?? 1, 'independent assessor')} scored it blind (rubric <code>${esc(imp.rubric)}</code>), 0 worst to 100 best.</p>
|
|
608
|
+
<table class="scores"><thead><tr><th>Metric</th><th>Before</th><th>After</th><th>Change</th></tr></thead><tbody>${rows}</tbody></table>
|
|
255
609
|
<p class="muted">Lines ${s.before.lines} → ${s.after.lines} · nesting ${s.before.maxNesting} → ${s.after.maxNesting} · comments ${s.before.commentLines} → ${s.after.commentLines} · mocks ${s.before.mocks} → ${s.after.mocks} · suppressions ${s.before.suppressions} → ${s.after.suppressions}</p>
|
|
256
610
|
<p>${chip(imp.improved ? 'improved' : 'not improved', imp.improved ? 'ok' : 'bad')} ${esc(imp.reason)}</p>`;
|
|
257
611
|
}
|
|
@@ -262,107 +616,136 @@ function proofHtml(proof) {
|
|
|
262
616
|
|
|
263
617
|
// ---------- scorecard ----------
|
|
264
618
|
|
|
265
|
-
|
|
266
|
-
if (values.length < 2) return '';
|
|
267
|
-
const w = 120;
|
|
268
|
-
const h = 28;
|
|
269
|
-
const points = values
|
|
270
|
-
.map((v, i) => `${((i / (values.length - 1)) * w).toFixed(1)},${(h - (v / 4) * h).toFixed(1)}`)
|
|
271
|
-
.join(' ');
|
|
272
|
-
return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${points}" /></svg>`;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function scoreCell(score) {
|
|
276
|
-
if (score === undefined) return '<td class="na">·</td>';
|
|
277
|
-
return `<td><span class="score ${band(score.score)}">${pct(score.score)}%</span></td>`;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const TABLE_ROWS = 60;
|
|
619
|
+
const TABLE_ROWS = 80;
|
|
281
620
|
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return `<div class="coverage"><div class="coverage-bar"><span style="width:${share}%"></span></div>
|
|
286
|
-
<span>Scored ${c.files.toLocaleString()} of ${c.totalFiles.toLocaleString()} files · ${share}% of ${c.totalLines.toLocaleString()} lines. Each cycle scores more; <code>sloppatrol score --files N</code> scores more now.</span></div>`;
|
|
621
|
+
function avg(unit) {
|
|
622
|
+
const scores = Object.values(unit.scores).map((s) => s.score);
|
|
623
|
+
return scores.reduce((a, b) => a + b, 0) / (scores.length || 1);
|
|
287
624
|
}
|
|
288
625
|
|
|
289
626
|
function renderScorecard() {
|
|
290
|
-
const
|
|
291
|
-
const current =
|
|
292
|
-
if (!
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
627
|
+
const q = state.summary.quality;
|
|
628
|
+
const current = state.scorecards.at(-1);
|
|
629
|
+
if (!q || !current)
|
|
630
|
+
return `<div class="card">${emptyState({
|
|
631
|
+
title: 'No scorecard yet',
|
|
632
|
+
why:
|
|
633
|
+
state.summary.main?.state === 'failed'
|
|
634
|
+
? 'Each cycle scores the files of main, but the checks fail on main now. Score the code now, also while main is red.'
|
|
635
|
+
: 'Each cycle scores the most changed and the largest files of main first. Score the code now to start.',
|
|
636
|
+
command: 'sloppatrol score',
|
|
637
|
+
})}</div>`;
|
|
638
|
+
const lowest = [...q.metrics].sort((a, b) => a.value - b.value)[0];
|
|
639
|
+
const numbers = statBand([
|
|
640
|
+
{
|
|
641
|
+
label: 'Overall',
|
|
642
|
+
value: `${q.overall ?? '–'}% ${delta(q.delta)}`,
|
|
643
|
+
note: q.compared
|
|
644
|
+
? `Change over the ${plural(q.compared, 'file')} in both of the last two scorecards`
|
|
645
|
+
: 'The mean of each metric',
|
|
646
|
+
tone: q.overall === undefined ? undefined : q.overall >= 70 ? 'pass' : q.overall >= 40 ? 'warn' : 'fail',
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
label: 'Files scored',
|
|
650
|
+
value: `${number(q.coverage.files)} <span class="of">of ${number(q.coverage.totalFiles)}</span>`,
|
|
651
|
+
note: `${q.coverage.share}% of the lines`,
|
|
652
|
+
},
|
|
653
|
+
lowest
|
|
654
|
+
? {
|
|
655
|
+
label: 'Lowest metric',
|
|
656
|
+
value: esc(lowest.name),
|
|
657
|
+
note: `${lowest.value}%`,
|
|
658
|
+
tone: lowest.value < 40 ? 'fail' : 'warn',
|
|
659
|
+
}
|
|
660
|
+
: null,
|
|
661
|
+
{
|
|
662
|
+
label: 'Guardrails',
|
|
663
|
+
value: `${q.guardrails.present} <span class="of">of ${q.guardrails.total}</span>`,
|
|
664
|
+
note:
|
|
665
|
+
q.guardrails.present < q.guardrails.total
|
|
666
|
+
? `${q.guardrails.total - q.guardrails.present} missing`
|
|
667
|
+
: 'All present',
|
|
668
|
+
},
|
|
669
|
+
{ label: 'Scored', value: esc(ago(q.at)), note: `Commit ${short(q.sha)} · rubric ${q.rubric}` },
|
|
670
|
+
]);
|
|
671
|
+
const tiles = q.metrics
|
|
327
672
|
.map((m) => {
|
|
328
|
-
const
|
|
329
|
-
return `<div class="metric" title="${esc(
|
|
673
|
+
const meta = state.metrics.find((x) => x.id === m.id);
|
|
674
|
+
return `<div class="metric" title="${esc(meta?.question)}">
|
|
330
675
|
<div class="metric-name">${esc(m.name)}</div>
|
|
331
|
-
<div class="metric-value ${band(
|
|
332
|
-
${sparkline(trend)}
|
|
333
|
-
<div class="metric-q">${esc(m.question)}</div>
|
|
676
|
+
<div class="metric-value"><span class="score-text ${band(m.value)}">${m.value}%</span> ${delta(m.delta)}</div>
|
|
677
|
+
${sparkline(m.trend)}
|
|
334
678
|
</div>`;
|
|
335
679
|
})
|
|
336
680
|
.join('');
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
681
|
+
const coverage =
|
|
682
|
+
q.coverage.share < 100
|
|
683
|
+
? `<div class="callout"><p>The scores cover ${q.coverage.share}% of the lines. Each cycle scores more files. Score each file now:</p>${commandBox('sloppatrol score')}</div>`
|
|
684
|
+
: '';
|
|
685
|
+
const guardrails = [...(current.guardrails ?? [])]
|
|
686
|
+
.sort((a, b) => Number(a.present) - Number(b.present))
|
|
340
687
|
.map(
|
|
341
|
-
(
|
|
342
|
-
`<
|
|
688
|
+
(g) =>
|
|
689
|
+
`<li class="${g.present ? 'ok' : 'bad'}"><span class="mark" aria-hidden="true">${g.present ? '✓' : '✗'}</span><b>${esc(g.name)}</b><span class="muted">${g.present ? '' : 'Missing: '}${esc(g.evidence)}</span></li>`,
|
|
343
690
|
)
|
|
344
691
|
.join('');
|
|
345
|
-
const
|
|
692
|
+
const units = current.units;
|
|
693
|
+
const isTest = (u) => u.scores.tests !== undefined;
|
|
694
|
+
const tests = units.filter(isTest).length;
|
|
695
|
+
const low = units.filter((u) => pct(avg(u)) < 50).length;
|
|
696
|
+
const list = units
|
|
697
|
+
.filter((u) =>
|
|
698
|
+
filters.scorecard === 'low'
|
|
699
|
+
? pct(avg(u)) < 50
|
|
700
|
+
: filters.scorecard === 'tests'
|
|
701
|
+
? isTest(u)
|
|
702
|
+
: filters.scorecard === 'source'
|
|
703
|
+
? !isTest(u)
|
|
704
|
+
: true,
|
|
705
|
+
)
|
|
706
|
+
.sort((a, b) => avg(a) - avg(b));
|
|
707
|
+
const shown = list.slice(0, TABLE_ROWS);
|
|
708
|
+
const columns = state.metrics.map((m) => `<th scope="col" title="${esc(m.question)}">${esc(m.name)}</th>`).join('');
|
|
709
|
+
const cell = (score) =>
|
|
710
|
+
score === undefined
|
|
711
|
+
? '<td class="na"><span class="sr-only">does not apply</span>·</td>'
|
|
712
|
+
: `<td><span class="score ${band(pct(score.score))}">${pct(score.score)}</span></td>`;
|
|
713
|
+
const rows = shown
|
|
346
714
|
.map(
|
|
347
|
-
(
|
|
348
|
-
`<
|
|
715
|
+
(u) =>
|
|
716
|
+
`<tr class="${selected('unit', u.contentHash)}"><th scope="row" class="path"><button type="button" class="link" data-select="unit:${esc(u.contentHash)}" title="${esc(u.path)}">${esc(u.path)}</button></th>${state.metrics.map((m) => cell(u.scores[m.id])).join('')}<td class="num">${number(u.signals.lines)}</td></tr>`,
|
|
349
717
|
)
|
|
350
718
|
.join('');
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
719
|
+
const table = shown.length
|
|
720
|
+
? `<div class="table-wrap"><table class="units"><thead><tr><th scope="col">File, lowest first</th>${columns}<th scope="col">Lines</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
721
|
+
${list.length > shown.length ? `<p class="muted small">The table shows the ${shown.length} lowest of ${number(list.length)} files.</p>` : ''}`
|
|
722
|
+
: emptyState({
|
|
723
|
+
title: 'No file in this group',
|
|
724
|
+
why: 'No scored file matches this filter. Select All to see each file.',
|
|
725
|
+
compact: true,
|
|
726
|
+
});
|
|
727
|
+
return `<div class="page-actions"><a class="share-link" href="#share">Share the scorecard</a></div>
|
|
728
|
+
${numbers}
|
|
729
|
+
<div class="metrics">${tiles}</div>
|
|
730
|
+
${coverage}
|
|
731
|
+
<div class="grid two narrow-right">
|
|
732
|
+
<section class="card">
|
|
733
|
+
<div class="card-head"><h2>Files</h2><span class="muted">Each score is 0 worst to 100 best</span></div>
|
|
734
|
+
${filterBar('scorecard', [
|
|
735
|
+
{ id: 'all', label: 'All', count: units.length },
|
|
736
|
+
{ id: 'low', label: 'Below 50', count: low },
|
|
737
|
+
{ id: 'source', label: 'Source', count: units.length - tests },
|
|
738
|
+
{ id: 'tests', label: 'Tests', count: tests },
|
|
739
|
+
])}
|
|
740
|
+
${table}
|
|
741
|
+
</section>
|
|
742
|
+
${card('Repo guardrails', `${q.guardrails.present} of ${q.guardrails.total}`, `<ul class="guardrails">${guardrails}</ul>`)}
|
|
743
|
+
</div>`;
|
|
361
744
|
}
|
|
362
745
|
|
|
363
746
|
function unitDetail(hash) {
|
|
364
747
|
const unit = state.scorecards.at(-1)?.units.find((u) => u.contentHash === hash);
|
|
365
|
-
if (!unit) return '<p>
|
|
748
|
+
if (!unit) return '<p>This file is not in the newest scorecard.</p>';
|
|
366
749
|
const s = unit.signals;
|
|
367
750
|
return `<h2><code>${esc(unit.path)}</code></h2>
|
|
368
751
|
<p class="muted">${s.lines} lines · nesting ${s.maxNesting} · ${s.commentLines} comment lines · ${s.mocks} mocks · ${s.suppressions} suppressions</p>
|
|
@@ -370,112 +753,555 @@ function unitDetail(hash) {
|
|
|
370
753
|
.filter((m) => unit.scores[m.id])
|
|
371
754
|
.map((m) => {
|
|
372
755
|
const score = unit.scores[m.id];
|
|
373
|
-
return `<h3>${esc(m.name)} <span class="score ${band(score.score)}">${pct(score.score)}
|
|
756
|
+
return `<h3>${esc(m.name)} <span class="score ${band(pct(score.score))}">${pct(score.score)}</span></h3>
|
|
374
757
|
<p class="muted">Level ${Math.round(score.score)} of 4: ${esc(m.levels[Math.round(score.score)])}</p><p>${esc(score.evidence)}</p>`;
|
|
375
758
|
})
|
|
376
759
|
.join('')}`;
|
|
377
760
|
}
|
|
378
761
|
|
|
762
|
+
// ---------- share ----------
|
|
763
|
+
// One SVG card, 1200 by 675 (the 16:9 of a post on X or LinkedIn), in the
|
|
764
|
+
// colours of the banner. It is one image, so the browser saves it as a PNG.
|
|
765
|
+
|
|
766
|
+
const CARD = { w: 1200, h: 675, pad: 64 };
|
|
767
|
+
const CARD_FONT = "-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Helvetica, Arial, sans-serif";
|
|
768
|
+
const CARD_MONO = "ui-monospace, 'SF Mono', Menlo, Consolas, monospace";
|
|
769
|
+
const CARD_TONE = { low: '#f87171', mid: '#fbbf24', high: '#4ade80' };
|
|
770
|
+
|
|
771
|
+
let measurer;
|
|
772
|
+
/** The width of `text` in pixels, so the card can lay out a row of words. */
|
|
773
|
+
function textWidth(text, font) {
|
|
774
|
+
measurer ??= document.createElement('canvas').getContext('2d');
|
|
775
|
+
measurer.font = font;
|
|
776
|
+
return measurer.measureText(text).width;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function clip(text, max) {
|
|
780
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The logo's sparkle: a 7 by 7 pixel star, `size` pixels wide, at x, y. */
|
|
784
|
+
function sparkle(x, y, size) {
|
|
785
|
+
const cells = [
|
|
786
|
+
[3, 0],
|
|
787
|
+
[3, 1],
|
|
788
|
+
[2, 2],
|
|
789
|
+
[3, 2],
|
|
790
|
+
[4, 2],
|
|
791
|
+
[0, 3],
|
|
792
|
+
[1, 3],
|
|
793
|
+
[2, 3],
|
|
794
|
+
[3, 3],
|
|
795
|
+
[4, 3],
|
|
796
|
+
[5, 3],
|
|
797
|
+
[6, 3],
|
|
798
|
+
[2, 4],
|
|
799
|
+
[3, 4],
|
|
800
|
+
[4, 4],
|
|
801
|
+
[3, 5],
|
|
802
|
+
];
|
|
803
|
+
const step = size / 7;
|
|
804
|
+
return cells
|
|
805
|
+
.map(
|
|
806
|
+
([cx, cy]) =>
|
|
807
|
+
`<rect x="${(x + cx * step).toFixed(1)}" y="${(y + cy * step).toFixed(1)}" width="${(step * 0.84).toFixed(1)}" height="${(step * 0.84).toFixed(1)}" rx="${(step * 0.14).toFixed(1)}" fill="url(#c-cyan)"/>`,
|
|
808
|
+
)
|
|
809
|
+
.join('');
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The scorecard as an SVG card: the overall score, each metric, the guardrails, and the coverage. */
|
|
813
|
+
function shareCard(q, current) {
|
|
814
|
+
const { w, h, pad } = CARD;
|
|
815
|
+
const right = w - pad;
|
|
816
|
+
const tone = (value) => CARD_TONE[band(value)];
|
|
817
|
+
const date = new Date(q.at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
818
|
+
|
|
819
|
+
// The overall score: a ring, its share of a full turn coloured.
|
|
820
|
+
const ring = { x: 250, y: 362, r: 118 };
|
|
821
|
+
const turn = 2 * Math.PI * ring.r;
|
|
822
|
+
const overall = q.overall ?? 0;
|
|
823
|
+
const c = q.coverage;
|
|
824
|
+
const lines = current.coverage?.totalLines ?? 0;
|
|
825
|
+
const covered =
|
|
826
|
+
c.files < c.totalFiles
|
|
827
|
+
? `Scored ${number(c.files)} of ${number(c.totalFiles)} files · ${c.share}% of the lines`
|
|
828
|
+
: `${plural(c.totalFiles, 'file')} · ${number(lines)} lines`;
|
|
829
|
+
|
|
830
|
+
// The metrics: one row each, name, bar, and score.
|
|
831
|
+
const top = 196;
|
|
832
|
+
const row = q.metrics.length > 6 ? 52 : 60;
|
|
833
|
+
const barX = 560;
|
|
834
|
+
const barW = right - barX;
|
|
835
|
+
const metrics = q.metrics
|
|
836
|
+
.map((m, i) => {
|
|
837
|
+
const y = top + i * row;
|
|
838
|
+
return `<text x="${barX}" y="${y}" font-size="19" fill="#e2e8f0">${esc(m.name)}</text>
|
|
839
|
+
<text x="${right}" y="${y}" font-size="22" font-weight="700" fill="${tone(m.value)}" text-anchor="end">${m.value}</text>
|
|
840
|
+
<rect x="${barX}" y="${y + 12}" width="${barW}" height="8" rx="4" fill="#ffffff" fill-opacity="0.08"/>
|
|
841
|
+
<rect x="${barX}" y="${y + 12}" width="${((barW * m.value) / 100).toFixed(1)}" height="8" rx="4" fill="${tone(m.value)}"/>`;
|
|
842
|
+
})
|
|
843
|
+
.join('');
|
|
844
|
+
|
|
845
|
+
// The footer: the guardrails as a row of marks, then the command to run.
|
|
846
|
+
const command = '$ npx sloppatrol score';
|
|
847
|
+
const commandFont = `16px ${CARD_MONO}`;
|
|
848
|
+
const pillW = textWidth(command, commandFont) + 36;
|
|
849
|
+
const footY = 622;
|
|
850
|
+
const label = 'GUARDRAILS';
|
|
851
|
+
// The label has a letter spacing of 2 that the canvas does not measure.
|
|
852
|
+
const start = pad + textWidth(label, `600 12px ${CARD_FONT}`) + 2 * label.length + 16;
|
|
853
|
+
const itemFont = `14px ${CARD_FONT}`;
|
|
854
|
+
let x = start;
|
|
855
|
+
const room = right - pillW - 24;
|
|
856
|
+
const items = [];
|
|
857
|
+
for (const g of [...(current.guardrails ?? [])].sort((a, b) => Number(b.present) - Number(a.present))) {
|
|
858
|
+
const text = `${g.present ? '✓' : '✗'} ${g.name}`;
|
|
859
|
+
const width = textWidth(text, itemFont);
|
|
860
|
+
if (x + width > room) break;
|
|
861
|
+
items.push(
|
|
862
|
+
`<text x="${x.toFixed(1)}" y="${footY}" font-size="14" fill="${g.present ? '#cbd5e1' : '#f87171'}">${esc(text)}</text>`,
|
|
863
|
+
);
|
|
864
|
+
x += width + 14;
|
|
865
|
+
}
|
|
866
|
+
const guardrails =
|
|
867
|
+
items.length === (current.guardrails ?? []).length
|
|
868
|
+
? items.join('')
|
|
869
|
+
: `<text x="${start.toFixed(1)}" y="${footY}" font-size="14" fill="#cbd5e1">${q.guardrails.present} of ${q.guardrails.total} present</text>`;
|
|
870
|
+
|
|
871
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" font-family="${CARD_FONT}" role="img" aria-label="${esc(`The code quality scorecard of ${state.project}: ${overall} out of 100 overall.`)}">
|
|
872
|
+
<defs>
|
|
873
|
+
<linearGradient id="c-bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#151a33"/><stop offset="1" stop-color="#070914"/></linearGradient>
|
|
874
|
+
<linearGradient id="c-cyan" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cffafe"/><stop offset="1" stop-color="#0891b2"/></linearGradient>
|
|
875
|
+
<radialGradient id="c-glow" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#22d3ee" stop-opacity="0.18"/><stop offset="1" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
|
|
876
|
+
<pattern id="c-grid" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M32 0H0V32" fill="none" stroke="#ffffff" stroke-opacity="0.04"/></pattern>
|
|
877
|
+
</defs>
|
|
878
|
+
<rect width="${w}" height="${h}" fill="url(#c-bg)"/>
|
|
879
|
+
<rect width="${w}" height="${h}" fill="url(#c-grid)"/>
|
|
880
|
+
<circle cx="${ring.x}" cy="${ring.y}" r="240" fill="url(#c-glow)"/>
|
|
881
|
+
|
|
882
|
+
${sparkle(pad, 42, 38)}
|
|
883
|
+
<text x="${pad + 50}" y="72" font-size="30" font-weight="800" letter-spacing="-0.5"><tspan fill="#f8fafc">slop</tspan><tspan fill="#67e8f9">patrol</tspan></text>
|
|
884
|
+
<text x="${right}" y="62" font-size="12" font-weight="600" letter-spacing="2" fill="#94a3b8" text-anchor="end">CODE QUALITY SCORECARD</text>
|
|
885
|
+
<text x="${right}" y="84" font-size="14" fill="#64748b" text-anchor="end">${esc(date)} · <tspan font-family="${CARD_MONO}">${esc(short(q.sha))}</tspan></text>
|
|
886
|
+
|
|
887
|
+
<text x="${pad}" y="150" font-size="40" font-weight="800" fill="#f8fafc" letter-spacing="-0.5">${esc(clip(state.project, 24))}</text>
|
|
888
|
+
<circle cx="${ring.x}" cy="${ring.y}" r="${ring.r}" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="18"/>
|
|
889
|
+
<circle cx="${ring.x}" cy="${ring.y}" r="${ring.r}" fill="none" stroke="${tone(overall)}" stroke-width="18" stroke-linecap="round" stroke-dasharray="${((turn * overall) / 100).toFixed(1)} ${turn.toFixed(1)}" transform="rotate(-90 ${ring.x} ${ring.y})"/>
|
|
890
|
+
<text x="${ring.x}" y="${ring.y + 26}" font-size="84" font-weight="800" fill="#f8fafc" text-anchor="middle" letter-spacing="-2">${q.overall ?? '–'}</text>
|
|
891
|
+
<text x="${ring.x}" y="${ring.y + 58}" font-size="13" font-weight="600" letter-spacing="2" fill="#94a3b8" text-anchor="middle">OVERALL · OF 100</text>
|
|
892
|
+
<text x="${ring.x}" y="${ring.y + ring.r + 58}" font-size="16" fill="#cbd5e1" text-anchor="middle">${esc(covered)}</text>
|
|
893
|
+
|
|
894
|
+
<text x="${barX}" y="150" font-size="12" font-weight="600" letter-spacing="2" fill="#94a3b8">EACH METRIC · 0 WORST TO 100 BEST</text>
|
|
895
|
+
${metrics}
|
|
896
|
+
|
|
897
|
+
<line x1="${pad}" y1="572" x2="${right}" y2="572" stroke="#ffffff" stroke-opacity="0.08"/>
|
|
898
|
+
<text x="${pad}" y="${footY - 1}" font-size="12" font-weight="600" letter-spacing="2" fill="#94a3b8">${label}</text>
|
|
899
|
+
${guardrails}
|
|
900
|
+
<rect x="${(right - pillW).toFixed(1)}" y="${footY - 27}" width="${pillW.toFixed(1)}" height="40" rx="10" fill="#0b1020" stroke="#22d3ee" stroke-opacity="0.45"/>
|
|
901
|
+
<text x="${(right - pillW + 18).toFixed(1)}" y="${footY}" font-size="16" font-family="${CARD_MONO}"><tspan fill="#67e8f9">$</tspan><tspan fill="#f8fafc">${esc(command.slice(1))}</tspan></text>
|
|
902
|
+
</svg>`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function renderShare() {
|
|
906
|
+
const q = state.summary.quality;
|
|
907
|
+
const current = state.scorecards.at(-1);
|
|
908
|
+
const back = '<a class="share-back" href="#scorecard">← The dashboard</a>';
|
|
909
|
+
if (!q || !current)
|
|
910
|
+
return `<div class="share">${back}<div class="card">${emptyState({
|
|
911
|
+
title: 'No scorecard yet',
|
|
912
|
+
why: 'The card shows the scores of each file. Score the code to make it.',
|
|
913
|
+
command: 'sloppatrol score',
|
|
914
|
+
})}</div></div>`;
|
|
915
|
+
const status =
|
|
916
|
+
q.coverage.files < q.coverage.totalFiles
|
|
917
|
+
? `<span class="muted">The scores cover ${q.coverage.share}% of the lines. The card updates while files get scored.</span>`
|
|
918
|
+
: '';
|
|
919
|
+
return `<div class="share">
|
|
920
|
+
<div class="share-bar">${back}${status}<span class="share-actions">
|
|
921
|
+
<button type="button" class="copy" data-share="copy">Copy image</button>
|
|
922
|
+
<button type="button" class="copy" data-share="png">Download PNG</button>
|
|
923
|
+
</span></div>
|
|
924
|
+
<div class="share-card" id="share-card">${shareCard(q, current)}</div>
|
|
925
|
+
</div>`;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** The card as a PNG, at twice its size, so it stays sharp on a phone. */
|
|
929
|
+
function cardPng() {
|
|
930
|
+
const svg = $('share-card').querySelector('svg');
|
|
931
|
+
const image = new Image();
|
|
932
|
+
const loaded = new Promise((done, fail) => {
|
|
933
|
+
image.onload = done;
|
|
934
|
+
image.onerror = fail;
|
|
935
|
+
});
|
|
936
|
+
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
|
|
937
|
+
return loaded.then(() => {
|
|
938
|
+
const canvas = document.createElement('canvas');
|
|
939
|
+
canvas.width = CARD.w * 2;
|
|
940
|
+
canvas.height = CARD.h * 2;
|
|
941
|
+
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
942
|
+
return new Promise((done) => canvas.toBlob(done, 'image/png'));
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function shareAction(button) {
|
|
947
|
+
const word = button.textContent;
|
|
948
|
+
try {
|
|
949
|
+
if (button.dataset.share === 'copy') {
|
|
950
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': cardPng() })]);
|
|
951
|
+
button.textContent = 'Copied';
|
|
952
|
+
} else {
|
|
953
|
+
const link = document.createElement('a');
|
|
954
|
+
link.href = URL.createObjectURL(await cardPng());
|
|
955
|
+
link.download = `sloppatrol-${state.project.replace(/[^\w.-]+/g, '-')}.png`;
|
|
956
|
+
link.click();
|
|
957
|
+
URL.revokeObjectURL(link.href);
|
|
958
|
+
button.textContent = 'Saved';
|
|
959
|
+
}
|
|
960
|
+
} catch {
|
|
961
|
+
button.textContent = 'Failed';
|
|
962
|
+
}
|
|
963
|
+
setTimeout(() => {
|
|
964
|
+
button.textContent = word;
|
|
965
|
+
}, 1500);
|
|
966
|
+
}
|
|
967
|
+
|
|
379
968
|
// ---------- checks ----------
|
|
380
969
|
|
|
970
|
+
const PURPOSE = { base: 'main', gate: 'gate', refactorer: 'refactorer try', mutation: 'mutation' };
|
|
971
|
+
const RUN_TONE = { ok: 'ok', bad: 'bad', run: 'run', stop: 'stop' };
|
|
972
|
+
|
|
381
973
|
function checkPills(run) {
|
|
382
|
-
const done = run.results.map((r) => chip(`${r.name} ${
|
|
383
|
-
if (run.status === 'running' && run.current)
|
|
974
|
+
const done = run.results.map((r) => chip(`${r.name} ${duration(r.durationMs)}`, r.ok ? 'ok' : 'bad'));
|
|
975
|
+
if (run.status === 'running' && run.current)
|
|
976
|
+
done.push(chip(`${run.current}…`, run.outcome.tone === 'run' ? 'run' : 'stop'));
|
|
384
977
|
return `<div class="pills">${done.join('')}</div>`;
|
|
385
978
|
}
|
|
386
979
|
|
|
387
|
-
|
|
980
|
+
function checkRowHtml(run) {
|
|
981
|
+
const end = run.finishedAt ? Date.parse(run.finishedAt) : Date.parse(state.now);
|
|
982
|
+
const took = duration(end - Date.parse(run.startedAt));
|
|
983
|
+
const where =
|
|
984
|
+
run.purpose === 'base'
|
|
985
|
+
? `main at ${short(run.sha)}`
|
|
986
|
+
: (refactorById(run.refactorId)?.title ?? run.branch ?? short(run.sha));
|
|
987
|
+
return `<button type="button" class="row ${selected('check', run.id)}" data-select="check:${esc(run.id)}">
|
|
988
|
+
<span class="row-head">${chip(run.outcome.text, RUN_TONE[run.outcome.tone])}${tag(PURPOSE[run.purpose])}<span class="row-title">${esc(where)}</span></span>
|
|
989
|
+
${checkPills(run)}
|
|
990
|
+
<span class="row-sub"><span>started ${ago(run.startedAt)}</span><span>${run.outcome.tone === 'run' ? 'running for' : 'took'} ${took}</span>${run.branch && run.purpose !== 'base' ? `<code>${esc(run.branch)}</code>` : ''}</span>
|
|
991
|
+
</button>`;
|
|
992
|
+
}
|
|
388
993
|
|
|
389
994
|
function renderChecks() {
|
|
995
|
+
const c = state.summary.checks;
|
|
996
|
+
const main = state.summary.main;
|
|
997
|
+
const numbers = statBand([
|
|
998
|
+
{
|
|
999
|
+
label: 'Checks on main',
|
|
1000
|
+
value: main ? (main.state === 'passed' ? 'Pass' : 'Fail') : '–',
|
|
1001
|
+
note: main
|
|
1002
|
+
? main.failed
|
|
1003
|
+
? `${main.failed} fails on ${short(main.sha)}`
|
|
1004
|
+
: `On ${short(main.sha)}`
|
|
1005
|
+
: 'No run yet',
|
|
1006
|
+
tone: main ? (main.state === 'passed' ? 'pass' : 'fail') : undefined,
|
|
1007
|
+
go: 'checks:main',
|
|
1008
|
+
},
|
|
1009
|
+
{ label: 'Runs', value: number(c.runs), note: `${plural(c.problems, 'problem')}`, go: 'checks:bad' },
|
|
1010
|
+
{
|
|
1011
|
+
label: 'Gate',
|
|
1012
|
+
value: c.gate.runs ? `${Math.round(((c.gate.runs - c.gate.bad) / c.gate.runs) * 100)}%` : '–',
|
|
1013
|
+
note: `${number(c.gate.runs - c.gate.bad)} of ${plural(c.gate.runs, 'refactor')} passed`,
|
|
1014
|
+
go: 'checks:gate',
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
label: 'Refactorer tries',
|
|
1018
|
+
value: number(c.refactorer.runs),
|
|
1019
|
+
note: `${number(c.refactorer.bad)} failed before the gate`,
|
|
1020
|
+
go: 'checks:refactorer',
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
label: 'Planted bugs caught',
|
|
1024
|
+
value: c.planted ? `${c.caught} <span class="of">of ${c.planted}</span>` : '–',
|
|
1025
|
+
note: 'A tests refactor must catch its bug',
|
|
1026
|
+
go: 'checks:mutation',
|
|
1027
|
+
},
|
|
1028
|
+
]);
|
|
390
1029
|
if (!state.checkRuns.length)
|
|
391
|
-
return
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
1030
|
+
return `${numbers}<div class="card">${emptyState({
|
|
1031
|
+
title: 'No check run yet',
|
|
1032
|
+
why: 'Sloppatrol runs your checks on main first. Green checks on main are the oracle for each refactor.',
|
|
1033
|
+
command: 'sloppatrol check',
|
|
1034
|
+
})}</div>`;
|
|
1035
|
+
const match = {
|
|
1036
|
+
all: () => true,
|
|
1037
|
+
bad: (r) => r.problem,
|
|
1038
|
+
main: (r) => r.purpose === 'base',
|
|
1039
|
+
gate: (r) => r.purpose === 'gate',
|
|
1040
|
+
refactorer: (r) => r.purpose === 'refactorer',
|
|
1041
|
+
mutation: (r) => r.purpose === 'mutation',
|
|
1042
|
+
};
|
|
1043
|
+
const list = state.checkRuns.filter(match[filters.checks]);
|
|
1044
|
+
const bar = filterBar('checks', [
|
|
1045
|
+
{ id: 'all', label: 'All', count: c.runs },
|
|
1046
|
+
{ id: 'bad', label: 'Problems', count: c.problems },
|
|
1047
|
+
{ id: 'main', label: 'Main', count: c.base.runs },
|
|
1048
|
+
{ id: 'gate', label: 'Gate', count: c.gate.runs },
|
|
1049
|
+
{ id: 'refactorer', label: 'Refactorer', count: c.refactorer.runs },
|
|
1050
|
+
{ id: 'mutation', label: 'Mutation', count: c.mutation.runs },
|
|
1051
|
+
]);
|
|
1052
|
+
const problems = list.filter((r) => r.problem);
|
|
1053
|
+
const others = list.filter((r) => !r.problem);
|
|
1054
|
+
const body = !list.length
|
|
1055
|
+
? emptyState({
|
|
1056
|
+
title: 'No run in this group',
|
|
1057
|
+
why: 'No recent check run matches this filter. Select All to see each run.',
|
|
1058
|
+
compact: true,
|
|
1059
|
+
})
|
|
1060
|
+
: filters.checks === 'all'
|
|
1061
|
+
? `${problems.length ? group('checks:bad', 'Problems', problems.length, problems.map(checkRowHtml).join(''), 'bad') : ''}${group('checks:other', 'Other runs', others.length, others.map(checkRowHtml).join(''))}`
|
|
1062
|
+
: `<div class="list">${list.map(checkRowHtml).join('')}</div>`;
|
|
1063
|
+
const note =
|
|
1064
|
+
c.runs > state.checkRuns.length
|
|
1065
|
+
? `<p class="muted small">The list shows the newest ${state.checkRuns.length} of ${number(c.runs)} runs. The numbers above count each run.</p>`
|
|
1066
|
+
: '';
|
|
1067
|
+
return `${numbers}${bar}${note}${body}`;
|
|
404
1068
|
}
|
|
405
1069
|
|
|
406
1070
|
function checkDetail(id) {
|
|
407
|
-
const
|
|
408
|
-
|
|
1071
|
+
const row = state.checkRuns.find((r) => r.id === id);
|
|
1072
|
+
const run = { ...row, ...fetched.get(`check:${id}`) };
|
|
1073
|
+
if (!run.id) return '<p>Loading…</p>';
|
|
409
1074
|
return `
|
|
410
|
-
<h2>Checks on ${run.
|
|
411
|
-
<div class="pills">${chip(run.
|
|
1075
|
+
<h2>Checks on ${run.purpose === 'base' ? `main ${esc(short(run.sha))}` : esc(run.branch ?? short(run.sha))}</h2>
|
|
1076
|
+
<div class="pills">${run.outcome ? chip(run.outcome.text, RUN_TONE[run.outcome.tone]) : ''}${tag(PURPOSE[run.purpose])}</div>
|
|
1077
|
+
${run.purpose === 'mutation' ? '<p class="muted">A mutation run plants a bug in the source. A failed test here means that the tests caught the bug.</p>' : ''}
|
|
1078
|
+
${run.refactorId ? `<p><button type="button" class="link" data-select="refactor:${esc(run.refactorId)}">Open the refactor</button></p>` : ''}
|
|
412
1079
|
${run.results
|
|
413
1080
|
.map(
|
|
414
|
-
(r) => `<h3>${r.ok ? '✓' : '✗'} ${esc(r.name)} · ${
|
|
1081
|
+
(r) => `<h3>${r.ok ? '✓' : '✗'} ${esc(r.name)} · ${duration(r.durationMs)}</h3>
|
|
415
1082
|
<p><code>${esc(r.command)}</code></p>
|
|
416
1083
|
${r.output ? `<pre class="output">${esc(r.output)}</pre>` : '<p class="muted">No output.</p>'}`,
|
|
417
1084
|
)
|
|
418
1085
|
.join('')}
|
|
419
|
-
${run.status === 'running' && run.current ? `<h3>
|
|
1086
|
+
${run.status === 'running' && run.current ? `<h3>Now: ${esc(run.current)}</h3>` : ''}`;
|
|
420
1087
|
}
|
|
421
1088
|
|
|
422
1089
|
// ---------- backlog ----------
|
|
423
1090
|
|
|
424
|
-
|
|
1091
|
+
const BACKLOG_FILTERS = [
|
|
1092
|
+
['open', 'Open'],
|
|
1093
|
+
['verified', 'To publish'],
|
|
1094
|
+
['proposed', 'Pull request open'],
|
|
1095
|
+
['done', 'Done'],
|
|
1096
|
+
['exhausted', 'Exhausted'],
|
|
1097
|
+
['dismissed', 'Dismissed'],
|
|
1098
|
+
['all', 'All'],
|
|
1099
|
+
];
|
|
1100
|
+
|
|
1101
|
+
/** An open opportunity with a verified branch waits to be published. */
|
|
425
1102
|
function backlogStatus(o) {
|
|
426
1103
|
const verified = state.refactors.some((r) => r.opportunityId === o.id && r.status === 'verified');
|
|
427
1104
|
return o.status === 'open' && verified ? 'verified' : o.status;
|
|
428
1105
|
}
|
|
429
1106
|
|
|
1107
|
+
const STRENGTH = { strong: ['strong', 'ok'], 'worth-exploring': ['worth a try', ''], speculative: ['speculative', ''] };
|
|
1108
|
+
|
|
1109
|
+
function opportunityRowHtml(o, rank) {
|
|
1110
|
+
const status = state.summary.backlog.working.includes(o.id) ? 'in progress' : backlogStatus(o);
|
|
1111
|
+
return `<button type="button" class="row ${selected('opportunity', o.id)}" data-select="opportunity:${esc(o.id)}">
|
|
1112
|
+
<span class="row-head">${rank ? `<span class="rank" aria-label="Number ${rank} in the pick order">${rank}</span>` : chip(status, { done: 'ok', proposed: 'warn', verified: 'warn', exhausted: 'bad', 'in progress': 'run' }[status] ?? '')}<span class="row-title">${esc(o.title)}</span>${chip(...STRENGTH[o.strength])}${tag(o.kind)}</span>
|
|
1113
|
+
<span class="row-sub"><span>${esc(o.side)} only</span><span>${plural(o.files.length, 'file')}</span><span>${plural(o.attempts, 'attempt')}</span><span>found ${ago(o.createdAt)}</span>${o.reason && status !== 'done' ? `<span class="why">${esc(o.reason.split('\n')[0].slice(0, 140))}</span>` : ''}</span>
|
|
1114
|
+
</button>`;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
430
1117
|
function renderBacklog() {
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
1118
|
+
const b = state.summary.backlog;
|
|
1119
|
+
const numbers = statBand([
|
|
1120
|
+
{
|
|
1121
|
+
label: 'Open',
|
|
1122
|
+
value: number(b.open),
|
|
1123
|
+
note: `${b.byStrength.strong} strong · ${b.byStrength['worth-exploring']} worth a try · ${b.byStrength.speculative} speculative`,
|
|
1124
|
+
go: 'backlog:open',
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
label: 'Ready',
|
|
1128
|
+
value: number(b.ready.length),
|
|
1129
|
+
note:
|
|
1130
|
+
state.summary.main?.state === 'failed'
|
|
1131
|
+
? 'None can start while the checks fail on main'
|
|
1132
|
+
: 'The refactorer can take these now',
|
|
1133
|
+
tone: state.summary.main?.state === 'failed' && b.ready.length ? 'fail' : undefined,
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
label: 'Waits for a file',
|
|
1137
|
+
value: number(b.busy.length),
|
|
1138
|
+
note: 'A refactor that waits for you changes its files',
|
|
1139
|
+
},
|
|
1140
|
+
{
|
|
1141
|
+
label: 'Done',
|
|
1142
|
+
value: number(b.done),
|
|
1143
|
+
note: `${number(b.proposed)} more in an open pull request`,
|
|
1144
|
+
go: 'backlog:done',
|
|
1145
|
+
},
|
|
1146
|
+
{ label: 'Exhausted', value: number(b.exhausted), note: 'Each attempt failed the gate', go: 'backlog:exhausted' },
|
|
1147
|
+
{ label: 'Dismissed', value: number(b.dismissed), go: 'backlog:dismissed' },
|
|
1148
|
+
]);
|
|
1149
|
+
if (!state.opportunities.length)
|
|
1150
|
+
return `${numbers}<div class="card">${emptyState({
|
|
1151
|
+
title: 'The backlog is empty',
|
|
1152
|
+
why:
|
|
1153
|
+
state.summary.main?.state === 'failed'
|
|
1154
|
+
? 'The surveyor records opportunities only while the checks pass on main. Fix main first.'
|
|
1155
|
+
: 'The surveyor reads the repo and records the improvements that it finds. Each cycle runs it when the backlog is low.',
|
|
1156
|
+
command: state.summary.main?.state === 'failed' ? 'sloppatrol check' : 'sloppatrol survey',
|
|
1157
|
+
})}</div>`;
|
|
1158
|
+
const counts = {
|
|
1159
|
+
open: b.open,
|
|
1160
|
+
verified: b.verified,
|
|
1161
|
+
proposed: b.proposed,
|
|
1162
|
+
done: b.done,
|
|
1163
|
+
exhausted: b.exhausted,
|
|
1164
|
+
dismissed: b.dismissed,
|
|
1165
|
+
all: b.total,
|
|
1166
|
+
};
|
|
1167
|
+
const bar = filterBar(
|
|
1168
|
+
'backlog',
|
|
1169
|
+
BACKLOG_FILTERS.map(([id, label]) => ({ id, label, count: counts[id] })),
|
|
1170
|
+
);
|
|
1171
|
+
let body;
|
|
1172
|
+
if (filters.backlog === 'open') {
|
|
1173
|
+
const ids = (list) => list.map(opportunityById).filter(Boolean);
|
|
1174
|
+
const working = ids(b.working);
|
|
1175
|
+
const ready = ids(b.ready);
|
|
1176
|
+
const busy = ids(b.busy);
|
|
1177
|
+
body =
|
|
1178
|
+
(working.length
|
|
1179
|
+
? group(
|
|
1180
|
+
'backlog:working',
|
|
1181
|
+
'In progress now',
|
|
1182
|
+
working.length,
|
|
1183
|
+
working.map((o) => opportunityRowHtml(o)).join(''),
|
|
1184
|
+
'run',
|
|
1185
|
+
)
|
|
1186
|
+
: '') +
|
|
1187
|
+
(ready.length
|
|
1188
|
+
? group(
|
|
1189
|
+
'backlog:ready',
|
|
1190
|
+
'Next for the refactorer, in pick order',
|
|
1191
|
+
ready.length,
|
|
1192
|
+
ready.map((o, i) => opportunityRowHtml(o, i + 1)).join(''),
|
|
1193
|
+
)
|
|
1194
|
+
: '') +
|
|
1195
|
+
(busy.length
|
|
1196
|
+
? group(
|
|
1197
|
+
'backlog:busy',
|
|
1198
|
+
'Waits for a refactor that changes the same files',
|
|
1199
|
+
busy.length,
|
|
1200
|
+
busy.map((o) => opportunityRowHtml(o)).join(''),
|
|
1201
|
+
'warn',
|
|
1202
|
+
)
|
|
1203
|
+
: '');
|
|
1204
|
+
if (!body)
|
|
1205
|
+
body = emptyState({
|
|
1206
|
+
title: 'No open opportunity',
|
|
1207
|
+
why: 'The refactorer took or closed each opportunity. The surveyor records more when the backlog is low.',
|
|
1208
|
+
command: 'sloppatrol survey',
|
|
1209
|
+
compact: true,
|
|
1210
|
+
});
|
|
1211
|
+
} else {
|
|
1212
|
+
const list = state.opportunities.filter((o) => filters.backlog === 'all' || backlogStatus(o) === filters.backlog);
|
|
1213
|
+
body = list.length
|
|
1214
|
+
? `<div class="list">${list.map((o) => opportunityRowHtml(o)).join('')}</div>`
|
|
1215
|
+
: emptyState({
|
|
1216
|
+
title: 'No opportunity in this group',
|
|
1217
|
+
why: 'No opportunity has this status now. Select All to see each one.',
|
|
1218
|
+
compact: true,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
return `${numbers}${bar}${body}`;
|
|
448
1222
|
}
|
|
449
1223
|
|
|
450
1224
|
function opportunityDetail(o) {
|
|
1225
|
+
if (!o) return '<p>This opportunity is not in the data.</p>';
|
|
451
1226
|
const refactors = state.refactors.filter((r) => r.opportunityId === o.id);
|
|
452
1227
|
return `
|
|
453
1228
|
<h2>${esc(o.title)}</h2>
|
|
454
|
-
<div class="pills">${chip(backlogStatus(o))}
|
|
1229
|
+
<div class="pills">${chip(backlogStatus(o))}${tag(o.kind)}${tag(STRENGTH[o.strength][0])}</div>
|
|
455
1230
|
${o.reason ? `<h3>Note</h3><p>${esc(o.reason)}</p>` : ''}
|
|
456
1231
|
<h3>Problem</h3><p>${esc(o.problem)}</p>
|
|
457
1232
|
<h3>Change</h3><p>${esc(o.change)}</p>
|
|
458
1233
|
<h3>Benefits</h3><p>${esc(o.benefits)}</p>
|
|
459
1234
|
<h3>Files</h3><div class="files">${o.files.map((f) => `<code>${esc(f)}</code>`).join('')}</div>
|
|
460
|
-
|
|
461
|
-
${refactors.length ? `<h3>Attempts</h3
|
|
1235
|
+
${o.status === 'open' ? `<h3>Try it now</h3>${commandBox(`sloppatrol refactor ${o.id}`)}<h3>Dismiss it</h3>${commandBox(`sloppatrol dismiss ${o.id} --reason "..."`)}` : ''}
|
|
1236
|
+
${refactors.length ? `<h3>Attempts</h3><div class="list">${refactors.map(refactorRowHtml).join('')}</div>` : ''}`;
|
|
462
1237
|
}
|
|
463
1238
|
|
|
464
1239
|
// ---------- sessions ----------
|
|
465
1240
|
|
|
466
|
-
function
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
)
|
|
474
|
-
.join('')}</div>`;
|
|
1241
|
+
function sessionRowHtml(s) {
|
|
1242
|
+
const tone = { running: 'run', done: 'ok', failed: 'bad', stopped: 'stop' }[s.state];
|
|
1243
|
+
const took = s.endedAt ? duration(Date.parse(s.endedAt) - Date.parse(s.startedAt)) : '';
|
|
1244
|
+
return `<button type="button" class="row ${selected('session', s.id)}" data-select="session:${esc(s.id)}">
|
|
1245
|
+
<span class="row-head">${chip(s.state, tone)}${tag(s.role)}<span class="row-title">${esc(subjectWords(s.subject) || s.last)}</span></span>
|
|
1246
|
+
<span class="row-sub"><span>started ${ago(s.startedAt)}</span>${took ? `<span>took ${took}</span>` : ''}${s.costUsd ? `<span>${money(s.costUsd)}</span>` : ''}<span class="why">${esc(s.last)}</span></span>
|
|
1247
|
+
</button>`;
|
|
475
1248
|
}
|
|
476
1249
|
|
|
477
1250
|
function renderSessions() {
|
|
478
|
-
|
|
1251
|
+
const s = state.summary.sessions;
|
|
1252
|
+
const numbers = statBand([
|
|
1253
|
+
{ label: 'Sessions', value: number(s.total) },
|
|
1254
|
+
{ label: 'Running', value: number(s.running), tone: s.running ? 'run' : undefined },
|
|
1255
|
+
{
|
|
1256
|
+
label: 'Failed',
|
|
1257
|
+
value: number(s.failed),
|
|
1258
|
+
note: `${number(s.failedRecent)} in the last 7 days`,
|
|
1259
|
+
tone: s.failedRecent ? 'fail' : undefined,
|
|
1260
|
+
go: 'sessions:failed',
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
label: 'Stopped',
|
|
1264
|
+
value: number(s.stopped),
|
|
1265
|
+
note: 'Sloppatrol stopped during the session',
|
|
1266
|
+
go: 'sessions:stopped',
|
|
1267
|
+
},
|
|
1268
|
+
{
|
|
1269
|
+
label: 'Agent cost',
|
|
1270
|
+
value: money(s.costUsd),
|
|
1271
|
+
note: `${money(s.costRecentUsd)} in the last 7 days. Only the costs that the agent reports.`,
|
|
1272
|
+
},
|
|
1273
|
+
]);
|
|
1274
|
+
if (!state.sessions.length)
|
|
1275
|
+
return `${numbers}<div class="card">${emptyState({
|
|
1276
|
+
title: 'No agent session yet',
|
|
1277
|
+
why: 'Each agent works in a session. Its tool calls show here as a timeline. A cycle starts the first sessions.',
|
|
1278
|
+
command: 'sloppatrol --once',
|
|
1279
|
+
})}</div>`;
|
|
1280
|
+
const roles = Object.keys(s.byRole);
|
|
1281
|
+
const bar = filterBar('sessions', [
|
|
1282
|
+
{ id: 'all', label: 'All', count: s.total },
|
|
1283
|
+
{ id: 'failed', label: 'Failed', count: s.failed },
|
|
1284
|
+
{ id: 'stopped', label: 'Stopped', count: s.stopped },
|
|
1285
|
+
...roles.map((role) => ({ id: role, label: capital(role), count: s.byRole[role] })),
|
|
1286
|
+
]);
|
|
1287
|
+
const f = filters.sessions;
|
|
1288
|
+
const list = state.sessions.filter((x) => f === 'all' || x.state === f || x.role === f);
|
|
1289
|
+
const failed = list.filter((x) => x.state === 'failed');
|
|
1290
|
+
const others = list.filter((x) => x.state !== 'failed');
|
|
1291
|
+
const body = !list.length
|
|
1292
|
+
? emptyState({
|
|
1293
|
+
title: 'No session in this group',
|
|
1294
|
+
why: 'No recent session matches this filter. Select All to see each session.',
|
|
1295
|
+
compact: true,
|
|
1296
|
+
})
|
|
1297
|
+
: f === 'all' && failed.length
|
|
1298
|
+
? `${group('sessions:failed', 'Failed', failed.length, failed.map(sessionRowHtml).join(''), 'bad')}${group('sessions:other', 'Other sessions', others.length, others.map(sessionRowHtml).join(''))}`
|
|
1299
|
+
: `<div class="list">${list.map(sessionRowHtml).join('')}</div>`;
|
|
1300
|
+
const note =
|
|
1301
|
+
s.total > state.sessions.length
|
|
1302
|
+
? `<p class="muted small">The list shows the newest ${state.sessions.length} of ${number(s.total)} sessions. The numbers above count each session.</p>`
|
|
1303
|
+
: '';
|
|
1304
|
+
return `${numbers}${bar}${note}${body}`;
|
|
479
1305
|
}
|
|
480
1306
|
|
|
481
1307
|
function sessionDetail(id) {
|
|
@@ -492,27 +1318,32 @@ function sessionDetail(id) {
|
|
|
492
1318
|
return `<li><time>${clock(e.at)}</time><div>${text}</div></li>`;
|
|
493
1319
|
})
|
|
494
1320
|
.join('');
|
|
1321
|
+
const role = summary?.role ?? events[0]?.role ?? 'Session';
|
|
495
1322
|
return `
|
|
496
|
-
<h2>${esc(summary?.
|
|
497
|
-
<div class="pills">${summary ? chip(summary.state) : ''}${summary?.
|
|
498
|
-
|
|
1323
|
+
<h2>${esc(capital(role))}${summary?.subject ? `: ${esc(subjectWords(summary.subject))}` : ''}</h2>
|
|
1324
|
+
<div class="pills">${summary ? chip(summary.state, { running: 'run', done: 'ok', failed: 'bad', stopped: 'stop' }[summary.state]) : ''}${summary?.costUsd ? tag(money(summary.costUsd)) : ''}</div>
|
|
1325
|
+
${summary?.subject?.startsWith('refactor:') ? `<p><button type="button" class="link" data-select="refactor:${esc(summary.subject.slice(9))}">Open the refactor</button></p>` : ''}
|
|
1326
|
+
<h3>Timeline</h3>
|
|
499
1327
|
${items ? `<ul class="events">${items}</ul>` : '<p>Loading…</p>'}`;
|
|
500
1328
|
}
|
|
501
1329
|
|
|
502
1330
|
// ---------- shell ----------
|
|
503
1331
|
|
|
504
|
-
function empty(text) {
|
|
505
|
-
return `<div class="empty">${esc(text)}</div>`;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
1332
|
function selected(type, id) {
|
|
509
1333
|
return selection?.type === type && selection.id === id ? 'selected' : '';
|
|
510
1334
|
}
|
|
511
1335
|
|
|
512
|
-
|
|
1336
|
+
function setHtml(element, html) {
|
|
1337
|
+
if (element.dataset.html !== html) {
|
|
1338
|
+
element.innerHTML = html;
|
|
1339
|
+
element.dataset.html = html;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** `#<tab>` or `#<tab>/<type>:<id>`, so a page and its detail can be linked. */
|
|
513
1344
|
function tab() {
|
|
514
1345
|
const name = location.hash.slice(1).split('/')[0];
|
|
515
|
-
return
|
|
1346
|
+
return TABS.includes(name) ? name : 'overview';
|
|
516
1347
|
}
|
|
517
1348
|
|
|
518
1349
|
async function loadSelection() {
|
|
@@ -521,8 +1352,10 @@ async function loadSelection() {
|
|
|
521
1352
|
if (type === 'check') {
|
|
522
1353
|
const run = state.checkRuns.find((r) => r.id === id);
|
|
523
1354
|
const cached = fetched.get(`check:${id}`);
|
|
524
|
-
if (!cached || cached.status === 'running' || run?.status === 'running')
|
|
525
|
-
|
|
1355
|
+
if (!cached || cached.status === 'running' || run?.status === 'running') {
|
|
1356
|
+
const response = await fetch(`/api/checks/${id}`);
|
|
1357
|
+
if (response.ok) fetched.set(`check:${id}`, await response.json());
|
|
1358
|
+
}
|
|
526
1359
|
}
|
|
527
1360
|
if (type === 'session') {
|
|
528
1361
|
const summary = state.sessions.find((s) => s.id === id);
|
|
@@ -540,61 +1373,140 @@ function renderDetail() {
|
|
|
540
1373
|
const { type, id } = selection;
|
|
541
1374
|
const body =
|
|
542
1375
|
type === 'refactor'
|
|
543
|
-
? refactorDetail(
|
|
1376
|
+
? refactorDetail(refactorById(id))
|
|
544
1377
|
: type === 'opportunity'
|
|
545
|
-
? opportunityDetail(
|
|
1378
|
+
? opportunityDetail(opportunityById(id))
|
|
546
1379
|
: type === 'check'
|
|
547
1380
|
? checkDetail(id)
|
|
548
1381
|
: type === 'unit'
|
|
549
1382
|
? unitDetail(id)
|
|
550
1383
|
: sessionDetail(id);
|
|
551
1384
|
panel.hidden = false;
|
|
552
|
-
|
|
1385
|
+
// The heading takes the focus when the details open.
|
|
1386
|
+
setHtml(
|
|
1387
|
+
panel,
|
|
1388
|
+
`<button type="button" class="close icon-button" data-close aria-label="Close the details" title="Close">×</button>${body.replace('<h2>', '<h2 tabindex="-1">')}`,
|
|
1389
|
+
);
|
|
553
1390
|
}
|
|
554
1391
|
|
|
1392
|
+
const VIEWS = {
|
|
1393
|
+
overview: renderOverview,
|
|
1394
|
+
refactors: renderRefactors,
|
|
1395
|
+
scorecard: renderScorecard,
|
|
1396
|
+
checks: renderChecks,
|
|
1397
|
+
backlog: renderBacklog,
|
|
1398
|
+
sessions: renderSessions,
|
|
1399
|
+
share: renderShare,
|
|
1400
|
+
};
|
|
1401
|
+
|
|
555
1402
|
function render() {
|
|
556
1403
|
if (!state) return;
|
|
557
1404
|
renderTop();
|
|
558
1405
|
const current = tab();
|
|
559
1406
|
for (const link of document.querySelectorAll('.tabs a')) {
|
|
560
|
-
|
|
1407
|
+
const active = link.dataset.tab === current;
|
|
1408
|
+
link.classList.toggle('active', active);
|
|
1409
|
+
if (active) link.setAttribute('aria-current', 'page');
|
|
1410
|
+
else link.removeAttribute('aria-current');
|
|
561
1411
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
};
|
|
569
|
-
setHtml($('view'), views[current]());
|
|
1412
|
+
// A refresh replaces the controls: give the focus back to the same one.
|
|
1413
|
+
const focused = document.activeElement;
|
|
1414
|
+
const key = ['select', 'filter', 'go', 'copy'].find((k) => focused?.dataset?.[k] !== undefined);
|
|
1415
|
+
document.body.classList.toggle('sharing', current === 'share');
|
|
1416
|
+
const name = document.querySelector(`.tabs a[data-tab="${current}"]`)?.textContent ?? 'Share the scorecard';
|
|
1417
|
+
setHtml($('view'), `<h1 class="sr-only">${name}</h1>${VIEWS[current]()}`);
|
|
570
1418
|
renderDetail();
|
|
1419
|
+
if (key && !document.body.contains(focused))
|
|
1420
|
+
document.querySelector(`[data-${key}="${CSS.escape(focused.dataset[key])}"]`)?.focus();
|
|
571
1421
|
}
|
|
572
1422
|
|
|
573
1423
|
async function refresh() {
|
|
574
1424
|
try {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1425
|
+
const response = await fetch('/api/state');
|
|
1426
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
1427
|
+
state = await response.json();
|
|
1428
|
+
$('offline').hidden = true;
|
|
578
1429
|
} catch {
|
|
579
|
-
$('
|
|
1430
|
+
$('offline').hidden = false;
|
|
1431
|
+
return;
|
|
580
1432
|
}
|
|
1433
|
+
await loadSelection();
|
|
1434
|
+
render();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/** The control that opened the details, to give the focus back when they close. */
|
|
1438
|
+
let opener;
|
|
1439
|
+
|
|
1440
|
+
function select(type, id) {
|
|
1441
|
+
selection = { type, id };
|
|
1442
|
+
history.replaceState(null, '', `#${tab()}/${type}:${id}`);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function closeDetail() {
|
|
1446
|
+
selection = undefined;
|
|
1447
|
+
history.replaceState(null, '', `#${tab()}`);
|
|
1448
|
+
render();
|
|
1449
|
+
document.querySelector(`[data-select="${CSS.escape(opener ?? '')}"]`)?.focus();
|
|
581
1450
|
}
|
|
582
1451
|
|
|
583
1452
|
document.addEventListener('click', async (event) => {
|
|
584
|
-
const target = event.target.closest(
|
|
1453
|
+
const target = event.target.closest(
|
|
1454
|
+
'[data-select], [data-filter], [data-close], [data-go], [data-copy], [data-share]',
|
|
1455
|
+
);
|
|
585
1456
|
if (!target) return;
|
|
586
|
-
if (target.dataset.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1457
|
+
if (target.dataset.share) return shareAction(target);
|
|
1458
|
+
if (target.dataset.copy !== undefined) {
|
|
1459
|
+
try {
|
|
1460
|
+
await navigator.clipboard.writeText(target.dataset.copy);
|
|
1461
|
+
target.textContent = 'Copied';
|
|
1462
|
+
} catch {
|
|
1463
|
+
target.textContent = 'Copy failed';
|
|
1464
|
+
}
|
|
1465
|
+
setTimeout(() => {
|
|
1466
|
+
target.textContent = 'Copy';
|
|
1467
|
+
}, 1500);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (target.dataset.close !== undefined) return closeDetail();
|
|
1471
|
+
if (target.dataset.filter) {
|
|
1472
|
+
const [page, value] = target.dataset.filter.split(':');
|
|
1473
|
+
filters[page] = value;
|
|
1474
|
+
} else if (target.dataset.go) {
|
|
1475
|
+
const [page, value] = target.dataset.go.split(':');
|
|
1476
|
+
if (value) filters[page] = value;
|
|
1477
|
+
if (tab() !== page) {
|
|
1478
|
+
location.hash = page;
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
} else {
|
|
590
1482
|
const [type, id] = target.dataset.select.split(':');
|
|
591
|
-
|
|
592
|
-
|
|
1483
|
+
select(type, id);
|
|
1484
|
+
if (!target.closest('#detail')) opener = target.dataset.select;
|
|
1485
|
+
await loadSelection();
|
|
1486
|
+
render();
|
|
1487
|
+
$('detail').querySelector('h2')?.focus();
|
|
1488
|
+
return;
|
|
593
1489
|
}
|
|
594
1490
|
await loadSelection();
|
|
595
1491
|
render();
|
|
596
1492
|
});
|
|
597
1493
|
|
|
1494
|
+
// A person's open or closed group stays that way after a refresh.
|
|
1495
|
+
document.addEventListener(
|
|
1496
|
+
'toggle',
|
|
1497
|
+
(event) => {
|
|
1498
|
+
const id = event.target.dataset?.group;
|
|
1499
|
+
if (!id) return;
|
|
1500
|
+
if (event.target.open) closed.delete(id);
|
|
1501
|
+
else closed.add(id);
|
|
1502
|
+
},
|
|
1503
|
+
true,
|
|
1504
|
+
);
|
|
1505
|
+
|
|
1506
|
+
document.addEventListener('keydown', (event) => {
|
|
1507
|
+
if (event.key === 'Escape' && selection) closeDetail();
|
|
1508
|
+
});
|
|
1509
|
+
|
|
598
1510
|
function selectFromHash() {
|
|
599
1511
|
const link = location.hash.slice(1).split('/')[1];
|
|
600
1512
|
if (!link) return;
|