@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
@@ -0,0 +1,1523 @@
1
+ /**
2
+ * A {@link StewardDataSource} that shows live CONFIG, MODELS and SLOTS over an
3
+ * otherwise simulated dashboard, and performs load/unload for real.
4
+ *
5
+ * It is composite: it holds a `fallback` (the mock) and delegates the panels it
6
+ * does not own yet — the host-metrics band, requests, throughput history, and the
7
+ * log console — straight to it, overriding `config`, `service`, `models`, and
8
+ * `slots` with what a real `llama-server` reports. This is a step in a gradual
9
+ * mock→live migration; the remaining panels stay simulated until later phases.
10
+ *
11
+ * Reading the server must never throw or hang the dashboard: it is not always
12
+ * up, and that is a state to show, not an error to crash on. Every failure —
13
+ * connection refused, timeout, a 401, any non-2xx, a malformed body — degrades
14
+ * that section honestly while the fallback keeps every other panel animating. A
15
+ * per-model read that fails drops only that model's slots and rate, never the
16
+ * whole snapshot.
17
+ *
18
+ * `setModel` is the exception: an operator action that fails is an error to
19
+ * surface, so it rejects with a message rather than swallowing it. It does not
20
+ * wait for the model to finish loading — the POST returns while the child is
21
+ * still spawning, and the poll layer watches the status reach its terminal
22
+ * value.
23
+ *
24
+ * Keep this module free of Node and DOM APIs: `fetch`, `AbortController`, and
25
+ * `AbortSignal` are all cross-runtime globals.
26
+ */
27
+
28
+ import type { ConsentDrift, DriftProbe, DriftState, LaunchDrift } from "./drift.js";
29
+ import { NO_CONSENT_DRIFT, unknownLaunchDrift } from "./drift.js";
30
+ import type { HostMetricsProvider } from "./host-metrics.js";
31
+ import { parseRouterConfig } from "./llama-config.js";
32
+ import type { LlamaConnection } from "./llama-connection.js";
33
+ import { listenAddress } from "./llama-connection.js";
34
+ import { parseModelPorts, parseModels } from "./llama-models.js";
35
+ import { parseMetrics, parseSlots } from "./llama-slots.js";
36
+ import { createSlotActivity, type SlotActivity, type SlotActivityState } from "./slot-activity.js";
37
+ import type { LogAttachment, StewardDataSource, Unsubscribe } from "./source.js";
38
+ import {
39
+ type ConfigEntry,
40
+ type HostMetrics,
41
+ type LogLine,
42
+ type LogStreamStatus,
43
+ type MemoryTopology,
44
+ type ModelAction,
45
+ type ModelInfo,
46
+ type ServiceAction,
47
+ type ServiceInfo,
48
+ type SlotInfo,
49
+ type Snapshot,
50
+ THROUGHPUT_HISTORY_SIZE,
51
+ THROUGHPUT_SAMPLE_SECONDS,
52
+ } from "./types.js";
53
+
54
+ /** The local process behind the server, for facts HTTP does not expose. */
55
+ export interface ServiceProcess {
56
+ pid: number;
57
+ /** Epoch ms the process started, or null when the probe cannot read it. */
58
+ startedAt: number | null;
59
+ }
60
+
61
+ /**
62
+ * Resolves the OS process listening on a host:port. Node-side and
63
+ * platform-specific (it shells out), so it is injected rather than imported here
64
+ * — this module stays free of Node APIs. Returns null when nothing is found.
65
+ */
66
+ export type ServiceProbe = (host: string, port: number) => Promise<ServiceProcess | null>;
67
+
68
+ /**
69
+ * The outcome of one control command. `ok` reports only that the command ran
70
+ * and reported success — NOT that the service reached the state the operator
71
+ * asked for. A launchd job with `KeepAlive`, for instance, exits 0 from a stop
72
+ * and is relaunched a moment later. The snapshot poll (`/props` reachability)
73
+ * is the source of truth; this is just the command's own verdict.
74
+ */
75
+ export interface ServiceControlResult {
76
+ ok: boolean;
77
+ /** A readable reason when `ok` is false (permission denied, not found, …). */
78
+ detail: string | null;
79
+ }
80
+
81
+ /**
82
+ * Runs the operator's declared start/stop/restart commands. Node-side and
83
+ * platform-specific (it executes a program), so it is injected rather than
84
+ * imported here — this module stays free of Node APIs. See
85
+ * `server/service-control.ts` for the real one.
86
+ */
87
+ export interface ServiceController {
88
+ /**
89
+ * The actions this machine has a declared, consented command for, in
90
+ * start/stop/restart order. Rides onto {@link ServiceInfo.controls} so the
91
+ * dashboard offers exactly what can actually run.
92
+ */
93
+ readonly actions: readonly ServiceAction[];
94
+ /**
95
+ * Runs one action. Never rejects: a non-zero exit, a timeout, a missing
96
+ * binary, or an action with no command all resolve as a failure carrying a
97
+ * readable detail.
98
+ */
99
+ run(action: ServiceAction): Promise<ServiceControlResult>;
100
+ }
101
+
102
+ /**
103
+ * Follows the running server's log and hands the console real lines.
104
+ *
105
+ * Node-side (it reads a file, or shells out to a journal), so it is injected
106
+ * rather than imported here — this module stays free of Node APIs. See
107
+ * `server/log-tailer.ts` for the file implementation. With none configured, the
108
+ * log console keeps delegating to the fallback source exactly as before.
109
+ */
110
+ export interface LogTailer {
111
+ /** The most recent lines the tailer holds, oldest first. */
112
+ recent(limit: number): LogLine[];
113
+ /** Streams every line read after this call. */
114
+ subscribe(listener: (line: LogLine) => void): Unsubscribe;
115
+ /**
116
+ * The backlog and the subscription in one step. Backlog and live tail come
117
+ * from one offset, so this — and only this — delivers every line exactly once:
118
+ * a poll landing between a separate `recent` and `subscribe` would put its
119
+ * lines in neither.
120
+ */
121
+ attach(listener: (line: LogLine) => void, limit: number): LogAttachment;
122
+ /**
123
+ * Refreshes the port→model map the tailer attributes child lines with. The
124
+ * router prefixes child lines with `[port]` and nothing else, and the log's own
125
+ * mapping line is written once per load — so the live `/models` body, which
126
+ * carries each loaded model's `--port`, is the reliable source.
127
+ */
128
+ setPorts(ports: ReadonlyMap<number, string>): void;
129
+ /** Whether the source is connected, and how it failed when it is not. */
130
+ status(): LogStreamStatus;
131
+ /** Releases timers and file handles. */
132
+ close(): void;
133
+ }
134
+
135
+ /**
136
+ * The minimal HTTP surface Steward uses. The global `fetch` satisfies it, and a
137
+ * test can supply a stub without standing up a server — narrower than the DOM
138
+ * `fetch` type on purpose. `text()` is here for the Prometheus `/metrics` body.
139
+ */
140
+ export type FetchLike = (
141
+ input: string,
142
+ init?: {
143
+ method?: string;
144
+ headers?: Record<string, string>;
145
+ body?: string;
146
+ signal?: AbortSignal;
147
+ },
148
+ ) => Promise<{ status: number; ok: boolean; json(): Promise<unknown>; text(): Promise<string> }>;
149
+
150
+ /**
151
+ * The live host-metrics overlay: a collector to read the readings off, the
152
+ * machine's static memory topology (from `steward.json`, it picks the gauge
153
+ * SET), and the staleness horizon. Injected together and only when the operator
154
+ * has configured AND consented to a collector; absent, the HOST band keeps
155
+ * delegating to the fallback exactly as before.
156
+ */
157
+ export interface HostMetricsOverlay {
158
+ /** The running collector. Owned and closed by this source. */
159
+ provider: HostMetricsProvider;
160
+ /** Static machine memory layout, overlaid onto {@link Snapshot.memoryTopology}. */
161
+ topology: MemoryTopology;
162
+ /**
163
+ * A sample whose arrival is older than this (typically `3 × intervalMs`) is
164
+ * treated as unavailable: its readings are nulled rather than held, so the
165
+ * band never shows a dimmed-old number — n/a is honest, a stale figure is not.
166
+ */
167
+ staleMs: number;
168
+ }
169
+
170
+ /**
171
+ * The parts of the live source that `steward.json` decides — and that it can
172
+ * therefore gain, change, or lose while the dashboard is open.
173
+ *
174
+ * They are grouped because they are swapped together, by
175
+ * {@link LlamaSource.reconfigure}. An ABSENT key means "this machine has none",
176
+ * never "keep what you had": a source still serving a collector the config
177
+ * stopped declaring is serving a config that is gone, which is the same
178
+ * dishonesty as a held-stale reading.
179
+ *
180
+ * IDENTITY is the swap protocol for the two parts that own an OS resource. Hand
181
+ * back the same {@link HostMetricsOverlay.provider} — or the same
182
+ * {@link LogTailer} — and the source leaves the running one completely alone;
183
+ * hand back a different one, or none, and the source closes what it held. That
184
+ * is what keeps a `steward.json` rewrite whose collector is unchanged from
185
+ * dropping the metrics stream and re-running the collector's warmup, while a
186
+ * rewrite that really does change the command still stops the old child.
187
+ */
188
+ export interface LlamaLiveParts {
189
+ /**
190
+ * Runs the operator's declared start/stop/restart commands. Injected (it is
191
+ * Node-side) and present only when `steward.json` declares control commands
192
+ * the operator has consented to. Omitted, {@link LlamaSource.setService}
193
+ * keeps delegating to the fallback and the block offers no controls.
194
+ */
195
+ control?: ServiceController;
196
+ /**
197
+ * The live host-metrics collector and its topology. Omitted, the HOST band
198
+ * (memory topology and sensors) rides through from the fallback unchanged.
199
+ */
200
+ host?: HostMetricsOverlay;
201
+ /**
202
+ * Re-reads the launch argv of the process behind {@link ServiceInfo.pid} and
203
+ * diffs it against the one `steward.json` recorded. Injected (it is Node-side
204
+ * and platform-specific) and present only when the config carries a
205
+ * `llama.launchArgv` to compare against; omitted, the snapshot reports drift
206
+ * `unknown` — the check is unavailable, which is not the same as passing it.
207
+ * It needs {@link LlamaSourceOptions.probeService} to have found a pid, so
208
+ * without a service probe the check reports itself unavailable too.
209
+ */
210
+ probeDrift?: DriftProbe;
211
+ /**
212
+ * Commands `steward.json` declares but has not approved, computed from the
213
+ * config each time it is read. Omitted, nothing is reported as unapproved —
214
+ * which is also the honest answer once there is no config to declare anything.
215
+ */
216
+ consentDrift?: ConsentDrift;
217
+ /**
218
+ * The machine's memory layout as `steward.json` declares it, independent of
219
+ * whether a collector was consented.
220
+ *
221
+ * This used to ride along with {@link LlamaLiveParts.host}, so a config
222
+ * declaring `discrete` was ignored unless its collector had also been
223
+ * approved — and the dashboard then drew whatever topology the fallback
224
+ * happened to carry. Topology is a static fact about the hardware, not a
225
+ * reading, so it is known the moment the config is read.
226
+ */
227
+ topology?: MemoryTopology;
228
+ /**
229
+ * The live log tail, owned and closed by this source. Present only when a log
230
+ * source was discovered (see `server/log-tailer.ts`); omitted, the console
231
+ * keeps delegating to the fallback exactly as it does today rather than
232
+ * showing an empty panel with no explanation.
233
+ */
234
+ logTail?: LogTailer;
235
+ }
236
+
237
+ export interface LlamaSourceOptions extends LlamaLiveParts {
238
+ /** Where to read the server. */
239
+ connection: LlamaConnection;
240
+ /** The source every non-live fact comes from; owned and closed by this one. */
241
+ fallback: StewardDataSource;
242
+ /** HTTP transport. Defaults to the global `fetch`; injected in tests. */
243
+ fetch?: FetchLike;
244
+ /**
245
+ * Resolves the OS process behind the connection, for the SERVICE panel's real
246
+ * pid and uptime — facts llama-server does not report over HTTP. Injected
247
+ * (it is Node-side and platform-specific); omitted, pid and uptime read n/a
248
+ * while the live/stopped state still comes through.
249
+ */
250
+ probeService?: ServiceProbe;
251
+ /**
252
+ * Subscribes this source to later versions of its {@link LlamaLiveParts} —
253
+ * the `steward.json` watcher (`server/config-wiring.ts`), injected because
254
+ * watching a file is Node-side. It is handed this source's own
255
+ * {@link LlamaSource.reconfigure} and returns the unsubscribe, which
256
+ * {@link LlamaSource.close} calls FIRST: nothing may hand a freshly spawned
257
+ * collector to a source that has stopped being able to close one.
258
+ *
259
+ * Omitted, the parts this source was constructed with are the ones it keeps —
260
+ * which is what every test that does not care about re-wiring gets.
261
+ */
262
+ rewire?: (apply: (parts: LlamaLiveParts) => void) => Unsubscribe;
263
+ }
264
+
265
+ /** How long to wait on any one call before treating the server as unreachable. */
266
+ const CALL_TIMEOUT_MS = 4000;
267
+
268
+ /**
269
+ * The cadence at which a throughput sample is appended to the rolling history.
270
+ * llama.cpp reports an instantaneous rate, not a series, so Steward accumulates
271
+ * one, at the same cadence the mock uses so the sparkline's two-minute axis is
272
+ * true. Gating on the snapshot clock (not the call count) keeps the window at
273
+ * that span regardless of how many clients are polling.
274
+ */
275
+ const THROUGHPUT_SAMPLE_MS = THROUGHPUT_SAMPLE_SECONDS * 1000;
276
+
277
+ /**
278
+ * The span the sparkline claims to show — its sample count times its cadence,
279
+ * so the two can never drift apart. A history whose newest sample is older than
280
+ * this no longer describes that window and is discarded rather than displayed.
281
+ */
282
+ const THROUGHPUT_WINDOW_MS = THROUGHPUT_HISTORY_SIZE * THROUGHPUT_SAMPLE_MS;
283
+
284
+ /**
285
+ * How much of the tailer's buffer is folded into slot state when the source
286
+ * starts. The tailer holds 500 lines, so this takes all of it: a Steward that
287
+ * starts mid-flight then knows what every slot was doing as of the newest line
288
+ * in the file, instead of waiting for the next request to tell it.
289
+ */
290
+ const ACTIVITY_BACKLOG = 500;
291
+
292
+ /**
293
+ * How much history an open console is handed when the log source underneath it
294
+ * changes. The same window the API route replays to a console that connects
295
+ * fresh, for the same reason: it is what the client's buffer holds.
296
+ */
297
+ const CONSOLE_REPLAY_LINES = 200;
298
+
299
+ export class LlamaSource implements StewardDataSource {
300
+ readonly name = "llama.cpp";
301
+
302
+ readonly #connection: LlamaConnection;
303
+ readonly #fallback: StewardDataSource;
304
+ readonly #fetch: FetchLike;
305
+ readonly #probeService: ServiceProbe | null;
306
+ // Every part `steward.json` decides is mutable, because the artifact is: the
307
+ // operator can run `/steward_initialize` — or delete the file — with the
308
+ // dashboard open, and `reconfigure` swaps these in place rather than making
309
+ // them wait for a new Pi session.
310
+ /** The declared control commands, or null when none are configured/consented. */
311
+ #control: ServiceController | null;
312
+ /** The live host-metrics overlay, or null when no collector is configured. */
313
+ #host: HostMetricsOverlay | null;
314
+ /** The launch-argv re-check, or null when nothing was recorded to check. */
315
+ #probeDrift: DriftProbe | null;
316
+ /** Declared-but-unapproved commands, as of the config we last read. */
317
+ #consentDrift: ConsentDrift;
318
+ /** Declared memory layout from `steward.json`, or null when none is declared. */
319
+ #topology: MemoryTopology | null = null;
320
+ /** The live log tail, or null when no log source was discovered. */
321
+ #logTail: LogTailer | null;
322
+ /**
323
+ * Slot occupancy folded from the log, or null when there is no log to fold.
324
+ *
325
+ * Its presence is the either/or: with it, SLOTS and the request/throughput
326
+ * figures come from events and the per-model `/slots` and `/metrics` polls do
327
+ * not run at all; without it they are the only way to know anything and run
328
+ * exactly as they always did. Never both — running the timers alongside the
329
+ * event stream would keep every line of the noise this exists to remove.
330
+ */
331
+ #activity: SlotActivity | null;
332
+ /** Detaches {@link #activity} from the tailer; null when there is no tail. */
333
+ #detachActivity: Unsubscribe | null;
334
+ /**
335
+ * The consoles listening to this source, held HERE rather than on whatever is
336
+ * feeding them.
337
+ *
338
+ * A browser's log stream is opened once and lives for as long as the tab does,
339
+ * so subscribing it straight to the tailer would end the moment the tailer was
340
+ * replaced: the console would go quiet while `logStatus()` cheerfully reported
341
+ * a healthy new source. The subscribers belong to the source, which keeps one
342
+ * upstream subscription and re-points it at each swap — so a console opened
343
+ * before `/steward_initialize` ran is reading the real log a moment after it
344
+ * did, without being reopened.
345
+ */
346
+ readonly #logListeners = new Set<(line: LogLine) => void>();
347
+ /** Detaches the fan-out from whatever currently feeds it (a tail, or the fallback). */
348
+ #detachLogFeed: Unsubscribe | null = null;
349
+ /**
350
+ * Which log source the console is being fed from, bumped on every swap and
351
+ * stamped onto every line that leaves here.
352
+ *
353
+ * `LogLine.seq` is monotonic per SOURCE, and a swap changes the source: the
354
+ * file tailer anchors on a 256 KB backlog window as it opens, so a
355
+ * replacement's counter is already in the thousands before it delivers a
356
+ * line, and the fallback's starts from its own base. Without this the client
357
+ * would read those numbers as ordinary progress and append — quietly showing
358
+ * one buffer of two different logs, with nothing on screen to say so.
359
+ */
360
+ #logGeneration = 0;
361
+ /** Stops the `steward.json` watcher; null when nothing is watching. */
362
+ #stopRewire: Unsubscribe | null = null;
363
+ /** Set by {@link close}, so nothing can be handed to a spent source. */
364
+ #closed = false;
365
+ /**
366
+ * Every collector and tailer this source has closed.
367
+ *
368
+ * The swap protocol is identity-based, so "have I already released this one?"
369
+ * is a question with an exact answer, and this is it. It matters because the
370
+ * resources on the other side of it are a detached PROCESS GROUP and an open
371
+ * file handle: closing one twice is not obviously harmless (the collector's
372
+ * own `close` happens to guard, but a source must not depend on its callee to
373
+ * make its accounting true), and closing one never leaks a process.
374
+ */
375
+ readonly #released = new WeakSet<object>();
376
+ /** In-flight reads and actions, aborted on {@link close}. */
377
+ readonly #inFlight = new Set<AbortController>();
378
+ /**
379
+ * Closed throughput samples, oldest first — the band's sparkline, measured
380
+ * from generated tokens. Used on the event path only.
381
+ */
382
+ readonly #samples: ThroughputSample[] = [];
383
+ /**
384
+ * Tokens drained from the tracker that the open sample has not closed over
385
+ * yet. Snapshots arrive faster than samples close (and from every connected
386
+ * browser), so the ledger is drained every snapshot and banked here.
387
+ */
388
+ #pendingTokens = 0;
389
+ /**
390
+ * Rolling `/metrics` gauge readings, oldest first — the same sparkline on the
391
+ * POLLING path, where tokens cannot be counted and a sampled gauge is all
392
+ * there is.
393
+ */
394
+ readonly #gaugeHistory: number[] = [];
395
+ /** Snapshot clock at the last closed sample, so sampling stays time-paced. */
396
+ #lastSampleAt = 0;
397
+ /**
398
+ * The log source's health at the previous snapshot, so a change of state can
399
+ * be treated as a break in the event stream. Null before the first snapshot.
400
+ */
401
+ #lastLogSource: string | null = null;
402
+
403
+ constructor(options: LlamaSourceOptions) {
404
+ this.#connection = options.connection;
405
+ this.#fallback = options.fallback;
406
+ this.#fetch = options.fetch ?? globalThis.fetch;
407
+ this.#probeService = options.probeService ?? null;
408
+
409
+ // The config-driven parts start empty and are installed through the same
410
+ // path every later change takes, so a source that is built with a collector
411
+ // and one that gains a collector an hour later are wired identically —
412
+ // there is no construction-only branch to fall out of step.
413
+ this.#control = null;
414
+ this.#host = null;
415
+ this.#probeDrift = null;
416
+ this.#consentDrift = NO_CONSENT_DRIFT;
417
+ this.#topology = null;
418
+ this.#logTail = null;
419
+ this.#activity = null;
420
+ this.#detachActivity = null;
421
+ this.reconfigure(options);
422
+ // `reconfigure` pointed the console's fan-out at a tail if the config named
423
+ // one. With no tail it is the fallback's simulated lines the console shows,
424
+ // and the fan-out has to be pointed at those from here.
425
+ if (this.#detachLogFeed === null) this.#feedConsole();
426
+
427
+ // Subscribed last, so the first parts this source ever sees are the ones it
428
+ // was constructed with, and a watcher that fires synchronously on subscribe
429
+ // cannot reach a half-built source.
430
+ this.#stopRewire = options.rewire?.((parts) => this.reconfigure(parts)) ?? null;
431
+ }
432
+
433
+ /**
434
+ * Installs a new set of config-driven parts — what `steward.json` says about
435
+ * this machine, as of now.
436
+ *
437
+ * This is the whole of Steward's answer to an artifact that changes under it.
438
+ * `/steward_initialize` can be run, re-run, or its output deleted while the
439
+ * dashboard is open, and each of those has to take effect on the next repaint
440
+ * rather than on the next Pi session: a collector appears, a log path moves,
441
+ * a control command loses its consent hash, the file is removed entirely.
442
+ *
443
+ * Two rules make that safe. Every part is REPLACED, never merged — an absent
444
+ * part means this machine has none, so a config that is gone takes its
445
+ * collector, its buttons and its drift baseline with it instead of leaving
446
+ * them running on an approval that no longer exists. And a part that owns an
447
+ * OS resource is closed when it is replaced by a different one, which is why
448
+ * the caller must hand back the SAME provider/tailer instance for anything it
449
+ * decided not to rebuild (see {@link LlamaLiveParts}) — the collector is a
450
+ * detached process group, and dropping a reference to one leaks a process.
451
+ */
452
+ reconfigure(parts: LlamaLiveParts): void {
453
+ if (this.#closed) {
454
+ // A source that can no longer serve anything must still not leak what it
455
+ // is handed. The watcher is stopped before this can happen, so it is
456
+ // insurance and not a path — but the thing it would leak is a process
457
+ // group. {@link #release} makes it exact in both directions: a resource
458
+ // this source already closed is not closed again, and one it has never
459
+ // seen is closed once however many times it arrives.
460
+ this.#release(parts.host?.provider);
461
+ this.#release(parts.logTail);
462
+ return;
463
+ }
464
+ // Neither of these owns a resource: control is argv the executor re-reads
465
+ // per action, drift is a probe holding a per-pid cache and a plain record.
466
+ this.#control = parts.control ?? null;
467
+ this.#probeDrift = parts.probeDrift ?? null;
468
+ this.#consentDrift = parts.consentDrift ?? NO_CONSENT_DRIFT;
469
+ this.#topology = parts.topology ?? null;
470
+ this.#swapHost(parts.host ?? null);
471
+ this.#swapTail(parts.logTail ?? null);
472
+ }
473
+
474
+ /**
475
+ * Swaps the host-metrics overlay, closing the collector only when the
476
+ * PROVIDER itself changed.
477
+ *
478
+ * The overlay object and the collector inside it have separate lifetimes on
479
+ * purpose: an operator who edits `memoryTopology` (or whose collector cadence
480
+ * is unchanged but whose file was rewritten) gets a new overlay around the
481
+ * same running child, and the metrics stream never breaks. Killing and
482
+ * respawning it would blank the HOST band for the length of the collector's
483
+ * warmup — n/a readings that describe nothing but Steward's own churn.
484
+ */
485
+ #swapHost(next: HostMetricsOverlay | null): void {
486
+ const previous = this.#host;
487
+ this.#host = next;
488
+ if (previous !== null && previous.provider !== next?.provider) {
489
+ this.#release(previous.provider);
490
+ }
491
+ }
492
+
493
+ /** Closes a collector or a tailer, once, ever. */
494
+ #release(resource: { close(): void } | null | undefined): void {
495
+ if (resource === null || resource === undefined) return;
496
+ if (this.#released.has(resource)) return;
497
+ this.#released.add(resource);
498
+ resource.close();
499
+ }
500
+
501
+ /**
502
+ * Swaps the log tail, and with it everything that was derived from the old
503
+ * one.
504
+ *
505
+ * A tail that is replaced (the path moved) or removed (the config that named
506
+ * it is gone) is a break in the event stream, not a continuation of it: the
507
+ * slot tracker's occupancy came from lines of a file we have stopped reading,
508
+ * and the throughput ledger's banked tokens were counted from it. Both are
509
+ * dropped, exactly as {@link #syncActivity} drops them when the file itself
510
+ * goes missing — the strip empties and refills rather than straddling the gap
511
+ * with samples that understate a window they claim to measure.
512
+ */
513
+ #swapTail(next: LogTailer | null): void {
514
+ const previous = this.#logTail;
515
+ if (previous === next) return;
516
+
517
+ this.#detachActivity?.();
518
+ this.#detachActivity = null;
519
+ this.#activity = null;
520
+ this.#logTail = next;
521
+ // The console is about to start hearing a different source, whose sequence
522
+ // numbers have nothing to do with the ones it holds. Saying so is the only
523
+ // thing that stops the client merging two logs into one buffer.
524
+ this.#logGeneration += 1;
525
+ // Re-pointed before the old tailer is closed, so no console is ever
526
+ // subscribed to something that has stopped reading.
527
+ this.#feedConsole();
528
+ this.#release(previous);
529
+ // And handed the new source's own recent history in the same breath, so a
530
+ // console that was open across the swap shows the new log rather than
531
+ // refilling one line at a time from whatever happens next.
532
+ this.#replayToConsoles();
533
+
534
+ if (next !== null) {
535
+ const activity = createSlotActivity();
536
+ // `attach` hands back the backlog and registers the listener with no
537
+ // suspension point between them, and folding the backlog immediately
538
+ // after — synchronously, before the tailer's next poll can run — means
539
+ // every line is folded exactly once and in order. Attaching here rather
540
+ // than on the first snapshot means occupancy is tracked from the moment
541
+ // the tail exists: a request that starts and finishes before the browser
542
+ // has even connected is still accounted for.
543
+ const attachment = next.attach((line) => activity.observe(line), ACTIVITY_BACKLOG);
544
+ for (const line of attachment.backlog) activity.observe(line);
545
+ this.#activity = activity;
546
+ this.#detachActivity = attachment.unsubscribe;
547
+ }
548
+
549
+ // Both throughput paths reset: the swap also decides WHICH of them runs, and
550
+ // carrying either series across it would plot one measurement under the
551
+ // other's axis.
552
+ this.#samples.length = 0;
553
+ this.#gaugeHistory.length = 0;
554
+ this.#pendingTokens = 0;
555
+ this.#lastSampleAt = 0;
556
+ this.#lastLogSource = null;
557
+ }
558
+
559
+ async snapshot(): Promise<Snapshot> {
560
+ // The fallback owns every panel we have not moved yet and is the dashboard's
561
+ // data spine. The live reads are fully guarded and never reject, so they can
562
+ // only ever add the overlay — never be the reason a repaint fails. Config
563
+ // and the model list are independent, so fetch them alongside the fallback
564
+ // rather than serialising. (A fallback that itself fails is the spine
565
+ // failing; the server turns that into a 5xx and the client retries, which
566
+ // beats painting invented panels over a dead source.)
567
+ const [base, props, modelsRaw] = await Promise.all([
568
+ this.#fallback.snapshot(),
569
+ this.#readProps(),
570
+ this.#getJson("/models"),
571
+ ]);
572
+
573
+ // The log console attributes a child line by the `[port]` the router
574
+ // prefixed it with, and this body is where the ports are: one refresh per
575
+ // snapshot keeps the map right across load/unload cycles without the tailer
576
+ // needing an HTTP client of its own. The same map is what joins a tracked
577
+ // port back to the model whose slots it is, so it is parsed once and used
578
+ // for both.
579
+ const ports = parseModelPorts(modelsRaw);
580
+ // `setPorts` is deliberately given the empty map from a failed read: the
581
+ // tailer MERGES, so an empty map is a no-op there and cannot blank out
582
+ // attribution. The tracker replaces, so it is told the difference instead.
583
+ this.#logTail?.setPorts(ports);
584
+ this.#syncActivity(modelsRaw === undefined ? null : ports);
585
+
586
+ const config = this.#configFromProps(props);
587
+ const service = await this.#serviceFromProps(props);
588
+ const { models, slots, throughputTps, requestsInFlight, requestsQueued } =
589
+ await this.#readModelsAndSlots(modelsRaw, ports, base.now);
590
+ // The two paths measure different things and say so. With a log there are
591
+ // token counts to divide by wall clock, which is throughput; without one
592
+ // there is only llama.cpp's own rate gauge, sampled.
593
+ const activity = this.#activity;
594
+ const throughput =
595
+ activity === null
596
+ ? this.#sampleGauge(base.now, throughputTps)
597
+ : this.#sampleGenerated(base.now, activity);
598
+ // When a collector is configured, the HOST band is live: its topology comes
599
+ // from `steward.json` and its readings from the collector's latest sample.
600
+ // With no collector, the band — including `memoryTopology` — rides through
601
+ // from the fallback via `...base`, exactly as before.
602
+ const host = this.#overlayHost(base);
603
+ const drift = await this.#readDrift(service);
604
+ return {
605
+ ...base,
606
+ config,
607
+ service,
608
+ drift,
609
+ models,
610
+ slots,
611
+ throughputTps: throughput.tps,
612
+ throughputHistory: throughput.history,
613
+ throughputWindowSeconds: throughput.windowSeconds,
614
+ requestsInFlight,
615
+ requestsQueued,
616
+ ...host,
617
+ };
618
+ }
619
+
620
+ /**
621
+ * The live HOST band, or an empty overlay (`{}`) when no collector is
622
+ * configured — leaving the fallback's `metrics` and `memoryTopology` in place.
623
+ *
624
+ * With a collector: `memoryTopology` is the machine's static config, and the
625
+ * sensors are the latest validated sample. A sample older than the staleness
626
+ * horizon, or no sample yet, nulls every reading (`NaN` for the always-present
627
+ * figures, `null` for temperatures) so Phase 1's dashed/hatched gauges show it
628
+ * honestly — a held-stale number is never shown.
629
+ */
630
+ #overlayHost(base: Snapshot): Partial<Snapshot> {
631
+ // Topology first, and on its own: a config that declares `discrete` is
632
+ // telling us about the hardware, which is true whether or not its collector
633
+ // was ever approved. Without this the fallback's topology won and the
634
+ // dashboard drew the wrong gauges on a correctly configured machine.
635
+ const declared: Partial<Snapshot> =
636
+ this.#topology === null ? {} : { memoryTopology: this.#topology };
637
+ if (this.#host === null) return declared;
638
+ const sample = this.#host.provider.latest();
639
+ const fresh = sample !== null && base.now - sample.receivedAt <= this.#host.staleMs;
640
+ const metrics: HostMetrics = fresh
641
+ ? {
642
+ vramUsedGB: finiteOr(sample.reading.vramUsedGB, Number.NaN),
643
+ vramTotalGB: finiteOr(sample.reading.vramTotalGB, Number.NaN),
644
+ ramUsedGB: finiteOr(sample.reading.ramUsedGB, Number.NaN),
645
+ ramTotalGB: finiteOr(sample.reading.ramTotalGB, Number.NaN),
646
+ gpuUtil: finiteOr(sample.reading.gpuUtil, Number.NaN),
647
+ cpuUtil: finiteOr(sample.reading.cpuUtil, Number.NaN),
648
+ gpuTempC: sample.reading.gpuTempC,
649
+ cpuTempC: sample.reading.cpuTempC,
650
+ }
651
+ : UNAVAILABLE_METRICS;
652
+ return { metrics, memoryTopology: this.#host.topology };
653
+ }
654
+
655
+ /**
656
+ * Whether this machine still matches what `steward.json` says about it.
657
+ *
658
+ * The launch check runs against the process the SERVICE block just resolved —
659
+ * the same pid, from the same lookup, in the same snapshot — so the two can
660
+ * never describe different processes across a restart, and the port is not
661
+ * looked up twice. A stopped service (or one whose pid we could not resolve)
662
+ * has nothing to re-read and reports `unknown` rather than "clean": a server
663
+ * that is not running cannot be running the right flags. A probe that throws
664
+ * is a failed check, never a verdict — the whole point of this field is that
665
+ * Steward stops asserting facts it did not verify.
666
+ *
667
+ * Consent drift is config, not a reading: it is computed once and reported
668
+ * every snapshot unchanged.
669
+ */
670
+ async #readDrift(service: ServiceInfo): Promise<DriftState> {
671
+ const consent = this.#consentDrift;
672
+ const probe = this.#probeDrift;
673
+ if (probe === null) {
674
+ return {
675
+ launch: unknownLaunchDrift("no launch command was recorded for this machine"),
676
+ consent,
677
+ };
678
+ }
679
+ if (!service.running) {
680
+ return { launch: unknownLaunchDrift("the service is not running"), consent };
681
+ }
682
+ let launch: LaunchDrift;
683
+ try {
684
+ launch = await probe(service.pid);
685
+ } catch {
686
+ launch = unknownLaunchDrift("the launch command line could not be read");
687
+ }
688
+ return { launch, consent };
689
+ }
690
+
691
+ /**
692
+ * THROUGHPUT from the log: tokens the server generated, over the wall clock it
693
+ * had to generate them in.
694
+ *
695
+ * This is what the word means, and it is the only reading of it the log can
696
+ * support honestly. The alternative — the rate a request prints when it
697
+ * finishes — is a real measurement of that request, but it exists in the log
698
+ * for the 17 microseconds between `eval time` and `release`, and a dashboard
699
+ * sampling every 1.6 s never lands inside it. Reading it that way is what this
700
+ * replaced, and against a real 10-minute stretch of log at one request every
701
+ * 5 s it produced 372 readings: 0 tok/s when idle, a dash when busy, not one
702
+ * non-zero number, and 42 sparkline bars of 0 while the box generated 10,881
703
+ * tokens. Tokens do not have that problem. They are counted once, by the
704
+ * tracker, and they wait in its ledger until a sample closes over them.
705
+ *
706
+ * A sample closes no more than once per {@link THROUGHPUT_SAMPLE_MS} of
707
+ * snapshot time, so the span the strip covers is set by the clock rather than
708
+ * by how often — or from how many browsers — snapshots are taken (which is why
709
+ * the axis reports the span it measured rather than the nominal one).
710
+ *
711
+ * Every sample is a measurement, including the ones that read 0: a span in
712
+ * which the server generated nothing
713
+ * really did have a throughput of nothing. That is not the fabricated zero the
714
+ * dash exists to avoid — the dash is for a window we cannot vouch for, and it
715
+ * is what this returns until one has closed.
716
+ */
717
+ #sampleGenerated(now: number, activity: SlotActivity): ThroughputReading {
718
+ // Drained every snapshot, banked here: reading the ledger more often than
719
+ // samples close cannot lose tokens, because it is a count and not a rate.
720
+ this.#pendingTokens += activity.takeGeneratedTokens();
721
+ const elapsed = now - this.#lastSampleAt;
722
+ // Nothing here can be attributed to a span the strip is showing: either this
723
+ // is the first snapshot (the tail's backlog can hold hours of completed
724
+ // requests) or nobody has asked for one in longer than the window itself, so
725
+ // the banked tokens cover an unknown stretch. Piling them into the next bar
726
+ // would draw a spike that never happened, and keeping the old bars under an
727
+ // axis that says "the last two minutes" is the held-stale-value dishonesty
728
+ // this whole change set out to remove, relocated into a chart. Both are
729
+ // dropped and the accounting starts here.
730
+ if (this.#lastSampleAt === 0 || elapsed > THROUGHPUT_WINDOW_MS) {
731
+ this.#samples.length = 0;
732
+ this.#pendingTokens = 0;
733
+ this.#lastSampleAt = now;
734
+ return noThroughput();
735
+ }
736
+ if (elapsed >= THROUGHPUT_SAMPLE_MS) {
737
+ // The sample records the span it actually covered, not the nominal one, so
738
+ // its rate is true even when a snapshot arrived late.
739
+ this.#samples.push({ tokens: this.#pendingTokens, spanMs: elapsed });
740
+ this.#pendingTokens = 0;
741
+ this.#lastSampleAt = now;
742
+ while (this.#samples.length > THROUGHPUT_HISTORY_SIZE) this.#samples.shift();
743
+ }
744
+ return readThroughput(this.#samples);
745
+ }
746
+
747
+ /**
748
+ * THROUGHPUT with no log: llama.cpp's own rate gauge, sampled.
749
+ *
750
+ * The polling path cannot count tokens. `/metrics` does carry a token counter,
751
+ * but it is printed to five significant figures (`1.2757e+06` on a live
752
+ * server), so a difference across one sample is quantised to steps of a
753
+ * hundred tokens and a 90-token request is as likely to read 0 as 100. So this
754
+ * path samples the gauge exactly as it always did, and reports no window,
755
+ * which is how the tile knows not to claim one.
756
+ *
757
+ * A tick whose rate could not be measured contributes no sample at all.
758
+ * Pushing a `0` for it would draw a trough the server never had, and holding
759
+ * the previous bar would draw a plateau it never had either — so the series
760
+ * stays a series of measurements, and the clock is not advanced, so the very
761
+ * next snapshot that CAN measure takes the sample instead. A history whose
762
+ * newest sample has fallen out of the window it claims to span is dropped, and
763
+ * the strip renders empty — which is what "nothing was measured recently"
764
+ * looks like.
765
+ */
766
+ #sampleGauge(now: number, throughputTps: number | null): ThroughputReading {
767
+ // Checked before the append so it applies to a resumed poll loop as well as
768
+ // to an unmeasurable stretch: in both cases the retained bars are older than
769
+ // the window and describe a period the strip is no longer showing.
770
+ if (this.#gaugeHistory.length > 0 && now - this.#lastSampleAt > THROUGHPUT_WINDOW_MS) {
771
+ this.#gaugeHistory.length = 0;
772
+ }
773
+ if (throughputTps !== null && now - this.#lastSampleAt >= THROUGHPUT_SAMPLE_MS) {
774
+ this.#lastSampleAt = now;
775
+ this.#gaugeHistory.push(throughputTps);
776
+ while (this.#gaugeHistory.length > THROUGHPUT_HISTORY_SIZE) {
777
+ this.#gaugeHistory.shift();
778
+ }
779
+ }
780
+ // A copy, so a consumer cannot mutate the live buffer.
781
+ return { tps: throughputTps, history: [...this.#gaugeHistory], windowSeconds: null };
782
+ }
783
+
784
+ /**
785
+ * The console's backlog: real lines when a log source was discovered, else the
786
+ * fallback's simulated ones. Both come from the same buffer the live tail
787
+ * feeds, so replaying this and then subscribing loses nothing and repeats
788
+ * nothing.
789
+ */
790
+ recentLogs(limit: number): LogLine[] {
791
+ const lines =
792
+ this.#logTail !== null ? this.#logTail.recent(limit) : this.#fallback.recentLogs(limit);
793
+ return lines.map((line) => this.#stamp(line));
794
+ }
795
+
796
+ subscribeLogs(listener: (line: LogLine) => void): Unsubscribe {
797
+ this.#logListeners.add(listener);
798
+ return () => {
799
+ this.#logListeners.delete(listener);
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Opens a console against whichever source is live, atomically — and keeps it
805
+ * open across a change of source.
806
+ *
807
+ * The backlog is taken and the listener registered with no suspension point
808
+ * between them, which is the guarantee that costs nothing to keep and a
809
+ * dropped line to lose: the tailer and the mock both emit from a timer, so
810
+ * neither can land between these two statements. What the listener is
811
+ * registered ON is this source, not the thing currently feeding it — see
812
+ * {@link #logListeners} — so a tail that appears or moves later reaches this
813
+ * console without it being reopened.
814
+ */
815
+ attachLogs(listener: (line: LogLine) => void, limit: number): LogAttachment {
816
+ const backlog = this.recentLogs(limit);
817
+ this.#logListeners.add(listener);
818
+ return {
819
+ backlog,
820
+ unsubscribe: () => {
821
+ this.#logListeners.delete(listener);
822
+ },
823
+ };
824
+ }
825
+
826
+ /**
827
+ * Points the console fan-out at whatever is live now: the tail when there is
828
+ * one, the fallback's simulation when there is not. Exactly one upstream
829
+ * subscription is held, and the consoles never see the seam.
830
+ */
831
+ #feedConsole(): void {
832
+ this.#detachLogFeed?.();
833
+ const emit = (line: LogLine): void => {
834
+ const stamped = this.#stamp(line);
835
+ for (const listener of this.#logListeners) listener(stamped);
836
+ };
837
+ this.#detachLogFeed =
838
+ this.#logTail === null ? this.#fallback.subscribeLogs(emit) : this.#logTail.subscribe(emit);
839
+ }
840
+
841
+ /**
842
+ * Marks a line with the source it came from. The stamp is applied here, on
843
+ * the way out, rather than by the tailer: the tailer has no idea it is one of
844
+ * several, and the fallback's lines need the same mark for the swap in the
845
+ * other direction — a tail that goes away — to be legible too.
846
+ */
847
+ #stamp(line: LogLine): LogLine {
848
+ return { ...line, gen: this.#logGeneration };
849
+ }
850
+
851
+ /**
852
+ * Hands every open console the current source's recent history, so a swap
853
+ * re-populates them instead of leaving them empty.
854
+ *
855
+ * These lines carry the new generation, so the client replaces its buffer
856
+ * with them rather than appending — which is what makes this safe to send to
857
+ * a console that is already showing hundreds of lines from the old source.
858
+ */
859
+ #replayToConsoles(): void {
860
+ if (this.#logListeners.size === 0) return;
861
+ for (const line of this.recentLogs(CONSOLE_REPLAY_LINES)) {
862
+ for (const listener of this.#logListeners) listener(line);
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Whether the log console is looking at anything, and what went wrong when it
868
+ * is not.
869
+ *
870
+ * With no tailer configured this reports `unavailable` WHILE
871
+ * {@link recentLogs} and {@link subscribeLogs} keep serving the fallback's
872
+ * simulated lines — that combination is deliberate (the fallback behaviour is
873
+ * preserved byte for byte) and the console must render it honestly: it has
874
+ * lines, and they are not the server's. An empty console and a console that
875
+ * was never connected are different states, and only one of them is worth an
876
+ * operator's time.
877
+ */
878
+ logStatus(): LogStreamStatus {
879
+ if (this.#logTail !== null) return this.#logTail.status();
880
+ return {
881
+ source: "unavailable",
882
+ path: null,
883
+ detail: "no llama-server log file was discovered",
884
+ };
885
+ }
886
+
887
+ /**
888
+ * Runs the operator's declared command for `action`, or — with no controller
889
+ * configured — keeps delegating to the fallback exactly as before.
890
+ *
891
+ * Resolving means the command reported success, NOT that the service reached
892
+ * the requested state: the exit code is never treated as truth (a `KeepAlive`
893
+ * job relaunches itself after a clean stop, and a start returns long before
894
+ * the port is listening). The caller re-polls {@link snapshot}, whose
895
+ * `running` comes from `/props` reachability, to find out what actually
896
+ * happened. A failed command rejects with its readable detail so the operator
897
+ * sees "permission denied" rather than a silent no-op.
898
+ */
899
+ async setService(action: ServiceAction): Promise<void> {
900
+ const control = this.#control;
901
+ if (control === null) return this.#fallback.setService(action);
902
+ const result = await control.run(action);
903
+ if (!result.ok) throw new Error(result.detail ?? `${action} failed`);
904
+ }
905
+
906
+ /**
907
+ * Loads or unloads a model on the router. Resolves once the server accepts the
908
+ * request (`{"success":true}`), which is *before* a load has finished — the
909
+ * caller polls {@link snapshot} for the status to reach its terminal value.
910
+ * Rejects with a readable message on any non-2xx, surfacing a 404 for an
911
+ * unknown id, so the UI can notify the operator.
912
+ */
913
+ async setModel(modelId: string, action: ModelAction): Promise<void> {
914
+ const path = action === "load" ? "/models/load" : "/models/unload";
915
+ const controller = new AbortController();
916
+ this.#inFlight.add(controller);
917
+ try {
918
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)]);
919
+ const response = await this.#fetch(`${this.#connection.baseUrl}${path}`, {
920
+ method: "POST",
921
+ headers: { ...this.#authHeaders(), "Content-Type": "application/json" },
922
+ body: JSON.stringify({ model: modelId }),
923
+ signal,
924
+ });
925
+
926
+ if (!response.ok) {
927
+ throw new Error(`${action} failed: HTTP ${response.status}`);
928
+ }
929
+ const body = await response.json();
930
+ if (!(isRecord(body) && body.success === true)) {
931
+ throw new Error(`${action} failed: llama.cpp did not confirm`);
932
+ }
933
+ } finally {
934
+ this.#inFlight.delete(controller);
935
+ }
936
+ }
937
+
938
+ close(): void {
939
+ if (this.#closed) return;
940
+ this.#closed = true;
941
+ // Stopped first: a watcher that fired after this point would spawn a
942
+ // collector for a source that has already released everything it owns.
943
+ this.#stopRewire?.();
944
+ this.#stopRewire = null;
945
+ for (const controller of this.#inFlight) controller.abort();
946
+ this.#inFlight.clear();
947
+ this.#detachLogFeed?.();
948
+ this.#detachLogFeed = null;
949
+ this.#logListeners.clear();
950
+ this.#detachActivity?.();
951
+ this.#detachActivity = null;
952
+ this.#activity = null;
953
+ this.#release(this.#host?.provider);
954
+ this.#release(this.#logTail);
955
+ // Dropped as well as closed: a spent source holds nothing, and the parts it
956
+ // is handed afterwards are judged against what it has released rather than
957
+ // against what it happens to still be pointing at.
958
+ this.#host = null;
959
+ this.#logTail = null;
960
+ this.#fallback.close();
961
+ }
962
+
963
+ /** The bearer header, present only when the connection carries a key. */
964
+ #authHeaders(): Record<string, string> {
965
+ return this.#connection.apiKey === ""
966
+ ? {}
967
+ : { Authorization: `Bearer ${this.#connection.apiKey}` };
968
+ }
969
+
970
+ /**
971
+ * A guarded JSON GET: the parsed body, or `undefined` on any failure (non-2xx,
972
+ * timeout, refused connection, unreadable body). Callers turn `undefined` into
973
+ * a degraded section rather than a thrown error.
974
+ */
975
+ async #getJson(path: string): Promise<unknown | undefined> {
976
+ const controller = new AbortController();
977
+ this.#inFlight.add(controller);
978
+ try {
979
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)]);
980
+ const response = await this.#fetch(`${this.#connection.baseUrl}${path}`, {
981
+ headers: this.#authHeaders(),
982
+ signal,
983
+ });
984
+ if (!response.ok) return undefined;
985
+ return await response.json();
986
+ } catch {
987
+ return undefined;
988
+ } finally {
989
+ this.#inFlight.delete(controller);
990
+ }
991
+ }
992
+
993
+ /** A guarded text GET, for the Prometheus `/metrics` scrape. */
994
+ async #getText(path: string): Promise<string | undefined> {
995
+ const controller = new AbortController();
996
+ this.#inFlight.add(controller);
997
+ try {
998
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)]);
999
+ const response = await this.#fetch(`${this.#connection.baseUrl}${path}`, {
1000
+ headers: this.#authHeaders(),
1001
+ signal,
1002
+ });
1003
+ if (!response.ok) return undefined;
1004
+ return await response.text();
1005
+ } catch {
1006
+ return undefined;
1007
+ } finally {
1008
+ this.#inFlight.delete(controller);
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * Keeps the tracker attached to reality: children that have exited are
1014
+ * forgotten, and a change in the log source's own health is treated as a break
1015
+ * in the event stream.
1016
+ *
1017
+ * The health transition is the honest half of re-sync. When the file is
1018
+ * deleted (macOS unlinks a stale `/tmp` log daily) and later recreated, lines
1019
+ * were lost in between and there is no way to know which — so nothing is
1020
+ * carried across it. What that does NOT catch is the tailer re-anchoring on a
1021
+ * file that stayed readable throughout (a truncate, a same-second replace),
1022
+ * which is invisible from out here; the staleness bound inside the tracker is
1023
+ * what covers that case, and it resolves to `unknown` rather than guessing.
1024
+ *
1025
+ * `ports` is `null` when the `/models` read itself failed, and that case must
1026
+ * not be confused with "no models are loaded". Both parse to an empty map, but
1027
+ * one is news and the other is the absence of news: retaining against a failed
1028
+ * read would drop every port record on a single 4 s timeout against a busy
1029
+ * router, discarding all slot state and forcing a fresh `/slots` read for every
1030
+ * model — turning a flapping `/models` into exactly the per-snapshot polling
1031
+ * this replaced.
1032
+ */
1033
+ #syncActivity(ports: ReadonlyMap<number, string> | null): void {
1034
+ const activity = this.#activity;
1035
+ if (activity === null) return;
1036
+ const source = this.#logTail?.status().source ?? null;
1037
+ if (this.#lastLogSource !== null && source !== this.#lastLogSource) {
1038
+ activity.resync();
1039
+ // The throughput window goes with the tracker's state. Lines were lost, so
1040
+ // the tokens generated across the break were not counted and the samples
1041
+ // that straddle it understate a span they claim to measure. Resetting the
1042
+ // clock to 0 makes the next snapshot start the accounting over: the strip
1043
+ // empties and refills, which is what "we lost the stream" looks like.
1044
+ this.#samples.length = 0;
1045
+ this.#pendingTokens = 0;
1046
+ this.#lastSampleAt = 0;
1047
+ }
1048
+ this.#lastLogSource = source;
1049
+ if (ports === null) return;
1050
+ // A model that was unloaded takes its port's slot and task numbering with
1051
+ // it; a model reloaded on a fresh port must not inherit either.
1052
+ activity.retain(ports.keys());
1053
+ }
1054
+
1055
+ /**
1056
+ * The live `models` and flat `slots` for one snapshot — from the log when
1057
+ * there is one, and from the per-model endpoints when there is not.
1058
+ *
1059
+ * The branch is the whole point of this seam, and it is exclusive. With a log
1060
+ * source, occupancy is folded from events that the server writes anyway, and
1061
+ * the only HTTP a loaded model costs is a single `/slots` read when its child
1062
+ * first appears. With no log source there is no other way to know anything, so
1063
+ * the original per-snapshot `/slots` + `/metrics` polls run unchanged — a
1064
+ * Steward with no logging is a degraded Steward, and polling is what it has.
1065
+ */
1066
+ async #readModelsAndSlots(
1067
+ modelsRaw: unknown,
1068
+ ports: ReadonlyMap<number, string>,
1069
+ now: number,
1070
+ ): Promise<ModelsAndSlots> {
1071
+ const parsed = parseModels(modelsRaw);
1072
+ const activity = this.#activity;
1073
+ if (activity !== null) return this.#modelsFromEvents(parsed, ports, activity, now);
1074
+ return this.#modelsFromPolling(parsed);
1075
+ }
1076
+
1077
+ /**
1078
+ * SLOTS from the log.
1079
+ *
1080
+ * Structure and state come from different places on purpose. How many slots a
1081
+ * model has and how big each one's context is are fixed by its launch
1082
+ * arguments — `--parallel` and `--ctx-size`, both of which `/v1/models` states
1083
+ * for loaded and unloaded models alike, and which the router answers from its
1084
+ * own memory without proxying anything or writing a line. Occupancy is the
1085
+ * only part that changes request to request, and that is what the events
1086
+ * carry.
1087
+ *
1088
+ * A slot the events have said nothing about is `unknown`, not idle, and a
1089
+ * model whose child has no port we can join to (so no events can be
1090
+ * attributed) is every slot `unknown`. That is the state the seed exists to
1091
+ * clear, and the state a lost `release` decays back into rather than sticking
1092
+ * on `busy`.
1093
+ */
1094
+ async #modelsFromEvents(
1095
+ parsed: ModelInfo[],
1096
+ ports: ReadonlyMap<number, string>,
1097
+ activity: SlotActivity,
1098
+ now: number,
1099
+ ): Promise<ModelsAndSlots> {
1100
+ const portByModel = new Map<string, number>();
1101
+ for (const [port, id] of ports) portByModel.set(id, port);
1102
+
1103
+ // The one-shot seed: ONE `/slots` read per child process, taken when its
1104
+ // occupancy has never been established (it just loaded, or Steward just
1105
+ // started, or we lost track of it). `needsSeed` goes false the moment the
1106
+ // port is settled and stays false, and it is budget-capped, so a caller
1107
+ // asking on every snapshot still cannot turn this back into a poll.
1108
+ await Promise.all(
1109
+ parsed.map(async (model) => {
1110
+ if (model.status !== "resident") return;
1111
+ const port = portByModel.get(model.id);
1112
+ // `--parallel` is passed through so the tracker can tell "every lane I
1113
+ // know about is settled" from "three of this model's four lanes have
1114
+ // never been mentioned", which from inside it look identical.
1115
+ if (port === undefined || !activity.needsSeed(port, now, model.parallel)) return;
1116
+ const stamp = activity.beginSeed(port, now);
1117
+ const raw = await this.#getJson(`/slots?model=${encodeURIComponent(model.id)}`);
1118
+ // A read that failed spent one of the budget and nothing more: the slots
1119
+ // stay `unknown` and the next event establishes them.
1120
+ if (raw === undefined) return;
1121
+ activity.applySeed(
1122
+ port,
1123
+ stamp,
1124
+ parseSlots(raw, model.id).map((slot) => ({
1125
+ slot: slot.id,
1126
+ state: slot.state,
1127
+ promptTokens: slot.promptTokens,
1128
+ decoded: slot.decoded,
1129
+ })),
1130
+ now,
1131
+ );
1132
+ }),
1133
+ );
1134
+
1135
+ const models: ModelInfo[] = [];
1136
+ const slots: SlotInfo[] = [];
1137
+ let busyTotal = 0;
1138
+ /** Any slot, on any model, whose occupancy we cannot state at all. */
1139
+ let uncertain = false;
1140
+
1141
+ for (const model of parsed) {
1142
+ if (model.status !== "resident") {
1143
+ models.push(model);
1144
+ continue;
1145
+ }
1146
+ const port = portByModel.get(model.id);
1147
+ const tracked = port === undefined ? EMPTY_ACTIVITY : activity.resolve(port, now);
1148
+ // `--parallel` is the authority on how many lanes exist. Without it, the
1149
+ // lanes the log has actually mentioned are all we can honestly draw.
1150
+ const count = model.parallel ?? highestSlot(tracked);
1151
+
1152
+ // Every figure below is scoped to THIS model and rolled up afterwards.
1153
+ // They were once declared outside the loop, which quietly made them
1154
+ // dashboard-global: one model with one unresolvable lane then dashed the
1155
+ // rate and the request count for every other model on the box, including
1156
+ // ones that were perfectly well understood.
1157
+ let busy = 0;
1158
+ let rate = 0;
1159
+ let measured = false;
1160
+ let modelUncertain = false;
1161
+ for (let id = 0; id < count; id += 1) {
1162
+ const state = tracked.get(id);
1163
+ slots.push({
1164
+ id,
1165
+ modelId: model.id,
1166
+ promptTokens: state?.promptTokens ?? null,
1167
+ // Structural, from the launch args — the same per-slot figure the
1168
+ // model card shows, so the two can never disagree.
1169
+ ctxTotal: model.ctx,
1170
+ decoded: state?.decoded ?? null,
1171
+ state: state?.state ?? "unknown",
1172
+ });
1173
+ if (state === undefined || state.state === "unknown") {
1174
+ modelUncertain = true;
1175
+ continue;
1176
+ }
1177
+ if (state.state !== "processing") continue;
1178
+ busy += 1;
1179
+ // A lane generating with no rate reading yet leaves this model's card
1180
+ // dashed — llama.cpp prints no live rate until a generation crosses 100
1181
+ // tokens AND ~3 s, so most requests never have one while they run. It
1182
+ // does not touch the band's throughput, which is measured from completed
1183
+ // tokens rather than from whatever is legible mid-request.
1184
+ if (state.rateTps !== null) {
1185
+ rate += state.rateTps;
1186
+ measured = true;
1187
+ }
1188
+ }
1189
+
1190
+ busyTotal += busy;
1191
+ uncertain = uncertain || modelUncertain;
1192
+ models.push({
1193
+ ...model,
1194
+ // `active` is only ever claimed for a slot we watched take a task.
1195
+ status: busy > 0 ? "active" : "resident",
1196
+ // Structure comes from `--parallel` and nowhere else. `count` can fall
1197
+ // back to the highest lane the log happened to mention, which is a lower
1198
+ // bound inferred from traffic — fine for deciding how many rows to draw,
1199
+ // not something to state as the model's lane count.
1200
+ parallel: model.parallel,
1201
+ // A model's own rate is unaffected by what any other model is doing.
1202
+ tokensPerSecond: busy > 0 && measured ? rate : null,
1203
+ });
1204
+ }
1205
+
1206
+ return {
1207
+ models,
1208
+ slots,
1209
+ // Throughput is not a per-snapshot figure on this path. It is measured
1210
+ // from the tokens the log reports generated, over the wall clock they took
1211
+ // — accounting that spans snapshots and belongs to the source, not to one
1212
+ // read of the slot table.
1213
+ throughputTps: null,
1214
+ // A lower bound is not a count. With any slot unknown the honest answer is
1215
+ // that we do not know how many requests are in flight.
1216
+ requestsInFlight: uncertain ? null : busyTotal,
1217
+ // `requests_deferred` — requests accepted and waiting for a free slot —
1218
+ // has no log line at all. The events say when a slot is taken and given
1219
+ // back, never what is queued behind it, so there is nothing to derive and
1220
+ // nothing is invented: the tile reads n/a and says why.
1221
+ requestsQueued: null,
1222
+ };
1223
+ }
1224
+
1225
+ /**
1226
+ * SLOTS from the per-model endpoints — the path taken only when no log source
1227
+ * was discovered. Each loaded model gets its `/slots` and `/metrics` read
1228
+ * concurrently; a model with a busy slot is upgraded to `active` and given its
1229
+ * rate. A failure to read `/models` yields empty lists (an honest "nothing to
1230
+ * show", not the mock's invented models), and a per-model read that fails
1231
+ * drops only that model's slots and rate.
1232
+ */
1233
+ async #modelsFromPolling(parsed: ModelInfo[]): Promise<ModelsAndSlots> {
1234
+ const enriched = await Promise.all(
1235
+ parsed.map(async (model): Promise<EnrichedModel> => {
1236
+ // Only a resident model has slots to read; loading/downloading/unloaded
1237
+ // models have none yet, so we do not probe for them.
1238
+ if (model.status !== "resident") {
1239
+ return { model, slots: [], tps: 0, processing: 0, deferred: 0 };
1240
+ }
1241
+
1242
+ const [slotsRaw, metricsText] = await Promise.all([
1243
+ this.#getJson(`/slots?model=${encodeURIComponent(model.id)}`),
1244
+ this.#getText(`/metrics?model=${encodeURIComponent(model.id)}`),
1245
+ ]);
1246
+
1247
+ // A dropped per-model read leaves the model resident with no slots
1248
+ // rather than removing it from the list entirely.
1249
+ if (slotsRaw === undefined) {
1250
+ return { model, slots: [], tps: 0, processing: 0, deferred: 0 };
1251
+ }
1252
+
1253
+ const slots = parseSlots(slotsRaw, model.id);
1254
+ const busy = slots.some((slot) => slot.state === "processing");
1255
+ const metrics = metricsText === undefined ? null : parseMetrics(metricsText);
1256
+
1257
+ return {
1258
+ model: {
1259
+ ...model,
1260
+ status: busy ? "active" : "resident",
1261
+ parallel: slots.length,
1262
+ tokensPerSecond: busy ? (metrics?.tps ?? null) : null,
1263
+ },
1264
+ slots,
1265
+ // llama.cpp's rate gauge persists its last value after generation
1266
+ // ends, so a model only contributes to throughput while it is
1267
+ // actually processing — an idle model reads 0, not a stale average.
1268
+ tps: busy ? (metrics?.tps ?? 0) : 0,
1269
+ // Request gauges are live counts, taken as-is from every resident
1270
+ // model and summed for the band's requests tile.
1271
+ processing: metrics?.requestsProcessing ?? 0,
1272
+ deferred: metrics?.requestsDeferred ?? 0,
1273
+ };
1274
+ }),
1275
+ );
1276
+
1277
+ return {
1278
+ models: enriched.map((entry) => entry.model),
1279
+ slots: enriched.flatMap((entry) => entry.slots),
1280
+ throughputTps: enriched.reduce((sum, entry) => sum + entry.tps, 0),
1281
+ requestsInFlight: enriched.reduce((sum, entry) => sum + entry.processing, 0),
1282
+ requestsQueued: enriched.reduce((sum, entry) => sum + entry.deferred, 0),
1283
+ };
1284
+ }
1285
+
1286
+ /**
1287
+ * One guarded `/props` read, shared by CONFIG and SERVICE so the server is hit
1288
+ * once per snapshot. `status` is 0 when the server could not be reached at all
1289
+ * (refused, timeout, an aborted close) — distinct from an HTTP error it did
1290
+ * answer with. `body` is the parsed props only on a 2xx.
1291
+ */
1292
+ async #readProps(): Promise<PropsRead> {
1293
+ const controller = new AbortController();
1294
+ this.#inFlight.add(controller);
1295
+ try {
1296
+ // A dead server must not stall the metrics poll, so the read is bounded
1297
+ // by a timeout as well as by close().
1298
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)]);
1299
+ const response = await this.#fetch(`${this.#connection.baseUrl}/props`, {
1300
+ headers: this.#authHeaders(),
1301
+ signal,
1302
+ });
1303
+ const body = response.ok ? await response.json() : null;
1304
+ return { status: response.status, body };
1305
+ } catch {
1306
+ return { status: 0, body: null };
1307
+ } finally {
1308
+ this.#inFlight.delete(controller);
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * The live CONFIG rows, or a degraded block that always keeps the address in
1314
+ * view. Never throws: every failure mode maps to an honest status row.
1315
+ */
1316
+ #configFromProps(read: PropsRead): ConfigEntry[] {
1317
+ const address = listenAddress(this.#connection.baseUrl);
1318
+ if (read.status === 401) {
1319
+ return [
1320
+ { key: "status", value: "API key required — run /login llama.cpp" },
1321
+ { key: "address", value: address },
1322
+ ];
1323
+ }
1324
+ if (read.status === 0) {
1325
+ // Refused, timed out, or an aborted close — we could not reach it.
1326
+ return [
1327
+ { key: "status", value: "llama.cpp not reachable" },
1328
+ { key: "address", value: address },
1329
+ ];
1330
+ }
1331
+ if (read.body === null) {
1332
+ return [
1333
+ { key: "status", value: `llama.cpp error (HTTP ${read.status})` },
1334
+ { key: "address", value: address },
1335
+ ];
1336
+ }
1337
+ return parseRouterConfig(read.body, this.#connection.baseUrl);
1338
+ }
1339
+
1340
+ /**
1341
+ * The real SERVICE panel. The service is started or it is stopped, and the
1342
+ * honest test for that is whether a process holds the port — not whether it
1343
+ * answered an HTTP request just now.
1344
+ *
1345
+ * A 2xx to `/props` proves it is started. A failure does not prove the
1346
+ * opposite: a server still loading a model, or briefly wedged, refuses
1347
+ * connections while very much running. So a failed read falls through to the
1348
+ * process probe, and only a port with nothing on it reads stopped. Reporting
1349
+ * "stopped" for a server that is up would put a Start button in front of an
1350
+ * operator whose service is already running.
1351
+ *
1352
+ * pid and uptime have no HTTP source and come from the same probe.
1353
+ */
1354
+ async #serviceFromProps(read: PropsRead): Promise<ServiceInfo> {
1355
+ const { host, port } = splitHostPort(this.#connection.baseUrl);
1356
+ const answered = read.status >= 200 && read.status < 300;
1357
+ // What the operator may do is config, not a reading: it is the same list
1358
+ // whether the server answers or not, so a stopped service can still be
1359
+ // started.
1360
+ const controls = [...(this.#control?.actions ?? [])];
1361
+ if (!answered) {
1362
+ // Nothing answered — ask the OS whether anything is listening before
1363
+ // calling it stopped.
1364
+ const holding = this.#probeService === null ? null : await this.#safeProbe(host, port);
1365
+ return {
1366
+ running: holding !== null,
1367
+ startedAt: holding?.startedAt ?? null,
1368
+ pid: holding?.pid ?? null,
1369
+ host,
1370
+ port,
1371
+ build: "",
1372
+ controls,
1373
+ };
1374
+ }
1375
+ const build =
1376
+ isRecord(read.body) && typeof read.body.build_info === "string" ? read.body.build_info : "";
1377
+ const process = this.#probeService === null ? null : await this.#safeProbe(host, port);
1378
+ return {
1379
+ running: true,
1380
+ startedAt: process?.startedAt ?? null,
1381
+ pid: process?.pid ?? null,
1382
+ host,
1383
+ port,
1384
+ build,
1385
+ controls,
1386
+ };
1387
+ }
1388
+
1389
+ /** The injected probe, guarded: a probe that throws yields no pid or uptime. */
1390
+ async #safeProbe(host: string, port: number): Promise<ServiceProcess | null> {
1391
+ if (this.#probeService === null) return null;
1392
+ try {
1393
+ return await this.#probeService(host, port);
1394
+ } catch {
1395
+ return null;
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ /** The outcome of one `/props` read: `status` 0 means the server was unreachable. */
1401
+ interface PropsRead {
1402
+ status: number;
1403
+ body: unknown | null;
1404
+ }
1405
+
1406
+ /** The MODELS, SLOTS and band figures one snapshot resolved to. */
1407
+ interface ModelsAndSlots {
1408
+ models: ModelInfo[];
1409
+ slots: SlotInfo[];
1410
+ /**
1411
+ * The aggregate rate llama.cpp's `/metrics` gauges reported — the POLLING path
1412
+ * only. The event path answers `null` here: it measures throughput from the
1413
+ * tokens the log says were generated, over the wall clock they took, and that
1414
+ * accounting spans snapshots rather than living inside one (see
1415
+ * {@link LlamaSource.snapshot}).
1416
+ */
1417
+ throughputTps: number | null;
1418
+ requestsInFlight: number | null;
1419
+ requestsQueued: number | null;
1420
+ }
1421
+
1422
+ /** One closed throughput sample: what was generated in it, and how long it ran. */
1423
+ interface ThroughputSample {
1424
+ tokens: number;
1425
+ spanMs: number;
1426
+ }
1427
+
1428
+ /** The throughput figures one snapshot reports: the tile, the strip, the span. */
1429
+ interface ThroughputReading {
1430
+ tps: number | null;
1431
+ history: number[];
1432
+ windowSeconds: number | null;
1433
+ }
1434
+
1435
+ /** No window has been measured — the tile dashes and the strip is empty. */
1436
+ function noThroughput(): ThroughputReading {
1437
+ return { tps: null, history: [], windowSeconds: null };
1438
+ }
1439
+
1440
+ /**
1441
+ * The tile and the strip for a run of closed samples.
1442
+ *
1443
+ * The tile is the whole window's tokens over the whole window's wall clock —
1444
+ * literally the throughput of the span the strip is showing — and each bar is
1445
+ * its own sample's tokens over its own sample's span. A sample is never assumed
1446
+ * to be nominal length: the snapshot clock is the browser's, so a sample closes
1447
+ * on the first snapshot past the cadence and is a little longer than it, and
1448
+ * dividing by the nominal figure would report a rate the server never reached.
1449
+ */
1450
+ function readThroughput(samples: readonly ThroughputSample[]): ThroughputReading {
1451
+ if (samples.length === 0) return noThroughput();
1452
+ let tokens = 0;
1453
+ let spanMs = 0;
1454
+ const history: number[] = [];
1455
+ for (const sample of samples) {
1456
+ tokens += sample.tokens;
1457
+ spanMs += sample.spanMs;
1458
+ history.push(sample.tokens / (sample.spanMs / 1000));
1459
+ }
1460
+ return { tps: tokens / (spanMs / 1000), history, windowSeconds: spanMs / 1000 };
1461
+ }
1462
+
1463
+ /** Shared empty result for a model whose child port we could not resolve. */
1464
+ const EMPTY_ACTIVITY: ReadonlyMap<number, SlotActivityState> = new Map();
1465
+
1466
+ /**
1467
+ * How many lanes to draw for a model whose `--parallel` is not stated: as many
1468
+ * as the log has actually mentioned. Drawing none would hide a model that is
1469
+ * visibly working; drawing a guess would invent lanes that may not exist.
1470
+ */
1471
+ function highestSlot(tracked: ReadonlyMap<number, SlotActivityState>): number {
1472
+ let highest = -1;
1473
+ for (const id of tracked.keys()) highest = Math.max(highest, id);
1474
+ return highest + 1;
1475
+ }
1476
+
1477
+ /** One model after its live `/slots` and `/metrics` reads, before aggregation. */
1478
+ interface EnrichedModel {
1479
+ model: ModelInfo;
1480
+ slots: SlotInfo[];
1481
+ /** This model's throughput contribution (0 unless a slot is processing). */
1482
+ tps: number;
1483
+ /** Requests this model's instance is processing. */
1484
+ processing: number;
1485
+ /** Requests this model's instance has deferred. */
1486
+ deferred: number;
1487
+ }
1488
+
1489
+ /** Splits the connection's base URL into a host and a numeric port. */
1490
+ function splitHostPort(baseUrl: string): { host: string; port: number } {
1491
+ try {
1492
+ const url = new URL(baseUrl);
1493
+ const port = url.port !== "" ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
1494
+ return { host: url.hostname, port };
1495
+ } catch {
1496
+ return { host: baseUrl, port: 0 };
1497
+ }
1498
+ }
1499
+
1500
+ /** True for a non-null object we can read string-keyed fields off. */
1501
+ function isRecord(value: unknown): value is Record<string, unknown> {
1502
+ return typeof value === "object" && value !== null;
1503
+ }
1504
+
1505
+ /** The reading if it is a real value, else the given fallback (`NaN` for a no-reading gauge). */
1506
+ function finiteOr(value: number | null, fallback: number): number {
1507
+ return value === null ? fallback : value;
1508
+ }
1509
+
1510
+ /**
1511
+ * The HOST sensors when the collector has no fresh sample: every figure is a
1512
+ * non-reading, so Phase 1's gauges dash/hatch rather than plot a fabricated 0.
1513
+ */
1514
+ const UNAVAILABLE_METRICS: HostMetrics = {
1515
+ vramUsedGB: Number.NaN,
1516
+ vramTotalGB: Number.NaN,
1517
+ ramUsedGB: Number.NaN,
1518
+ ramTotalGB: Number.NaN,
1519
+ gpuUtil: Number.NaN,
1520
+ cpuUtil: Number.NaN,
1521
+ gpuTempC: null,
1522
+ cpuTempC: null,
1523
+ };