@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,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The log console.
|
|
3
|
+
*
|
|
4
|
+
* Lines arrive faster than the metrics poll, so the console never rebuilds:
|
|
5
|
+
* rows that have fallen out of the window are removed from the front, new ones
|
|
6
|
+
* are appended, and the rest are patched in place. Only a change that genuinely
|
|
7
|
+
* reorders the window — a filter moving under us, or a fold opening — falls
|
|
8
|
+
* back to a rebuild.
|
|
9
|
+
*
|
|
10
|
+
* It is deliberately NOT a live region. A tail that announced itself would read
|
|
11
|
+
* every arriving line aloud forever; what matters about the stream is announced
|
|
12
|
+
* through the page's status line instead, once per state change.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
ConsoleActionVm,
|
|
17
|
+
ConsoleBannerVm,
|
|
18
|
+
ConsoleNoticeVm,
|
|
19
|
+
ConsoleVm,
|
|
20
|
+
LogBadgeVm,
|
|
21
|
+
LogRowVm,
|
|
22
|
+
} from "../../core/select.js";
|
|
23
|
+
import { consoleFocusRestore, countNewLines, taskKey } from "../../core/select.js";
|
|
24
|
+
import type { View } from "../dom.js";
|
|
25
|
+
import { clear, el, setAttr, setStyle, setText, setVar, syncRows } from "../dom.js";
|
|
26
|
+
|
|
27
|
+
export interface ConsoleHandlers {
|
|
28
|
+
/**
|
|
29
|
+
* Toggles one args fold, keyed by the run's first `seq`. `forced` says the
|
|
30
|
+
* fold is held open by the active query, so the press changes only what
|
|
31
|
+
* happens once that query clears.
|
|
32
|
+
*/
|
|
33
|
+
onFold: (seq: number, forced: boolean, count: number) => void;
|
|
34
|
+
/** Opens a trace on one `(port, task)`, anchored at the row that opened it. */
|
|
35
|
+
onTrace: (port: number, task: number, anchorSeq: number) => void;
|
|
36
|
+
/** Closes the open trace: `[ back ]`, `Escape`, or the same cell again. */
|
|
37
|
+
onExitTrace: () => void;
|
|
38
|
+
/** The button inside a notice or banner: clear filters, show all models. */
|
|
39
|
+
onAction: (kind: ConsoleActionVm["kind"]) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How close to the bottom still counts as following the tail, in pixels. */
|
|
43
|
+
const FOLLOW_THRESHOLD_PX = 24;
|
|
44
|
+
|
|
45
|
+
interface BadgeCell {
|
|
46
|
+
root: HTMLElement;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface LogRow {
|
|
50
|
+
root: HTMLElement;
|
|
51
|
+
key: string;
|
|
52
|
+
seq: number;
|
|
53
|
+
/** The run key this row toggles; only meaningful on a fold row. */
|
|
54
|
+
foldSeq: number;
|
|
55
|
+
foldForced: boolean;
|
|
56
|
+
foldCount: number;
|
|
57
|
+
fold: boolean;
|
|
58
|
+
ts: HTMLElement;
|
|
59
|
+
level: HTMLElement;
|
|
60
|
+
model: HTMLElement;
|
|
61
|
+
/**
|
|
62
|
+
* Always present so the row's shape never changes and the incremental patcher
|
|
63
|
+
* is untouched; hidden when the row has no task. A `<button>` on an ordinary
|
|
64
|
+
* row and a `<span>` on a fold row, whose own root is already a button.
|
|
65
|
+
*/
|
|
66
|
+
task: HTMLElement;
|
|
67
|
+
/** The `(port, task)` this cell traces, or `""` when it has none. */
|
|
68
|
+
taskKey: string;
|
|
69
|
+
taskPort: number;
|
|
70
|
+
taskId: number;
|
|
71
|
+
message: HTMLElement;
|
|
72
|
+
text: HTMLElement;
|
|
73
|
+
badgeHost: HTMLElement;
|
|
74
|
+
badges: BadgeCell[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface BannerRow {
|
|
78
|
+
root: HTMLElement;
|
|
79
|
+
text: HTMLElement;
|
|
80
|
+
detail: HTMLElement;
|
|
81
|
+
action: HTMLButtonElement;
|
|
82
|
+
kind: ConsoleActionVm["kind"];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createLogConsole(handlers: ConsoleHandlers): View<ConsoleVm> {
|
|
86
|
+
const rendered: LogRow[] = [];
|
|
87
|
+
const topBanners: BannerRow[] = [];
|
|
88
|
+
const bottomBanners: BannerRow[] = [];
|
|
89
|
+
|
|
90
|
+
const heading = el("h2", { class: "visually-hidden" });
|
|
91
|
+
const top = el("div", { class: "console__banners console__banners--top" });
|
|
92
|
+
/**
|
|
93
|
+
* The column labels.
|
|
94
|
+
*
|
|
95
|
+
* `aria-hidden`, and deliberately so: the rows below are plain elements, not
|
|
96
|
+
* a `role="grid"`, and announcing a header over a non-tabular structure would
|
|
97
|
+
* promise `columnheader` semantics on every row that are not there. Each cell
|
|
98
|
+
* already carries its own name — the level word, the model word, the task
|
|
99
|
+
* cell's full sentence — so nothing is lost by keeping this visual.
|
|
100
|
+
*
|
|
101
|
+
* They are LABELS, never controls: no button, no `cursor: pointer`, no hover
|
|
102
|
+
* or press state, nothing that offers to sort. File order is the only valid
|
|
103
|
+
* order here — task ids are allocated at enqueue and logged at dequeue, so a
|
|
104
|
+
* deferred task logs later with a LOWER id, and a sorted view of this data
|
|
105
|
+
* would be wrong rather than merely different.
|
|
106
|
+
*/
|
|
107
|
+
const head = el("div", {
|
|
108
|
+
class: "log-row console__head",
|
|
109
|
+
attrs: { "aria-hidden": "true", hidden: true },
|
|
110
|
+
children: [
|
|
111
|
+
el("span", { class: "log-row__ts", text: "time" }),
|
|
112
|
+
el("span", { class: "log-row__level", text: "level" }),
|
|
113
|
+
el("span", { class: "log-row__model", text: "model" }),
|
|
114
|
+
el("span", { class: "log-row__task", text: "task" }),
|
|
115
|
+
el("span", { class: "log-row__msg", text: "message" }),
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
// One sticky block, so the labels can never separate from the strips above
|
|
119
|
+
// them or slide out from over their own columns.
|
|
120
|
+
const sticky = el("div", { class: "console__sticky", children: [top, head] });
|
|
121
|
+
const rows = el("div", { class: "console__rows" });
|
|
122
|
+
const bottom = el("div", { class: "console__banners console__banners--bottom" });
|
|
123
|
+
|
|
124
|
+
const noticeGlyph = el("span", {
|
|
125
|
+
class: "console__notice-glyph",
|
|
126
|
+
attrs: { "aria-hidden": "true" },
|
|
127
|
+
});
|
|
128
|
+
const noticeTitle = el("p", { class: "console__notice-title" });
|
|
129
|
+
const noticeDetail = el("p", { class: "console__notice-detail" });
|
|
130
|
+
const noticeAction = el("button", {
|
|
131
|
+
class: "btn btn--sm console__notice-action",
|
|
132
|
+
attrs: { type: "button" },
|
|
133
|
+
});
|
|
134
|
+
const notice = el("div", {
|
|
135
|
+
class: "console__notice",
|
|
136
|
+
children: [el("p", { class: "console__notice-head", children: [noticeGlyph, noticeTitle] })],
|
|
137
|
+
});
|
|
138
|
+
notice.append(noticeDetail, noticeAction);
|
|
139
|
+
// Hidden until the first paint decides otherwise: an empty notice box between
|
|
140
|
+
// page load and the first snapshot is a claim about a console nobody has
|
|
141
|
+
// looked at yet.
|
|
142
|
+
setStyle(notice, "display", "none");
|
|
143
|
+
let noticeKind: ConsoleActionVm["kind"] = "clear-filters";
|
|
144
|
+
noticeAction.addEventListener("click", () => {
|
|
145
|
+
handlers.onAction(noticeKind);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const jump = el("button", {
|
|
149
|
+
class: "console__jump",
|
|
150
|
+
attrs: { type: "button", hidden: true },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const root = el("div", {
|
|
154
|
+
class: "console",
|
|
155
|
+
attrs: {
|
|
156
|
+
role: "region",
|
|
157
|
+
"aria-label": "Server log",
|
|
158
|
+
tabindex: "0",
|
|
159
|
+
},
|
|
160
|
+
children: [heading, sticky, notice, rows, bottom, jump],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** The trace open at the last paint, so a transition is detectable. */
|
|
164
|
+
let openTrace: string | null = null;
|
|
165
|
+
|
|
166
|
+
root.addEventListener("keydown", (event) => {
|
|
167
|
+
if (!(event instanceof KeyboardEvent) || event.key !== "Escape") return;
|
|
168
|
+
// `preventDefault` only while a trace is open, so Escape keeps whatever
|
|
169
|
+
// meaning the browser gives it everywhere else in the console.
|
|
170
|
+
if (openTrace === null) return;
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
handlers.onExitTrace();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Follow-state: soft, and distinct from the hard Pause button. Scrolling up
|
|
176
|
+
// holds the view while the buffer keeps filling; scrolling back to the bottom
|
|
177
|
+
// resumes the tail on its own.
|
|
178
|
+
let following = true;
|
|
179
|
+
/** The newest seq on screen when following was lost, so "N new" is countable. */
|
|
180
|
+
let pinnedSeq = Number.NEGATIVE_INFINITY;
|
|
181
|
+
let newCount = 0;
|
|
182
|
+
|
|
183
|
+
function paintJump(): void {
|
|
184
|
+
const show = !following && newCount > 0;
|
|
185
|
+
setAttr(jump, "hidden", !show);
|
|
186
|
+
if (!show) return;
|
|
187
|
+
const label = `Jump to latest · ${newCount} new`;
|
|
188
|
+
setText(jump, label);
|
|
189
|
+
setAttr(
|
|
190
|
+
jump,
|
|
191
|
+
"aria-label",
|
|
192
|
+
`Jump to latest — ${newCount} new line${newCount === 1 ? "" : "s"}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toBottom(): void {
|
|
197
|
+
// An instant jump, never a smooth scroll: this fires on every append, and a
|
|
198
|
+
// smooth one would be motion an operator did not ask for.
|
|
199
|
+
root.scrollTop = root.scrollHeight;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
root.addEventListener("scroll", () => {
|
|
203
|
+
const atBottom = root.scrollHeight - root.scrollTop - root.clientHeight < FOLLOW_THRESHOLD_PX;
|
|
204
|
+
if (atBottom === following) return;
|
|
205
|
+
following = atBottom;
|
|
206
|
+
if (following) {
|
|
207
|
+
newCount = 0;
|
|
208
|
+
pinnedSeq = Number.NEGATIVE_INFINITY;
|
|
209
|
+
} else {
|
|
210
|
+
pinnedSeq = rendered.at(-1)?.seq ?? Number.NEGATIVE_INFINITY;
|
|
211
|
+
}
|
|
212
|
+
paintJump();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
jump.addEventListener("click", () => {
|
|
216
|
+
following = true;
|
|
217
|
+
newCount = 0;
|
|
218
|
+
pinnedSeq = Number.NEGATIVE_INFINITY;
|
|
219
|
+
paintJump();
|
|
220
|
+
toBottom();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
function createBadge(): BadgeCell {
|
|
224
|
+
return { root: el("span", { class: "log-badge" }) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createRow(fold: boolean): LogRow {
|
|
228
|
+
const ts = el("span", { class: "log-row__ts" });
|
|
229
|
+
const level = el("span", { class: "log-row__level" });
|
|
230
|
+
const model = el("span", { class: "log-row__model" });
|
|
231
|
+
// A fold row's own root is a `<button>`, and a button inside a button is
|
|
232
|
+
// not a thing the DOM will keep. A fold stands for an args run, which is
|
|
233
|
+
// never framed and so never has a task to trace, so its cell is a span that
|
|
234
|
+
// holds the grid column open and nothing else.
|
|
235
|
+
const task: HTMLElement = fold
|
|
236
|
+
? el("span", { class: "log-row__task", attrs: { "data-empty": "true" } })
|
|
237
|
+
: el("button", {
|
|
238
|
+
class: "log-row__task",
|
|
239
|
+
attrs: { type: "button", "data-empty": "true" },
|
|
240
|
+
});
|
|
241
|
+
const text = el("span", { class: "log-row__text" });
|
|
242
|
+
const badgeHost = el("span", { class: "log-badges" });
|
|
243
|
+
const message = el("span", { class: "log-row__msg", children: [text, badgeHost] });
|
|
244
|
+
const children = [ts, level, model, task, message];
|
|
245
|
+
const row: LogRow = {
|
|
246
|
+
root: fold
|
|
247
|
+
? el("button", { class: "log-row log-row--fold", attrs: { type: "button" }, children })
|
|
248
|
+
: el("div", { class: "log-row", children }),
|
|
249
|
+
key: "",
|
|
250
|
+
seq: -1,
|
|
251
|
+
foldSeq: -1,
|
|
252
|
+
foldForced: false,
|
|
253
|
+
foldCount: 0,
|
|
254
|
+
fold,
|
|
255
|
+
ts,
|
|
256
|
+
level,
|
|
257
|
+
model,
|
|
258
|
+
task,
|
|
259
|
+
taskKey: "",
|
|
260
|
+
taskPort: -1,
|
|
261
|
+
taskId: -1,
|
|
262
|
+
message,
|
|
263
|
+
text,
|
|
264
|
+
badgeHost,
|
|
265
|
+
badges: [],
|
|
266
|
+
};
|
|
267
|
+
if (fold) {
|
|
268
|
+
row.root.addEventListener("click", () => {
|
|
269
|
+
handlers.onFold(row.foldSeq, row.foldForced, row.foldCount);
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
task.addEventListener("click", () => {
|
|
273
|
+
if (row.taskKey === "") return;
|
|
274
|
+
// Pressing the cell that opened the trace closes it, which is what the
|
|
275
|
+
// `▾`/`▸` glyph and `aria-pressed` have been saying all along.
|
|
276
|
+
if (task.getAttribute("aria-pressed") === "true") handlers.onExitTrace();
|
|
277
|
+
else handlers.onTrace(row.taskPort, row.taskId, row.seq);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return row;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function paint(row: LogRow, line: LogRowVm): void {
|
|
284
|
+
row.key = line.key;
|
|
285
|
+
row.seq = line.seq;
|
|
286
|
+
setText(row.ts, line.time);
|
|
287
|
+
setText(row.level, line.level);
|
|
288
|
+
setText(row.model, line.model);
|
|
289
|
+
setVar(row.model, "model-color", line.modelColor);
|
|
290
|
+
setAttr(row.model, "data-scope", line.scope);
|
|
291
|
+
setAttr(row.model, "title", line.modelTitle === "" ? false : line.modelTitle);
|
|
292
|
+
|
|
293
|
+
// The cell is always in the grid; when there is no task it is
|
|
294
|
+
// `visibility: hidden`, which takes it out of the tab order and the
|
|
295
|
+
// accessibility tree while keeping the column's rhythm intact.
|
|
296
|
+
const cell = line.task;
|
|
297
|
+
row.taskKey = cell?.key ?? "";
|
|
298
|
+
row.taskPort = cell?.port ?? -1;
|
|
299
|
+
row.taskId = cell?.task ?? -1;
|
|
300
|
+
setText(row.task, cell?.label ?? "");
|
|
301
|
+
setAttr(row.task, "data-empty", cell === null ? "true" : false);
|
|
302
|
+
setAttr(row.task, "aria-label", cell?.ariaLabel ?? false);
|
|
303
|
+
setAttr(row.task, "title", cell?.ariaLabel ?? false);
|
|
304
|
+
setAttr(row.task, "aria-pressed", cell === null ? false : String(cell.active));
|
|
305
|
+
|
|
306
|
+
setText(row.text, line.message);
|
|
307
|
+
syncRows(row.badgeHost, row.badges, line.badges.length, createBadge);
|
|
308
|
+
line.badges.forEach((badge, index) => {
|
|
309
|
+
const cellNode = row.badges[index];
|
|
310
|
+
if (cellNode === undefined) return;
|
|
311
|
+
paintBadge(cellNode, badge);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Severity is carried by `data-level` in CSS — a filled badge for WARN and
|
|
315
|
+
// ERROR against a plain token for INFO and DEBUG — so it is a difference in
|
|
316
|
+
// shape, not only in hue.
|
|
317
|
+
setAttr(row.root, "data-level", line.level);
|
|
318
|
+
setAttr(row.root, "data-kind", line.kind);
|
|
319
|
+
setAttr(row.root, "data-fold", line.folded ? "member" : false);
|
|
320
|
+
setAttr(row.root, "data-traced", line.traced ? "true" : false);
|
|
321
|
+
if (line.fold !== null) {
|
|
322
|
+
row.foldSeq = line.fold.seq;
|
|
323
|
+
row.foldForced = line.fold.forced;
|
|
324
|
+
row.foldCount = line.fold.count;
|
|
325
|
+
setAttr(row.root, "aria-expanded", String(line.fold.expanded));
|
|
326
|
+
setAttr(row.root, "aria-label", line.fold.ariaLabel);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function paintBadge(cell: BadgeCell, vm: LogBadgeVm): void {
|
|
331
|
+
setText(cell.root, vm.label);
|
|
332
|
+
setAttr(cell.root, "title", vm.title);
|
|
333
|
+
setAttr(cell.root, "data-tone", vm.tone);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function createBanner(): BannerRow {
|
|
337
|
+
const text = el("span", { class: "console__banner-text" });
|
|
338
|
+
const detail = el("span", { class: "console__banner-detail" });
|
|
339
|
+
const action = el("button", {
|
|
340
|
+
class: "btn btn--sm console__banner-action",
|
|
341
|
+
attrs: { type: "button" },
|
|
342
|
+
});
|
|
343
|
+
const banner: BannerRow = {
|
|
344
|
+
root: el("div", { class: "console__banner", children: [text, detail, action] }),
|
|
345
|
+
text,
|
|
346
|
+
detail,
|
|
347
|
+
action,
|
|
348
|
+
kind: "clear-filters",
|
|
349
|
+
};
|
|
350
|
+
action.addEventListener("click", () => {
|
|
351
|
+
handlers.onAction(banner.kind);
|
|
352
|
+
});
|
|
353
|
+
return banner;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function paintBanner(row: BannerRow, vm: ConsoleBannerVm): void {
|
|
357
|
+
setText(row.text, vm.text);
|
|
358
|
+
setText(row.detail, vm.detail);
|
|
359
|
+
setAttr(row.detail, "hidden", vm.detail === "");
|
|
360
|
+
setAttr(row.root, "data-tone", vm.tone);
|
|
361
|
+
if (vm.action === null) {
|
|
362
|
+
setAttr(row.action, "hidden", true);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
row.kind = vm.action.kind;
|
|
366
|
+
setAttr(row.action, "hidden", false);
|
|
367
|
+
setText(row.action, vm.action.label);
|
|
368
|
+
setAttr(row.action, "aria-label", vm.action.ariaLabel);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function paintNotice(vm: ConsoleNoticeVm | null): void {
|
|
372
|
+
setStyle(notice, "display", vm === null ? "none" : "grid");
|
|
373
|
+
if (vm === null) return;
|
|
374
|
+
setAttr(notice, "data-tone", vm.tone);
|
|
375
|
+
setAttr(notice, "data-state", vm.state);
|
|
376
|
+
setText(noticeGlyph, vm.glyph);
|
|
377
|
+
setText(noticeTitle, vm.title);
|
|
378
|
+
setText(noticeDetail, vm.detail);
|
|
379
|
+
if (vm.action === null) {
|
|
380
|
+
setAttr(noticeAction, "hidden", true);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
noticeKind = vm.action.kind;
|
|
384
|
+
setAttr(noticeAction, "hidden", false);
|
|
385
|
+
setText(noticeAction, vm.action.label);
|
|
386
|
+
setAttr(noticeAction, "aria-label", vm.action.ariaLabel);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
el: root,
|
|
391
|
+
update(vm) {
|
|
392
|
+
// Read before anything is patched: once the element is gone,
|
|
393
|
+
// `document.activeElement` is the body and the trail is cold.
|
|
394
|
+
const active = document.activeElement;
|
|
395
|
+
const focusWasInside = active instanceof HTMLElement && root.contains(active);
|
|
396
|
+
const activeFoldKey = focusWasInside
|
|
397
|
+
? (rendered.find((row) => row.fold && row.root === active)?.key ?? null)
|
|
398
|
+
: null;
|
|
399
|
+
const activeTaskKey = focusWasInside
|
|
400
|
+
? (rendered.find((row) => row.task === active)?.taskKey ?? null)
|
|
401
|
+
: null;
|
|
402
|
+
|
|
403
|
+
const nextTrace = vm.trace === null ? null : taskKey(vm.trace.port, vm.trace.task);
|
|
404
|
+
const enteringTrace = nextTrace !== null && nextTrace !== openTrace;
|
|
405
|
+
const leavingTrace = nextTrace === null && openTrace !== null;
|
|
406
|
+
const exitingTaskKey = openTrace;
|
|
407
|
+
openTrace = nextTrace;
|
|
408
|
+
|
|
409
|
+
setText(heading, vm.heading);
|
|
410
|
+
paintNotice(vm.notice);
|
|
411
|
+
// The header collapses with the column it labels, so it can never sit
|
|
412
|
+
// over 64px of nothing.
|
|
413
|
+
setAttr(root, "data-tasks", vm.showTaskColumn ? "some" : "none");
|
|
414
|
+
|
|
415
|
+
const above = vm.banners.filter((banner) => banner.placement === "top");
|
|
416
|
+
const below = vm.banners.filter((banner) => banner.placement === "bottom");
|
|
417
|
+
syncRows(top, topBanners, above.length, createBanner);
|
|
418
|
+
above.forEach((banner, index) => {
|
|
419
|
+
const row = topBanners[index];
|
|
420
|
+
if (row !== undefined) paintBanner(row, banner);
|
|
421
|
+
});
|
|
422
|
+
syncRows(bottom, bottomBanners, below.length, createBanner);
|
|
423
|
+
below.forEach((banner, index) => {
|
|
424
|
+
const row = bottomBanners[index];
|
|
425
|
+
if (row !== undefined) paintBanner(row, banner);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const lines = vm.lines;
|
|
429
|
+
// Labels over nothing are noise, so they go with the rows.
|
|
430
|
+
setAttr(head, "hidden", lines.length === 0);
|
|
431
|
+
const first0 = lines[0];
|
|
432
|
+
let structureChanged = false;
|
|
433
|
+
|
|
434
|
+
// Retire rows that have scrolled out of the window.
|
|
435
|
+
while (rendered.length > 0) {
|
|
436
|
+
const first = rendered[0];
|
|
437
|
+
if (first === undefined) break;
|
|
438
|
+
if (first0 !== undefined && first.seq >= first0.seq) break;
|
|
439
|
+
rows.removeChild(first.root);
|
|
440
|
+
rendered.shift();
|
|
441
|
+
structureChanged = true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// If what is left no longer lines up with the window, the filter moved
|
|
445
|
+
// under us — or a fold opened, which reshapes the run in place — and an
|
|
446
|
+
// in-place patch would put the wrong element on screen. Keys, not
|
|
447
|
+
// sequence numbers, because a fold row and the first line of its run
|
|
448
|
+
// share a `seq`.
|
|
449
|
+
const aligned =
|
|
450
|
+
rendered.length <= lines.length && rendered.every((row, i) => lines[i]?.key === row.key);
|
|
451
|
+
if (!aligned) {
|
|
452
|
+
clear(rows);
|
|
453
|
+
rendered.length = 0;
|
|
454
|
+
structureChanged = true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (let i = rendered.length; i < lines.length; i += 1) {
|
|
458
|
+
const line = lines[i];
|
|
459
|
+
if (line === undefined) break;
|
|
460
|
+
const row = createRow(line.fold !== null);
|
|
461
|
+
rendered.push(row);
|
|
462
|
+
rows.appendChild(row.root);
|
|
463
|
+
structureChanged = true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
lines.forEach((line, index) => {
|
|
467
|
+
const row = rendered[index];
|
|
468
|
+
if (row !== undefined) paint(row, line);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
newCount = following ? 0 : countNewLines(lines, pinnedSeq);
|
|
472
|
+
paintJump();
|
|
473
|
+
|
|
474
|
+
// The browser blurs synchronously when the focused element is removed OR
|
|
475
|
+
// when an ancestor turns `display: none`, so "the active element moved
|
|
476
|
+
// while we were patching" catches all three cases without guessing at
|
|
477
|
+
// layout — the notice is hidden, not removed, and the banner is removed.
|
|
478
|
+
const restore = consoleFocusRestore({
|
|
479
|
+
wasInside: focusWasInside,
|
|
480
|
+
moved: focusWasInside && document.activeElement !== active,
|
|
481
|
+
enteringTrace,
|
|
482
|
+
leavingTrace,
|
|
483
|
+
foldKey: activeFoldKey,
|
|
484
|
+
taskKey: activeTaskKey,
|
|
485
|
+
exitingTaskKey,
|
|
486
|
+
keys: rendered.map((row) => row.key),
|
|
487
|
+
taskKeys: rendered.map((row) => row.taskKey).filter((key) => key !== ""),
|
|
488
|
+
});
|
|
489
|
+
if (restore.target === "fold") {
|
|
490
|
+
rendered.find((row) => row.key === restore.key)?.root.focus();
|
|
491
|
+
} else if (restore.target === "task") {
|
|
492
|
+
rendered.find((row) => row.taskKey === restore.key)?.task.focus();
|
|
493
|
+
} else if (restore.target === "back") {
|
|
494
|
+
// The trace banner's own action button — the safe landing, and the same
|
|
495
|
+
// pattern the service block's confirm strip uses.
|
|
496
|
+
const back = topBanners.find(
|
|
497
|
+
(banner) => banner.kind === "exit-trace" && !banner.action.hasAttribute("hidden"),
|
|
498
|
+
);
|
|
499
|
+
if (back !== undefined) back.action.focus();
|
|
500
|
+
else root.focus();
|
|
501
|
+
} else if (restore.target === "region") {
|
|
502
|
+
root.focus();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// A live tail follows the newest line, but only while the operator is
|
|
506
|
+
// actually at the bottom: a poll that changed nothing must not yank the
|
|
507
|
+
// view, and neither must an append while they are reading further up.
|
|
508
|
+
if (structureChanged && following && !vm.paused) toBottom();
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rail's HOST block: labelled bars for the box's own vitals, plus the one
|
|
3
|
+
* control that changes how they are labelled.
|
|
4
|
+
*
|
|
5
|
+
* The temperature-unit button lives here and nowhere else. Proximity is the
|
|
6
|
+
* whole of its discoverability — a unit token beside the numbers it labels
|
|
7
|
+
* needs no explanation, and the same button in the SERVICE box would be a
|
|
8
|
+
* mystery glyph next to the theme toggle. It is also the only block that knows
|
|
9
|
+
* whether it has anything to relabel.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { GaugeVm, TemperatureControlVm } from "../../core/select.js";
|
|
13
|
+
import type { TemperaturePreference } from "../../core/temperature.js";
|
|
14
|
+
import type { View } from "../dom.js";
|
|
15
|
+
import { el, setAttr, setStyle, setText, setVar, syncRows } from "../dom.js";
|
|
16
|
+
|
|
17
|
+
export interface HostHandlers {
|
|
18
|
+
/** Cycles the stored preference: auto → °C → °F → auto. */
|
|
19
|
+
onCycleTemperature: (next: TemperaturePreference) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What this block renders: the gauge rows, and the control that labels them. */
|
|
23
|
+
export interface HostVm {
|
|
24
|
+
gauges: GaugeVm[];
|
|
25
|
+
/** `null` when no gauge is a temperature row — the control is then absent. */
|
|
26
|
+
temperature: TemperatureControlVm | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface GaugeRow {
|
|
30
|
+
root: HTMLElement;
|
|
31
|
+
label: HTMLElement;
|
|
32
|
+
value: HTMLElement;
|
|
33
|
+
bar: HTMLElement;
|
|
34
|
+
fill: HTMLElement;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createRow(): GaugeRow {
|
|
38
|
+
const label = el("span", { class: "gauge__label" });
|
|
39
|
+
const value = el("span", { class: "gauge__value" });
|
|
40
|
+
const fill = el("div", { class: "bar__fill" });
|
|
41
|
+
const bar = el("div", { class: "bar", children: [fill] });
|
|
42
|
+
const root = el("div", {
|
|
43
|
+
class: "gauge",
|
|
44
|
+
children: [el("div", { class: "gauge__head", children: [label, value] }), bar],
|
|
45
|
+
});
|
|
46
|
+
return { root, label, value, bar, fill };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createHostBlock(handlers: HostHandlers): View<HostVm> {
|
|
50
|
+
const rows: GaugeRow[] = [];
|
|
51
|
+
const list = el("div", { class: "gauges" });
|
|
52
|
+
|
|
53
|
+
// The preference this button will move to on its next press. It lives on the
|
|
54
|
+
// closure rather than in the listener so the row can be patched across
|
|
55
|
+
// repaints without rebinding, exactly as the service controls are.
|
|
56
|
+
let nextPreference: TemperaturePreference = "celsius";
|
|
57
|
+
const unit = el("button", {
|
|
58
|
+
class: "btn btn--sm host__unit",
|
|
59
|
+
attrs: { type: "button", hidden: true },
|
|
60
|
+
on: {
|
|
61
|
+
click: () => {
|
|
62
|
+
handlers.onCycleTemperature(nextPreference);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const root = el("section", {
|
|
68
|
+
class: "rail__block rail__block--host",
|
|
69
|
+
// `tabindex="-1"` makes the block itself a focus anchor. It is never in the
|
|
70
|
+
// tab order; it exists so that when the unit control unmounts under a
|
|
71
|
+
// keyboard operator's focus, there is somewhere in this block to land.
|
|
72
|
+
attrs: { "aria-labelledby": "steward-host-title", tabindex: "-1" },
|
|
73
|
+
children: [
|
|
74
|
+
el("div", {
|
|
75
|
+
class: "block__head",
|
|
76
|
+
children: [
|
|
77
|
+
el("h2", { class: "eyebrow", text: "Host", attrs: { id: "steward-host-title" } }),
|
|
78
|
+
unit,
|
|
79
|
+
],
|
|
80
|
+
}),
|
|
81
|
+
list,
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
el: root,
|
|
87
|
+
update(vm) {
|
|
88
|
+
// The row count only moves when the host stops reporting a sensor, so
|
|
89
|
+
// this is a resize on a handful of elements, not a rebuild.
|
|
90
|
+
syncRows(list, rows, vm.gauges.length, createRow);
|
|
91
|
+
vm.gauges.forEach((gauge, index) => {
|
|
92
|
+
const row = rows[index];
|
|
93
|
+
if (row === undefined) return;
|
|
94
|
+
setText(row.label, gauge.label);
|
|
95
|
+
setText(row.value, gauge.value);
|
|
96
|
+
setVar(row.value, "fill", gauge.color);
|
|
97
|
+
setVar(row.fill, "fill", gauge.color);
|
|
98
|
+
setStyle(row.fill, "width", `${gauge.percent}%`);
|
|
99
|
+
// The track texture reinforces the value token: a hatched bar reads as
|
|
100
|
+
// "no reading" and can't be mistaken for a real, solid 0% fill.
|
|
101
|
+
setAttr(row.bar, "data-track", gauge.track);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Read BEFORE the control is hidden. A machine whose last temperature
|
|
105
|
+
// sensor stops reporting drops its temperature rows, which takes this
|
|
106
|
+
// button with them — and hiding the focused element drops focus to the
|
|
107
|
+
// document. Landing on the block keeps the operator where they were.
|
|
108
|
+
const focusWasOnUnit = unit.contains(document.activeElement);
|
|
109
|
+
setAttr(unit, "hidden", vm.temperature === null);
|
|
110
|
+
if (vm.temperature === null) {
|
|
111
|
+
if (focusWasOnUnit) root.focus();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
nextPreference = vm.temperature.next;
|
|
115
|
+
setText(unit, vm.temperature.label);
|
|
116
|
+
setAttr(unit, "aria-label", vm.temperature.ariaLabel);
|
|
117
|
+
setAttr(unit, "title", vm.temperature.title);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The metrics band across the top of the main column: three KPI tiles plus the
|
|
3
|
+
* throughput-history cell, which is passed in so this module owns only the
|
|
4
|
+
* band's grid.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { KpiVm } from "../../core/select.js";
|
|
8
|
+
import type { View } from "../dom.js";
|
|
9
|
+
import { el, setStyle, setText, setVar, syncRows } from "../dom.js";
|
|
10
|
+
|
|
11
|
+
interface KpiTile {
|
|
12
|
+
root: HTMLElement;
|
|
13
|
+
label: HTMLElement;
|
|
14
|
+
value: HTMLElement;
|
|
15
|
+
unit: HTMLElement;
|
|
16
|
+
fill: HTMLElement;
|
|
17
|
+
sub: HTMLElement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createTile(): KpiTile {
|
|
21
|
+
const label = el("div", { class: "eyebrow" });
|
|
22
|
+
const value = el("span", { class: "kpi__value" });
|
|
23
|
+
const unit = el("span", { class: "kpi__unit" });
|
|
24
|
+
const fill = el("div", { class: "bar__fill" });
|
|
25
|
+
const sub = el("span", { class: "kpi__sub" });
|
|
26
|
+
const root = el("div", {
|
|
27
|
+
class: "metric",
|
|
28
|
+
children: [
|
|
29
|
+
label,
|
|
30
|
+
el("div", { class: "kpi__row", children: [value, unit] }),
|
|
31
|
+
el("div", { class: "bar bar--kpi", children: [fill] }),
|
|
32
|
+
sub,
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
return { root, label, value, unit, fill, sub };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createMetricsBand(sparkline: HTMLElement): View<KpiVm[]> {
|
|
39
|
+
const tiles: KpiTile[] = [];
|
|
40
|
+
const root = el("section", { class: "metrics", attrs: { "aria-label": "Service metrics" } });
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
el: root,
|
|
44
|
+
update(kpis) {
|
|
45
|
+
syncRows(root, tiles, kpis.length, createTile);
|
|
46
|
+
// The chart is the band's last cell; re-appending keeps it there after a
|
|
47
|
+
// tile is added or dropped, and is a no-op otherwise.
|
|
48
|
+
if (root.lastElementChild !== sparkline) root.appendChild(sparkline);
|
|
49
|
+
|
|
50
|
+
kpis.forEach((kpi, index) => {
|
|
51
|
+
const tile = tiles[index];
|
|
52
|
+
if (tile === undefined) return;
|
|
53
|
+
setText(tile.label, kpi.label);
|
|
54
|
+
setText(tile.value, kpi.value);
|
|
55
|
+
setVar(tile.value, "fill", kpi.color);
|
|
56
|
+
setText(tile.unit, kpi.unit);
|
|
57
|
+
setVar(tile.fill, "fill", kpi.color);
|
|
58
|
+
setStyle(tile.fill, "width", `${kpi.percent}%`);
|
|
59
|
+
setText(tile.sub, kpi.sub);
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|