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,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
|
+
}
|