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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- 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
|
+
}
|