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,672 @@
1
+ /* The page around the panels: the theme, the tab strip, and whether the
2
+ * program behind the page is still there.
3
+ *
4
+ * None of this is about a device. Both programs that use this package had
5
+ * re-typed all of it -- a three-way theme with the same two storage keys,
6
+ * hash routing over a table of tabs, a six-second grace before calling the
7
+ * server gone, a tablist with the same roving tabindex -- and the copies
8
+ * had already begun to disagree about which key the theme is under.
9
+ */
10
+
11
+ import { get, onUnreachable } from '/core/js/api.js';
12
+ import { Dialog, Lines, span } from '/core/js/ui.js';
13
+ import { h, html, useEffect, useRef, useState } from '/core/vendor/preact-htm.module.js';
14
+
15
+ /* What the program calls itself, which is what its storage keys are named
16
+ * after: two of these served from one machine are two origins only by
17
+ * port, and `localStorage` is not scoped by port. */
18
+ let program = 'devicectl';
19
+
20
+ export function configure({ name }) {
21
+ program = name;
22
+ }
23
+
24
+ const themeKey = () => `${program}-theme-mode`;
25
+ /* The key an earlier version wrote on every load, so every browser that
26
+ * ever opened the page has "dark" in it and would never see the system
27
+ * default. Dropped on the way past. */
28
+ const oldThemeKey = () => `${program}-theme`;
29
+
30
+ /* system first: a device checked from a laptop in a bright office and one
31
+ * checked from a phone in a dark garage want different pages, and the
32
+ * machine already knows which. */
33
+ const THEMES = ['system', 'light', 'dark'];
34
+
35
+ const LIGHT_QUERY = '(prefers-color-scheme: light)';
36
+
37
+ /* The page's theme: the system's, unless this browser has said otherwise.
38
+ *
39
+ * Resolved here rather than in the stylesheet. The sheets are dark on
40
+ * `:root` and light behind `[data-theme="light"]`, so answering
41
+ * `prefers-color-scheme` in CSS as well would mean keeping the whole light
42
+ * palette written twice; this stamps the attribute the sheets already
43
+ * read. The page does not run without JavaScript anyway -- there is a
44
+ * `<noscript>` on it saying so.
45
+ */
46
+ export function useTheme() {
47
+ const [mode, setMode] = useState(() => {
48
+ const stored = localStorage.getItem(themeKey());
49
+ return THEMES.includes(stored) ? stored : 'system';
50
+ });
51
+
52
+ useEffect(() => localStorage.removeItem(oldThemeKey()), []);
53
+
54
+ useEffect(() => {
55
+ const media = window.matchMedia(LIGHT_QUERY);
56
+ const stamp = () => {
57
+ document.documentElement.dataset.theme =
58
+ mode === 'system' ? (media.matches ? 'light' : 'dark') : mode;
59
+ };
60
+ stamp();
61
+ if (mode !== 'system') return undefined;
62
+ // Only while following the system is there anything to follow.
63
+ media.addEventListener('change', stamp);
64
+ return () => media.removeEventListener('change', stamp);
65
+ }, [mode]);
66
+
67
+ const next = THEMES[(THEMES.indexOf(mode) + 1) % THEMES.length];
68
+ return {
69
+ mode,
70
+ next,
71
+ /* A choice is remembered; the resolved theme is not. Writing it back
72
+ * on every load is what left every browser pinned to the old default. */
73
+ cycle: () => {
74
+ setMode(next);
75
+ localStorage.setItem(themeKey(), next);
76
+ },
77
+ };
78
+ }
79
+
80
+ /* What all three theme marks share: the live switch's box, its stroke
81
+ * weight and its cap -- so the header's icons are one set. */
82
+ const THEME_SVG = {
83
+ viewBox: '0 0 16 16',
84
+ width: 15,
85
+ height: 15,
86
+ fill: 'none',
87
+ stroke: 'currentColor',
88
+ 'stroke-width': 1.5,
89
+ 'stroke-linecap': 'round',
90
+ 'stroke-linejoin': 'round',
91
+ 'aria-hidden': 'true',
92
+ focusable: 'false',
93
+ };
94
+
95
+ /* The three theme marks, drawn rather than typed.
96
+ *
97
+ * They were the characters U+25D0, U+2600 and U+263E, and a font decides
98
+ * how big a character is: the sun came with its own generous side
99
+ * bearings, the moon was set at cap height beside it, and the half-filled
100
+ * circle standing for "follow the system" was drawn smaller than either.
101
+ * Three buttons of one size holding three marks of three sizes, in a
102
+ * header whose other icon -- the live switch -- is a 15px drawing. These
103
+ * are that drawing's siblings, at one optical size, with no font in the
104
+ * decision.
105
+ *
106
+ * The system mark is a disc half filled: the two themes in one circle,
107
+ * which says "whichever of them the machine is on" without introducing a
108
+ * third idea for it. */
109
+ const THEME_ICON = {
110
+ system: html`<svg ...${THEME_SVG}>
111
+ <circle cx="8" cy="8" r="5.6" />
112
+ <path d="M8 2.4a5.6 5.6 0 0 0 0 11.2z" fill="currentColor" stroke="none" />
113
+ </svg>`,
114
+ light: html`<svg ...${THEME_SVG}>
115
+ <circle cx="8" cy="8" r="3.4" />
116
+ <path
117
+ d="M8 1.1v1.7M8 13.2v1.7M1.1 8h1.7M13.2 8h1.7M3.15 3.15l1.2 1.2M11.65 11.65l1.2 1.2M12.85 3.15l-1.2 1.2M4.35 11.65l-1.2 1.2"
118
+ />
119
+ </svg>`,
120
+ dark: html`<svg ...${THEME_SVG}>
121
+ <path d="M13.4 9.6A5.9 5.9 0 0 1 6.4 2.6a5.9 5.9 0 1 0 7 7z" />
122
+ </svg>`,
123
+ };
124
+
125
+ /* The theme, as one button in the header. */
126
+ export function ThemeToggle({ theme }) {
127
+ return html`<button
128
+ class="btn small ghost icon"
129
+ type="button"
130
+ title=${`theme: ${theme.mode} -- click for ${theme.next}`}
131
+ aria-label=${`theme: ${theme.mode}`}
132
+ onClick=${theme.cycle}
133
+ >
134
+ ${THEME_ICON[theme.mode]}
135
+ </button>`;
136
+ }
137
+
138
+ /* --- what the program says about itself ------------------------------------
139
+ *
140
+ * Its name, the version being served, and the project's own URLs -- the
141
+ * page it lives on, its release notes, its licence. All of it comes from
142
+ * `GET /api/about`, which answers out of the packaging metadata, so no URL
143
+ * is written into the browser half at all: `[project.urls]` in the
144
+ * program's `pyproject.toml` is the one place any of them is typed.
145
+ *
146
+ * One request per page, whatever asks: the answer never changes while the
147
+ * server is up, so the promise is kept and handed to every caller. A
148
+ * failed one is *not* kept -- a page loaded while the server was still
149
+ * coming up would otherwise have no version and no links for as long as it
150
+ * stayed open.
151
+ */
152
+ let pending = null;
153
+
154
+ function about() {
155
+ if (!pending) {
156
+ pending = get('/api/about').catch((err) => {
157
+ pending = null;
158
+ throw err;
159
+ });
160
+ }
161
+ return pending;
162
+ }
163
+
164
+ export function useAbout() {
165
+ const [doc, setDoc] = useState(null);
166
+ useEffect(() => {
167
+ let alive = true;
168
+ about().then(
169
+ (answer) => alive && setDoc(answer),
170
+ () => {
171
+ /* No version and no links, which is a wordmark that is not
172
+ * clickable. The page is about a device and is worth drawing
173
+ * without them; `useServerLink` is what says the server has gone. */
174
+ }
175
+ );
176
+ return () => {
177
+ alive = false;
178
+ };
179
+ }, []);
180
+ return doc;
181
+ }
182
+
183
+ /* --- a program's own glyph -------------------------------------------------
184
+ *
185
+ * Each of these programs has one mark: jkctl a battery, alfenctl a bolt. It
186
+ * is drawn in two places -- in front of the wordmark, and in the browser's
187
+ * tab -- and those two had drifted apart, because they were two separate
188
+ * drawings kept in two languages. The header's was built out of divs and
189
+ * borders in the program's own stylesheet, or was an emoji the font decided
190
+ * the shape of; the tab's was an SVG typed into a percent-encoded `data:`
191
+ * URI in `index.html`, which is unreadable, unreachable from anything, and
192
+ * was never going to be edited twice. Nothing could have told you the two
193
+ * disagreed except looking at them, and by the time anyone did they were a
194
+ * battery next to a toolbox.
195
+ *
196
+ * So a mark is declared once, as data: a viewBox and a list of shapes.
197
+ * `Glyph` renders it into the header and `useFavicon` serialises the same
198
+ * list into the `data:` URI the tab wants. There is one drawing and it
199
+ * cannot drift from itself.
200
+ *
201
+ * A shape is `[tag, attributes]`. Anything in it set to `currentColor`
202
+ * follows the text in the header, and becomes the tab icon's own colour on
203
+ * the way into the favicon, which is a document of its own with no text to
204
+ * inherit from.
205
+ */
206
+
207
+ /* The house style every mark and every header icon is drawn in: one box,
208
+ * one stroke weight, one cap. A mark that opts out of it is a mark that
209
+ * looks like it came from somewhere else. */
210
+ const MARK_BOX = '0 0 16 16';
211
+
212
+ const MARK_STROKE = {
213
+ fill: 'none',
214
+ stroke: 'currentColor',
215
+ 'stroke-width': 1.5,
216
+ 'stroke-linecap': 'round',
217
+ 'stroke-linejoin': 'round',
218
+ };
219
+
220
+ /* The program's mark, in front of its name.
221
+ *
222
+ * The shapes go through `h` rather than through a template, because their
223
+ * tags are data -- `rect` here, `path` there -- and a template with a
224
+ * variable tag in it is a template nothing can check. `h(tag, attrs)` is
225
+ * what the template would have compiled to anyway. */
226
+ export function Glyph({ mark, size = 16 }) {
227
+ return html`<svg
228
+ class="glyph"
229
+ viewBox=${mark.viewBox || MARK_BOX}
230
+ width=${size}
231
+ height=${size}
232
+ ...${MARK_STROKE}
233
+ aria-hidden="true"
234
+ focusable="false"
235
+ >
236
+ ${mark.shapes.map(([tag, attrs], i) => h(tag, { key: i, ...attrs }))}
237
+ </svg>`;
238
+ }
239
+
240
+ /* How much of the tab icon is the tile around the mark rather than the mark.
241
+ * A favicon is drawn at 16px inside browser chrome that crops and rounds it,
242
+ * so the drawing is inset rather than run to the edges. */
243
+ const TILE_PAD = 2.2;
244
+
245
+ /* Attributes, as the text of them, with `currentColor` resolved. A favicon
246
+ * is a document of its own with no text to inherit a colour from. */
247
+ function attrText(attrs, color) {
248
+ return Object.entries(attrs)
249
+ .map(([key, value]) => `${key}="${value === 'currentColor' ? color : value}"`)
250
+ .join(' ');
251
+ }
252
+
253
+ /* The mark as a whole SVG document, on its tile: what a tab icon is.
254
+ *
255
+ * The house style goes on the group and each shape carries only what is its
256
+ * own, which is exactly how `Glyph` hangs it on the `<svg>` above the same
257
+ * shapes. Two ways of drawing one list of shapes is how this drifted in the
258
+ * first place; this way an attribute that a shape overrides overrides it in
259
+ * both, and a shape that says nothing looks the same in both.
260
+ */
261
+ function faviconSvg(mark, { color, tile }) {
262
+ const box = mark.viewBox || MARK_BOX;
263
+ const side = Number(box.split(/\s+/)[3]);
264
+ const scale = (side - 2 * TILE_PAD) / side;
265
+ const inset = `translate(${TILE_PAD} ${TILE_PAD}) scale(${scale.toFixed(4)})`;
266
+ return (
267
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box}">` +
268
+ `<rect width="${side}" height="${side}" rx="${side * 0.22}" fill="${tile}"/>` +
269
+ `<g transform="${inset}" ${attrText(MARK_STROKE, color)}>` +
270
+ mark.shapes.map(([tag, attrs]) => `<${tag} ${attrText(attrs, color)}/>`).join('') +
271
+ `</g></svg>`
272
+ );
273
+ }
274
+
275
+ /* Put the program's mark in the tab, in the colours its own stylesheet
276
+ * names for it.
277
+ *
278
+ * The colours are read off `:root` rather than passed as hexes, because a
279
+ * colour written in JavaScript is a colour that is not in the palette with
280
+ * the others. They are their own two tokens and not the theme's `--brand`:
281
+ * the tile is dark under both themes, so the mark on it has to be the shade
282
+ * that reads on a dark tile whichever theme the page itself is wearing.
283
+ *
284
+ * Stamped from here rather than written into `index.html` because that is
285
+ * what makes it the same drawing as the header's. The page does not run
286
+ * without JavaScript anyway -- there is a `<noscript>` on it saying so.
287
+ */
288
+ export function useFavicon(mark, { color = '--tab-mark', tile = '--tab-tile' } = {}) {
289
+ useEffect(() => {
290
+ const token = (name) =>
291
+ name.startsWith('--')
292
+ ? getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'
293
+ : name;
294
+ let link = document.querySelector('link[rel="icon"]');
295
+ if (!link) {
296
+ link = document.createElement('link');
297
+ link.rel = 'icon';
298
+ document.head.appendChild(link);
299
+ }
300
+ link.type = 'image/svg+xml';
301
+ link.href = `data:image/svg+xml,${encodeURIComponent(
302
+ faviconSvg(mark, { color: token(color), tile: token(tile) })
303
+ )}`;
304
+ }, [mark, color, tile]);
305
+ }
306
+
307
+ /* The wordmark: the program's name, and the version beside it.
308
+ *
309
+ * The name links to the project and the version to the release notes for
310
+ * exactly this build -- which is the question a version number in a header
311
+ * is actually asking, and one nobody can answer by reading the number.
312
+ * Both hrefs come from `about()`; without them the same two pieces of text
313
+ * are drawn, unlinked.
314
+ *
315
+ * `children` is the program's own glyph, in front of the name.
316
+ */
317
+ export function Brand({ children }) {
318
+ const doc = useAbout();
319
+ const name = doc?.app || program;
320
+ const home = doc?.links?.homepage || '';
321
+ const releases = doc?.links?.releases || '';
322
+ const mark = html`${children}<span class="name">${name}</span>`;
323
+ return html`<div class="brand">
324
+ ${home
325
+ ? html`<a class="home" href=${home} target="_blank" rel="noreferrer" title=${`${name} on the web`}>
326
+ ${mark}
327
+ </a>`
328
+ : html`<span class="home">${mark}</span>`}
329
+ ${doc?.version
330
+ ? releases
331
+ ? html`<a
332
+ class="ver"
333
+ href=${releases}
334
+ target="_blank"
335
+ rel="noreferrer"
336
+ title=${`release notes for ${name} ${doc.version}`}
337
+ >${doc.version}</a>`
338
+ : html`<span class="ver">${doc.version}</span>`
339
+ : null}
340
+ </div>`;
341
+ }
342
+
343
+ /* Which device this page is about, in the two lines every one of these
344
+ * headers carries: what it is called, and what it is.
345
+ *
346
+ * `primary` is the one string no other device on the bench shares -- a name
347
+ * it was given, or its serial number. `secondary` is the model and the
348
+ * versions of the hardware and the software that are answering, because
349
+ * what a device will do at all depends on them.
350
+ *
351
+ * With `onPick` the block is itself the way to change device, and there is
352
+ * no separate button. There had been one, a ghost button reading "change"
353
+ * sitting next to the name -- a second control, with its own hit area and
354
+ * its own word, for an action whose subject was the thing right beside it.
355
+ * The name is what somebody looks at to decide they are on the wrong
356
+ * device, so the name is where they try to click.
357
+ *
358
+ * Nothing is drawn beside it to say so. There was a chevron, and a
359
+ * chevron is the disclosure mark: it promises a list that drops from the
360
+ * control it is on, and what this opens is a dialog over the middle of the
361
+ * page. The other conventional mark, a trailing ellipsis, is the one
362
+ * character this particular button must not end in -- the name truncates
363
+ * with an ellipsis of its own, so "BMS 3 on the bench…" would be saying
364
+ * either "there is more of this name" or "this opens something", and the
365
+ * reader cannot tell which. What says it is a control is that it becomes
366
+ * one under the pointer and under the keyboard's focus, in the same fill
367
+ * and edge as every button on the page.
368
+ *
369
+ * It stays exactly two lines tall. The header's height is set by this
370
+ * block and by nothing else, so a control that grew by even its own padding
371
+ * would push the tab strip and the whole page down; the padding it needs to
372
+ * show a hover is taken back out as a negative margin, which leaves the
373
+ * outer box the size the two lines always were. See `.device-id.pick`.
374
+ */
375
+ export function DeviceId({ primary, secondary, onPick, title }) {
376
+ const lines = html`<${Lines} primary=${primary} secondary=${secondary} />`;
377
+ if (!onPick) return html`<div class="device-id">${lines}</div>`;
378
+ return html`<button class="device-id pick" type="button" title=${title} onClick=${onPick}>
379
+ ${lines}
380
+ </button>`;
381
+ }
382
+
383
+ /* Which tab the address bar is asking for, and the way to change it.
384
+ *
385
+ * The tab is in the URL so a reload comes back where you were, and so a
386
+ * link to "the logs on this device" is a link somebody can send. `tabs`
387
+ * is the program's own `[id, label]` table; the first is the default and
388
+ * the fallback for a hash naming nothing.
389
+ *
390
+ * A program whose page is about one of several devices carries that too,
391
+ * as the first segment: `#3/dashboard`. `prefix` is whatever the program
392
+ * puts there, or null for a page that is about the one device it is
393
+ * connected to.
394
+ *
395
+ * `replaceState` rather than assigning to `location.hash`: walking the tab
396
+ * strip is not navigation, and a Back button that has to be pressed nine
397
+ * times to leave the page is a Back button that does not work. The
398
+ * `hashchange` listener is still there, because a Back out of the page --
399
+ * or a pasted link -- does fire it.
400
+ */
401
+ export function useTabs(tabs, { prefix: initial = null } = {}) {
402
+ const read = () => {
403
+ const raw = (window.location.hash || '').replace(/^#/, '');
404
+ const cut = raw.indexOf('/');
405
+ const [head, rest] = cut === -1 ? [raw, null] : [raw.slice(0, cut), raw.slice(cut + 1)];
406
+ const want = rest === null ? head : rest;
407
+ return {
408
+ prefix: rest === null ? initial : head || null,
409
+ tab: tabs.some(([id]) => id === want) ? want : tabs[0][0],
410
+ };
411
+ };
412
+ const [route, setRoute] = useState(read);
413
+
414
+ useEffect(() => {
415
+ const follow = () => setRoute(read());
416
+ window.addEventListener('hashchange', follow);
417
+ return () => window.removeEventListener('hashchange', follow);
418
+ }, [tabs]);
419
+
420
+ const show = (id, prefix = route.prefix) => {
421
+ setRoute({ tab: id, prefix });
422
+ const next = prefix === null || prefix === undefined ? `#${id}` : `#${prefix}/${id}`;
423
+ if (window.location.hash !== next) window.history.replaceState(null, '', next);
424
+ };
425
+
426
+ return { tab: route.tab, prefix: route.prefix, show, tabs };
427
+ }
428
+
429
+ /* The tab strip, with the keyboard the WAI-ARIA tablist pattern asks for:
430
+ * one stop in the page's tab order, arrows between the tabs, Home and End
431
+ * to the ends, and the focus following the selection. */
432
+ export function Tabs({ nav, label }) {
433
+ const onKey = (event, index) => {
434
+ const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
435
+ const count = nav.tabs.length;
436
+ const at =
437
+ event.key === 'Home'
438
+ ? 0
439
+ : event.key === 'End'
440
+ ? count - 1
441
+ : step
442
+ ? (index + step + count) % count
443
+ : null;
444
+ if (at === null) return;
445
+ event.preventDefault();
446
+ nav.show(nav.tabs[at][0]);
447
+ document.getElementById(`tab-${nav.tabs[at][0]}`)?.focus();
448
+ };
449
+
450
+ return html`<nav class="tabs" role="tablist" aria-label=${label || 'What to look at'}>
451
+ ${nav.tabs.map(
452
+ ([id, text], index) => html`<button
453
+ key=${id}
454
+ id=${`tab-${id}`}
455
+ role="tab"
456
+ type="button"
457
+ aria-selected=${nav.tab === id}
458
+ aria-controls=${`pane-${id}`}
459
+ tabIndex=${nav.tab === id ? 0 : -1}
460
+ onKeyDown=${(event) => onKey(event, index)}
461
+ onClick=${() => nav.show(id)}
462
+ >
463
+ ${text}
464
+ </button>`
465
+ )}
466
+ </nav>`;
467
+ }
468
+
469
+ /* One tab's pane. Every tab is rendered and all but one is `hidden`, so a
470
+ * panel keeps its scroll position and its half-typed field across a switch
471
+ * -- and so the pane exists for `aria-controls` to point at. */
472
+ export function Pane({ id, shown, children }) {
473
+ return html`<div
474
+ class="pane"
475
+ id=${`pane-${id}`}
476
+ role="tabpanel"
477
+ aria-labelledby=${`tab-${id}`}
478
+ hidden=${!shown}
479
+ >
480
+ ${children}
481
+ </div>`;
482
+ }
483
+
484
+ /* How long a dropped event stream may spend trying before the page calls
485
+ * the server gone.
486
+ *
487
+ * EventSource reconnects on its own within a few seconds, so a server
488
+ * being restarted comes back inside this and the page never says anything.
489
+ * Longer than this is a server that is not coming back by itself, which is
490
+ * worth a banner. */
491
+ const SERVER_GRACE_MS = 6000;
492
+
493
+ /* Whether the program behind this page is still there.
494
+ *
495
+ * `connecting` until the stream first opens, then `online`; `reconnecting`
496
+ * the moment anything fails to reach the server, and `offline` if it is
497
+ * still failing when the grace runs out. A failed fetch counts as well as
498
+ * a dropped stream: a click is often what finds out first, and a page that
499
+ * only listened to the stream would keep taking orders for a device it
500
+ * cannot reach.
501
+ */
502
+ export function useServerLink() {
503
+ const [state, setState] = useState('connecting');
504
+ const timer = useRef(null);
505
+
506
+ const clear = () => {
507
+ if (timer.current) {
508
+ clearTimeout(timer.current);
509
+ timer.current = null;
510
+ }
511
+ };
512
+
513
+ const lost = () => {
514
+ setState((was) => (was === 'offline' ? was : 'reconnecting'));
515
+ if (timer.current) return;
516
+ timer.current = setTimeout(() => {
517
+ timer.current = null;
518
+ setState('offline');
519
+ }, SERVER_GRACE_MS);
520
+ };
521
+
522
+ const found = () => {
523
+ clear();
524
+ setState('online');
525
+ };
526
+
527
+ useEffect(() => onUnreachable(lost), []);
528
+ useEffect(() => clear, []);
529
+
530
+ return { state, lost, found, offline: state === 'offline' };
531
+ }
532
+
533
+ /* The class the header wears while the server is in doubt. */
534
+ function headerClass(state) {
535
+ if (state === 'offline') return 'top gone';
536
+ if (state === 'reconnecting') return 'top lost';
537
+ return 'top';
538
+ }
539
+
540
+ /* The whole of the page's chrome: what the page is about on one row, the
541
+ * tabs on the next, and the hairline under both.
542
+ *
543
+ * Both programs had written this out, and had written it differently -- one
544
+ * put the tab strip outside the header with a second hairline of its own,
545
+ * one centred the top row on the content column and the other ran it to the
546
+ * window's edges. Side by side the two pages did not line up at the one
547
+ * place a person looks first. `children` is what the program hangs on that
548
+ * top row; everything about where the row *is* is here.
549
+ */
550
+ export function Header({ state, nav, label, children }) {
551
+ return html`<header class=${headerClass(state)}>
552
+ <div class="top-inner">${children}</div>
553
+ ${nav ? html`<${Tabs} nav=${nav} label=${label} />` : null}
554
+ </header>`;
555
+ }
556
+
557
+ /* The EU flag, drawn inline to the official geometry: twelve upright
558
+ * five-pointed gold stars, each with a circumscribed radius of 1/18 of the
559
+ * flag height, on a circle of radius 1/3 of it, on a 3:2 field of the
560
+ * official blue. The viewBox is the whole rectangle; the frame and the
561
+ * halo are set in CSS so they follow the theme. */
562
+ /* --- who else has this page open ------------------------------------------
563
+ *
564
+ * Both programs put one device behind one connection and let any number of
565
+ * browsers watch it, which makes "something is holding the device" a
566
+ * question with an answer: the tab on the next desk, the phone in the
567
+ * garage, or nobody at all and the page is simply slow. The server has
568
+ * always been able to say -- `/api/clients` and the `hello` event are in
569
+ * the shared server, and the count is already in every link document -- but
570
+ * only one of the two pages asked, so the other had the endpoint, a
571
+ * function in its API module to call it, and nothing that ever did.
572
+ */
573
+
574
+ /* The count, as the control that opens the list. It lives in the menu
575
+ * under the link pill in both programs, because the link is the thing being
576
+ * shared and this is who it is being shared with. */
577
+ export function Watching({ clients, onOpen }) {
578
+ const many = clients || 1;
579
+ return html`<button class="btn small ghost" onClick=${onOpen}>
580
+ ${many} ${many === 1 ? 'browser' : 'browsers'} watching
581
+ </button>`;
582
+ }
583
+
584
+ /* Which they are. `me` is this browser's own id, which only the stream can
585
+ * say: several tabs share one address and one user agent, so nothing the
586
+ * browser knows about itself would tell it from its neighbour. */
587
+ export function Watchers({ me, onClose, toast }) {
588
+ const [rows, setRows] = useState(null);
589
+
590
+ useEffect(() => {
591
+ get('/api/clients')
592
+ .then((doc) => setRows(doc.clients))
593
+ .catch((err) => {
594
+ toast.error(err.message);
595
+ setRows([]);
596
+ });
597
+ }, []);
598
+
599
+ const now = Date.now() / 1000;
600
+ return html`<${Dialog} title="Browsers watching" onClose=${onClose} width=${560}>
601
+ ${rows === null
602
+ ? html`<p class="note flush">Asking the server…</p>`
603
+ : rows.length === 0
604
+ ? html`<div class="empty">No open event streams.</div>`
605
+ : html`<div class="scroller">
606
+ ${rows.map(
607
+ (row) => html`<div class=${`entry${row.id === me ? ' you' : ''}`} key=${row.id}>
608
+ <div class="head">
609
+ <span class="name">${row.label}</span>
610
+ ${row.id === me && html`<span class="badge good">this browser</span>`}
611
+ <span class="act name data">${row.address}${row.port ? `:${row.port}` : ''}</span>
612
+ </div>
613
+ <div class="meta" title=${row.agent}>
614
+ watching for ${span(Math.max(0, now - (row.since || now)))}
615
+ </div>
616
+ </div>`
617
+ )}
618
+ </div>`}
619
+ <//>`;
620
+ }
621
+
622
+ export function EuFlag() {
623
+ return html`<svg class="eu-flag" viewBox="0 0 810 540" width="30" height="20" aria-hidden="true">
624
+ <defs>
625
+ <path
626
+ id="eu-star"
627
+ fill="#ffcc00"
628
+ d="M0.00,-33.33L7.48,-10.30L31.70,-10.30L12.11,3.93L19.59,26.97L0.00,12.73L-19.59,26.97L-12.11,3.93L-31.70,-10.30L-7.48,-10.30Z"
629
+ />
630
+ </defs>
631
+ <rect x="0" y="0" width="810" height="540" fill="#003399" />
632
+ <use href="#eu-star" x="405" y="90" />
633
+ <use href="#eu-star" x="495" y="114.1" />
634
+ <use href="#eu-star" x="560.9" y="180" />
635
+ <use href="#eu-star" x="585" y="270" />
636
+ <use href="#eu-star" x="560.9" y="360" />
637
+ <use href="#eu-star" x="495" y="425.9" />
638
+ <use href="#eu-star" x="405" y="450" />
639
+ <use href="#eu-star" x="315" y="425.9" />
640
+ <use href="#eu-star" x="249.1" y="360" />
641
+ <use href="#eu-star" x="225" y="270" />
642
+ <use href="#eu-star" x="249.1" y="180" />
643
+ <use href="#eu-star" x="315" y="114.1" />
644
+ </svg>`;
645
+ }
646
+
647
+ /* The licence line every one of these pages carries, flag and all.
648
+ *
649
+ * It goes last inside `.app > .body`, where `core.css` makes it a footer:
650
+ * on a short tab it sits on the bottom edge of the window rather than
651
+ * halfway up it under the last card.
652
+ *
653
+ * The href is the ``License`` entry in the program's own `[project.urls]`,
654
+ * fetched with everything else it says about itself, so neither program
655
+ * writes the URL down. Without one the sentence and the flag still draw --
656
+ * what this copy is under is true whether or not the text of it is one
657
+ * click away.
658
+ */
659
+ export function License() {
660
+ const doc = useAbout();
661
+ const name = doc?.app || program;
662
+ const href = doc?.links?.license || '';
663
+ const title = `The licence this copy of ${name} is under`;
664
+ return html`<footer class="license">
665
+ <${EuFlag} />
666
+ ${href
667
+ ? html`<a href=${href} target="_blank" rel="noreferrer" title=${title}>
668
+ Licensed under the EUPL-1.2
669
+ </a>`
670
+ : html`<span title=${title}>Licensed under the EUPL-1.2</span>`}
671
+ </footer>`;
672
+ }