devicectl-core 0.1.0__py3-none-any.whl

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.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,1139 @@
1
+ /* The shared pieces every panel is built from.
2
+ *
3
+ * One card shape, one row shape, one badge, one dialog, one toast -- so a
4
+ * page of ten cards reads as one page rather than as ten opinions, and so
5
+ * a new panel is a list of rows rather than a fresh set of decisions about
6
+ * padding. Two programs draw from this now, which is the point: a
7
+ * primitive that suits only one of them is a primitive that was really a
8
+ * page.
9
+ *
10
+ * Nothing here names a device, a program or a colour. What a card is made
11
+ * of lives in `core.css`, written against tokens alone; what those tokens
12
+ * are is each program's own `app.css`.
13
+ */
14
+
15
+ import { html, useEffect, useRef, useState } from '../vendor/preact-htm.module.js';
16
+
17
+ export const DASH = '—';
18
+
19
+ /* A value as text, with an em dash where there is none.
20
+ *
21
+ * A whole number stays whole: a limit of 16 A reads "16", not "16.0". Use
22
+ * `fixed` where a column of readings is watched over time -- there, a
23
+ * value that changes width between polls is the worse fault. */
24
+ export function fmt(value, digits = 1) {
25
+ if (value === null || value === undefined || value === '') return DASH;
26
+ if (typeof value === 'number') {
27
+ if (Number.isNaN(value)) return DASH;
28
+ return Number.isInteger(value) ? String(value) : value.toFixed(digits);
29
+ }
30
+ return String(value);
31
+ }
32
+
33
+ /* A number at a fixed number of decimals, or an em dash where the device
34
+ * did not answer. A row that disappears when a register is unmapped
35
+ * changes the card's height between polls; a row with a dash in it does
36
+ * not, and neither does a reading that keeps its decimals. */
37
+ export function fixed(value, digits = 2) {
38
+ if (value === null || value === undefined || Number.isNaN(value)) return DASH;
39
+ if (typeof value !== 'number') return String(value);
40
+ return value.toFixed(digits);
41
+ }
42
+
43
+ /* A span of time in the largest unit that still says something useful.
44
+ * It measures how long an operation took, so it runs from milliseconds --
45
+ * a write that lands in 150 ms -- to hours. */
46
+ export function span(seconds) {
47
+ if (seconds < 1) return `${Math.round(seconds * 1000)} ms`;
48
+ if (seconds < 60) return `${seconds.toFixed(1)} s`;
49
+ if (seconds < 5400) return `${Math.round(seconds / 60)} min`;
50
+ return `${(seconds / 3600).toFixed(1)} h`;
51
+ }
52
+
53
+ /* How long ago something happened, said in whole units. A reading is
54
+ * either current or it is not, and "3m ago" is the whole of what a
55
+ * timestamp on a dashboard has to say. */
56
+ export function ago(seconds) {
57
+ if (seconds === null || seconds === undefined) return DASH;
58
+ const n = Math.max(0, Math.round(seconds));
59
+ if (n < 60) return `${n}s ago`;
60
+ if (n < 3600) return `${Math.round(n / 60)}m ago`;
61
+ if (n < 86400) return `${Math.round(n / 3600)}h ago`;
62
+ return `${Math.round(n / 86400)}d ago`;
63
+ }
64
+
65
+ /* A long span -- an uptime, a time to full -- in the two largest units it
66
+ * has. `span` is for something that just happened; this is for something
67
+ * that has been going on. */
68
+ export function duration(seconds) {
69
+ if (seconds === null || seconds === undefined) return DASH;
70
+ const n = Math.max(0, Math.round(seconds));
71
+ const days = Math.floor(n / 86400);
72
+ const hours = Math.floor((n % 86400) / 3600);
73
+ const mins = Math.floor((n % 3600) / 60);
74
+ if (days) return `${days}d ${hours}h`;
75
+ if (hours) return `${hours}h ${mins}m`;
76
+ return `${mins}m ${n % 60}s`;
77
+ }
78
+
79
+ /* A moment, the one way every one of these pages writes one: ISO order,
80
+ * with a space where the `T` goes. `2026-09-26 08:03:46` is unambiguous on
81
+ * both sides of the Atlantic, which "26/09/2026, 08:03:46" and
82
+ * "9/26/2026, 8:03:46 AM" -- what a browser's own locale makes of the same
83
+ * instant -- are not; and a reader comparing it with a terminal's output is
84
+ * reading the same shape. Takes what a server sends, an ISO string with or
85
+ * without a zone, and keeps the wall clock in it as it was sent: the zone it
86
+ * is in is the device's to say, not the browser's. */
87
+ export function stamp(iso) {
88
+ if (!iso) return null;
89
+ return String(iso).replace('T', ' ').slice(0, 19);
90
+ }
91
+
92
+ /* How long the link has to stay busy before the page treats it as busy.
93
+ *
94
+ * The live refresh holds the connection for as long as a device takes to
95
+ * answer, several times a minute. Reacting to that on the beat meant
96
+ * every button on the page went grey and came back every few seconds,
97
+ * which reads as a fault rather than as a refresh. Work nobody asked for
98
+ * is filtered out by `link.quiet` before it gets here; this is for the
99
+ * rest -- a write that lands in 150 ms should not flicker the page on its
100
+ * way past either.
101
+ */
102
+ const BUSY_SETTLE_MS = 400;
103
+
104
+ /* True once `busy` has held for BUSY_SETTLE_MS, false the moment it drops.
105
+ *
106
+ * Slow one way and immediate the other, deliberately: a control should
107
+ * come back the instant it can be used, and go away only when there is
108
+ * really something to wait for. Nothing is lost by letting a click
109
+ * through in the meantime -- the server queues every request behind the
110
+ * one connection anyway, and answers it when its turn comes.
111
+ */
112
+ export function useSteadyBusy(busy, delay = BUSY_SETTLE_MS) {
113
+ const [steady, setSteady] = useState(false);
114
+ useEffect(() => {
115
+ if (!busy) {
116
+ setSteady(false);
117
+ return undefined;
118
+ }
119
+ const timer = setTimeout(() => setSteady(true), delay);
120
+ return () => clearTimeout(timer);
121
+ }, [busy, delay]);
122
+ return steady;
123
+ }
124
+
125
+ /* One field of a card: its name on the left, its value on the right.
126
+ *
127
+ * `data` marks a value that is read character by character -- an id, a
128
+ * licence key, a timestamp. Those get fixed-width digits, and the whole
129
+ * value on hover, since a long one wraps rather than being cut off.
130
+ * `tone` colours the value (`good`, `warn`, `bad`); `token` sets it in the
131
+ * monospace face without claiming it is data. `pending` is a field holding
132
+ * an edit nobody has applied: its name goes the colour every unsent edit on
133
+ * the page wears -- see `.row.pending` and `Card`'s `draft`.
134
+ *
135
+ * Two tooltips, for two questions. `hint` is on the name and says what
136
+ * the field *is* -- an acronym spelled out, the register behind it;
137
+ * `title` is on the value and says what it may *be* -- its range, its
138
+ * default. They were one tooltip on the whole row, so the meaning of a
139
+ * setting and its bounds came up together wherever the pointer was.
140
+ */
141
+ export function Row({ k, v, title, hint, data, tone, token, pending }) {
142
+ const plain = typeof v === 'string' || typeof v === 'number' ? String(v) : '';
143
+ const cls = ['v', data ? 'data' : '', tone || '', token ? 'token' : '']
144
+ .filter(Boolean)
145
+ .join(' ');
146
+ return html`<div class=${pending ? 'row pending' : 'row'}>
147
+ <div class=${hint ? 'k hinted' : 'k'} title=${hint}>${k}</div>
148
+ <div class=${cls} title=${title || plain}>
149
+ ${v === null || v === undefined || v === ''
150
+ ? html`<span class="muted">${DASH}</span>`
151
+ : v}
152
+ </div>
153
+ </div>`;
154
+ }
155
+
156
+ /* A word about a value, in the colour of what it says. */
157
+ export function Badge({ tone, children, title }) {
158
+ return html`<span class=${tone ? `badge ${tone}` : 'badge'} title=${title}
159
+ >${children}</span
160
+ >`;
161
+ }
162
+
163
+ /* Where a list would be, if there were anything in it. */
164
+ export function Empty({ children }) {
165
+ return html`<div class="empty">${children}</div>`;
166
+ }
167
+
168
+ /* One labelled reading, of a row of them: what it is above, the figure
169
+ * below. Pack voltage, current, power; average cell, spread, balance
170
+ * current. Three or six of these under a card's title is how a page says
171
+ * "here are the figures" before anything has to be read.
172
+ *
173
+ * Not a `Row`: a row is a field of a card, as wide as the card, with its
174
+ * value against the far edge. These are read across, not down.
175
+ */
176
+ export function Stat({ k, tone, title, children }) {
177
+ return html`<div class=${tone ? `stat ${tone}` : 'stat'} title=${title}>
178
+ <span class="k">${k}</span>
179
+ <span class="v">${children}</span>
180
+ </div>`;
181
+ }
182
+
183
+ /* A row of them. */
184
+ export function Stats({ children }) {
185
+ return html`<div class="stats">${children}</div>`;
186
+ }
187
+
188
+ /* The chevron, wherever one says a thing opens.
189
+ *
190
+ * It was the character `▸` -- and `▾` where it pointed down -- set at the
191
+ * smallest size on the page. Unicode's "small" triangles are drawn small
192
+ * *within* their em as well, so a 12px one came out a few pixels across:
193
+ * at a glance a full stop that had drifted up the line, and on a hidpi
194
+ * screen a dot that the reader is meant to recognise as a direction. A
195
+ * drawn chevron is the same shape at any size and carries a stroke weight,
196
+ * so it can sit at the size of the letters beside it and still read as one
197
+ * mark rather than a smudge.
198
+ *
199
+ * `down` is a chevron that has been turned a quarter turn, which is what a
200
+ * menu trigger wants: the shape is one shape, turned, rather than two
201
+ * characters that happen to be neighbours in a font. A disclosure that
202
+ * turns as it opens does it from CSS, on `details[open]`.
203
+ */
204
+ export function Chev({ down = false }) {
205
+ return html`<svg
206
+ class=${down ? 'chev down' : 'chev'}
207
+ viewBox="0 0 16 16"
208
+ fill="none"
209
+ stroke="currentColor"
210
+ stroke-width="2"
211
+ stroke-linecap="round"
212
+ stroke-linejoin="round"
213
+ aria-hidden="true"
214
+ focusable="false"
215
+ >
216
+ <path d="M6 3l5 5-5 5" />
217
+ </svg>`;
218
+ }
219
+
220
+ /* One line about a card, with the rest of what there is to say folded
221
+ * behind it.
222
+ *
223
+ * Half the cards on a page opened with three or four sentences of
224
+ * explanation, which is a paragraph of prose above two controls: it pushes
225
+ * the controls down, it wraps to a different number of lines in every
226
+ * card, and the cards in a row then end at four different heights. The
227
+ * summary is the one line worth reading every time; the paragraph is worth
228
+ * reading once, so it opens.
229
+ *
230
+ * The chevron is what says it opens. It read "why?" -- a word small
231
+ * enough and faint enough to be taken for part of the sentence, which then
232
+ * went away once the line was open, leaving nothing to press to close it
233
+ * again.
234
+ */
235
+ export function Help({ summary, children }) {
236
+ if (!children) return html`<p class="note">${summary}</p>`;
237
+ return html`<details class="help">
238
+ <summary>
239
+ <${Chev} />
240
+ <span class="short">${summary}</span>
241
+ </summary>
242
+ <p>${children}</p>
243
+ </details>`;
244
+ }
245
+
246
+ /* A setting that is legal but will not do what it looks like -- a socket
247
+ * limit above the station's, a cell count the pack does not have. The
248
+ * card has one line for it and the explanation takes three, so the short
249
+ * form is what shows: the whole of it is the hover title, and clicking
250
+ * opens it in place for anyone without a mouse to hover with. */
251
+ export function Caveats({ items }) {
252
+ if (!items?.length) return null;
253
+ return html`<div class="caveats">
254
+ ${items.map(
255
+ (item) => html`<details class="why" key=${item.short || item}>
256
+ <summary title=${item.detail || item}>
257
+ <span class="mark">!</span>
258
+ <span class="short">${item.short || item}</span>
259
+ <${Chev} />
260
+ </summary>
261
+ <p>${item.detail || item}</p>
262
+ </details>`
263
+ )}
264
+ </div>`;
265
+ }
266
+
267
+ /* A panel.
268
+ *
269
+ * `width` is one of three, not a spans-everything boolean: the default is
270
+ * one column of the grid, `wide` is two, and `full` is the row. The
271
+ * boolean it replaced was on eleven cards, which is what made a page of
272
+ * them read as a stack of banners rather than a grid -- so `full` is now
273
+ * for the things that genuinely are a row wide (a table, the log, a plot)
274
+ * and `wide` for a form of two columns. Everything else is one column,
275
+ * and roughly square. `tools/frontlint.py` (C005) holds cards to it.
276
+ *
277
+ * `badge` and `actions` both hang on the right of the title: a badge says
278
+ * what the card is, and actions are what can be done to it. `foot` is
279
+ * ruled off below the body, for what belongs under a card's content.
280
+ *
281
+ * `draft` is a view from `useDraft` (drafts.js) -- or `pending`, a count,
282
+ * where the edits are counted some other way. A card holding any wears
283
+ * them: its edge goes the colour of an unsent edit and its title says how
284
+ * many are waiting. A long page of settings scrolls out of sight, and a
285
+ * changed field three cards down is one amber outline among forty grey
286
+ * ones; the card is the unit somebody scrolling past actually sees. When
287
+ * the draft's scope can be sent, the card's own Apply and Discard join the
288
+ * count in its title (`CardApply`) -- the same edits the header's Apply
289
+ * counts among everything else that is waiting.
290
+ *
291
+ * `immediate` is a card whose controls write the moment they are used,
292
+ * with no draft and no Apply: it says so in its title, so nobody goes
293
+ * looking for the Apply that sends it.
294
+ */
295
+ export function Card({ title, badge, actions, width, help, foot, children, draft, pending, immediate }) {
296
+ const held = pending ?? draft?.count ?? 0;
297
+ const cls = ['card', width || '', held ? 'pending' : ''].filter(Boolean).join(' ');
298
+ return html`<section class=${cls}>
299
+ ${title
300
+ ? html`<h2>
301
+ <span class="grow">${title}</span>
302
+ ${held ? html`<${Unsent} count=${held} />` : null}
303
+ ${held && draft?.writable
304
+ ? html`<${CardApply}
305
+ count=${held}
306
+ busy=${draft.busy}
307
+ disabled=${draft.disabled}
308
+ onApply=${draft.apply}
309
+ onDiscard=${draft.clear}
310
+ />`
311
+ : null}
312
+ ${immediate ? html`<${Immediate} />` : null}
313
+ ${badge}${actions}
314
+ </h2>`
315
+ : null}
316
+ ${help ? html`<${Help} summary=${help.summary}>${help.body}<//>` : null}
317
+ <div class="card-body">${children}</div>
318
+ ${foot ? html`<div class="card-foot">${foot}</div>` : null}
319
+ </section>`;
320
+ }
321
+
322
+ /* The badge on a card that has no Apply because it needs none. */
323
+ export function Immediate() {
324
+ return html`<span
325
+ class="badge"
326
+ title="What is changed in this card is written to the device straight away; there is nothing to apply."
327
+ >
328
+ applied immediately
329
+ </span>`;
330
+ }
331
+
332
+ /* The words on a card holding edits: how many, and that they have not gone
333
+ * anywhere yet -- the same "not sent yet" the header's Apply says. */
334
+ export function Unsent({ count }) {
335
+ return html`<span class="badge unsent" title="changed here and not written to the device yet">
336
+ ${count} not sent
337
+ </span>`;
338
+ }
339
+
340
+ /* Apply and Discard for one card's edits, in its title beside the count.
341
+ *
342
+ * They were a bar in the card's foot, which appeared with the first edit
343
+ * and so made the card taller -- and a grid row is as tall as its tallest
344
+ * card, so one changed field moved every card below it down the page. In
345
+ * the title they take no height: two buttons the size of the badge beside
346
+ * them, a tick to send and a cross to throw away, each with its words in
347
+ * its tooltip and its accessible name. `Card` draws them from its draft:
348
+ * the tick is the draft's `apply`, which shows a plan first where the
349
+ * scope has one, and the cross its `clear`. */
350
+ const TICK_SVG = {
351
+ viewBox: '0 0 16 16',
352
+ width: 12,
353
+ height: 12,
354
+ fill: 'none',
355
+ stroke: 'currentColor',
356
+ 'stroke-width': 2.2,
357
+ 'stroke-linecap': 'round',
358
+ 'stroke-linejoin': 'round',
359
+ 'aria-hidden': 'true',
360
+ focusable: 'false',
361
+ };
362
+
363
+ export function CardApply({ count, busy, disabled, onApply, onDiscard }) {
364
+ const what = `${count} change${count === 1 ? '' : 's'}`;
365
+ const send = `Apply ${what}`;
366
+ const drop = `Discard ${what}`;
367
+ return html`<span class="card-apply" role="group" aria-label=${`${what} not sent`}>
368
+ <button
369
+ type="button"
370
+ class="btn tick primary"
371
+ title=${send}
372
+ aria-label=${send}
373
+ disabled=${busy || disabled}
374
+ onClick=${onApply}
375
+ >
376
+ <svg ...${TICK_SVG}><path d="M3 8.5l3.2 3.2L13 4.8" /></svg>
377
+ </button>
378
+ <button
379
+ type="button"
380
+ class="btn tick"
381
+ title=${drop}
382
+ aria-label=${drop}
383
+ disabled=${busy}
384
+ onClick=${onDiscard}
385
+ >
386
+ <svg ...${TICK_SVG}><path d="M4 4l8 8M12 4l-8 8" /></svg>
387
+ </button>
388
+ </span>`;
389
+ }
390
+
391
+ /* A popover that belongs to the pointer and the keyboard both: a click
392
+ * anywhere else, or Escape, puts it away. Returns the open flag, a
393
+ * toggle, the ref to hang on whatever counts as "inside", and the ref for
394
+ * the button that opens it.
395
+ *
396
+ * Closing gives the keyboard back. Escape used to leave focus on a button
397
+ * that no longer existed, which drops it at the top of the document: the
398
+ * menu is dismissed and the next Tab starts the page over. So the trigger
399
+ * is remembered and refocused, and only when the popover itself had the
400
+ * focus -- closing because a click landed somewhere else must not pull the
401
+ * keyboard away from wherever that click went. */
402
+ export function usePopover() {
403
+ const [open, setOpen] = useState(false);
404
+ const box = useRef(null);
405
+ const trigger = useRef(null);
406
+
407
+ const close = () => {
408
+ if (box.current?.contains(document.activeElement)) trigger.current?.focus();
409
+ setOpen(false);
410
+ };
411
+
412
+ useEffect(() => {
413
+ if (!open) return undefined;
414
+ /* A dialog opened from inside the popover -- the list behind a count
415
+ * in a menu -- sits on top of it rather than somewhere else: a click in
416
+ * it, or the Escape that closes it, is about the dialog. The popover
417
+ * goes on the next click outside both, not on the way back from it. */
418
+ const elsewhere = (event) => {
419
+ if (event.target.closest?.('.backdrop')) return;
420
+ if (!box.current?.contains(event.target)) setOpen(false);
421
+ };
422
+ const key = (event) => {
423
+ if (event.key !== 'Escape') return;
424
+ if (document.querySelector('[aria-modal="true"]')) return;
425
+ if (box.current?.contains(document.activeElement)) trigger.current?.focus();
426
+ setOpen(false);
427
+ };
428
+ document.addEventListener('mousedown', elsewhere);
429
+ window.addEventListener('keydown', key);
430
+ return () => {
431
+ document.removeEventListener('mousedown', elsewhere);
432
+ window.removeEventListener('keydown', key);
433
+ };
434
+ }, [open]);
435
+
436
+ return {
437
+ open,
438
+ box,
439
+ trigger,
440
+ toggle: () => (open ? close() : setOpen(true)),
441
+ close,
442
+ };
443
+ }
444
+
445
+ /* What a browser will let the keyboard reach. */
446
+ const FOCUSABLE = [
447
+ 'a[href]',
448
+ 'button:not([disabled])',
449
+ 'input:not([disabled]):not([type="hidden"])',
450
+ 'select:not([disabled])',
451
+ 'textarea:not([disabled])',
452
+ '[tabindex]:not([tabindex="-1"])',
453
+ ].join(',');
454
+
455
+ /* Everything a modal owes the keyboard, as one ref to hang on its box.
456
+ *
457
+ * `aria-modal` is a promise to a screen reader that the rest of the page
458
+ * is not there; without a trap it is a promise the page does not keep, and
459
+ * Tab walks straight out of the dialog into the controls behind it -- with
460
+ * the reader still saying it is inside. So: focus moves in when the
461
+ * dialog opens (to whatever it holds that can take it, or the box itself),
462
+ * Tab and Shift+Tab wrap at the ends, Escape asks it to close, and the
463
+ * element that opened it gets the keyboard back when it goes.
464
+ */
465
+ export function useModal(onClose) {
466
+ const box = useRef(null);
467
+
468
+ useEffect(() => {
469
+ const from = document.activeElement;
470
+ const first = box.current?.querySelector(FOCUSABLE);
471
+ (first || box.current)?.focus();
472
+
473
+ const key = (event) => {
474
+ if (event.key === 'Escape') {
475
+ onClose?.();
476
+ return;
477
+ }
478
+ if (event.key !== 'Tab' || !box.current) return;
479
+ const inside = [...box.current.querySelectorAll(FOCUSABLE)];
480
+ if (!inside.length) {
481
+ event.preventDefault();
482
+ return;
483
+ }
484
+ const edge = event.shiftKey ? inside[0] : inside[inside.length - 1];
485
+ if (document.activeElement !== edge) return;
486
+ event.preventDefault();
487
+ (event.shiftKey ? inside[inside.length - 1] : inside[0]).focus();
488
+ };
489
+ window.addEventListener('keydown', key);
490
+ return () => {
491
+ window.removeEventListener('keydown', key);
492
+ /* Only if the dialog still had it: a click that lands somewhere
493
+ * else has already chosen where the keyboard should be. */
494
+ if (from && (!box.current || box.current.contains(document.activeElement))) {
495
+ from.focus?.();
496
+ }
497
+ };
498
+ }, [onClose]);
499
+
500
+ return box;
501
+ }
502
+
503
+ /* A dialog: a title, some content, and a way out.
504
+ *
505
+ * The title is the accessible name, through `aria-labelledby` rather than
506
+ * a second copy of it in an `aria-label` that can drift from the heading a
507
+ * reader can see. */
508
+ let dialogSeq = 0;
509
+
510
+ export function Dialog({ title, children, onClose, width, actions }) {
511
+ const [id] = useState(() => {
512
+ dialogSeq += 1;
513
+ return `dialog-title-${dialogSeq}`;
514
+ });
515
+ const box = useModal(onClose);
516
+ return html`<div
517
+ class="backdrop"
518
+ onClick=${(e) => e.target === e.currentTarget && onClose?.()}
519
+ >
520
+ <div
521
+ class="modal"
522
+ ref=${box}
523
+ tabindex="-1"
524
+ style=${width ? `width:min(${width}px,100%)` : ''}
525
+ role="dialog"
526
+ aria-modal="true"
527
+ aria-labelledby=${id}
528
+ >
529
+ <h3 id=${id}>${title}</h3>
530
+ ${children}
531
+ <div class="buttons">
532
+ ${actions || html`<button class="btn" onClick=${onClose}>Close</button>`}
533
+ </div>
534
+ </div>
535
+ </div>`;
536
+ }
537
+
538
+ /* The two lines a device is named by: the one string no other device on the
539
+ * bench shares, and what the thing is.
540
+ *
541
+ * Wherever a device is named it is named in this shape -- in the header, and
542
+ * in the dialog that changes which one the page is about -- because those
543
+ * two places answer one question between them: am I on the right device,
544
+ * and if not, which of these is it? They had not been. The header carried
545
+ * a serial number over a model and two version strings; the dialog beside
546
+ * it listed the same boards as "BMS 1" with the model after it, so a bank
547
+ * of matching units read as four identical rows and the line the header had
548
+ * just shown was nowhere on the page.
549
+ */
550
+ export function Lines({ primary, secondary }) {
551
+ return html`<span class="lines">
552
+ <span class="primary">${primary}</span>
553
+ <span class="secondary">${secondary}</span>
554
+ </span>`;
555
+ }
556
+
557
+ /* Which of the devices on the other end this page is about.
558
+ *
559
+ * One button in the header, one dialog of choices. Both programs had a way
560
+ * to switch: one a button opening a list, the other a bare `<select>` in the
561
+ * header -- a dropdown that named boards in twenty characters, could not say
562
+ * which of them was in trouble, and looked like nothing else on either page.
563
+ * A list is what this is: a name, a line about it, and the one you are on
564
+ * marked.
565
+ *
566
+ * `entries` is `{ key, label, detail, aside, tone, selected }` each: the two
567
+ * lines a device is named by, and then whatever this program files a device
568
+ * under that is neither of them -- an address on a bus, where a name was
569
+ * found. `children` is whatever the program needs under the list -- typing
570
+ * an address in by hand, a rescan -- because what "a device you have not got
571
+ * yet" means differs between a bus you can sweep and a network you can only
572
+ * ask.
573
+ */
574
+ export function Picker({ title, entries, onPick, onClose, looking, empty, width, children }) {
575
+ const list = entries || [];
576
+ return html`<${Dialog}
577
+ title=${title}
578
+ onClose=${onClose}
579
+ width=${width || 560}
580
+ actions=${html`<button class="btn ghost" onClick=${onClose}>Cancel</button>`}
581
+ >
582
+ ${looking
583
+ ? html`<p class="note flush">Looking...</p>`
584
+ : list.length === 0
585
+ ? html`<p class="note flush">${empty || 'Nothing to choose from yet.'}</p>`
586
+ : html`<div class="scroller stack">
587
+ ${list.map(
588
+ (entry) => html`<button
589
+ class=${`btn pick${entry.tone ? ` ${entry.tone}` : ''}`}
590
+ key=${entry.key}
591
+ aria-current=${entry.selected ? 'true' : undefined}
592
+ onClick=${() => onPick(entry)}
593
+ >
594
+ <${Lines} primary=${entry.label} secondary=${entry.detail} />
595
+ ${entry.aside ? html`<span class="aside">${entry.aside}</span>` : null}
596
+ ${entry.selected ? html`<span class="badge good">this one</span>` : null}
597
+ </button>`
598
+ )}
599
+ </div>`}
600
+ ${children}
601
+ <//>`;
602
+ }
603
+
604
+ /* Anything irreversible asks first, with the reason in the dialog rather
605
+ * than in a tooltip somebody has to go looking for.
606
+ *
607
+ * `typed` is for the few that are worse than irreversible -- erasing a
608
+ * transaction database, writing firmware: the button stays dead until the
609
+ * word is typed, so the dialog cannot be dismissed by reflex. */
610
+ export function Confirm({
611
+ title,
612
+ body,
613
+ confirmLabel,
614
+ danger,
615
+ typed,
616
+ onConfirm,
617
+ onCancel,
618
+ }) {
619
+ const [text, setText] = useState('');
620
+ const ready = !typed || text.trim().toLowerCase() === typed;
621
+ return html`<${Dialog}
622
+ title=${title}
623
+ onClose=${onCancel}
624
+ actions=${html`
625
+ <button class="btn ghost" onClick=${onCancel}>Cancel</button>
626
+ <button
627
+ class=${danger ? 'btn danger' : 'btn primary'}
628
+ disabled=${!ready}
629
+ onClick=${onConfirm}
630
+ >
631
+ ${confirmLabel || 'Confirm'}
632
+ </button>
633
+ `}
634
+ >
635
+ <p>${body}</p>
636
+ ${typed
637
+ ? html`<p class="muted">
638
+ Type <b>${typed}</b> to confirm:
639
+ <input
640
+ type="text"
641
+ class="typed"
642
+ value=${text}
643
+ onInput=${(e) => setText(e.target.value)}
644
+ />
645
+ </p>`
646
+ : null}
647
+ <//>`;
648
+ }
649
+
650
+ /* Asking before doing, as one line in the panel that does it.
651
+ *
652
+ * Nine panels had written this out for themselves: a `useState(null)`, a
653
+ * `<${Confirm}>` at the bottom of the render, and the same ordering to get
654
+ * right every time -- run the action *before* clearing the dialog, or the
655
+ * closure that holds what to do is gone by the time it is called. Here it
656
+ * is once.
657
+ *
658
+ * `ask` takes the dialog whole -- its words and its `run`, which is what
659
+ * to do if the answer is yes. `node` is what the panel renders, and is
660
+ * null until something has been asked.
661
+ */
662
+ export function useConfirm() {
663
+ const [pending, setPending] = useState(null);
664
+
665
+ const ask = (question) => setPending(question);
666
+
667
+ const node = pending
668
+ ? html`<${Confirm}
669
+ ...${pending}
670
+ onConfirm=${() => {
671
+ const { run } = pending;
672
+ setPending(null);
673
+ run?.();
674
+ }}
675
+ onCancel=${() => setPending(null)}
676
+ />`
677
+ : null;
678
+
679
+ return { ask, node, open: Boolean(pending) };
680
+ }
681
+
682
+ /* Every notice the page raises, and the one place a screen reader is told
683
+ * about them. `polite` rather than `assertive`: these report what just
684
+ * happened, and none of them is worth cutting somebody off mid-sentence.
685
+ *
686
+ * A notice can carry buttons -- what to do about it, right there -- which
687
+ * is what a failure that started a recording needs: the way to fetch the
688
+ * recording, and the way to stop it, beside the sentence that said so. */
689
+ export function Toasts({ toasts, dismiss }) {
690
+ return html`<div class="toasts" role="status" aria-live="polite">
691
+ ${toasts.map(
692
+ (toast) => html`<div class=${`toast ${toast.kind}`} key=${toast.id}>
693
+ <div class="said">
694
+ <span>${toast.text}</span>
695
+ ${toast.actions?.length
696
+ ? html`<div class="toast-actions">
697
+ ${toast.actions.map(
698
+ (action) => html`<button
699
+ class="btn small"
700
+ key=${action.label}
701
+ disabled=${action.disabled}
702
+ onClick=${() => {
703
+ action.onClick();
704
+ if (action.dismiss !== false) dismiss(toast.id);
705
+ }}
706
+ >
707
+ ${action.label}
708
+ </button>`
709
+ )}
710
+ </div>`
711
+ : null}
712
+ </div>
713
+ <button
714
+ class="x"
715
+ title="dismiss"
716
+ aria-label="dismiss"
717
+ onClick=${() => dismiss(toast.id)}
718
+ >
719
+ ×
720
+ </button>
721
+ </div>`
722
+ )}
723
+ </div>`;
724
+ }
725
+
726
+ /* Toast list plus the helpers views use to add to it.
727
+ *
728
+ * A failure stays until it is dismissed. It used to go after twelve
729
+ * seconds, which is long enough to see that something is red and not long
730
+ * enough to read what, let alone copy it into a message -- and the one
731
+ * notice on a page that says a write did not happen is the last one that
732
+ * should take itself away while somebody is still looking for it. Good
733
+ * news and information still go on their own. */
734
+ export function useToasts() {
735
+ const [toasts, setToasts] = useState([]);
736
+ const seq = useRef(0);
737
+ const dismiss = (id) => setToasts((list) => list.filter((t) => t.id !== id));
738
+ const push = (kind, text, linger, actions) => {
739
+ seq.current += 1;
740
+ const id = seq.current;
741
+ setToasts((list) => [...list, { id, kind, text, actions }]);
742
+ if (linger) setTimeout(() => dismiss(id), linger);
743
+ return id;
744
+ };
745
+ return {
746
+ toasts,
747
+ dismiss,
748
+ ok: (text) => push('ok', String(text), 6000),
749
+ info: (text, { actions } = {}) => push('info', text, actions?.length ? 0 : 6000, actions),
750
+ error: (text, { actions } = {}) => push('error', text, 0, actions),
751
+ };
752
+ }
753
+
754
+ /* Doing something to the device, and saying how it went, in one call.
755
+ *
756
+ * Nineteen handlers had written `.then(toast.ok).catch(toast.error)` out
757
+ * by hand, each with its own idea of what to say and its own chance of
758
+ * forgetting the catch -- which is an unhandled rejection and a page that
759
+ * says nothing at all. This is that line, once. It returns the promise,
760
+ * already handled, so a caller that wants to do something after can, and a
761
+ * caller that does not is still safe -- unless `raise` asks for the failure
762
+ * back, which is what a draft's write needs to know it did not go.
763
+ */
764
+ export function caller(toast) {
765
+ return (work, said, { raise = false } = {}) =>
766
+ Promise.resolve()
767
+ .then(work)
768
+ .then((answer) => {
769
+ if (said) toast.ok(typeof said === 'function' ? said(answer) : said);
770
+ return answer;
771
+ })
772
+ .catch((err) => {
773
+ toast.error(err.message || String(err));
774
+ /* A draft's write has to fail to keep its edits: see drafts.js. */
775
+ if (raise) throw err;
776
+ return undefined;
777
+ });
778
+ }
779
+
780
+ /* A select that does not re-render when nothing about it changed.
781
+ *
782
+ * The page re-renders on every link beat -- once a second while a device
783
+ * is connected -- and each one rebuilt the option vnodes, so the differ
784
+ * rewrote every option's value on the way past. That is a mutation of the
785
+ * very list a browser's open dropdown is showing, so the popup rebuilt
786
+ * under the pointer and the highlight moved with it: a category dropdown
787
+ * jumped to a different option from time to time, and only while a device
788
+ * was connected, which is the only time the page re-renders on a beat.
789
+ * Handing the differ back the same vnode it already rendered
790
+ * short-circuits the diff before it reaches the DOM, and the open popup
791
+ * keeps its hover.
792
+ *
793
+ * `entries` is compared by value rather than identity on purpose: callers
794
+ * build it inline, and a stable identity is exactly what a plain array
795
+ * built on render does not have. Each entry is `{value, title}` or the
796
+ * `[value, title]` pair the same table is often already in.
797
+ *
798
+ * `onChange` is handed the chosen value, and the event after it: what a
799
+ * caller wants is the value in all but a handful of cases, and reading it
800
+ * off the event was one more chance to write `e.target.checked`.
801
+ */
802
+ export function Select({ value, onChange, entries, disabled, pending, onKeyDown }) {
803
+ const options = (entries || []).map((entry) =>
804
+ Array.isArray(entry) ? { value: entry[0], title: entry[1] } : entry
805
+ );
806
+ const prev = useRef(null);
807
+ /* The handlers live in a ref that the memoised vnode reads through, so a
808
+ * cached `<select>` still calls the current ones. `onChange` would get
809
+ * away without it -- most callers close over a state setter, which is
810
+ * the same function for the life of the page -- but `onKeyDown` cannot:
811
+ * an Enter-to-save closes over the value being edited, and a copy cached
812
+ * with the vnode would save the value it held when the dropdown was
813
+ * first drawn. */
814
+ const live = useRef(null);
815
+ live.current = { onChange, onKeyDown };
816
+ const shown = value === null || value === undefined ? '' : String(value);
817
+ if (
818
+ !prev.current ||
819
+ shown !== prev.current.value ||
820
+ disabled !== prev.current.disabled ||
821
+ pending !== prev.current.pending ||
822
+ !sameEntries(prev.current.entries, options)
823
+ ) {
824
+ prev.current = {
825
+ value: shown,
826
+ disabled,
827
+ pending,
828
+ entries: options,
829
+ /* The whole of what is selected, on hover. A `<select>` is capped
830
+ * at the width of the column it sits in, so the longest options are
831
+ * cut with an ellipsis, and the pointer is how the rest is read. */
832
+ vnode: html`<select
833
+ class=${pending ? 'pending' : ''}
834
+ value=${shown}
835
+ onChange=${(event) => live.current.onChange?.(event.target.value, event)}
836
+ onKeyDown=${(event) => live.current.onKeyDown?.(event)}
837
+ disabled=${disabled}
838
+ title=${options.find((e) => String(e.value) === shown)?.title || ''}
839
+ >
840
+ ${options.map(
841
+ (e) => html`<option value=${e.value} key=${e.value}>${e.title}</option>`
842
+ )}
843
+ </select>`,
844
+ };
845
+ }
846
+ return prev.current.vnode;
847
+ }
848
+
849
+ function sameEntries(a, b) {
850
+ return (
851
+ a.length === b.length &&
852
+ a.every((entry, i) => entry.value === b[i].value && entry.title === b[i].title)
853
+ );
854
+ }
855
+
856
+ /* A bar with a fraction in it, and a word about what the fraction is. */
857
+ export function Bar({ fraction, label }) {
858
+ const known = fraction !== null && fraction !== undefined;
859
+ const width = Math.max(0, Math.min(1, fraction ?? 0)) * 100;
860
+ return html`<div>
861
+ <div class=${known ? 'bar' : 'bar unknown'}>
862
+ <span style=${known ? `width:${width}%` : ''}></span>
863
+ </div>
864
+ ${label ? html`<div class="bar-note muted">${label}</div>` : null}
865
+ </div>`;
866
+ }
867
+
868
+ /* A small ring that turns, for a wait that fits inside a control.
869
+ *
870
+ * `Bar` is the other answer to "this is taking a while", and it is for a
871
+ * job with a start, an end and something to say about the distance
872
+ * between. This is for a wait that has none of those: a single register
873
+ * going out over a serial bus, which is done when it is done.
874
+ */
875
+ export function Spinner({ label }) {
876
+ return html`<span class="spinner" role="img" aria-label=${label || 'working'}></span>`;
877
+ }
878
+
879
+ /* Something is being read from the device, and it is taking a while.
880
+ *
881
+ * A read that pages the device -- an event log, a transaction database,
882
+ * every property for a backup -- holds the one connection for seconds at a
883
+ * time, and the page had nothing to show for it but the pill's "busy",
884
+ * which is also what a 150 ms write looks like. So the panel that asked
885
+ * draws a bar while its own read is running, with whatever the worker can
886
+ * say about how far it has got.
887
+ *
888
+ * `what` is the beginning of the operation's name, as the worker publishes
889
+ * it: a panel shows the bar for its own read and not for someone else's
890
+ * write going past. A panel whose read has more than one name passes an
891
+ * array, so that no panel has to widen its prefix until it matches
892
+ * everybody else's reads too. `tools/frontlint.py` (C004) checks every
893
+ * name here against the operations the program's `web/api.py` publishes.
894
+ */
895
+ export function Progress({ link, what }) {
896
+ const names = what === undefined ? [] : Array.isArray(what) ? what : [what];
897
+ const op = link?.op || '';
898
+ const running =
899
+ link &&
900
+ (link.state === 'busy' || link.state === 'opening') &&
901
+ !link.quiet &&
902
+ (names.length === 0 || names.some((name) => op.startsWith(name)));
903
+ if (!running) return null;
904
+ const known = link.progress !== null && link.progress !== undefined;
905
+ return html`<div class="reading" role="status">
906
+ <div class="what">
907
+ <span>${link.op || 'Reading the device'}</span>
908
+ <span class="muted"
909
+ >${link.note || (known ? `${Math.round(link.progress * 100)}%` : '')}</span
910
+ >
911
+ </div>
912
+ <div class=${known ? 'bar' : 'bar unknown'}>
913
+ <span style=${known ? `width:${Math.round(link.progress * 100)}%` : ''}></span>
914
+ </div>
915
+ </div>`;
916
+ }
917
+
918
+ /* Live updates, as one switch in the header.
919
+ *
920
+ * This lived in the link pill's menu, with Connect, Release and the
921
+ * watcher count, on the reasoning that those are controls nobody touches
922
+ * twice an hour. That is true of the other three and was never true of
923
+ * this one: it decides whether every number on the page is a reading or a
924
+ * memory, and it is the thing people reach for the moment a device starts
925
+ * doing something. It costs one click here and cost two there.
926
+ *
927
+ * A mode rather than an action, so `aria-pressed` rather than a label that
928
+ * changes. The state is in the shape as well as the colour -- the line is
929
+ * flat when the page is not reading and beats when it is -- so it survives
930
+ * a screen that has no colour to read.
931
+ *
932
+ * It was three arcs rising from a dot, which is the signal-strength mark
933
+ * every phone and laptop draws for a radio: on a page about a device on a
934
+ * network, beside a Wi-Fi tab, a switch wearing that icon reads as "the
935
+ * link is good", which is a different claim entirely and one this button
936
+ * does not make. A pulse says what this actually is -- something being
937
+ * read over and over -- and belongs to no other meaning here.
938
+ */
939
+ export function LiveToggle({ link, offline, onLive, program }) {
940
+ const live = Boolean(link?.live);
941
+ const every = link?.pollInterval || 3;
942
+ const title = offline
943
+ ? `Live updates need the ${program} server.`
944
+ : live
945
+ ? `Live updates on -- reading the device every ${every}s. Click to pause.`
946
+ : 'Live updates paused. Click to resume.';
947
+ return html`<button
948
+ type="button"
949
+ class=${`btn small ghost icon live${live ? ' on' : ''}`}
950
+ title=${title}
951
+ aria-label=${title}
952
+ aria-pressed=${live}
953
+ disabled=${offline}
954
+ onClick=${() => onLive(!live)}
955
+ >
956
+ <svg
957
+ viewBox="0 0 16 16"
958
+ width="15"
959
+ height="15"
960
+ fill="none"
961
+ stroke="currentColor"
962
+ stroke-width="1.6"
963
+ stroke-linecap="round"
964
+ stroke-linejoin="round"
965
+ aria-hidden="true"
966
+ focusable="false"
967
+ >
968
+ <path class="flat" d="M1.6 8h12.8" />
969
+ <path class="beat" d="M1.6 8h2.6l1.7-4.2L8.9 12.4l1.4-4.4h2.7" />
970
+ </svg>
971
+ </button>`;
972
+ }
973
+
974
+ /* The server has gone, and every control on the page is about to lie about
975
+ * a device it can no longer reach.
976
+ *
977
+ * The page cannot fix that and does not pretend to. It says which of the
978
+ * two things went -- the program on this machine, not the device on the
979
+ * other end of the wire -- keeps trying, and offers the one thing a person
980
+ * can do about it. Nothing here is dismissible: a banner you can wave
981
+ * away is a banner that stops being true quietly.
982
+ *
983
+ * It sits *in* the header rather than under it. As its own bar it was a
984
+ * block that appeared and disappeared with the server, and every blip
985
+ * pushed the tabs and the whole page down and back -- movement caused by
986
+ * the one message on the page that is not about the device. The header is
987
+ * a flex row with a spacer in it, which on any laptop is hundreds of
988
+ * pixels of slack, and its height is set by the two-line device name, so
989
+ * one line of small text costs nothing. The pill beside it already reads
990
+ * "server unreachable"; this is the sentence that says which server and
991
+ * what to do.
992
+ *
993
+ * The sentence yields space rather than taking it, and below the narrow
994
+ * breakpoint it goes entirely -- so the announcement is carried by a
995
+ * visually-hidden copy that is always there in full, and the visible text
996
+ * is plain. Eliding text for the eye must not elide it for a reader.
997
+ */
998
+ export function OfflineNotice({ state, onRetry, program }) {
999
+ if (state !== 'offline' && state !== 'reconnecting') return null;
1000
+ const gone = state === 'offline';
1001
+ const said = gone
1002
+ ? `The ${program} server is not answering. Nothing on this page is live.`
1003
+ : `Lost the ${program} server -- trying to get it back...`;
1004
+ return html`<div class=${gone ? 'offline-note gone' : 'offline-note'}>
1005
+ <span class="sr-only" role="alert">${said}</span>
1006
+ <span class="dot" aria-hidden="true"></span>
1007
+ <span class="what" aria-hidden="true">${said}</span>
1008
+ <button class="btn small" onClick=${onRetry}>Try now</button>
1009
+ </div>`;
1010
+ }
1011
+
1012
+ /* --- a device's clock, said the same way on both dashboards ---------------
1013
+ *
1014
+ * Both programs put the device's clock on the card that names the device,
1015
+ * and each had said it its own way: "Unit clock" in whatever format the
1016
+ * browser's locale picked, beside "Off this host by 03h 30m" with no
1017
+ * direction; and "Local time" in ISO beside "3.5 hours ahead of this
1018
+ * computer". Neither a charger nor a battery board keeps a zone -- the
1019
+ * charger keeps UTC and an offset it was given, the board a count from local
1020
+ * midnight -- so what either can show is its local time, and it is named
1021
+ * that. The difference is the server's phrase, `devicectl.clock`, which
1022
+ * leaves out whose computer it is: the page is often read on another one.
1023
+ *
1024
+ * `clock` is `{ local, drift, driftS }`: an ISO wall-clock time, the phrase,
1025
+ * and the signed seconds it was made from. `zone` is what the device says
1026
+ * about where its local time is, as the local row's hover; `outAfter` is
1027
+ * how far out, in seconds, is worth a warning.
1028
+ */
1029
+ export function ClockRows({ clock, zone, outAfter = 60 }) {
1030
+ const drift = clock?.driftS;
1031
+ const out = typeof drift === 'number' && Math.abs(drift) > outAfter;
1032
+ return html`
1033
+ <${Row} k="Local time" v=${stamp(clock?.local)} data=${true} title=${zone} />
1034
+ <${Row}
1035
+ k="Clock difference"
1036
+ v=${clock?.drift ? html`<span class=${out ? 'badge warn' : ''}>${clock.drift}</span>` : null}
1037
+ title="how far the device's clock is from the clock of the computer running this program"
1038
+ />
1039
+ `;
1040
+ }
1041
+
1042
+ /* Setting the device's clock from the server's, as the one action on the
1043
+ * card that shows the clock -- so it goes in the card's title, where it
1044
+ * costs no height, and says the same two words in both programs. */
1045
+ export function SyncClock({ busy, onSync }) {
1046
+ return html`<button
1047
+ class="btn small"
1048
+ disabled=${busy}
1049
+ onClick=${onSync}
1050
+ title="set the device's clock from the clock of the computer running this program"
1051
+ >
1052
+ Sync clock
1053
+ </button>`;
1054
+ }
1055
+
1056
+ /* The health check's one button, in its card's title. "Run check" until it
1057
+ * has run, "Check again" after -- both programs, the same words. */
1058
+ export function RunCheck({ ran, running, busy, onRun }) {
1059
+ return html`<button class="btn small" disabled=${busy || running} onClick=${onRun}>
1060
+ ${running ? 'Checking…' : ran ? 'Check again' : 'Run check'}
1061
+ </button>`;
1062
+ }
1063
+
1064
+ /* --- a health check -------------------------------------------------------
1065
+ *
1066
+ * Both programs have a doctor, and both put it in a card on the dashboard
1067
+ * -- which each had written for itself, so one said "nothing wrong" where
1068
+ * the other said "nothing to report", one listed findings with the command
1069
+ * that fixes them and the other put them in rows without it. "Nothing to
1070
+ * report" is the one to keep: a check can only say what it looked at, and
1071
+ * a pack or a charger it found nothing on is not thereby a pack or a
1072
+ * charger with nothing wrong.
1073
+ *
1074
+ * `report` is the shape devicectl.doctor gives every program: `findings`,
1075
+ * each `{ severity, area, detail, fix }`, and `unavailable`, what could not
1076
+ * be looked at. `onRun` asks for a new one and says itself if that fails. */
1077
+ const SEVERITY_TONE = { error: 'bad', warning: 'warn' };
1078
+ const SEVERITY_ORDER = ['error', 'warning', 'note'];
1079
+
1080
+ function plural(n, word) {
1081
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
1082
+ }
1083
+
1084
+ /* What a report comes to, on the badge beside the card's title. */
1085
+ export function HealthBadge({ report }) {
1086
+ if (!report) return null;
1087
+ const findings = report.findings || [];
1088
+ if (!findings.length) return html`<${Badge} tone="good">nothing to report<//>`;
1089
+ const counts = SEVERITY_ORDER.map((severity) => [
1090
+ severity,
1091
+ findings.filter((f) => f.severity === severity).length,
1092
+ ]).filter(([, n]) => n);
1093
+ const worst = counts[0]?.[0];
1094
+ return html`<${Badge} tone=${SEVERITY_TONE[worst] || ''}>
1095
+ ${counts.map(([severity, n]) => plural(n, severity)).join(', ')}
1096
+ <//>`;
1097
+ }
1098
+
1099
+ export function HealthCard({ report, busy, onRun, help, idle }) {
1100
+ const [running, setRunning] = useState(false);
1101
+ const run = async () => {
1102
+ setRunning(true);
1103
+ try {
1104
+ await onRun();
1105
+ } catch {
1106
+ /* Said already, by whoever ran it. */
1107
+ } finally {
1108
+ setRunning(false);
1109
+ }
1110
+ };
1111
+ const findings = report?.findings || [];
1112
+ return html`<${Card}
1113
+ title="Health"
1114
+ badge=${html`<${HealthBadge} report=${report} />`}
1115
+ actions=${html`<${RunCheck} ran=${!!report} running=${running} busy=${busy} onRun=${run} />`}
1116
+ help=${help}
1117
+ >
1118
+ ${!report
1119
+ ? html`<${Empty}>${idle || 'Not run yet.'}<//>`
1120
+ : findings.length
1121
+ ? html`<ul class="list">
1122
+ ${findings.map(
1123
+ (f, i) => html`<li key=${i}>
1124
+ <${Badge} tone=${SEVERITY_TONE[f.severity] || ''}>${f.severity}<//>
1125
+ <span>
1126
+ ${f.area ? html`<b>${f.area}</b>: ` : null}${f.detail}
1127
+ ${f.fix ? html`<br /><span class="muted mono">${f.fix}</span>` : null}
1128
+ </span>
1129
+ </li>`
1130
+ )}
1131
+ </ul>`
1132
+ : html`<${Empty}>Nothing to report.<//>`}
1133
+ ${report?.unavailable?.length
1134
+ ? html`<div class="caveat">
1135
+ <span>!</span><span>Could not check: ${report.unavailable.join('; ')}</span>
1136
+ </div>`
1137
+ : null}
1138
+ <//>`;
1139
+ }