@jmcombs/pi-steward 0.0.0
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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- package/ui/steward.css +2008 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rail's MODELS block.
|
|
3
|
+
*
|
|
4
|
+
* A card is a filter control and a container for a second control at once, so
|
|
5
|
+
* it cannot be a `<button>` — it is a `role="button"` with the keyboard
|
|
6
|
+
* handling written out, and the Load/Unload button inside it keeps both the
|
|
7
|
+
* click and the key events it handles natively away from the card.
|
|
8
|
+
*
|
|
9
|
+
* The block header carries a single info legend for the whole list: a real
|
|
10
|
+
* button that toggles a definitions panel, one entry per card field label
|
|
11
|
+
* (`Quant`, `Context`, `KV Cache`, …), so the compact labeled grid can be read
|
|
12
|
+
* once and understood.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ModelCardVm, PillVm } from "../../core/select.js";
|
|
16
|
+
import type { ModelAction } from "../../core/types.js";
|
|
17
|
+
import type { View } from "../dom.js";
|
|
18
|
+
import { el, setAttr, setText, setVar, syncRows } from "../dom.js";
|
|
19
|
+
|
|
20
|
+
export interface ModelsVm {
|
|
21
|
+
models: ModelCardVm[];
|
|
22
|
+
allLogsPill: PillVm;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelsHandlers {
|
|
26
|
+
onFilterModel: (modelId: string) => void;
|
|
27
|
+
onShowAllLogs: () => void;
|
|
28
|
+
onModelAction: (modelId: string, action: ModelAction) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One labeled cell of the body grid: its value span, patched per repaint. */
|
|
32
|
+
interface FieldCell {
|
|
33
|
+
root: HTMLElement;
|
|
34
|
+
label: HTMLElement;
|
|
35
|
+
value: HTMLElement;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ModelRow {
|
|
39
|
+
root: HTMLElement;
|
|
40
|
+
dot: HTMLElement;
|
|
41
|
+
name: HTMLElement;
|
|
42
|
+
/** The seven body cells, in the fixed order the view model lists them. */
|
|
43
|
+
cells: FieldCell[];
|
|
44
|
+
button: HTMLButtonElement;
|
|
45
|
+
vm: ModelCardVm | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Rows are recycled rather than keyed, so their ids only have to be unique. */
|
|
49
|
+
let rowSeq = 0;
|
|
50
|
+
|
|
51
|
+
/** How many labeled cells a card body always has, so the grid can be built once. */
|
|
52
|
+
const FIELD_COUNT = 7;
|
|
53
|
+
|
|
54
|
+
function createFieldCell(): FieldCell {
|
|
55
|
+
const label = el("span", { class: "model-card__label" });
|
|
56
|
+
const value = el("span", { class: "model-card__value" });
|
|
57
|
+
return {
|
|
58
|
+
root: el("div", { class: "model-card__field", children: [label, value] }),
|
|
59
|
+
label,
|
|
60
|
+
value,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createRow(handlers: ModelsHandlers): ModelRow {
|
|
65
|
+
rowSeq += 1;
|
|
66
|
+
const prefix = `steward-model-${rowSeq}`;
|
|
67
|
+
const dot = el("span", { class: "model-card__dot" });
|
|
68
|
+
const name = el("span", { class: "model-card__name", attrs: { id: `${prefix}-name` } });
|
|
69
|
+
const cells = Array.from({ length: FIELD_COUNT }, createFieldCell);
|
|
70
|
+
// The body grid is named as one group: the label set is identical on every
|
|
71
|
+
// card, so a screen reader reading the whole grid's text (label + value, seven
|
|
72
|
+
// times) is exactly what distinguishes this card, and no per-field id juggling
|
|
73
|
+
// is needed as the values change.
|
|
74
|
+
const fields = el("div", {
|
|
75
|
+
class: "model-card__fields",
|
|
76
|
+
attrs: { id: `${prefix}-fields` },
|
|
77
|
+
children: cells.map((cell) => cell.root),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const row: ModelRow = {
|
|
81
|
+
root: el("div", { class: "model-card" }),
|
|
82
|
+
dot,
|
|
83
|
+
name,
|
|
84
|
+
cells,
|
|
85
|
+
button: el("button", { class: "btn btn--filled btn--sm", attrs: { type: "button" } }),
|
|
86
|
+
vm: null,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
row.button.addEventListener("click", (event) => {
|
|
90
|
+
// Without this the card underneath would also take the click and toggle
|
|
91
|
+
// the log filter every time the operator loads a model.
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
if (row.vm !== null) handlers.onModelAction(row.vm.id, row.vm.buttonAction);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const filter = (): void => {
|
|
97
|
+
if (row.vm !== null) handlers.onFilterModel(row.vm.id);
|
|
98
|
+
};
|
|
99
|
+
row.root.addEventListener("click", filter);
|
|
100
|
+
row.root.addEventListener("keydown", (event) => {
|
|
101
|
+
if (!(event instanceof KeyboardEvent)) return;
|
|
102
|
+
// Enter and Space belong to the Load/Unload button while it holds focus:
|
|
103
|
+
// taking them here would preventDefault() its native activation and filter
|
|
104
|
+
// the log instead of loading the model.
|
|
105
|
+
if (event.target !== row.root) return;
|
|
106
|
+
// A held key repeats; a real button would still have fired exactly once.
|
|
107
|
+
if (event.repeat) return;
|
|
108
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
filter();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
setAttr(row.root, "role", "button");
|
|
114
|
+
setAttr(row.root, "tabindex", "0");
|
|
115
|
+
// The card is named from its own contents: the name and the body grid as a
|
|
116
|
+
// whole. The label set is fixed and identical on every card, so this is stamped
|
|
117
|
+
// once here rather than rebuilt each repaint.
|
|
118
|
+
setAttr(row.root, "aria-labelledby", `${prefix}-name ${prefix}-fields`);
|
|
119
|
+
// The Load/Unload button rides the header row with the name — the one row every
|
|
120
|
+
// card always has, and the only place a transition is announced — so it sits in
|
|
121
|
+
// the same place independent of the labeled grid below it.
|
|
122
|
+
row.root.append(
|
|
123
|
+
el("div", {
|
|
124
|
+
class: "model-card__top",
|
|
125
|
+
children: [el("div", { class: "model-card__title", children: [dot, name] }), row.button],
|
|
126
|
+
}),
|
|
127
|
+
fields,
|
|
128
|
+
);
|
|
129
|
+
return row;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The header info legend: one entry per card field, keyed by its exact label. */
|
|
133
|
+
const LEGEND_TERMS: readonly { term: string; def: string }[] = [
|
|
134
|
+
{
|
|
135
|
+
term: "Quant",
|
|
136
|
+
def: "Weight quantization: how compressed the model's weights are. Fewer bits = smaller and faster, slightly less precise. 16-bit (F16) is uncompressed. Read from the loaded file; n/a until then.",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
term: "Size",
|
|
140
|
+
def: "On-disk size, and roughly the VRAM it needs once loaded. Known only while loaded.",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
term: "Context",
|
|
144
|
+
def: "Tokens each request slot gets: the loaded context window divided across the parallel slots (total ÷ slots). Known only while loaded.",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
term: "GPU Layers",
|
|
148
|
+
def: "Transformer layers offloaded to the GPU (-ngl); more on the GPU is faster, 99 means all. This is the requested value — the effective count is never reported, so it reads n/a unless it was pinned at launch.",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
term: "Flash",
|
|
152
|
+
def: "Flash Attention — an optimized attention kernel: faster, less memory. On / Off / Auto. Can stay Auto even loaded — the resolved value isn't reported back.",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
term: "KV Cache",
|
|
156
|
+
def: "Precision of the runtime attention cache — the per-request memory that grows with context, separate from the weight size. 8-bit is compact; 16-bit is the default.",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
term: "Type",
|
|
160
|
+
def: "Whether the model generates text (Generative) or produces embeddings (Embedder). Read from the router's modalities, so it's known even while unloaded.",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
/** localStorage key for whether the legend is left open across a page session. */
|
|
165
|
+
const LEGEND_STORAGE_KEY = "steward.legend";
|
|
166
|
+
|
|
167
|
+
/** Reads the remembered legend state; storage may be unavailable or blocked. */
|
|
168
|
+
function readLegendOpen(): boolean {
|
|
169
|
+
try {
|
|
170
|
+
return globalThis.localStorage?.getItem(LEGEND_STORAGE_KEY) === "1";
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Persists the legend state; a failure just means it is not remembered. */
|
|
177
|
+
function writeLegendOpen(open: boolean): void {
|
|
178
|
+
try {
|
|
179
|
+
globalThis.localStorage?.setItem(LEGEND_STORAGE_KEY, open ? "1" : "0");
|
|
180
|
+
} catch {
|
|
181
|
+
// No storage (private mode, disabled): the in-memory state still holds.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createModelsBlock(handlers: ModelsHandlers): View<ModelsVm> {
|
|
186
|
+
const rows: ModelRow[] = [];
|
|
187
|
+
const list = el("div", { class: "model-list" });
|
|
188
|
+
const pill = el("button", {
|
|
189
|
+
class: "pill",
|
|
190
|
+
attrs: { type: "button" },
|
|
191
|
+
text: "all logs",
|
|
192
|
+
on: { click: handlers.onShowAllLogs },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const panelId = "steward-models-legend";
|
|
196
|
+
const panel = el("div", {
|
|
197
|
+
class: "legend-panel",
|
|
198
|
+
attrs: { id: panelId, role: "group", "aria-label": "Field definitions", hidden: true },
|
|
199
|
+
children: [
|
|
200
|
+
el("dl", {
|
|
201
|
+
class: "legend-list",
|
|
202
|
+
children: LEGEND_TERMS.flatMap(({ term, def }) => [
|
|
203
|
+
el("dt", { class: "legend-term", text: term }),
|
|
204
|
+
el("dd", { class: "legend-def", text: def }),
|
|
205
|
+
]),
|
|
206
|
+
}),
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// A real button, not a hover affordance: it must be reachable and toggle-able
|
|
211
|
+
// from the keyboard and legible to a screen reader, which a tooltip is not.
|
|
212
|
+
let legendOpen = readLegendOpen();
|
|
213
|
+
const legendToggle = el("button", {
|
|
214
|
+
class: "legend-toggle",
|
|
215
|
+
text: "ⓘ",
|
|
216
|
+
attrs: {
|
|
217
|
+
type: "button",
|
|
218
|
+
"aria-label": "What these values mean",
|
|
219
|
+
"aria-controls": panelId,
|
|
220
|
+
// A string, not a boolean: `aria-expanded` is an enumerated ARIA value
|
|
221
|
+
// ("true"/"false"), not an HTML boolean attribute, and setAttr would drop a
|
|
222
|
+
// `false` entirely — leaving the state unspoken and the open-state style dead.
|
|
223
|
+
"aria-expanded": String(legendOpen),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const syncLegend = (): void => {
|
|
228
|
+
setAttr(legendToggle, "aria-expanded", String(legendOpen));
|
|
229
|
+
// `hidden` gives the panel zero height when collapsed, so it never pushes the
|
|
230
|
+
// cards down the rail until the operator asks for it.
|
|
231
|
+
setAttr(panel, "hidden", !legendOpen);
|
|
232
|
+
};
|
|
233
|
+
legendToggle.addEventListener("click", () => {
|
|
234
|
+
legendOpen = !legendOpen;
|
|
235
|
+
writeLegendOpen(legendOpen);
|
|
236
|
+
syncLegend();
|
|
237
|
+
});
|
|
238
|
+
syncLegend();
|
|
239
|
+
|
|
240
|
+
const root = el("section", {
|
|
241
|
+
class: "rail__block rail__block--models",
|
|
242
|
+
attrs: { "aria-labelledby": "steward-models-title" },
|
|
243
|
+
children: [
|
|
244
|
+
el("div", {
|
|
245
|
+
class: "block__head",
|
|
246
|
+
children: [
|
|
247
|
+
el("h2", { class: "eyebrow", text: "Models", attrs: { id: "steward-models-title" } }),
|
|
248
|
+
el("div", { class: "block__head-controls", children: [legendToggle, pill] }),
|
|
249
|
+
],
|
|
250
|
+
}),
|
|
251
|
+
panel,
|
|
252
|
+
list,
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
el: root,
|
|
258
|
+
update(vm) {
|
|
259
|
+
setVar(pill, "bg", vm.allLogsPill.background);
|
|
260
|
+
setVar(pill, "fg", vm.allLogsPill.color);
|
|
261
|
+
setVar(pill, "bd", vm.allLogsPill.borderColor);
|
|
262
|
+
setAttr(pill, "aria-pressed", String(vm.allLogsPill.active));
|
|
263
|
+
|
|
264
|
+
syncRows(list, rows, vm.models.length, () => createRow(handlers));
|
|
265
|
+
vm.models.forEach((model, index) => {
|
|
266
|
+
const row = rows[index];
|
|
267
|
+
if (row === undefined) return;
|
|
268
|
+
row.vm = model;
|
|
269
|
+
setVar(row.root, "bg", model.cardBackground);
|
|
270
|
+
setVar(row.root, "bd", model.cardBorder);
|
|
271
|
+
setAttr(row.root, "aria-pressed", String(model.selected));
|
|
272
|
+
setAttr(row.root, "title", `Filter the log to ${model.short}`);
|
|
273
|
+
setVar(row.dot, "model-color", model.color);
|
|
274
|
+
setText(row.name, model.short);
|
|
275
|
+
|
|
276
|
+
// The label set and order are fixed, so cell i always renders field i;
|
|
277
|
+
// an `n/a` value carries `data-na="true"` so the stylesheet dims it while
|
|
278
|
+
// the label beside it stays at full strength.
|
|
279
|
+
model.fields.forEach((field, fieldIndex) => {
|
|
280
|
+
const cell = row.cells[fieldIndex];
|
|
281
|
+
if (cell === undefined) return;
|
|
282
|
+
setText(cell.label, `${field.label}:`);
|
|
283
|
+
setText(cell.value, field.value);
|
|
284
|
+
setAttr(cell.value, "data-na", String(field.na));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
setText(row.button, model.buttonLabel);
|
|
288
|
+
setVar(row.button, "bg", model.buttonBackground);
|
|
289
|
+
setVar(row.button, "fg", model.buttonColor);
|
|
290
|
+
setVar(row.button, "bd", model.buttonBorder);
|
|
291
|
+
setAttr(row.button, "disabled", model.pending);
|
|
292
|
+
setAttr(row.button, "aria-label", `${model.buttonLabel} ${model.short}`);
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rail's SERVICE block — the "Steward" box.
|
|
3
|
+
*
|
|
4
|
+
* Three stacked zones: the brand lockup, the control row, and the router facts
|
|
5
|
+
* folded in from what used to be a separate CONFIG block. The status readout —
|
|
6
|
+
* `started` / `stopped` / `not connected` — is a chip riding in the lockup row
|
|
7
|
+
* beside the wordmark, not a zone of its own: it is the one thing in the block
|
|
8
|
+
* that cannot be acted on, so it does not get the block's heaviest element.
|
|
9
|
+
*
|
|
10
|
+
* The chip is deliberately not a button: it reports state and nothing more, so
|
|
11
|
+
* it is a `role="status"` region that a screen reader announces when the state
|
|
12
|
+
* changes. Its state rides a SHAPE (`data-state` → filled disc, ring, dotted
|
|
13
|
+
* ring) as well as a hue, so it survives a monochrome screen.
|
|
14
|
+
*
|
|
15
|
+
* The control row holds only actions this machine actually has a consented
|
|
16
|
+
* command for; with none, one setup affordance takes its place. Stop and
|
|
17
|
+
* restart never fire from a single click — they open an inline confirm strip
|
|
18
|
+
* that names what will be unloaded, moves focus to Cancel, and closes on Esc.
|
|
19
|
+
* A command that failed leaves an alert here rather than nothing at all.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { DriftNoticeVm, ServiceControlVm, ServiceVm } from "../../core/select.js";
|
|
23
|
+
import type { ServiceAction } from "../../core/types.js";
|
|
24
|
+
import type { View } from "../dom.js";
|
|
25
|
+
import { el, setAttr, setText, setVar, svg, syncRows } from "../dom.js";
|
|
26
|
+
|
|
27
|
+
export interface ServiceHandlers {
|
|
28
|
+
onToggleTheme: () => void;
|
|
29
|
+
/** Runs an action now: a non-disruptive one, or one already confirmed. */
|
|
30
|
+
onService: (action: ServiceAction) => void;
|
|
31
|
+
/** Asks for the confirm strip before a disruptive action. */
|
|
32
|
+
onConfirmService: (action: ServiceAction) => void;
|
|
33
|
+
/** Dismisses the confirm strip without acting. */
|
|
34
|
+
onCancelService: () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Quiets the drift notice for this session. It is keyed by what drifted, so
|
|
37
|
+
* the notice returns on a reload and the moment the mismatch changes.
|
|
38
|
+
*/
|
|
39
|
+
onDismissDrift: (key: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One router fact: `listen 127.0.0.1:8080`. Patched per repaint. */
|
|
43
|
+
interface FactRow {
|
|
44
|
+
root: HTMLElement;
|
|
45
|
+
key: HTMLElement;
|
|
46
|
+
value: HTMLElement;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createFactRow(): FactRow {
|
|
50
|
+
const key = el("span", { class: "config-row__key" });
|
|
51
|
+
const value = el("span", { class: "config-row__value" });
|
|
52
|
+
return { root: el("div", { class: "config-row", children: [key, value] }), key, value };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One control button. The action and whether it needs confirming live on the
|
|
57
|
+
* record rather than in the closure, so a row can be reused across repaints
|
|
58
|
+
* when the available set changes without rebinding its listener.
|
|
59
|
+
*/
|
|
60
|
+
interface ControlRow {
|
|
61
|
+
root: HTMLButtonElement;
|
|
62
|
+
action: ServiceAction;
|
|
63
|
+
confirms: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Steward mark: a serving dome over two trays, the lower one faded. */
|
|
67
|
+
function mark(): SVGElement {
|
|
68
|
+
return svg(
|
|
69
|
+
"svg",
|
|
70
|
+
{
|
|
71
|
+
width: "20",
|
|
72
|
+
height: "20",
|
|
73
|
+
viewBox: "0 0 40 40",
|
|
74
|
+
fill: "none",
|
|
75
|
+
"aria-hidden": "true",
|
|
76
|
+
class: "lockup__mark",
|
|
77
|
+
focusable: "false",
|
|
78
|
+
},
|
|
79
|
+
[
|
|
80
|
+
svg("path", {
|
|
81
|
+
d: "M8 17C8 10.9 13.4 6 20 6s12 4.9 12 11",
|
|
82
|
+
stroke: "currentColor",
|
|
83
|
+
"stroke-width": "4",
|
|
84
|
+
"stroke-linecap": "round",
|
|
85
|
+
}),
|
|
86
|
+
svg("rect", { x: "4", y: "21", width: "32", height: "5", rx: "2.5", fill: "currentColor" }),
|
|
87
|
+
svg("rect", {
|
|
88
|
+
x: "4",
|
|
89
|
+
y: "29.6",
|
|
90
|
+
width: "32",
|
|
91
|
+
height: "5",
|
|
92
|
+
rx: "2.5",
|
|
93
|
+
fill: "currentColor",
|
|
94
|
+
opacity: "0.55",
|
|
95
|
+
}),
|
|
96
|
+
],
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createServiceBlock(handlers: ServiceHandlers): View<ServiceVm> {
|
|
101
|
+
const statusDot = el("span", { class: "service__status-dot", attrs: { "aria-hidden": "true" } });
|
|
102
|
+
const statusLabel = el("span", { class: "service__status-label" });
|
|
103
|
+
// Not a <button>: it reports state and nothing more, so it takes no hover and
|
|
104
|
+
// no click. `tabindex="-1"` keeps it out of the tab order while still letting
|
|
105
|
+
// the confirm flow park focus here when the button it came from went inert.
|
|
106
|
+
const status = el("div", {
|
|
107
|
+
class: "service__status",
|
|
108
|
+
attrs: { role: "status", "aria-live": "polite", tabindex: "-1" },
|
|
109
|
+
children: [statusDot, statusLabel],
|
|
110
|
+
});
|
|
111
|
+
const theme = el("button", {
|
|
112
|
+
class: "btn btn--icon",
|
|
113
|
+
attrs: { type: "button" },
|
|
114
|
+
on: { click: handlers.onToggleTheme },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const controls = el("div", {
|
|
118
|
+
class: "service__controls",
|
|
119
|
+
attrs: { role: "group", "aria-label": "Service control" },
|
|
120
|
+
});
|
|
121
|
+
const controlRows: ControlRow[] = [];
|
|
122
|
+
function createControlRow(): ControlRow {
|
|
123
|
+
const row: ControlRow = {
|
|
124
|
+
root: el("button", { class: "btn btn--sm service__control", attrs: { type: "button" } }),
|
|
125
|
+
action: "start",
|
|
126
|
+
confirms: false,
|
|
127
|
+
};
|
|
128
|
+
row.root.addEventListener("click", () => {
|
|
129
|
+
if (row.confirms) handlers.onConfirmService(row.action);
|
|
130
|
+
else handlers.onService(row.action);
|
|
131
|
+
});
|
|
132
|
+
return row;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Not a button: the dashboard cannot run a Pi command, and a control that
|
|
136
|
+
// does nothing is worse than a sentence that says what to do.
|
|
137
|
+
const setupLabel = el("p", { class: "service__setup-label" });
|
|
138
|
+
const setupDetail = el("p", { class: "service__setup-detail" });
|
|
139
|
+
const setupCommand = el("code", { class: "service__setup-command" });
|
|
140
|
+
const setup = el("div", {
|
|
141
|
+
class: "service__setup",
|
|
142
|
+
attrs: { hidden: true },
|
|
143
|
+
children: [setupLabel, setupDetail, setupCommand],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const confirmText = el("p", { class: "service__confirm-text" });
|
|
147
|
+
const confirmCancel = el("button", {
|
|
148
|
+
class: "btn btn--sm",
|
|
149
|
+
attrs: { type: "button" },
|
|
150
|
+
on: { click: handlers.onCancelService },
|
|
151
|
+
});
|
|
152
|
+
const confirmAccept = el("button", {
|
|
153
|
+
class: "btn btn--sm service__control",
|
|
154
|
+
attrs: { type: "button", "data-tone": "danger" },
|
|
155
|
+
});
|
|
156
|
+
let confirmAction: ServiceAction | null = null;
|
|
157
|
+
confirmAccept.addEventListener("click", () => {
|
|
158
|
+
if (confirmAction !== null) handlers.onService(confirmAction);
|
|
159
|
+
});
|
|
160
|
+
const confirm = el("div", {
|
|
161
|
+
class: "service__confirm",
|
|
162
|
+
attrs: { role: "group", "aria-label": "Confirm service action", hidden: true },
|
|
163
|
+
children: [
|
|
164
|
+
confirmText,
|
|
165
|
+
el("div", { class: "service__confirm-row", children: [confirmCancel, confirmAccept] }),
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
// Esc backs out from anywhere inside the strip — focus is on Cancel when it
|
|
169
|
+
// opens, so the escape hatch is one key away without a pointer.
|
|
170
|
+
confirm.addEventListener("keydown", (event) => {
|
|
171
|
+
if (event instanceof KeyboardEvent && event.key === "Escape") handlers.onCancelService();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// A failed command is an operator-relevant event, not a status update, so it
|
|
175
|
+
// announces assertively rather than waiting behind the polite region. It is
|
|
176
|
+
// focusable programmatically (never by Tab) so the confirm flow can hand
|
|
177
|
+
// focus to the outcome when the button that started it has gone inert.
|
|
178
|
+
const notice = el("p", {
|
|
179
|
+
class: "service__notice",
|
|
180
|
+
attrs: { role: "alert", hidden: true, tabindex: "-1" },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// The drift notice. It sits directly under the status readout — above the
|
|
184
|
+
// controls and the router facts it contradicts — because a machine that no
|
|
185
|
+
// longer matches `steward.json` is the first thing about this block that is
|
|
186
|
+
// not true. It carries no `aria-live` of its own: `main.ts` announces it once
|
|
187
|
+
// through the page's polite status region, and a second live region would
|
|
188
|
+
// repeat every line on every 1.6 s poll.
|
|
189
|
+
const driftTitle = el("p", { class: "service__drift-title" });
|
|
190
|
+
const driftDismiss = el("button", {
|
|
191
|
+
class: "btn btn--sm service__drift-dismiss",
|
|
192
|
+
attrs: { type: "button" },
|
|
193
|
+
});
|
|
194
|
+
const driftList = el("ul", { class: "service__drift-list" });
|
|
195
|
+
const driftRows: { root: HTMLElement }[] = [];
|
|
196
|
+
const driftFix = el("p", { class: "service__drift-fix" });
|
|
197
|
+
const drift = el("section", {
|
|
198
|
+
class: "service__drift",
|
|
199
|
+
attrs: { hidden: true },
|
|
200
|
+
children: [
|
|
201
|
+
el("div", {
|
|
202
|
+
class: "service__drift-head",
|
|
203
|
+
children: [
|
|
204
|
+
// The tone is carried by the word "drift", the icon, and the border —
|
|
205
|
+
// never by colour alone.
|
|
206
|
+
el("span", {
|
|
207
|
+
class: "service__drift-icon",
|
|
208
|
+
text: "!",
|
|
209
|
+
attrs: { "aria-hidden": "true" },
|
|
210
|
+
}),
|
|
211
|
+
driftTitle,
|
|
212
|
+
driftDismiss,
|
|
213
|
+
],
|
|
214
|
+
}),
|
|
215
|
+
driftList,
|
|
216
|
+
driftFix,
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
let driftKey: string | null = null;
|
|
220
|
+
driftDismiss.addEventListener("click", () => {
|
|
221
|
+
if (driftKey !== null) handlers.onDismissDrift(driftKey);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const facts = el("div", { class: "config-list service__facts" });
|
|
225
|
+
const rows: FactRow[] = [];
|
|
226
|
+
|
|
227
|
+
const root = el("section", {
|
|
228
|
+
class: "rail__block rail__block--service",
|
|
229
|
+
// The block has no eyebrow to point at, and the lockup is the page's h1
|
|
230
|
+
// rather than this region's heading.
|
|
231
|
+
attrs: { "aria-label": "Service" },
|
|
232
|
+
children: [
|
|
233
|
+
// The chip and the theme control ride the lockup's dead space rather than
|
|
234
|
+
// taking a 36px row of their own beneath it.
|
|
235
|
+
el("div", {
|
|
236
|
+
class: "lockup",
|
|
237
|
+
children: [mark(), el("h1", { class: "lockup__name", text: "Steward" }), status, theme],
|
|
238
|
+
}),
|
|
239
|
+
drift,
|
|
240
|
+
controls,
|
|
241
|
+
setup,
|
|
242
|
+
confirm,
|
|
243
|
+
notice,
|
|
244
|
+
facts,
|
|
245
|
+
],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/** Patches the drift notice, or hides it when there is nothing to report. */
|
|
249
|
+
function updateDrift(vm: DriftNoticeVm | null): void {
|
|
250
|
+
driftKey = vm?.key ?? null;
|
|
251
|
+
// Dismissing hides the section the Dismiss button lives in, so read focus
|
|
252
|
+
// BEFORE hiding it: a keyboard operator who clicked Dismiss would otherwise
|
|
253
|
+
// be dropped at the top of the document. The status readout is the same
|
|
254
|
+
// anchor the confirm strip falls back to.
|
|
255
|
+
const focusWasInside = drift.contains(document.activeElement);
|
|
256
|
+
setAttr(drift, "hidden", vm === null);
|
|
257
|
+
if (vm === null) {
|
|
258
|
+
if (focusWasInside) status.focus();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
setAttr(drift, "aria-label", vm.ariaLabel);
|
|
262
|
+
setText(driftTitle, vm.title);
|
|
263
|
+
setText(driftDismiss, vm.dismissLabel);
|
|
264
|
+
setAttr(driftDismiss, "aria-label", vm.dismissAriaLabel);
|
|
265
|
+
syncRows(driftList, driftRows, vm.messages.length, () => ({
|
|
266
|
+
root: el("li", { class: "service__drift-item" }),
|
|
267
|
+
}));
|
|
268
|
+
vm.messages.forEach((message, index) => {
|
|
269
|
+
const row = driftRows[index];
|
|
270
|
+
if (row !== undefined) setText(row.root, message);
|
|
271
|
+
});
|
|
272
|
+
setText(driftFix, vm.fix);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
el: root,
|
|
277
|
+
update(vm) {
|
|
278
|
+
// The state carries in the dot's SHAPE (the stylesheet keys three forms
|
|
279
|
+
// off this attribute) and in the word. The hue only tints the dot: the
|
|
280
|
+
// word is painted with a text token so it clears AA in both themes.
|
|
281
|
+
setAttr(status, "data-state", vm.state);
|
|
282
|
+
setVar(statusDot, "fill", vm.statusDotColor);
|
|
283
|
+
setText(statusLabel, vm.statusLabel);
|
|
284
|
+
setAttr(status, "aria-label", `Service ${vm.statusLabel}`);
|
|
285
|
+
|
|
286
|
+
setText(theme, vm.themeGlyph);
|
|
287
|
+
setAttr(theme, "aria-label", vm.themeLabel);
|
|
288
|
+
setAttr(theme, "title", vm.themeLabel);
|
|
289
|
+
|
|
290
|
+
updateDrift(vm.drift);
|
|
291
|
+
|
|
292
|
+
const { controls: cvm } = vm;
|
|
293
|
+
setAttr(controls, "hidden", cvm.buttons.length === 0);
|
|
294
|
+
// The row is busy as a whole while a command is out: its buttons are
|
|
295
|
+
// disabled, and assistive tech is told why rather than finding them inert.
|
|
296
|
+
setAttr(controls, "aria-busy", cvm.pending ? "true" : "false");
|
|
297
|
+
syncRows(controls, controlRows, cvm.buttons.length, createControlRow);
|
|
298
|
+
cvm.buttons.forEach((button: ServiceControlVm, index) => {
|
|
299
|
+
const row = controlRows[index];
|
|
300
|
+
if (row === undefined) return;
|
|
301
|
+
row.action = button.action;
|
|
302
|
+
row.confirms = button.confirms;
|
|
303
|
+
setText(row.root, button.label);
|
|
304
|
+
setAttr(row.root, "disabled", button.disabled);
|
|
305
|
+
setAttr(row.root, "aria-label", button.ariaLabel);
|
|
306
|
+
setAttr(row.root, "title", button.disabledReason === "" ? false : button.disabledReason);
|
|
307
|
+
setAttr(row.root, "data-tone", button.danger ? "danger" : "neutral");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
setAttr(setup, "hidden", cvm.setup === null);
|
|
311
|
+
if (cvm.setup !== null) {
|
|
312
|
+
setText(setupLabel, cvm.setup.label);
|
|
313
|
+
setText(setupDetail, cvm.setup.detail);
|
|
314
|
+
setText(setupCommand, cvm.setup.command);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const wasConfirming = confirmAction;
|
|
318
|
+
// Read before the strip is hidden: hiding an ancestor drops the focus.
|
|
319
|
+
const focusWasInStrip = confirm.contains(document.activeElement);
|
|
320
|
+
confirmAction = cvm.confirm?.action ?? null;
|
|
321
|
+
setAttr(confirm, "hidden", cvm.confirm === null);
|
|
322
|
+
if (cvm.confirm !== null) {
|
|
323
|
+
setText(confirmText, cvm.confirm.consequence);
|
|
324
|
+
setText(confirmCancel, cvm.confirm.cancelLabel);
|
|
325
|
+
setText(confirmAccept, cvm.confirm.confirmLabel);
|
|
326
|
+
setAttr(confirmAccept, "aria-label", cvm.confirm.confirmAriaLabel);
|
|
327
|
+
// Opening moves focus to the safe choice, so a keyboard operator lands
|
|
328
|
+
// on Cancel and has to travel to confirm — never the other way round.
|
|
329
|
+
if (wasConfirming === null) confirmCancel.focus();
|
|
330
|
+
}
|
|
331
|
+
// Closing is where focus is most easily lost: accepting disables every
|
|
332
|
+
// button while the command is out, so the opener is usually inert by the
|
|
333
|
+
// time we get here. Resolve it after the notice is patched, and land on
|
|
334
|
+
// the outcome — the alert if there is one, else the status readout —
|
|
335
|
+
// rather than dropping a keyboard operator at the top of the document.
|
|
336
|
+
const restoreFrom = cvm.confirm === null && wasConfirming !== null && focusWasInStrip;
|
|
337
|
+
|
|
338
|
+
setAttr(notice, "hidden", cvm.notice === null);
|
|
339
|
+
if (cvm.notice !== null) setText(notice, cvm.notice);
|
|
340
|
+
|
|
341
|
+
if (restoreFrom) {
|
|
342
|
+
const opener = controlRows.find((row) => row.action === wasConfirming);
|
|
343
|
+
if (opener !== undefined && !opener.root.disabled) opener.root.focus();
|
|
344
|
+
else if (cvm.notice !== null) notice.focus();
|
|
345
|
+
else status.focus();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
syncRows(facts, rows, vm.config.length, createFactRow);
|
|
349
|
+
vm.config.forEach((entry, index) => {
|
|
350
|
+
const row = rows[index];
|
|
351
|
+
if (row === undefined) return;
|
|
352
|
+
// `label: value`, the same grammar the model-card fields use.
|
|
353
|
+
setText(row.key, `${entry.key}:`);
|
|
354
|
+
setText(row.value, entry.value);
|
|
355
|
+
});
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|