@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
package/ui/main.ts
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap for the Steward dashboard.
|
|
3
|
+
*
|
|
4
|
+
* This module is the only place in the browser that touches the network or the
|
|
5
|
+
* clock: it polls `/api/snapshot`, holds the log stream open, dispatches
|
|
6
|
+
* actions at the reducer, and hands the resulting view model to the components.
|
|
7
|
+
* Nothing here derives a displayed value — that all lives in `core/select.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CONTEXT_LOST_QUERY,
|
|
12
|
+
consoleAnnouncement,
|
|
13
|
+
driftAnnouncement,
|
|
14
|
+
foldAnnouncement,
|
|
15
|
+
selectDashboard,
|
|
16
|
+
selectLogExportSummary,
|
|
17
|
+
selectLogText,
|
|
18
|
+
truncationAnnouncement,
|
|
19
|
+
} from "../core/select.js";
|
|
20
|
+
import type { FamilyFilter, LevelFilter, Theme, UiAction, UiState } from "../core/state.js";
|
|
21
|
+
import { initialUiState, reduce } from "../core/state.js";
|
|
22
|
+
import type { TemperaturePreference, TemperatureUnit } from "../core/temperature.js";
|
|
23
|
+
import {
|
|
24
|
+
parseTemperaturePreference,
|
|
25
|
+
resolveTemperatureUnit,
|
|
26
|
+
temperatureUnitForLocales,
|
|
27
|
+
} from "../core/temperature.js";
|
|
28
|
+
import type {
|
|
29
|
+
LogLine,
|
|
30
|
+
LogStreamStatus,
|
|
31
|
+
ModelAction,
|
|
32
|
+
ServiceAction,
|
|
33
|
+
Snapshot,
|
|
34
|
+
} from "../core/types.js";
|
|
35
|
+
import { createLogConsole } from "./components/console.js";
|
|
36
|
+
import { createHostBlock } from "./components/gauges.js";
|
|
37
|
+
import { createMetricsBand } from "./components/metrics.js";
|
|
38
|
+
import { createModelsBlock } from "./components/models.js";
|
|
39
|
+
import { createServiceBlock } from "./components/service.js";
|
|
40
|
+
import { createSlotsStrip } from "./components/slots.js";
|
|
41
|
+
import { createSparkline } from "./components/sparkline.js";
|
|
42
|
+
import { createToolbar } from "./components/toolbar.js";
|
|
43
|
+
|
|
44
|
+
/** Matches the server's metrics cadence. */
|
|
45
|
+
const SNAPSHOT_INTERVAL_MS = 1600;
|
|
46
|
+
|
|
47
|
+
/** The uptime readouts tick between polls. */
|
|
48
|
+
const CLOCK_INTERVAL_MS = 1000;
|
|
49
|
+
|
|
50
|
+
/** How long the Copy button acknowledges for. */
|
|
51
|
+
const COPY_FEEDBACK_MS = 1400;
|
|
52
|
+
|
|
53
|
+
/** Backoff before re-opening a dropped log stream. */
|
|
54
|
+
const RECONNECT_DELAY_MS = 2000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How long typing settles before the search result is announced. Announcing per
|
|
58
|
+
* keystroke would machine-gun the polite region; announcing the RESULT COUNT
|
|
59
|
+
* once the operator stops is the useful half of it.
|
|
60
|
+
*/
|
|
61
|
+
const QUERY_ANNOUNCE_MS = 500;
|
|
62
|
+
|
|
63
|
+
const THEME_KEY = "steward.theme";
|
|
64
|
+
|
|
65
|
+
const TEMPERATURE_KEY = "steward.temperature";
|
|
66
|
+
|
|
67
|
+
const rail = document.getElementById("steward-rail");
|
|
68
|
+
const main = document.getElementById("steward-main");
|
|
69
|
+
const status = document.getElementById("steward-status");
|
|
70
|
+
if (rail === null || main === null) throw new Error("Steward: the page shell is missing.");
|
|
71
|
+
|
|
72
|
+
const storedTemperature = readTemperaturePreference();
|
|
73
|
+
let ui: UiState = initialUiState(
|
|
74
|
+
readTheme(),
|
|
75
|
+
applyTemperature(storedTemperature),
|
|
76
|
+
storedTemperature,
|
|
77
|
+
);
|
|
78
|
+
let snapshot: Snapshot | null = null;
|
|
79
|
+
let snapshotAt = 0;
|
|
80
|
+
let copyTimer = 0;
|
|
81
|
+
let queryTimer = 0;
|
|
82
|
+
/** The drift notice already announced, so the poll cannot repeat it. */
|
|
83
|
+
let announcedDrift: string | null = null;
|
|
84
|
+
/** The console state already announced, so the render clock cannot repeat it. */
|
|
85
|
+
let announcedConsole: string | null = null;
|
|
86
|
+
/** Set once the truncation banner has been announced, so it is said once. */
|
|
87
|
+
let announcedTruncation = false;
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Theme
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** The stored mode, defaulting to `system` when unset or unrecognized. */
|
|
94
|
+
function readTheme(): Theme {
|
|
95
|
+
try {
|
|
96
|
+
const stored = localStorage.getItem(THEME_KEY);
|
|
97
|
+
if (stored === "dark" || stored === "light" || stored === "system") return stored;
|
|
98
|
+
} catch {
|
|
99
|
+
// Storage refused (private mode): fall through to the default.
|
|
100
|
+
}
|
|
101
|
+
return "system";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True when the OS currently asks for a dark palette. */
|
|
105
|
+
function prefersDark(): boolean {
|
|
106
|
+
try {
|
|
107
|
+
return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Applies a mode by resolving it to the light/dark palette. `system` follows the
|
|
115
|
+
* OS; the CSS contract is unchanged — `data-theme="dark"` present means dark, its
|
|
116
|
+
* absence means light — so `system` simply computes which to set. The persisted
|
|
117
|
+
* value is the *mode*, not the resolved palette, so `system` survives a reload.
|
|
118
|
+
*/
|
|
119
|
+
function applyTheme(theme: Theme): void {
|
|
120
|
+
const dark = theme === "dark" || (theme === "system" && prefersDark());
|
|
121
|
+
if (dark) document.documentElement.setAttribute("data-theme", "dark");
|
|
122
|
+
else document.documentElement.removeAttribute("data-theme");
|
|
123
|
+
try {
|
|
124
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
125
|
+
} catch {
|
|
126
|
+
// Storage is optional; the choice simply will not survive a reload.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Temperature unit
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The locales this browser reports, most specific intent first.
|
|
136
|
+
*
|
|
137
|
+
* `Intl.DateTimeFormat` is asked first because it answers with the locale the
|
|
138
|
+
* runtime actually RESOLVED — the one it is already formatting dates and numbers
|
|
139
|
+
* with — while `navigator.language`/`languages` is the request list. In practice
|
|
140
|
+
* they agree; when they do not, the resolved one is what the rest of the page
|
|
141
|
+
* looks like. Every read is guarded: a browser that refuses any of them
|
|
142
|
+
* contributes nothing to the list rather than taking the page down.
|
|
143
|
+
*/
|
|
144
|
+
function browserLocales(): (string | null | undefined)[] {
|
|
145
|
+
const locales: (string | null | undefined)[] = [];
|
|
146
|
+
try {
|
|
147
|
+
locales.push(Intl.DateTimeFormat().resolvedOptions().locale);
|
|
148
|
+
} catch {
|
|
149
|
+
// No usable Intl data; the navigator list below may still name a region.
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const nav = globalThis.navigator as Navigator | undefined;
|
|
153
|
+
if (nav !== undefined) {
|
|
154
|
+
locales.push(nav.language);
|
|
155
|
+
for (const locale of nav.languages ?? []) locales.push(locale);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Same reasoning: an absent or hostile navigator is not an error here.
|
|
159
|
+
}
|
|
160
|
+
return locales;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The unit this browser's region implies. Celsius whenever nothing names a
|
|
165
|
+
* region — the honest answer for a browser that did not say where it is, and
|
|
166
|
+
* the right one for the overwhelming majority of regions that do.
|
|
167
|
+
*/
|
|
168
|
+
function detectTemperatureUnit(): TemperatureUnit {
|
|
169
|
+
return temperatureUnitForLocales(browserLocales());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The stored preference, defaulting to `auto` when unset or unrecognized. */
|
|
173
|
+
function readTemperaturePreference(): TemperaturePreference {
|
|
174
|
+
try {
|
|
175
|
+
return parseTemperaturePreference(localStorage.getItem(TEMPERATURE_KEY));
|
|
176
|
+
} catch {
|
|
177
|
+
// Storage refused (private mode): fall through to the default.
|
|
178
|
+
return "auto";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Applies a preference and returns the unit to label with — `auto` resolved
|
|
184
|
+
* against the detected browser region, an explicit choice taken as given.
|
|
185
|
+
*
|
|
186
|
+
* The *preference* is what persists, not the resolved unit, so an operator on
|
|
187
|
+
* `auto` who travels or changes their OS region follows it rather than being
|
|
188
|
+
* pinned to whatever their first visit detected. That is the same split
|
|
189
|
+
* {@link applyTheme} makes between the `system` mode and the palette it resolves
|
|
190
|
+
* to. The HOST block's unit control calls this with the preference it cycled
|
|
191
|
+
* to; the bootstrap calls it once with the stored one.
|
|
192
|
+
*/
|
|
193
|
+
function applyTemperature(preference: TemperaturePreference): TemperatureUnit {
|
|
194
|
+
try {
|
|
195
|
+
localStorage.setItem(TEMPERATURE_KEY, preference);
|
|
196
|
+
} catch {
|
|
197
|
+
// Storage is optional; the choice simply will not survive a reload.
|
|
198
|
+
}
|
|
199
|
+
return resolveTemperatureUnit(preference, detectTemperatureUnit());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function announce(message: string): void {
|
|
203
|
+
if (status !== null) status.textContent = message;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Components
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
const serviceBlock = createServiceBlock({
|
|
211
|
+
onToggleTheme: () => {
|
|
212
|
+
dispatch({ type: "theme/toggle" });
|
|
213
|
+
applyTheme(ui.theme);
|
|
214
|
+
announce(`Theme set to ${ui.theme}.`);
|
|
215
|
+
},
|
|
216
|
+
onService: (action) => {
|
|
217
|
+
void runService(action);
|
|
218
|
+
},
|
|
219
|
+
onConfirmService: (action) => {
|
|
220
|
+
dispatch({ type: "service/confirm", action });
|
|
221
|
+
},
|
|
222
|
+
onCancelService: () => {
|
|
223
|
+
if (ui.confirmService === null) return;
|
|
224
|
+
announce("Cancelled.");
|
|
225
|
+
dispatch({ type: "service/confirm", action: null });
|
|
226
|
+
},
|
|
227
|
+
onDismissDrift: (key) => {
|
|
228
|
+
dispatch({ type: "drift/dismiss", key });
|
|
229
|
+
announce("Drift notice dismissed. It returns while the mismatch is still there.");
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const hostBlock = createHostBlock({
|
|
234
|
+
onCycleTemperature: (next) => {
|
|
235
|
+
// `applyTemperature` persists the PREFERENCE and hands back the unit it
|
|
236
|
+
// resolves to; both halves ride one action so no repaint can land with the
|
|
237
|
+
// control's label and the gauges' unit disagreeing.
|
|
238
|
+
const unit = applyTemperature(next);
|
|
239
|
+
dispatch({ type: "temperature/unit", preference: next, unit });
|
|
240
|
+
// Said once, on the press. The control's own value cannot change without
|
|
241
|
+
// one, so the poll has nothing to repeat.
|
|
242
|
+
announce(
|
|
243
|
+
next === "auto"
|
|
244
|
+
? `Temperature unit: automatic — ${unit === "celsius" ? "°C" : "°F"} from your region.`
|
|
245
|
+
: `Temperature unit: always ${next === "celsius" ? "°C" : "°F"}.`,
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `88 of 340 lines` for the polite region — the result of a filter change, not
|
|
252
|
+
* the change itself. An operator who cannot see the console needs to know what
|
|
253
|
+
* the control did, and the count is the whole of what it did.
|
|
254
|
+
*/
|
|
255
|
+
function filterResult(): string {
|
|
256
|
+
if (snapshot === null) return "";
|
|
257
|
+
const counts = selectDashboard(snapshot, ui, snapshot.now).logCounts;
|
|
258
|
+
const hidden = counts.hiddenProxy > 0 ? ` ${counts.hiddenProxy} proxied lines are hidden.` : "";
|
|
259
|
+
return `${counts.matched} of ${counts.buffered} lines.${hidden}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The clause a filter announcement carries when it also closed a trace.
|
|
264
|
+
*
|
|
265
|
+
* The reducer clears the trace on every `filter/*` action, so a handler cannot
|
|
266
|
+
* forget it — but the OPERATOR still has to be told, in the same sentence as
|
|
267
|
+
* the thing they actually asked for. Read before the dispatch, since afterwards
|
|
268
|
+
* the trace is already gone.
|
|
269
|
+
*/
|
|
270
|
+
function traceClosedPrefix(): string {
|
|
271
|
+
return ui.trace === null ? "" : "Trace closed. ";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const modelsBlock = createModelsBlock({
|
|
275
|
+
onFilterModel: (modelId) => {
|
|
276
|
+
const closed = traceClosedPrefix();
|
|
277
|
+
dispatch({ type: "filter/model-toggle", modelId });
|
|
278
|
+
announce(
|
|
279
|
+
ui.filterModel === null
|
|
280
|
+
? `${closed}Log showing all models — ${filterResult()}`
|
|
281
|
+
: `${closed}Log scoped to ${ui.filterModel} — ${filterResult()}`,
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
onShowAllLogs: () => {
|
|
285
|
+
const closed = traceClosedPrefix();
|
|
286
|
+
dispatch({ type: "filter/model", modelId: null });
|
|
287
|
+
announce(`${closed}Log showing all models — ${filterResult()}`);
|
|
288
|
+
},
|
|
289
|
+
onModelAction: (modelId, action) => {
|
|
290
|
+
void runModel(modelId, action);
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const sparkline = createSparkline();
|
|
295
|
+
const metricsBand = createMetricsBand(sparkline.el);
|
|
296
|
+
|
|
297
|
+
const toolbar = createToolbar({
|
|
298
|
+
onLevel: (level: LevelFilter) => {
|
|
299
|
+
const closed = traceClosedPrefix();
|
|
300
|
+
dispatch({ type: "filter/level", level });
|
|
301
|
+
announce(`${closed}Level filter: ${level === "all" ? "any level" : level} — ${filterResult()}`);
|
|
302
|
+
},
|
|
303
|
+
onFamily: (family: FamilyFilter) => {
|
|
304
|
+
const closed = traceClosedPrefix();
|
|
305
|
+
dispatch({ type: "filter/family", family });
|
|
306
|
+
announce(`${closed}Kind filter: ${family === "any" ? "any kind" : family} — ${filterResult()}`);
|
|
307
|
+
},
|
|
308
|
+
onQuery: (query) => {
|
|
309
|
+
const closed = traceClosedPrefix();
|
|
310
|
+
dispatch({ type: "filter/query", query });
|
|
311
|
+
window.clearTimeout(queryTimer);
|
|
312
|
+
queryTimer = window.setTimeout(() => {
|
|
313
|
+
const trimmed = ui.query.trim();
|
|
314
|
+
announce(
|
|
315
|
+
trimmed === ""
|
|
316
|
+
? `${closed}Search cleared — ${filterResult()}`
|
|
317
|
+
: `${closed}Search "${trimmed}": ${filterResult()}`,
|
|
318
|
+
);
|
|
319
|
+
}, QUERY_ANNOUNCE_MS);
|
|
320
|
+
},
|
|
321
|
+
onToggleProxy: () => {
|
|
322
|
+
const closed = traceClosedPrefix();
|
|
323
|
+
dispatch({ type: "filter/proxy-toggle" });
|
|
324
|
+
if (snapshot === null) return;
|
|
325
|
+
const counts = selectDashboard(snapshot, ui, snapshot.now).logCounts;
|
|
326
|
+
announce(
|
|
327
|
+
ui.showProxy
|
|
328
|
+
? `${closed}Proxied requests shown. ${counts.matched} of ${counts.buffered} lines.`
|
|
329
|
+
: `${closed}Proxied requests hidden. ${counts.matched} of ${counts.buffered} lines. Most were Steward's own status polls.`,
|
|
330
|
+
);
|
|
331
|
+
},
|
|
332
|
+
onTogglePause: () => {
|
|
333
|
+
dispatch({ type: "logs/pause-toggle" });
|
|
334
|
+
announce(ui.paused ? "Log paused." : "Log resumed.");
|
|
335
|
+
},
|
|
336
|
+
onCopy: () => {
|
|
337
|
+
void copyLog();
|
|
338
|
+
},
|
|
339
|
+
onDownload: downloadLog,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const logConsole = createLogConsole({
|
|
343
|
+
onFold: (seq, forced, count) => {
|
|
344
|
+
dispatch({ type: "logs/fold-toggle", seq });
|
|
345
|
+
// Read back off the NEW state. Announced once — the lines themselves are
|
|
346
|
+
// never announced, not even for a user-initiated expansion: the console is
|
|
347
|
+
// not a live region and that rule does not bend for 31 argument lines.
|
|
348
|
+
announce(foldAnnouncement(count, forced, ui.expandedArgs[seq] === true));
|
|
349
|
+
},
|
|
350
|
+
onTrace: (port, task, anchorSeq) => {
|
|
351
|
+
dispatch({ type: "logs/trace", trace: { port, task, anchorSeq } });
|
|
352
|
+
if (snapshot === null) return;
|
|
353
|
+
const counts = selectDashboard(snapshot, ui, snapshot.now).logCounts;
|
|
354
|
+
announce(
|
|
355
|
+
`Tracing task ${task} on port ${port} — ${counts.traced} lines. Filters do not apply inside a trace.`,
|
|
356
|
+
);
|
|
357
|
+
},
|
|
358
|
+
onExitTrace: () => {
|
|
359
|
+
if (ui.trace === null) return;
|
|
360
|
+
dispatch({ type: "logs/trace", trace: null });
|
|
361
|
+
announce(`Trace closed. ${filterResult()}`);
|
|
362
|
+
},
|
|
363
|
+
onAction: (kind) => {
|
|
364
|
+
if (kind === "exit-trace") {
|
|
365
|
+
if (ui.trace === null) return;
|
|
366
|
+
dispatch({ type: "logs/trace", trace: null });
|
|
367
|
+
announce(`Trace closed. ${filterResult()}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (kind === "query-truncated") {
|
|
371
|
+
// A search, not a fourth filter axis: the box visibly fills with the
|
|
372
|
+
// literal the message carries, and the operator can edit or clear it.
|
|
373
|
+
const closed = traceClosedPrefix();
|
|
374
|
+
dispatch({ type: "filter/query", query: CONTEXT_LOST_QUERY });
|
|
375
|
+
announce(`${closed}Search "${CONTEXT_LOST_QUERY}": ${filterResult()}`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (kind === "show-all-models") {
|
|
379
|
+
const closed = traceClosedPrefix();
|
|
380
|
+
dispatch({ type: "filter/model", modelId: null });
|
|
381
|
+
announce(`${closed}Log showing all models — ${filterResult()}`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const closed = traceClosedPrefix();
|
|
385
|
+
dispatch({ type: "filter/model", modelId: null });
|
|
386
|
+
dispatch({ type: "filter/level", level: "all" });
|
|
387
|
+
dispatch({ type: "filter/family", family: "any" });
|
|
388
|
+
dispatch({ type: "filter/query", query: "" });
|
|
389
|
+
announce(`${closed}Filters cleared — ${filterResult()}`);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
const slotsStrip = createSlotsStrip();
|
|
393
|
+
|
|
394
|
+
rail.append(serviceBlock.el, hostBlock.el, modelsBlock.el);
|
|
395
|
+
main.append(metricsBand.el, toolbar.el, logConsole.el, slotsStrip.el);
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Render
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
function dispatch(action: UiAction): void {
|
|
402
|
+
const next = reduce(ui, action);
|
|
403
|
+
if (next === ui) return;
|
|
404
|
+
ui = next;
|
|
405
|
+
render();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function render(): void {
|
|
409
|
+
if (snapshot === null) return;
|
|
410
|
+
// The snapshot carries the server's clock, so the uptime readouts tick
|
|
411
|
+
// against it rather than against a browser clock that may be minutes off.
|
|
412
|
+
const now = snapshot.now + (Date.now() - snapshotAt);
|
|
413
|
+
const vm = selectDashboard(snapshot, ui, now);
|
|
414
|
+
|
|
415
|
+
// Drift is announced when it is NEW, never on every poll: the same mismatch
|
|
416
|
+
// is still there 1.6 s later, and repeating it would make the status region
|
|
417
|
+
// unusable. `driftAnnouncement` owns that decision (and the watermark reset
|
|
418
|
+
// that lets a mismatch which comes back be announced again).
|
|
419
|
+
const drift = driftAnnouncement(vm.service.drift, announcedDrift);
|
|
420
|
+
announcedDrift = drift.key;
|
|
421
|
+
if (drift.message !== null) announce(drift.message);
|
|
422
|
+
|
|
423
|
+
// The console's own state — no source, file gone, reconnecting, stopped,
|
|
424
|
+
// quiet — speaks through the same polite region and by the same rule: once
|
|
425
|
+
// per transition, never per poll and never per line.
|
|
426
|
+
const spoken = consoleAnnouncement(vm.console, ui.logSourcePath, announcedConsole);
|
|
427
|
+
announcedConsole = spoken.key;
|
|
428
|
+
if (spoken.message !== null) announce(spoken.message);
|
|
429
|
+
|
|
430
|
+
// Losing lines is worth saying once, when it first happens — and in the same
|
|
431
|
+
// two forms the banner uses, so it can never read "showing the latest 300 of
|
|
432
|
+
// 300". A source restart clears the flag and re-arms the announcement,
|
|
433
|
+
// because the next drop is a new thing that happened.
|
|
434
|
+
if (!vm.logCounts.bufferDropped) announcedTruncation = false;
|
|
435
|
+
else if (!announcedTruncation) {
|
|
436
|
+
announcedTruncation = true;
|
|
437
|
+
announce(truncationAnnouncement(vm.logCounts));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
serviceBlock.update(vm.service);
|
|
441
|
+
hostBlock.update({ gauges: vm.gauges, temperature: vm.temperature });
|
|
442
|
+
modelsBlock.update({ models: vm.models, allLogsPill: vm.allLogsPill });
|
|
443
|
+
metricsBand.update(vm.kpis);
|
|
444
|
+
sparkline.update(vm.spark);
|
|
445
|
+
toolbar.update(vm.toolbar);
|
|
446
|
+
logConsole.update(vm.console);
|
|
447
|
+
slotsStrip.update(vm.slots);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function currentLogText(): string {
|
|
451
|
+
if (snapshot === null) return "";
|
|
452
|
+
return selectLogText(selectDashboard(snapshot, ui, snapshot.now));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Data
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
async function refresh(): Promise<void> {
|
|
460
|
+
try {
|
|
461
|
+
const response = await fetch("/api/snapshot", { cache: "no-store" });
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
announce(`Steward server returned ${response.status}.`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
snapshot = (await response.json()) as Snapshot;
|
|
467
|
+
snapshotAt = Date.now();
|
|
468
|
+
// The reducer, not this module, decides when a load/unload is done: it
|
|
469
|
+
// clears a model's pending flag once this fresh snapshot shows the status
|
|
470
|
+
// it was waiting for. The POST returned while the model was still
|
|
471
|
+
// `loading`, so only the poll can confirm the transition.
|
|
472
|
+
dispatch({ type: "models/observed", models: snapshot.models });
|
|
473
|
+
render();
|
|
474
|
+
} catch {
|
|
475
|
+
announce("Lost contact with the Steward server.");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let inbox: LogLine[] = [];
|
|
480
|
+
let flushHandle = 0;
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The stream replays its backlog one event at a time on connect. Coalescing a
|
|
484
|
+
* frame's worth of lines turns that into a single reducer pass and a single
|
|
485
|
+
* paint instead of two hundred.
|
|
486
|
+
*/
|
|
487
|
+
function enqueue(line: LogLine): void {
|
|
488
|
+
inbox.push(line);
|
|
489
|
+
if (flushHandle !== 0) return;
|
|
490
|
+
flushHandle = requestAnimationFrame(() => {
|
|
491
|
+
flushHandle = 0;
|
|
492
|
+
const lines = inbox;
|
|
493
|
+
inbox = [];
|
|
494
|
+
dispatch({ type: "logs/append", lines });
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function connectLogs(): void {
|
|
499
|
+
const source = new EventSource("/api/logs/stream");
|
|
500
|
+
source.addEventListener("open", () => {
|
|
501
|
+
dispatch({ type: "logs/stream-status", status: "live" });
|
|
502
|
+
});
|
|
503
|
+
source.addEventListener("message", (event) => {
|
|
504
|
+
if (!(event instanceof MessageEvent)) return;
|
|
505
|
+
try {
|
|
506
|
+
enqueue(JSON.parse(String(event.data)) as LogLine);
|
|
507
|
+
} catch {
|
|
508
|
+
// A malformed frame is not worth tearing the stream down for.
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
// The health of the log SOURCE is a different question from the health of
|
|
512
|
+
// this connection: the server can be streaming perfectly and have no file to
|
|
513
|
+
// read, or be watching a path that macOS deleted out from under it. It rides
|
|
514
|
+
// its own named event so an old client's `message` handler never sees it.
|
|
515
|
+
source.addEventListener("source", (event) => {
|
|
516
|
+
if (!(event instanceof MessageEvent)) return;
|
|
517
|
+
try {
|
|
518
|
+
const status = JSON.parse(String(event.data)) as LogStreamStatus;
|
|
519
|
+
dispatch({
|
|
520
|
+
type: "logs/source-status",
|
|
521
|
+
source: status.source,
|
|
522
|
+
path: status.path,
|
|
523
|
+
detail: status.detail,
|
|
524
|
+
});
|
|
525
|
+
} catch {
|
|
526
|
+
// Same reasoning as a malformed line: not worth a teardown.
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
source.addEventListener("error", () => {
|
|
530
|
+
// EventSource retries on its own, but only while the connection is merely
|
|
531
|
+
// interrupted. A closed stream (server restart) needs a fresh one.
|
|
532
|
+
dispatch({ type: "logs/stream-status", status: "reconnecting" });
|
|
533
|
+
if (source.readyState !== EventSource.CLOSED) return;
|
|
534
|
+
window.setTimeout(connectLogs, RECONNECT_DELAY_MS);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Actions
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
/** What a POST did, plus the server's reason when it did not. */
|
|
543
|
+
interface PostOutcome {
|
|
544
|
+
ok: boolean;
|
|
545
|
+
/** The command's own words, for the inline notice. `null` when unavailable. */
|
|
546
|
+
detail: string | null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The server answers a refused action with a JSON `{ error }` naming what
|
|
551
|
+
* actually happened (`launchctl: permission denied`). That detail is the whole
|
|
552
|
+
* point of the notice, so it is read off the body rather than reduced to a
|
|
553
|
+
* status code.
|
|
554
|
+
*/
|
|
555
|
+
async function readError(response: Response): Promise<string | null> {
|
|
556
|
+
try {
|
|
557
|
+
const body: unknown = await response.json();
|
|
558
|
+
if (typeof body === "object" && body !== null && "error" in body) {
|
|
559
|
+
const error = (body as { error?: unknown }).error;
|
|
560
|
+
if (typeof error === "string" && error.trim() !== "") return error;
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
// Not JSON, or no body at all: fall back to the status line.
|
|
564
|
+
}
|
|
565
|
+
return `the Steward server returned ${response.status}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function post(path: string): Promise<PostOutcome> {
|
|
569
|
+
try {
|
|
570
|
+
const response = await fetch(path, { method: "POST", cache: "no-store" });
|
|
571
|
+
if (response.ok) return { ok: true, detail: null };
|
|
572
|
+
return { ok: false, detail: await readError(response) };
|
|
573
|
+
} catch {
|
|
574
|
+
return { ok: false, detail: "the Steward server could not be reached" };
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Runs a service action. The POST returning is not the outcome — a command can
|
|
580
|
+
* exit 0 and leave the service exactly as it was (a `KeepAlive` job relaunches
|
|
581
|
+
* itself after a stop) — so the refresh that follows is what tells the operator
|
|
582
|
+
* what happened, and the button stays pending until that snapshot lands. A
|
|
583
|
+
* refused command leaves an honest notice instead of a silent no-op.
|
|
584
|
+
*/
|
|
585
|
+
async function runService(action: ServiceAction): Promise<void> {
|
|
586
|
+
if (ui.pendingService !== null) return;
|
|
587
|
+
dispatch({ type: "service/confirm", action: null });
|
|
588
|
+
dispatch({ type: "service/failure", failure: null });
|
|
589
|
+
dispatch({ type: "service/pending", action });
|
|
590
|
+
announce(`Running ${action} on the llama.cpp service.`);
|
|
591
|
+
|
|
592
|
+
const outcome = await post(`/api/service/${action}`);
|
|
593
|
+
if (outcome.ok) {
|
|
594
|
+
announce(`${action} sent; confirming with the next poll.`);
|
|
595
|
+
} else {
|
|
596
|
+
dispatch({ type: "service/failure", failure: { action, detail: outcome.detail } });
|
|
597
|
+
announce(`Could not ${action} the service: ${outcome.detail ?? "no reason given"}.`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// The poll is the source of truth either way — including after a failure,
|
|
601
|
+
// where the service may still have moved.
|
|
602
|
+
await refresh();
|
|
603
|
+
dispatch({ type: "service/pending", action: null });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Load/unload is slow and asynchronous — the POST returns in tens of
|
|
608
|
+
* milliseconds while the model is still spawning — so the button is not cleared
|
|
609
|
+
* here. It is marked pending, and it stays pending until a polled snapshot
|
|
610
|
+
* shows the model reached its target status (the reducer clears it from
|
|
611
|
+
* `models/observed`). Only a rejected POST clears the flag directly, so a
|
|
612
|
+
* request that never took does not spin forever.
|
|
613
|
+
*/
|
|
614
|
+
async function runModel(modelId: string, action: ModelAction): Promise<void> {
|
|
615
|
+
if (ui.pendingModels[modelId] !== undefined) return;
|
|
616
|
+
dispatch({ type: "model/pending", modelId, action });
|
|
617
|
+
announce(`${action === "load" ? "Loading" : "Unloading"} ${modelId}.`);
|
|
618
|
+
const { ok } = await post(`/api/models/${encodeURIComponent(modelId)}/${action}`);
|
|
619
|
+
if (!ok) {
|
|
620
|
+
announce(`Could not ${action} ${modelId}.`);
|
|
621
|
+
dispatch({ type: "model/pending", modelId, action: null });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
await refresh();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** What was exported, said honestly — it is not what is on screen. */
|
|
628
|
+
function exportSummary(): string {
|
|
629
|
+
if (snapshot === null) return "";
|
|
630
|
+
return selectLogExportSummary(selectDashboard(snapshot, ui, snapshot.now).logCounts);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function copyLog(): Promise<void> {
|
|
634
|
+
const text = currentLogText();
|
|
635
|
+
const summary = exportSummary();
|
|
636
|
+
try {
|
|
637
|
+
await navigator.clipboard.writeText(text);
|
|
638
|
+
announce(`Copied ${summary}`);
|
|
639
|
+
} catch {
|
|
640
|
+
announce("The browser refused clipboard access.");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
dispatch({ type: "copy/flag", copied: true });
|
|
644
|
+
window.clearTimeout(copyTimer);
|
|
645
|
+
copyTimer = window.setTimeout(() => {
|
|
646
|
+
dispatch({ type: "copy/flag", copied: false });
|
|
647
|
+
}, COPY_FEEDBACK_MS);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function downloadLog(): void {
|
|
651
|
+
const summary = exportSummary();
|
|
652
|
+
const url = URL.createObjectURL(new Blob([currentLogText()], { type: "text/plain" }));
|
|
653
|
+
const anchor = document.createElement("a");
|
|
654
|
+
anchor.href = url;
|
|
655
|
+
anchor.download = "llama-server.log";
|
|
656
|
+
anchor.click();
|
|
657
|
+
announce(`Downloading llama-server.log — ${summary}`);
|
|
658
|
+
window.setTimeout(() => {
|
|
659
|
+
URL.revokeObjectURL(url);
|
|
660
|
+
}, 2000);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Start
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
applyTheme(ui.theme);
|
|
668
|
+
// While in System mode, a live OS light/dark switch must repaint the palette.
|
|
669
|
+
// The mode itself does not change, so only the resolved attribute is re-applied.
|
|
670
|
+
globalThis.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|
671
|
+
if (ui.theme === "system") applyTheme(ui.theme);
|
|
672
|
+
});
|
|
673
|
+
void refresh();
|
|
674
|
+
connectLogs();
|
|
675
|
+
window.setInterval(() => {
|
|
676
|
+
void refresh();
|
|
677
|
+
}, SNAPSHOT_INTERVAL_MS);
|
|
678
|
+
window.setInterval(render, CLOCK_INTERVAL_MS);
|