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,432 @@
|
|
|
1
|
+
/* Shared plumbing for the panel tabs: fetch-once state, edit-and-apply
|
|
2
|
+
* cards, and the small pieces every panel reuses.
|
|
3
|
+
*
|
|
4
|
+
* A panel tab is not a dashboard: it is opened to *do* something, so what
|
|
5
|
+
* it reads is kept here -- once per device, across tab switches -- rather
|
|
6
|
+
* than following the dashboard's reload-on-connect flow. What a panel
|
|
7
|
+
* has changed and not sent is in drafts.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Card, DASH, Help, Row, Select } from '/core/js/ui.js';
|
|
11
|
+
import { html, useEffect, useState } from '/core/vendor/preact-htm.module.js';
|
|
12
|
+
|
|
13
|
+
/* What every panel has read, keyed by the endpoint rather than by the
|
|
14
|
+
* component that asked for it.
|
|
15
|
+
*
|
|
16
|
+
* Two things wanted this out of the components' own `useState`. A panel
|
|
17
|
+
* that unmounts -- which is what switching tabs used to do -- took its
|
|
18
|
+
* document with it, so coming back re-read a device that had not changed.
|
|
19
|
+
* And two cards can want the same document: two halves of one settings
|
|
20
|
+
* page each used to fetch the same endpoint for itself, which is two round
|
|
21
|
+
* trips over the one connection for one answer, and two copies that could
|
|
22
|
+
* disagree. One entry per key fixes both: the second caller finds the
|
|
23
|
+
* first one's document, or waits on the fetch already in flight.
|
|
24
|
+
*
|
|
25
|
+
* It is module state on purpose. It belongs to the device, not to the
|
|
26
|
+
* tree, and the program empties it when the device changes.
|
|
27
|
+
*/
|
|
28
|
+
const cache = new Map(); // key -> { doc, loading, error, promise }
|
|
29
|
+
const listeners = new Set();
|
|
30
|
+
|
|
31
|
+
const EMPTY_ENTRY = { doc: null, loading: false, error: '', promise: null };
|
|
32
|
+
|
|
33
|
+
function announce() {
|
|
34
|
+
for (const listener of [...listeners]) listener();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function entryOf(key) {
|
|
38
|
+
return cache.get(key) || EMPTY_ENTRY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Forget everything: another device, another set of answers. */
|
|
42
|
+
export function resetPanels() {
|
|
43
|
+
cache.clear();
|
|
44
|
+
announce();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Replace what a key holds -- for a write whose reply is the new document,
|
|
48
|
+
* so the card that wrote it and the card beside it both move at once. */
|
|
49
|
+
export function putPanel(key, doc) {
|
|
50
|
+
cache.set(key, { doc, loading: false, error: '', promise: null });
|
|
51
|
+
announce();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Read a panel's document once per device. Returns [doc, loading, error,
|
|
55
|
+
* read], the same tuple every caller destructures.
|
|
56
|
+
*
|
|
57
|
+
* `eager` is what a panel turns off when its read is not something to do
|
|
58
|
+
* on arrival -- a listing fetched from the internet, a test that drives
|
|
59
|
+
* the device -- so the read waits for the button that asks for it and the
|
|
60
|
+
* answer is still kept, across tab switches, like every other panel's. */
|
|
61
|
+
export function usePanel(key, load, { eager = true } = {}) {
|
|
62
|
+
const [, bump] = useState(0);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const wake = () => bump((n) => n + 1);
|
|
66
|
+
listeners.add(wake);
|
|
67
|
+
return () => {
|
|
68
|
+
listeners.delete(wake);
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const read = () => {
|
|
73
|
+
const running = cache.get(key)?.promise;
|
|
74
|
+
if (running) return running; // someone else is already asking
|
|
75
|
+
const held = entryOf(key).doc;
|
|
76
|
+
const promise = Promise.resolve()
|
|
77
|
+
.then(load)
|
|
78
|
+
.then((doc) => {
|
|
79
|
+
cache.set(key, { doc, loading: false, error: '', promise: null });
|
|
80
|
+
announce();
|
|
81
|
+
return doc;
|
|
82
|
+
})
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
/* Keep whatever was read before: a failed refresh should not blank
|
|
85
|
+
* a card that was showing something a moment ago. */
|
|
86
|
+
cache.set(key, {
|
|
87
|
+
doc: held,
|
|
88
|
+
loading: false,
|
|
89
|
+
error: err.message,
|
|
90
|
+
promise: null,
|
|
91
|
+
});
|
|
92
|
+
announce();
|
|
93
|
+
});
|
|
94
|
+
cache.set(key, { doc: held, loading: true, error: '', promise });
|
|
95
|
+
announce();
|
|
96
|
+
return promise;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (eager && !cache.has(key)) read();
|
|
101
|
+
}, [key]);
|
|
102
|
+
|
|
103
|
+
const entry = entryOf(key);
|
|
104
|
+
return [entry.doc, entry.loading, entry.error, read];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* A whole card that could not be read: said once, with the way to try
|
|
108
|
+
* again, rather than a page of em dashes that each shrug.
|
|
109
|
+
*
|
|
110
|
+
* `title` keeps it inside the card it belongs to. Without one, a panel
|
|
111
|
+
* being read and a panel that failed were both a bare line of grey text
|
|
112
|
+
* where a card should be, so every card arriving changed the shape of the
|
|
113
|
+
* grid under the ones already there. */
|
|
114
|
+
export function PanelError({ error, loading, onRetry, title }) {
|
|
115
|
+
if (!error) return null;
|
|
116
|
+
const said = html`<div class="empty">
|
|
117
|
+
${error}
|
|
118
|
+
<div class="actions centred">
|
|
119
|
+
<button class="btn" disabled=${loading} onClick=${onRetry}>Try again</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>`;
|
|
122
|
+
return title
|
|
123
|
+
? html`<${Card} title=${title} badge=${html`<span class="badge bad">unread</span>`}>
|
|
124
|
+
${said}
|
|
125
|
+
<//>`
|
|
126
|
+
: said;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* A card that is being read. It holds its place in the grid, and shows
|
|
130
|
+
* the shape of what is coming rather than the word "loading" -- so a page
|
|
131
|
+
* of panels arriving settles into itself instead of jumping. */
|
|
132
|
+
export function Loading({ loading, what, title, rows }) {
|
|
133
|
+
if (!loading) return null;
|
|
134
|
+
const bones = html`<div class="skeleton" aria-label=${what || 'Reading...'}>
|
|
135
|
+
${Array.from({ length: rows || 4 }, (_, i) => html`<span class="bone" key=${i}></span>`)}
|
|
136
|
+
</div>`;
|
|
137
|
+
return title
|
|
138
|
+
? html`<${Card} title=${title}>${bones}<//>`
|
|
139
|
+
: html`<div class="empty">${what || 'Reading...'}</div>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* What a panel shows instead of itself, while it has nothing to show.
|
|
143
|
+
*
|
|
144
|
+
* Twelve panels wrote this pair of early returns out by hand, with the
|
|
145
|
+
* card's title typed three times at each site -- and three of them had
|
|
146
|
+
* drifted into returning `null` on an error, so a card that failed to read
|
|
147
|
+
* simply was not there and nothing said why. Here it is once: hand it the
|
|
148
|
+
* tuple `usePanel` returned, and render what comes back if anything does.
|
|
149
|
+
*
|
|
150
|
+
* const state = usePanel('auth', onLoad);
|
|
151
|
+
* const [doc] = state;
|
|
152
|
+
* const waiting = panelWait(state, { title: 'Authorization' });
|
|
153
|
+
* if (waiting !== undefined) return waiting;
|
|
154
|
+
*
|
|
155
|
+
* `undefined` -- not `null` -- is the answer that means "carry on": a card
|
|
156
|
+
* may legitimately want to render nothing, which is what `null` is, and
|
|
157
|
+
* `quiet` is when. That is the second and third card behind one read: the
|
|
158
|
+
* first of them reports the failure, and one endpoint saying it three
|
|
159
|
+
* times is one endpoint shouting. They still wait for themselves, though,
|
|
160
|
+
* because a card that renders nothing while a read is in flight is a card
|
|
161
|
+
* the grid does not know is coming -- three panels behind one read put up
|
|
162
|
+
* one skeleton and then landed as three, and everything below them jumped
|
|
163
|
+
* a row. A skeleton is a card's way of saying it will be here.
|
|
164
|
+
*/
|
|
165
|
+
export function panelWait(state, { title, what, rows, quiet = false } = {}) {
|
|
166
|
+
const [doc, loading, error, read] = state;
|
|
167
|
+
if (error) {
|
|
168
|
+
if (quiet) return null;
|
|
169
|
+
return html`<${PanelError}
|
|
170
|
+
error=${error}
|
|
171
|
+
loading=${loading}
|
|
172
|
+
onRetry=${read}
|
|
173
|
+
title=${title}
|
|
174
|
+
/>`;
|
|
175
|
+
}
|
|
176
|
+
if (!doc) {
|
|
177
|
+
return html`<${Loading} loading what=${what} title=${title} rows=${rows} />`;
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* A select for an enumerated register, offered as the device's own
|
|
183
|
+
* dropdown: value to send, title to show, in table order. */
|
|
184
|
+
function EnumSelect({ value, table, onChange, disabled, includeBlank, pending }) {
|
|
185
|
+
const entries = Object.entries(table || {}).map(([v, title]) => ({
|
|
186
|
+
value: String(v),
|
|
187
|
+
title,
|
|
188
|
+
}));
|
|
189
|
+
if (includeBlank) entries.unshift({ value: '', title: '—' });
|
|
190
|
+
return html`<${Select}
|
|
191
|
+
value=${value === null || value === undefined ? '' : String(value)}
|
|
192
|
+
entries=${entries}
|
|
193
|
+
onChange=${onChange}
|
|
194
|
+
disabled=${disabled}
|
|
195
|
+
pending=${pending}
|
|
196
|
+
/>`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* One number, as a small input with its unit, pending-coloured while it
|
|
200
|
+
* differs from what the device holds. */
|
|
201
|
+
function NumInput({ value, live, min, max, step, unit, onChange, disabled, pending: held }) {
|
|
202
|
+
const pending = held ?? Number(value) !== Number(live);
|
|
203
|
+
return html`<span class="set num">
|
|
204
|
+
<input
|
|
205
|
+
type="number"
|
|
206
|
+
class=${pending ? 'pending' : ''}
|
|
207
|
+
min=${min}
|
|
208
|
+
max=${max}
|
|
209
|
+
step=${step || 1}
|
|
210
|
+
value=${value ?? ''}
|
|
211
|
+
disabled=${disabled}
|
|
212
|
+
onInput=${(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
|
213
|
+
/>
|
|
214
|
+
${unit && html`<span class=${`unit${pending ? ' pending' : ''}`}>${unit}</span>`}
|
|
215
|
+
</span>`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* One text input, edged in amber while it differs from what the device
|
|
219
|
+
* holds -- the same "this is a draft, nothing has been written" cue a
|
|
220
|
+
* number or a slider gets. It was computed here and then thrown away, so
|
|
221
|
+
* a typed-over URL looked exactly like the one the device is using. */
|
|
222
|
+
function TextInput({ value, live, onChange, disabled, placeholder, width, pending: held }) {
|
|
223
|
+
const pending = held ?? (value ?? '') !== (live ?? '');
|
|
224
|
+
return html`<input
|
|
225
|
+
type="text"
|
|
226
|
+
class=${pending ? 'pending' : ''}
|
|
227
|
+
style=${width ? `width:${width}` : ''}
|
|
228
|
+
value=${value ?? ''}
|
|
229
|
+
|
|
230
|
+
placeholder=${placeholder || ''}
|
|
231
|
+
disabled=${disabled}
|
|
232
|
+
onInput=${(e) => onChange(e.target.value)}
|
|
233
|
+
/>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* --- one setting, as a row -------------------------------------------------
|
|
237
|
+
*
|
|
238
|
+
* Six tabs were each writing the same three shapes out by hand: a `Row`
|
|
239
|
+
* whose value is a ternary on `readOnly`, the reading spelled one way on
|
|
240
|
+
* one side of it and an input on the other. Which is why the same setting
|
|
241
|
+
* could show "16 A" on one tab and "16" on the next, why a missing value
|
|
242
|
+
* was a hand-typed em dash here and the muted one `Row` draws there, and
|
|
243
|
+
* why every enumerated field repeated the same
|
|
244
|
+
* `e.target.value === '' ? null : Number(...)` decode with its own chance
|
|
245
|
+
* of getting it wrong. These three say it once.
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
/* What a select hands back, in the type the device's register wants: the
|
|
249
|
+
* blank option is "unset" in every case, and the rest is the caller's
|
|
250
|
+
* `kind` -- most registers are numbers, a couple are strings, and the
|
|
251
|
+
* measurement source is a boolean the app writes as 0 or 1. */
|
|
252
|
+
function decode(kind, raw) {
|
|
253
|
+
if (raw === '') return null;
|
|
254
|
+
if (kind === 'boolean') return raw === '1' || raw === 'true';
|
|
255
|
+
if (kind === 'text') return raw;
|
|
256
|
+
return Number(raw);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ... and the option that value is, on the way back in.
|
|
260
|
+
*
|
|
261
|
+
* A boolean register is stored as 0 or 1 and named by a table keyed on
|
|
262
|
+
* those, but what the device is written with is a real boolean -- so a
|
|
263
|
+
* draft holds `true`, whose option is "1". Without this the select fell
|
|
264
|
+
* back to its first option the moment you changed it, which is an enumerated
|
|
265
|
+
* setting silently reading as its own opposite. */
|
|
266
|
+
function encode(kind, value) {
|
|
267
|
+
if (value === null || value === undefined) return '';
|
|
268
|
+
if (kind === 'boolean') return value ? '1' : '0';
|
|
269
|
+
return String(value);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* A number the device holds, with its unit. Read-only, the unit is part
|
|
273
|
+
* of the reading; editable, it is the label beside the input.
|
|
274
|
+
*
|
|
275
|
+
* `pending` says the row holds an edit nobody has applied; left out, it is
|
|
276
|
+
* worked out from `value` against `live`. Every row below takes it, so a
|
|
277
|
+
* card's changed fields are the same colour whatever kind of field they
|
|
278
|
+
* are -- see `Row`. */
|
|
279
|
+
export function NumRow({
|
|
280
|
+
k,
|
|
281
|
+
title,
|
|
282
|
+
readOnly,
|
|
283
|
+
value,
|
|
284
|
+
live,
|
|
285
|
+
unit,
|
|
286
|
+
min,
|
|
287
|
+
max,
|
|
288
|
+
step,
|
|
289
|
+
onChange,
|
|
290
|
+
disabled,
|
|
291
|
+
pending,
|
|
292
|
+
hint,
|
|
293
|
+
}) {
|
|
294
|
+
const said =
|
|
295
|
+
value === null || value === undefined ? undefined : `${value}${unit ? ` ${unit}` : ''}`;
|
|
296
|
+
const held = !readOnly && (pending ?? (live !== undefined && Number(value) !== Number(live)));
|
|
297
|
+
/* What it may be set to, on the value: the name's tooltip is for what it
|
|
298
|
+
* is (`hint`). */
|
|
299
|
+
const allowed =
|
|
300
|
+
!readOnly && min !== undefined && min !== null && max !== undefined && max !== null
|
|
301
|
+
? `allowed ${min}${unit ? ` ${unit}` : ''} to ${max}${unit ? ` ${unit}` : ''}`
|
|
302
|
+
: undefined;
|
|
303
|
+
return html`<${Row}
|
|
304
|
+
k=${k}
|
|
305
|
+
title=${title ?? allowed}
|
|
306
|
+
hint=${hint}
|
|
307
|
+
pending=${held}
|
|
308
|
+
v=${readOnly
|
|
309
|
+
? said
|
|
310
|
+
: html`<${NumInput}
|
|
311
|
+
value=${value}
|
|
312
|
+
live=${live}
|
|
313
|
+
min=${min}
|
|
314
|
+
max=${max}
|
|
315
|
+
step=${step}
|
|
316
|
+
unit=${unit}
|
|
317
|
+
onChange=${onChange}
|
|
318
|
+
disabled=${disabled}
|
|
319
|
+
pending=${held}
|
|
320
|
+
/>`}
|
|
321
|
+
/>`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* An enumerated register, shown as the catalog's word for it and edited as
|
|
325
|
+
* a dropdown. */
|
|
326
|
+
export function EnumRow({
|
|
327
|
+
k,
|
|
328
|
+
title,
|
|
329
|
+
readOnly,
|
|
330
|
+
value,
|
|
331
|
+
table,
|
|
332
|
+
kind,
|
|
333
|
+
onChange,
|
|
334
|
+
disabled,
|
|
335
|
+
includeBlank,
|
|
336
|
+
pending,
|
|
337
|
+
hint,
|
|
338
|
+
}) {
|
|
339
|
+
const held = !readOnly && Boolean(pending);
|
|
340
|
+
return html`<${Row}
|
|
341
|
+
k=${k}
|
|
342
|
+
title=${title}
|
|
343
|
+
hint=${hint}
|
|
344
|
+
pending=${held}
|
|
345
|
+
v=${readOnly
|
|
346
|
+
? table?.[encode(kind, value)]
|
|
347
|
+
: html`<${EnumSelect}
|
|
348
|
+
value=${encode(kind, value)}
|
|
349
|
+
table=${table}
|
|
350
|
+
onChange=${(value) => onChange(decode(kind, value))}
|
|
351
|
+
disabled=${disabled}
|
|
352
|
+
includeBlank=${includeBlank}
|
|
353
|
+
pending=${held}
|
|
354
|
+
/>`}
|
|
355
|
+
/>`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* A string register -- a URL, a path, a name. */
|
|
359
|
+
export function TextRow({
|
|
360
|
+
k,
|
|
361
|
+
title,
|
|
362
|
+
readOnly,
|
|
363
|
+
value,
|
|
364
|
+
live,
|
|
365
|
+
placeholder,
|
|
366
|
+
width,
|
|
367
|
+
onChange,
|
|
368
|
+
disabled,
|
|
369
|
+
pending,
|
|
370
|
+
hint,
|
|
371
|
+
}) {
|
|
372
|
+
const held = !readOnly && (pending ?? (live !== undefined && (value ?? '') !== (live ?? '')));
|
|
373
|
+
return html`<${Row}
|
|
374
|
+
k=${k}
|
|
375
|
+
title=${title}
|
|
376
|
+
hint=${hint}
|
|
377
|
+
pending=${held}
|
|
378
|
+
v=${readOnly
|
|
379
|
+
? value || undefined
|
|
380
|
+
: html`<${TextInput}
|
|
381
|
+
value=${value ?? ''}
|
|
382
|
+
live=${live}
|
|
383
|
+
placeholder=${placeholder}
|
|
384
|
+
width=${width}
|
|
385
|
+
onChange=${onChange}
|
|
386
|
+
disabled=${disabled}
|
|
387
|
+
pending=${held}
|
|
388
|
+
/>`}
|
|
389
|
+
/>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* A row whose value side is a toggle. */
|
|
393
|
+
export function ToggleRow({ k, value, onChange, disabled, label, title, hint, pending }) {
|
|
394
|
+
return html`<${Row}
|
|
395
|
+
k=${k}
|
|
396
|
+
title=${title}
|
|
397
|
+
hint=${hint}
|
|
398
|
+
pending=${pending}
|
|
399
|
+
v=${html`<label class=${pending ? 'toggle pending' : 'toggle'}>
|
|
400
|
+
<input
|
|
401
|
+
type="checkbox"
|
|
402
|
+
checked=${Boolean(value)}
|
|
403
|
+
disabled=${disabled}
|
|
404
|
+
onChange=${(e) => onChange(e.target.checked)}
|
|
405
|
+
/>
|
|
406
|
+
${label || (value ? 'on' : 'off')}
|
|
407
|
+
</label>`}
|
|
408
|
+
/>`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* Rows rendered from a backend [label, value] table (a network panel, a health check). */
|
|
412
|
+
function Rows({ rows }) {
|
|
413
|
+
if (!rows?.length) return html`<div class="empty">Nothing reported.</div>`;
|
|
414
|
+
return html`<div class="rows">
|
|
415
|
+
${rows.map((r) => html`<${Row} key=${r.label} k=${r.label} v=${r.value || DASH} />`)}
|
|
416
|
+
</div>`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/* A plain card of rows, for read-only panels.
|
|
420
|
+
*
|
|
421
|
+
* The note is a line *about* the card, so it goes above what it is about
|
|
422
|
+
* and folds -- the same `Help` every other card's explanation uses. It
|
|
423
|
+
* used to be a paragraph after the last row, which is where a card puts
|
|
424
|
+
* its footnotes: a sentence explaining what the rows are, printed
|
|
425
|
+
* underneath them, read last by anyone who read it at all. `more` is the
|
|
426
|
+
* rest of it, and without one the note is simply a line of prose. */
|
|
427
|
+
export function PanelCard({ title, rows, note, more, badge }) {
|
|
428
|
+
return html`<${Card} title=${title} actions=${badge}>
|
|
429
|
+
${note && html`<${Help} summary=${note}>${more}<//>`}
|
|
430
|
+
<${Rows} rows=${rows} />
|
|
431
|
+
<//>`;
|
|
432
|
+
}
|