@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,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SLOTS panel: how many concurrent requests each loaded model is running.
|
|
3
|
+
*
|
|
4
|
+
* A "slot" is one request lane inside a single model's `llama-server` — a model
|
|
5
|
+
* loaded with `--parallel 4` has four of them, and its context is split across
|
|
6
|
+
* them. It is not a seat for a model (that ceiling is the router's `max models`,
|
|
7
|
+
* shown in CONFIG). So the panel reads as one compact chip per loaded model:
|
|
8
|
+
* a colored dot, the model's short name, and how many of its slots are busy.
|
|
9
|
+
* The per-slot context detail — how full each lane's context is — rides on the
|
|
10
|
+
* chip's hover title rather than taking a row of its own. With nothing loaded
|
|
11
|
+
* the panel collapses to a single empty note.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { SlotGroupVm, SlotsVm } from "../../core/select.js";
|
|
15
|
+
import type { View } from "../dom.js";
|
|
16
|
+
import { el, setAttr, setText, setVar, syncRows } from "../dom.js";
|
|
17
|
+
|
|
18
|
+
interface ChipView {
|
|
19
|
+
root: HTMLElement;
|
|
20
|
+
dot: HTMLElement;
|
|
21
|
+
name: HTMLElement;
|
|
22
|
+
count: HTMLElement;
|
|
23
|
+
rate: HTMLElement;
|
|
24
|
+
peak: HTMLElement;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createChip(): ChipView {
|
|
28
|
+
const dot = el("span", { class: "slot-chip__dot", attrs: { "aria-hidden": "true" } });
|
|
29
|
+
const name = el("span", { class: "slot-chip__name" });
|
|
30
|
+
const count = el("span", { class: "slot-chip__count" });
|
|
31
|
+
const rate = el("span", { class: "slot-chip__rate" });
|
|
32
|
+
const peak = el("span", { class: "slot-chip__peak" });
|
|
33
|
+
const root = el("div", { class: "slot-chip", children: [dot, name, count, rate, peak] });
|
|
34
|
+
return { root, dot, name, count, rate, peak };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The hover text: each of the model's slots and how full its context is. */
|
|
38
|
+
function chipTitle(group: SlotGroupVm): string {
|
|
39
|
+
const lanes = group.slots.map((slot) => `slot ${slot.id}: ${slot.detail}`).join("\n");
|
|
40
|
+
return `${group.modelLabel} — ${group.summary}\n${lanes}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createSlotsStrip(): View<SlotsVm> {
|
|
44
|
+
const chips: ChipView[] = [];
|
|
45
|
+
const summary = el("span", { class: "slots__summary" });
|
|
46
|
+
const row = el("div", { class: "slots__chips" });
|
|
47
|
+
const empty = el("span", { class: "slots__empty", attrs: { hidden: true } });
|
|
48
|
+
|
|
49
|
+
const root = el("section", {
|
|
50
|
+
class: "slots",
|
|
51
|
+
attrs: { "aria-labelledby": "steward-slots-title" },
|
|
52
|
+
children: [
|
|
53
|
+
el("div", {
|
|
54
|
+
class: "slots__head",
|
|
55
|
+
children: [
|
|
56
|
+
el("h2", { class: "eyebrow", text: "Slots", attrs: { id: "steward-slots-title" } }),
|
|
57
|
+
summary,
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
empty,
|
|
61
|
+
row,
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
el: root,
|
|
67
|
+
update(vm) {
|
|
68
|
+
setText(summary, vm.totalSummary);
|
|
69
|
+
setText(empty, vm.emptyLabel);
|
|
70
|
+
// The note stands in for the chip row only when nothing is loaded.
|
|
71
|
+
setAttr(empty, "hidden", !vm.empty);
|
|
72
|
+
|
|
73
|
+
syncRows(row, chips, vm.empty ? 0 : vm.groups.length, createChip);
|
|
74
|
+
vm.groups.forEach((group, index) => {
|
|
75
|
+
const chip = chips[index];
|
|
76
|
+
if (chip === undefined) return;
|
|
77
|
+
const busy = group.busy > 0;
|
|
78
|
+
setVar(chip.dot, "model-color", group.modelColor);
|
|
79
|
+
setText(chip.name, group.modelLabel);
|
|
80
|
+
setText(chip.count, group.summary);
|
|
81
|
+
// A generating model shows how fast, right beside the fraction: the chip
|
|
82
|
+
// reads `2/4 busy · 63 t/s`. Idle models, and ones whose child lacks
|
|
83
|
+
// `--metrics`, have no rate, so the segment is dropped.
|
|
84
|
+
setText(chip.rate, `· ${group.rateLabel}`);
|
|
85
|
+
setAttr(chip.rate, "hidden", group.rateLabel === "");
|
|
86
|
+
// The busiest lane's context fill rides beside the fraction on a busy
|
|
87
|
+
// chip — the ambient overflow signal — colored by threshold but never
|
|
88
|
+
// color-only, since the percentage is printed. Idle chips carry none,
|
|
89
|
+
// and neither does a busy chip whose lanes have not reported a fill: an
|
|
90
|
+
// absent reading leaves the segment out rather than printing `0%`.
|
|
91
|
+
const showPeak = busy && group.peakLabel !== "";
|
|
92
|
+
setText(chip.peak, `· ${group.peakLabel}`);
|
|
93
|
+
setVar(chip.peak, "fg", group.peakColor);
|
|
94
|
+
setAttr(chip.peak, "hidden", !showPeak);
|
|
95
|
+
// A busy chip is called out so activity is legible without reading the
|
|
96
|
+
// fraction; the color alone never carries it.
|
|
97
|
+
setAttr(chip.root, "data-busy", busy ? "true" : "false");
|
|
98
|
+
// The whole chip is one label so a screen reader reads "model, 1/4 busy"
|
|
99
|
+
// (with the peak when busy) as a unit; the detail is on the title for a
|
|
100
|
+
// pointer user.
|
|
101
|
+
const ratePhrase = group.rateLabel === "" ? "" : `, ${group.rateLabel}`;
|
|
102
|
+
const peakPhrase = showPeak ? `, peak ${group.peakLabel} context` : "";
|
|
103
|
+
setAttr(
|
|
104
|
+
chip.root,
|
|
105
|
+
"aria-label",
|
|
106
|
+
busy
|
|
107
|
+
? `${group.modelLabel}, ${group.summary}${ratePhrase}${peakPhrase}`
|
|
108
|
+
: `${group.modelLabel}, ${group.summary}`,
|
|
109
|
+
);
|
|
110
|
+
setAttr(chip.root, "title", chipTitle(group));
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The throughput-history cell: the window's tokens/second as bars, with a dashed
|
|
3
|
+
* rule at its average.
|
|
4
|
+
*
|
|
5
|
+
* The axis's left end is measured rather than assumed — see {@link SparkVm} —
|
|
6
|
+
* because samples close on the browser's snapshot clock and a strip built for
|
|
7
|
+
* two minutes really spans a little more.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SparkVm } from "../../core/select.js";
|
|
11
|
+
import type { View } from "../dom.js";
|
|
12
|
+
import { el, setStyle, setText, setVar, syncRows } from "../dom.js";
|
|
13
|
+
|
|
14
|
+
interface Bar {
|
|
15
|
+
root: HTMLElement;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createBar(): Bar {
|
|
19
|
+
return { root: el("div", { class: "spark__bar" }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSparkline(): View<SparkVm> {
|
|
23
|
+
const bars: Bar[] = [];
|
|
24
|
+
const summary = el("span", { class: "spark__summary" });
|
|
25
|
+
const average = el("div", { class: "spark__avg" });
|
|
26
|
+
const plot = el("div", { class: "spark__bars" });
|
|
27
|
+
const axisStart = el("span", { text: "" });
|
|
28
|
+
|
|
29
|
+
const root = el("div", {
|
|
30
|
+
class: "metric",
|
|
31
|
+
children: [
|
|
32
|
+
el("div", {
|
|
33
|
+
class: "spark__head",
|
|
34
|
+
children: [el("span", { class: "eyebrow", text: "Throughput history" }), summary],
|
|
35
|
+
}),
|
|
36
|
+
el("div", { class: "spark__plot", children: [average, plot] }),
|
|
37
|
+
el("div", {
|
|
38
|
+
class: "spark__axis",
|
|
39
|
+
children: [axisStart, el("span", { text: "now" })],
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
el: root,
|
|
46
|
+
update(vm) {
|
|
47
|
+
setText(summary, vm.summary);
|
|
48
|
+
setText(axisStart, vm.axisStart);
|
|
49
|
+
setStyle(average, "bottom", `${vm.averageLine}%`);
|
|
50
|
+
syncRows(plot, bars, vm.bars.length, createBar);
|
|
51
|
+
vm.bars.forEach((bar, index) => {
|
|
52
|
+
const node = bars[index];
|
|
53
|
+
if (node === undefined) return;
|
|
54
|
+
setStyle(node.root, "height", `${bar.height}%`);
|
|
55
|
+
setVar(node.root, "fill", bar.color);
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console toolbar: the active-model pill, the two chip groups, the search
|
|
3
|
+
* box, the line count, and the pause/copy/download controls.
|
|
4
|
+
*
|
|
5
|
+
* It lays out as two explicit rows rather than one wrapping row, so chips from
|
|
6
|
+
* different groups can never end up adjacent across a wrap and read as one
|
|
7
|
+
* control set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { FamilyChipVm, LevelChipVm, ToolbarVm } from "../../core/select.js";
|
|
11
|
+
import type { FamilyFilter, LevelFilter } from "../../core/state.js";
|
|
12
|
+
import type { View } from "../dom.js";
|
|
13
|
+
import { el, setAttr, setText, setVar, syncRows } from "../dom.js";
|
|
14
|
+
|
|
15
|
+
export interface ToolbarHandlers {
|
|
16
|
+
onLevel: (level: LevelFilter) => void;
|
|
17
|
+
onFamily: (family: FamilyFilter) => void;
|
|
18
|
+
onQuery: (query: string) => void;
|
|
19
|
+
onToggleProxy: () => void;
|
|
20
|
+
onTogglePause: () => void;
|
|
21
|
+
onCopy: () => void;
|
|
22
|
+
onDownload: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One chip in either group: a label, a count, and the value it selects. */
|
|
26
|
+
interface ChipRow<T> {
|
|
27
|
+
root: HTMLElement;
|
|
28
|
+
label: HTMLElement;
|
|
29
|
+
count: HTMLElement;
|
|
30
|
+
value: T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The vm both groups satisfy — the same component, a different axis. */
|
|
34
|
+
interface ChipVm {
|
|
35
|
+
label: string;
|
|
36
|
+
countLabel: string;
|
|
37
|
+
ariaLabel: string;
|
|
38
|
+
active: boolean;
|
|
39
|
+
background: string;
|
|
40
|
+
color: string;
|
|
41
|
+
borderColor: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A group of chips. The record-type chips and the level chips are the same
|
|
46
|
+
* component with a different handler and vm, so there is one chip to style,
|
|
47
|
+
* one to keep accessible, and no second thing to keep in step.
|
|
48
|
+
*/
|
|
49
|
+
function createChipGroup<T>(
|
|
50
|
+
container: HTMLElement,
|
|
51
|
+
initial: T,
|
|
52
|
+
onPress: (value: T) => void,
|
|
53
|
+
): { rows: ChipRow<T>[]; update: (chips: readonly (ChipVm & { value: T })[]) => void } {
|
|
54
|
+
const rows: ChipRow<T>[] = [];
|
|
55
|
+
|
|
56
|
+
function create(): ChipRow<T> {
|
|
57
|
+
const label = el("span", { class: "chip__label" });
|
|
58
|
+
// The count is its own span so it can hold the muted colour whatever the
|
|
59
|
+
// chip's state is: a zero must never read as a tick, least of all on ERROR.
|
|
60
|
+
const count = el("span", { class: "chip__count" });
|
|
61
|
+
const row: ChipRow<T> = {
|
|
62
|
+
root: el("button", { class: "chip", attrs: { type: "button" }, children: [label, count] }),
|
|
63
|
+
label,
|
|
64
|
+
count,
|
|
65
|
+
value: initial,
|
|
66
|
+
};
|
|
67
|
+
row.root.addEventListener("click", () => {
|
|
68
|
+
onPress(row.value);
|
|
69
|
+
});
|
|
70
|
+
return row;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
rows,
|
|
75
|
+
update(chips) {
|
|
76
|
+
syncRows(container, rows, chips.length, create);
|
|
77
|
+
chips.forEach((chip, index) => {
|
|
78
|
+
const row = rows[index];
|
|
79
|
+
if (row === undefined) return;
|
|
80
|
+
row.value = chip.value;
|
|
81
|
+
setText(row.label, chip.label);
|
|
82
|
+
setText(row.count, chip.countLabel);
|
|
83
|
+
setVar(row.root, "bg", chip.background);
|
|
84
|
+
setVar(row.root, "fg", chip.color);
|
|
85
|
+
setVar(row.root, "bd", chip.borderColor);
|
|
86
|
+
setAttr(row.root, "aria-pressed", String(chip.active));
|
|
87
|
+
// The visible label is `WARN 2`, which a screen reader would read as
|
|
88
|
+
// two unrelated tokens. The name says what the number is.
|
|
89
|
+
setAttr(row.root, "aria-label", chip.ariaLabel);
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createToolbar(handlers: ToolbarHandlers): View<ToolbarVm> {
|
|
96
|
+
const families = el("div", {
|
|
97
|
+
class: "toolbar__chips",
|
|
98
|
+
attrs: { role: "group", "aria-label": "Record type" },
|
|
99
|
+
});
|
|
100
|
+
const levels = el("div", {
|
|
101
|
+
class: "toolbar__chips",
|
|
102
|
+
attrs: { role: "group", "aria-label": "Log level" },
|
|
103
|
+
});
|
|
104
|
+
const familyGroup = createChipGroup<FamilyFilter>(families, "any", handlers.onFamily);
|
|
105
|
+
const levelGroup = createChipGroup<LevelFilter>(levels, "all", handlers.onLevel);
|
|
106
|
+
|
|
107
|
+
const active = el("span", { class: "toolbar__active" });
|
|
108
|
+
const search = el("input", {
|
|
109
|
+
class: "search",
|
|
110
|
+
// A plain text input: `type="search"` would add a UA cancel button and, on
|
|
111
|
+
// WebKit, a searchfield appearance that overrides the box below.
|
|
112
|
+
attrs: { type: "text", id: "steward-search", placeholder: "search log…" },
|
|
113
|
+
on: {
|
|
114
|
+
input: (event) => {
|
|
115
|
+
const target = event.currentTarget;
|
|
116
|
+
if (target instanceof HTMLInputElement) handlers.onQuery(target.value);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
const proxy = el("button", {
|
|
121
|
+
class: "chip chip--proxy",
|
|
122
|
+
attrs: { type: "button" },
|
|
123
|
+
on: { click: handlers.onToggleProxy },
|
|
124
|
+
});
|
|
125
|
+
const count = el("span", { class: "toolbar__count" });
|
|
126
|
+
const pause = el("button", {
|
|
127
|
+
class: "chip chip--pause",
|
|
128
|
+
attrs: { type: "button" },
|
|
129
|
+
on: { click: handlers.onTogglePause },
|
|
130
|
+
});
|
|
131
|
+
const copy = el("button", {
|
|
132
|
+
class: "btn btn--toolbar",
|
|
133
|
+
attrs: { type: "button" },
|
|
134
|
+
text: "Copy",
|
|
135
|
+
on: { click: handlers.onCopy },
|
|
136
|
+
});
|
|
137
|
+
const download = el("button", {
|
|
138
|
+
class: "btn btn--toolbar",
|
|
139
|
+
attrs: { type: "button" },
|
|
140
|
+
text: "Download",
|
|
141
|
+
on: { click: handlers.onDownload },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Focus order: filter label → model pill → kind group → proxied toggle →
|
|
145
|
+
// level group → search → count → pause → copy → download.
|
|
146
|
+
const root = el("div", {
|
|
147
|
+
class: "toolbar",
|
|
148
|
+
children: [
|
|
149
|
+
el("div", {
|
|
150
|
+
class: "toolbar__row",
|
|
151
|
+
children: [
|
|
152
|
+
el("span", { class: "toolbar__label", text: "filter" }),
|
|
153
|
+
active,
|
|
154
|
+
el("span", { class: "toolbar__label", text: "kind" }),
|
|
155
|
+
families,
|
|
156
|
+
proxy,
|
|
157
|
+
],
|
|
158
|
+
}),
|
|
159
|
+
el("div", {
|
|
160
|
+
class: "toolbar__row",
|
|
161
|
+
children: [
|
|
162
|
+
el("span", { class: "toolbar__label", text: "level" }),
|
|
163
|
+
levels,
|
|
164
|
+
el("label", {
|
|
165
|
+
class: "visually-hidden",
|
|
166
|
+
text: "Search the log",
|
|
167
|
+
attrs: { for: "steward-search" },
|
|
168
|
+
}),
|
|
169
|
+
search,
|
|
170
|
+
count,
|
|
171
|
+
pause,
|
|
172
|
+
copy,
|
|
173
|
+
download,
|
|
174
|
+
],
|
|
175
|
+
}),
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
el: root,
|
|
181
|
+
update(vm) {
|
|
182
|
+
setText(active, vm.activeModelLabel);
|
|
183
|
+
setVar(active, "bg", vm.activeModelBackground);
|
|
184
|
+
setVar(active, "fg", vm.activeModelColor);
|
|
185
|
+
|
|
186
|
+
familyGroup.update(
|
|
187
|
+
vm.familyChips.map((chip: FamilyChipVm) => ({ ...chip, value: chip.family })),
|
|
188
|
+
);
|
|
189
|
+
levelGroup.update(vm.levelChips.map((chip: LevelChipVm) => ({ ...chip, value: chip.level })));
|
|
190
|
+
|
|
191
|
+
setText(proxy, vm.proxyToggle.label);
|
|
192
|
+
setAttr(proxy, "aria-pressed", String(vm.proxyToggle.pressed));
|
|
193
|
+
setAttr(proxy, "aria-label", vm.proxyToggle.ariaLabel);
|
|
194
|
+
setAttr(proxy, "title", vm.proxyToggle.title);
|
|
195
|
+
|
|
196
|
+
// The operator owns the field while typing; only correct it if the state
|
|
197
|
+
// and the box have genuinely drifted apart.
|
|
198
|
+
if (search.value !== vm.query) search.value = vm.query;
|
|
199
|
+
|
|
200
|
+
setText(count, vm.lineCountLabel);
|
|
201
|
+
|
|
202
|
+
setText(pause, vm.pauseLabel);
|
|
203
|
+
setVar(pause, "bg", vm.pauseBackground);
|
|
204
|
+
setVar(pause, "fg", vm.pauseColor);
|
|
205
|
+
setVar(pause, "bd", vm.pauseBorder);
|
|
206
|
+
setAttr(pause, "aria-pressed", String(vm.paused));
|
|
207
|
+
|
|
208
|
+
setText(copy, vm.copyLabel);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/ui/dom.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole of Steward's view layer's framework.
|
|
3
|
+
*
|
|
4
|
+
* Components build elements once and then patch them, so the helpers here are
|
|
5
|
+
* split between construction (`el`, `svg`) and the guarded writers (`setText`,
|
|
6
|
+
* `setVar`, `setAttr`) the update paths use. The guards matter: the metrics
|
|
7
|
+
* poll runs every 1.6 s and the log stream faster than that, and writing a
|
|
8
|
+
* property that has not changed is what turns a live dashboard into a
|
|
9
|
+
* flickering one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type Child = Node | string | null | undefined | false;
|
|
13
|
+
|
|
14
|
+
/** What every component in `./components/` hands back: a node and a patcher. */
|
|
15
|
+
export interface View<T> {
|
|
16
|
+
el: HTMLElement;
|
|
17
|
+
update(vm: T): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ElementSpec {
|
|
21
|
+
class?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
/** Plain HTML attributes. `false` removes the attribute. */
|
|
24
|
+
attrs?: Record<string, string | number | boolean>;
|
|
25
|
+
/** CSS custom properties, written without their leading dashes. */
|
|
26
|
+
vars?: Record<string, string>;
|
|
27
|
+
on?: Record<string, (event: Event) => void>;
|
|
28
|
+
children?: Child[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
32
|
+
|
|
33
|
+
export function el<K extends keyof HTMLElementTagNameMap>(
|
|
34
|
+
tag: K,
|
|
35
|
+
spec: ElementSpec = {},
|
|
36
|
+
): HTMLElementTagNameMap[K] {
|
|
37
|
+
const node = document.createElement(tag);
|
|
38
|
+
if (spec.class !== undefined) node.className = spec.class;
|
|
39
|
+
if (spec.text !== undefined) node.textContent = spec.text;
|
|
40
|
+
if (spec.attrs) {
|
|
41
|
+
for (const [name, value] of Object.entries(spec.attrs)) setAttr(node, name, value);
|
|
42
|
+
}
|
|
43
|
+
if (spec.vars) {
|
|
44
|
+
for (const [name, value] of Object.entries(spec.vars)) setVar(node, name, value);
|
|
45
|
+
}
|
|
46
|
+
if (spec.on) {
|
|
47
|
+
for (const [type, handler] of Object.entries(spec.on)) node.addEventListener(type, handler);
|
|
48
|
+
}
|
|
49
|
+
if (spec.children) append(node, spec.children);
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** SVG needs its own namespace, and its attributes are not DOM properties. */
|
|
54
|
+
export function svg(
|
|
55
|
+
tag: string,
|
|
56
|
+
attrs: Record<string, string>,
|
|
57
|
+
children: Element[] = [],
|
|
58
|
+
): SVGElement {
|
|
59
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
60
|
+
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
|
|
61
|
+
for (const child of children) node.appendChild(child);
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function append(parent: Node, children: readonly Child[]): void {
|
|
66
|
+
for (const child of children) {
|
|
67
|
+
if (child === null || child === undefined || child === false) continue;
|
|
68
|
+
parent.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function clear(node: Node): void {
|
|
73
|
+
while (node.firstChild !== null) node.removeChild(node.firstChild);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Writes `text` only when it differs, so unchanged rows are never touched. */
|
|
77
|
+
export function setText(node: Node, text: string): void {
|
|
78
|
+
if (node.textContent !== text) node.textContent = text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setAttr(node: Element, name: string, value: string | number | boolean): void {
|
|
82
|
+
if (value === false) {
|
|
83
|
+
node.removeAttribute(name);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const next = value === true ? "" : String(value);
|
|
87
|
+
if (node.getAttribute(name) !== next) node.setAttribute(name, next);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Sets a CSS custom property, named without its leading `--`. */
|
|
91
|
+
export function setVar(node: HTMLElement | SVGElement, name: string, value: string): void {
|
|
92
|
+
const property = `--${name}`;
|
|
93
|
+
if (node.style.getPropertyValue(property) !== value) node.style.setProperty(property, value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setStyle(node: HTMLElement, property: string, value: string): void {
|
|
97
|
+
if (node.style.getPropertyValue(property) !== value) node.style.setProperty(property, value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Grows or shrinks a list of row records so it is exactly `count` long,
|
|
102
|
+
* mirroring the change into `parent`. Callers keep their own handles on the
|
|
103
|
+
* pieces of each row, so patching a row afterwards costs no lookups.
|
|
104
|
+
*/
|
|
105
|
+
export function syncRows<R extends { root: HTMLElement }>(
|
|
106
|
+
parent: HTMLElement,
|
|
107
|
+
rows: R[],
|
|
108
|
+
count: number,
|
|
109
|
+
create: () => R,
|
|
110
|
+
): void {
|
|
111
|
+
while (rows.length > count) {
|
|
112
|
+
const extra = rows.pop();
|
|
113
|
+
if (extra !== undefined) parent.removeChild(extra.root);
|
|
114
|
+
}
|
|
115
|
+
while (rows.length < count) {
|
|
116
|
+
const row = create();
|
|
117
|
+
rows.push(row);
|
|
118
|
+
parent.appendChild(row.root);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/ui/favicon.svg
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40" role="img" aria-labelledby="steward-mark-title">
|
|
2
|
+
<title id="steward-mark-title">Steward</title>
|
|
3
|
+
<!--
|
|
4
|
+
The Steward mark: a serving dome over two trays, the lower one at 55%.
|
|
5
|
+
Drawn entirely in currentColor so it inverts with the theme. A standalone
|
|
6
|
+
favicon has no host element to inherit `color` from, so the accent is
|
|
7
|
+
resolved here — the one place in the package that names it outside the
|
|
8
|
+
stylesheet's tokens (Path Blue, lightened for dark tab bars).
|
|
9
|
+
-->
|
|
10
|
+
<style>
|
|
11
|
+
svg { color: #3465a4; }
|
|
12
|
+
@media (prefers-color-scheme: dark) { svg { color: #7aa2d6; } }
|
|
13
|
+
</style>
|
|
14
|
+
<path d="M8 17C8 10.9 13.4 6 20 6s12 4.9 12 11" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
|
15
|
+
<rect x="4" y="21" width="32" height="5" rx="2.5" fill="currentColor"/>
|
|
16
|
+
<rect x="4" y="29.6" width="32" height="5" rx="2.5" fill="currentColor" opacity="0.55"/>
|
|
17
|
+
</svg>
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Steward</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
8
|
+
<link rel="stylesheet" href="/ui/steward.css" />
|
|
9
|
+
<script>
|
|
10
|
+
// Applied before the first paint so a dark-mode operator never gets a
|
|
11
|
+
// flash of the light palette. main.ts owns the value from here on. The
|
|
12
|
+
// default (unset) is System, so an OS-dark machine must resolve to dark
|
|
13
|
+
// here too, not just an explicit "dark".
|
|
14
|
+
try {
|
|
15
|
+
const mode = localStorage.getItem("steward.theme") || "system";
|
|
16
|
+
const dark =
|
|
17
|
+
mode === "dark" ||
|
|
18
|
+
(mode === "system" &&
|
|
19
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
20
|
+
if (dark) document.documentElement.setAttribute("data-theme", "dark");
|
|
21
|
+
} catch {
|
|
22
|
+
/* private browsing can refuse storage; the light default still works */
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<div class="shell">
|
|
28
|
+
<aside class="rail" id="steward-rail" aria-label="Service, host, models and configuration"></aside>
|
|
29
|
+
<main class="main" id="steward-main" aria-label="Metrics, log console and slots"></main>
|
|
30
|
+
</div>
|
|
31
|
+
<p class="visually-hidden" id="steward-status" role="status" aria-live="polite"></p>
|
|
32
|
+
<script type="module" src="/ui/main.js"></script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|