@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/core/format.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation formatters.
|
|
3
|
+
*
|
|
4
|
+
* Everything the dashboard renders as text passes through here, so the strings
|
|
5
|
+
* live in one place and can be asserted on without a DOM. Keep this module free
|
|
6
|
+
* of Node and DOM APIs — see `./types.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TemperatureUnit } from "./temperature.js";
|
|
10
|
+
import { celsiusToFahrenheit } from "./temperature.js";
|
|
11
|
+
import type { ModelInfo } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** Lowest temperature the gauges plot. Below this the bar reads empty. */
|
|
14
|
+
export const TEMP_SCALE_MIN_C = 30;
|
|
15
|
+
|
|
16
|
+
/** Highest temperature the gauges plot. At or above this the bar reads full. */
|
|
17
|
+
export const TEMP_SCALE_MAX_C = 95;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Above this a temperature is amber.
|
|
21
|
+
*
|
|
22
|
+
* Every threshold in this module is Celsius, and every reading is compared
|
|
23
|
+
* against it in Celsius — the display unit is applied by
|
|
24
|
+
* {@link formatTemperature} alone. Converting before comparing would read a
|
|
25
|
+
* 79 °C warning as 174 against a 75 threshold and paint every gauge critical.
|
|
26
|
+
*/
|
|
27
|
+
export const TEMP_WARNING_C = 75;
|
|
28
|
+
|
|
29
|
+
/** Above this a temperature is red. Celsius, for the same reason. */
|
|
30
|
+
export const TEMP_ERROR_C = 85;
|
|
31
|
+
|
|
32
|
+
function clamp01(value: number): number {
|
|
33
|
+
if (!Number.isFinite(value)) return 0;
|
|
34
|
+
if (value < 0) return 0;
|
|
35
|
+
if (value > 1) return 1;
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pad(value: number, width: number): string {
|
|
40
|
+
return String(value).padStart(width, "0");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Uptime as `3h 34m`. Minutes are zero-padded so the value does not change
|
|
45
|
+
* width every ten minutes and jitter the rail.
|
|
46
|
+
*/
|
|
47
|
+
export function formatUptime(elapsedMs: number): string {
|
|
48
|
+
const seconds = Math.max(0, Math.floor((Number.isFinite(elapsedMs) ? elapsedMs : 0) / 1000));
|
|
49
|
+
const hours = Math.floor(seconds / 3600);
|
|
50
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
51
|
+
return `${hours}h ${pad(minutes, 2)}m`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Log timestamp as `HH:MM:SS.mmm` in the operator's local time — the same shape
|
|
56
|
+
* `llama-server` prints, so lines copied out of the console still line up with
|
|
57
|
+
* the raw log.
|
|
58
|
+
*/
|
|
59
|
+
export function formatClock(timestampMs: number): string {
|
|
60
|
+
const d = new Date(timestampMs);
|
|
61
|
+
return `${pad(d.getHours(), 2)}:${pad(d.getMinutes(), 2)}:${pad(d.getSeconds(), 2)}.${pad(
|
|
62
|
+
d.getMilliseconds(),
|
|
63
|
+
3,
|
|
64
|
+
)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The same clock without milliseconds, for prose that names a moment rather
|
|
69
|
+
* than identifying a line — `nothing new since 14:19:02`. The millisecond field
|
|
70
|
+
* is what makes two adjacent lines distinguishable; in a sentence it is noise.
|
|
71
|
+
*/
|
|
72
|
+
export function formatClockSeconds(timestampMs: number): string {
|
|
73
|
+
const d = new Date(timestampMs);
|
|
74
|
+
return `${pad(d.getHours(), 2)}:${pad(d.getMinutes(), 2)}:${pad(d.getSeconds(), 2)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A line count with thousands separators — `1,203`. Log counts run into the
|
|
79
|
+
* thousands within minutes, and an unseparated `1203` beside a `209` is a
|
|
80
|
+
* comparison the operator has to make character by character.
|
|
81
|
+
*/
|
|
82
|
+
export function formatCount(value: number): string {
|
|
83
|
+
const whole = Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
84
|
+
return whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `1 line` / `240 lines`, grouped. The singular is worth the branch. */
|
|
88
|
+
export function formatLines(value: number): string {
|
|
89
|
+
return `${formatCount(value)} ${value === 1 ? "line" : "lines"}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A 0–1 fraction as a whole-percent label, e.g. `78%`. */
|
|
93
|
+
export function formatPercent(fraction: number): string {
|
|
94
|
+
return `${Math.round(Number.isFinite(fraction) ? fraction * 100 : 0)}%`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A 0–1 fraction as a bar width in percent, clamped so overshoot cannot bleed. */
|
|
98
|
+
export function barPercent(fraction: number): number {
|
|
99
|
+
return Math.round(clamp01(fraction) * 100);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Memory gauges read `29.8 / 48 GB`; VRAM wants a decimal, RAM does not. */
|
|
103
|
+
export function formatMemory(usedGB: number, totalGB: number, decimals: number): string {
|
|
104
|
+
return `${usedGB.toFixed(decimals)} / ${totalGB} GB`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Temperature label, e.g. `64°C` or `148°F` — the ONE place a reading changes
|
|
109
|
+
* unit.
|
|
110
|
+
*
|
|
111
|
+
* The unit arrives as a parameter and is never sniffed for: this module has no
|
|
112
|
+
* business knowing what locale a browser is in, and the server that also imports
|
|
113
|
+
* it could not answer anyway. Both units round to a whole number, and Fahrenheit
|
|
114
|
+
* rounds after converting, so `64.4 °C` reads `148°F` (147.92) rather than the
|
|
115
|
+
* `147°F` a pre-rounded 64 would give.
|
|
116
|
+
*/
|
|
117
|
+
export function formatTemperature(celsius: number, unit: TemperatureUnit): string {
|
|
118
|
+
return unit === "fahrenheit"
|
|
119
|
+
? `${Math.round(celsiusToFahrenheit(celsius))}°F`
|
|
120
|
+
: `${Math.round(celsius)}°C`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Threshold color for a temperature gauge. Compares in Celsius, always. */
|
|
124
|
+
export function temperatureColor(celsius: number): string {
|
|
125
|
+
if (celsius > TEMP_ERROR_C) return "var(--error)";
|
|
126
|
+
if (celsius > TEMP_WARNING_C) return "var(--warning)";
|
|
127
|
+
return "var(--success)";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Temperatures never sit near zero, so the bar plots the 30–95 °C band rather
|
|
132
|
+
* than 0–100: at 0–100 every reading would hug the middle and say nothing.
|
|
133
|
+
*
|
|
134
|
+
* The band is Celsius whatever the label reads — the bar is a position in the
|
|
135
|
+
* thermal range, and that range does not move because the text beside it does.
|
|
136
|
+
*/
|
|
137
|
+
export function temperatureBarPercent(celsius: number): number {
|
|
138
|
+
return barPercent((celsius - TEMP_SCALE_MIN_C) / (TEMP_SCALE_MAX_C - TEMP_SCALE_MIN_C));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A size in GB with up to two decimals, trailing zeros trimmed: `18.4`, `0.42`,
|
|
143
|
+
* `0.5`. Two places keep sub-gigabyte models legible (`0.42`, not `0.4`) without
|
|
144
|
+
* making large ones noisy (`18.4`, not `18.40`).
|
|
145
|
+
*/
|
|
146
|
+
export function formatSizeGB(sizeGB: number): string {
|
|
147
|
+
return String(Number(sizeGB.toFixed(2)));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A token count for a tight label, keyed off binary thousands so that power-of-
|
|
152
|
+
* two context windows read as clean round values: `40960` → `40k`, `65536` →
|
|
153
|
+
* `64k`. Counts below 1024 print whole; larger ones show one decimal, trimmed
|
|
154
|
+
* when it is zero. A value that is not a number reads as `0`.
|
|
155
|
+
*/
|
|
156
|
+
export function formatTokenCount(value: number): string {
|
|
157
|
+
if (!Number.isFinite(value) || value <= 0) return "0";
|
|
158
|
+
if (value < 1024) return String(Math.round(value));
|
|
159
|
+
const thousands = Math.round((value / 1024) * 10) / 10;
|
|
160
|
+
return `${Number.isInteger(thousands) ? thousands : thousands.toFixed(1)}k`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A generation-rate label, e.g. `63 t/s`, or an em dash when there is none. */
|
|
164
|
+
export function formatTps(tokensPerSecond: number | null): string {
|
|
165
|
+
return tokensPerSecond !== null && Number.isFinite(tokensPerSecond)
|
|
166
|
+
? `${Math.round(tokensPerSecond)} t/s`
|
|
167
|
+
: "—";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Above this share of a slot's context the headroom reading turns amber. */
|
|
171
|
+
export const CONTEXT_WARNING_PCT = 85;
|
|
172
|
+
|
|
173
|
+
/** At or above this share the reading turns red — a lane about to overflow. */
|
|
174
|
+
export const CONTEXT_ERROR_PCT = 98;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Threshold color for a context-headroom reading (0–100). The number is always
|
|
178
|
+
* printed alongside it, so the color is a second cue, never the only one.
|
|
179
|
+
*/
|
|
180
|
+
export function contextHeadroomColor(percent: number): string {
|
|
181
|
+
if (percent >= CONTEXT_ERROR_PCT) return "var(--error)";
|
|
182
|
+
if (percent > CONTEXT_WARNING_PCT) return "var(--warning)";
|
|
183
|
+
return "var(--text-tertiary)";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The bit-depth a quantisation code implies, as `4-bit`, `16-bit`, … — the first
|
|
188
|
+
* run of digits in the code (`Q4_0`→`4-bit`, `Q5_K_M`→`5-bit`, `F16`→`16-bit`,
|
|
189
|
+
* `q8_0`→`8-bit`). It is the human reading that leads a card's identity line and
|
|
190
|
+
* labels its KV-cache; the exact code stays beside it. Empty when the code holds
|
|
191
|
+
* no digits (or is itself empty), so the caller drops the token rather than
|
|
192
|
+
* printing a bare `-bit`.
|
|
193
|
+
*/
|
|
194
|
+
export function bitsFromCode(code: string): string {
|
|
195
|
+
const digits = code.match(/\d+/);
|
|
196
|
+
return digits === null ? "" : `${digits[0]}-bit`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The KV-cache bit-depth label, `8-bit` or `8-bit / 5-bit`: each side run through
|
|
201
|
+
* {@link bitsFromCode}, collapsed to one token when K and V match (the common
|
|
202
|
+
* case) and kept split only when they genuinely differ. A side whose code has no
|
|
203
|
+
* digits to convert falls back to the raw code, so the label is never blank.
|
|
204
|
+
*/
|
|
205
|
+
export function formatKvBits(kvCache: string): string {
|
|
206
|
+
const slash = kvCache.indexOf("/");
|
|
207
|
+
const kCode = slash === -1 ? kvCache : kvCache.slice(0, slash);
|
|
208
|
+
const vCode = slash === -1 ? kvCache : kvCache.slice(slash + 1);
|
|
209
|
+
const k = bitsFromCode(kCode) || kCode;
|
|
210
|
+
const v = bitsFromCode(vCode) || vCode;
|
|
211
|
+
return k === v ? k : `${k} / ${v}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The value a labeled card field carries when the fact behind it is not
|
|
216
|
+
* confirmed. A field's fact is trusted only while a process is running for the
|
|
217
|
+
* model (see the `confirmed` flag the selectors pass): a filename is a guess and
|
|
218
|
+
* a preset's launch args are intent, neither true until the model loads. Every
|
|
219
|
+
* field but `Type` collapses to this token when unconfirmed, so every unloaded
|
|
220
|
+
* card reads the same rather than differing by how much each has configured.
|
|
221
|
+
*/
|
|
222
|
+
export const NA = "n/a";
|
|
223
|
+
|
|
224
|
+
/** `on`/`off`/`auto` shown title-cased; a lookup keeps the index access honest. */
|
|
225
|
+
const FLASH_LABELS: Record<ModelInfo["flashAttn"], string> = {
|
|
226
|
+
on: "On",
|
|
227
|
+
off: "Off",
|
|
228
|
+
auto: "Auto",
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The `Quant` field: `4-bit (Q4_0)` — the bit-depth reading leads, the raw code
|
|
233
|
+
* rides beside it. {@link NA} when unconfirmed, or when the code carries no
|
|
234
|
+
* digits to read a depth from (a bare code is the noise this field translates).
|
|
235
|
+
*/
|
|
236
|
+
export function formatQuantField(quant: string, confirmed: boolean): string {
|
|
237
|
+
if (!confirmed) return NA;
|
|
238
|
+
const bits = bitsFromCode(quant);
|
|
239
|
+
return bits === "" ? NA : `${bits} (${quant})`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The `Size` field: `0.42 GB`. {@link NA} until loaded (no `meta`, no bytes). */
|
|
243
|
+
export function formatSizeField(sizeGB: number | null, confirmed: boolean): string {
|
|
244
|
+
return confirmed && sizeGB !== null ? `${formatSizeGB(sizeGB)} GB` : NA;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The `Context` field: `40k / slot` — the per-slot window each request is handed.
|
|
249
|
+
* Per-slot only, with no native-window ceiling: the trained max is a separate
|
|
250
|
+
* fact the labeled grid does not carry. {@link NA} until loaded.
|
|
251
|
+
*/
|
|
252
|
+
export function formatContextField(ctx: number | null, confirmed: boolean): string {
|
|
253
|
+
return confirmed && ctx !== null ? `${formatTokenCount(ctx)} / slot` : NA;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The `GPU Layers` field: the requested `--n-gpu-layers` as a raw integer (`99`
|
|
258
|
+
* is llama.cpp's "all layers" sentinel, shown as-is). {@link NA} when the count
|
|
259
|
+
* is `null` — including a loaded model whose layers were never pinned, because
|
|
260
|
+
* the effective count is never reported back, so `n/a` is the honest reading.
|
|
261
|
+
*/
|
|
262
|
+
export function formatGpuLayersField(gpuLayers: number | null, confirmed: boolean): string {
|
|
263
|
+
return confirmed && gpuLayers !== null ? String(gpuLayers) : NA;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The `Flash` field: `On`/`Off`/`Auto`. Can read `Auto` even while loaded — the
|
|
268
|
+
* server does not report which way `auto` resolved. {@link NA} until loaded.
|
|
269
|
+
*/
|
|
270
|
+
export function formatFlashField(flashAttn: ModelInfo["flashAttn"], confirmed: boolean): string {
|
|
271
|
+
return confirmed ? FLASH_LABELS[flashAttn] : NA;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The `KV Cache` field: `8-bit`, or `8-bit / 5-bit` when K and V differ — the
|
|
276
|
+
* per-side bit-depth via {@link formatKvBits}. {@link NA} until loaded.
|
|
277
|
+
*/
|
|
278
|
+
export function formatKvCacheField(kvCache: string, confirmed: boolean): string {
|
|
279
|
+
return confirmed ? formatKvBits(kvCache) : NA;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The `Type` field: `Generative` or `Embedder`. The one field that never reads
|
|
284
|
+
* {@link NA} — the router reports a model's modalities even while it is unloaded,
|
|
285
|
+
* so this is confirmed for every card.
|
|
286
|
+
*/
|
|
287
|
+
export function formatTypeField(embedding: boolean): string {
|
|
288
|
+
return embedding ? "Embedder" : "Generative";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One exported log line. Structurally satisfied by the console's view model. */
|
|
292
|
+
export interface LogTextRow {
|
|
293
|
+
time: string;
|
|
294
|
+
level: string;
|
|
295
|
+
model: string;
|
|
296
|
+
/**
|
|
297
|
+
* The pipe frame the row moved into its own column, or `""`. Written back in
|
|
298
|
+
* front of the message so the exported text is the file's own line, not a
|
|
299
|
+
* reassembly of the parts Steward chose to display.
|
|
300
|
+
*/
|
|
301
|
+
frameRaw: string;
|
|
302
|
+
message: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The copy/download payload: `HH:MM:SS.mmm LEVEL model message`, one line each.
|
|
307
|
+
* It mirrors what is on screen, filters included — the operator is quoting the
|
|
308
|
+
* console, not dumping the buffer.
|
|
309
|
+
*
|
|
310
|
+
* The message half is byte-exact against the source file: the frame the task
|
|
311
|
+
* column took out of the message goes back in front of it. That is the
|
|
312
|
+
* guarantee that makes moving the frame legitimate, so it is written here, in
|
|
313
|
+
* the one function both Copy and Download go through.
|
|
314
|
+
*/
|
|
315
|
+
export function formatLogText(rows: readonly LogTextRow[]): string {
|
|
316
|
+
return rows.map((r) => `${r.time} ${r.level} ${r.model} ${r.frameRaw}${r.message}`).join("\n");
|
|
317
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-metrics seam: the contract between Steward and whatever collector a
|
|
3
|
+
* machine runs to measure its own GPU/CPU/memory.
|
|
4
|
+
*
|
|
5
|
+
* A collector is an operator-declared command (see `steward.json`) that streams
|
|
6
|
+
* NDJSON to stdout, one reading per line, tagged with a schema so a stray
|
|
7
|
+
* process cannot be mistaken for one of ours. This module owns the wire schema
|
|
8
|
+
* and its validator, plus the injectable {@link HostMetricsProvider} the live
|
|
9
|
+
* source reads. The Node implementation that spawns the command and drains its
|
|
10
|
+
* stdout lives in `server/host-collector.ts`; the parser is here, Node-free, so
|
|
11
|
+
* both the browser type-check and unit tests can reach it — mirroring how
|
|
12
|
+
* {@link import("./llama-source.js").ServiceProbe} keeps its interface in `core/`
|
|
13
|
+
* and its Node body in `server/`.
|
|
14
|
+
*
|
|
15
|
+
* Keep this module free of Node and DOM APIs — see `./types.ts`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The schema tag every collector line must carry. A line without it — even one
|
|
20
|
+
* that is otherwise valid JSON — is not a Steward reading and is dropped, so a
|
|
21
|
+
* collector command that accidentally prints other JSON to stdout cannot inject
|
|
22
|
+
* garbage into the host band.
|
|
23
|
+
*/
|
|
24
|
+
export const HOST_METRICS_SCHEMA = "steward.hostmetrics/1";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One validated host reading. Every metric field is `number | null`: `null`
|
|
28
|
+
* means "this machine cannot measure it" (a dashed/hatched gauge), never a real
|
|
29
|
+
* zero. `ts` is the producer's own clock, retained for diagnostics; staleness is
|
|
30
|
+
* judged on arrival wall-clock (see {@link HostSample.receivedAt}), not on this.
|
|
31
|
+
*
|
|
32
|
+
* Field names mirror {@link import("./types.js").HostMetrics} so the overlay is a
|
|
33
|
+
* straight copy. A `unified`-memory machine simply omits the VRAM fields (they
|
|
34
|
+
* arrive `null`); it exposes no readable VRAM total and one is never synthesised.
|
|
35
|
+
*/
|
|
36
|
+
export interface HostReading {
|
|
37
|
+
/** Producer timestamp, epoch ms. Required — a line missing it is malformed. */
|
|
38
|
+
ts: number;
|
|
39
|
+
/** GPU utilisation, 0–1. */
|
|
40
|
+
gpuUtil: number | null;
|
|
41
|
+
gpuTempC: number | null;
|
|
42
|
+
/** CPU utilisation, 0–1. */
|
|
43
|
+
cpuUtil: number | null;
|
|
44
|
+
cpuTempC: number | null;
|
|
45
|
+
ramUsedGB: number | null;
|
|
46
|
+
ramTotalGB: number | null;
|
|
47
|
+
vramUsedGB: number | null;
|
|
48
|
+
vramTotalGB: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The most recent validated reading, plus the wall-clock at which it arrived. */
|
|
52
|
+
export interface HostSample {
|
|
53
|
+
reading: HostReading;
|
|
54
|
+
/** Arrival wall-clock, epoch ms — the clock staleness is measured against. */
|
|
55
|
+
receivedAt: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The live source's view of the host collector: the latest sample (or `null`
|
|
60
|
+
* before the first one lands, or after the collector has been given up on), and
|
|
61
|
+
* a way to release the underlying process. Injected into the otherwise Node-free
|
|
62
|
+
* {@link import("./llama-source.js").LlamaSource}; the Node body is
|
|
63
|
+
* `createHostCollector`.
|
|
64
|
+
*/
|
|
65
|
+
export interface HostMetricsProvider {
|
|
66
|
+
/** The most recent validated reading, or `null` when there is none. */
|
|
67
|
+
latest(): HostSample | null;
|
|
68
|
+
/** Releases the collector process group. Safe to call more than once. */
|
|
69
|
+
close(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True only for a real, finite number — the same bar the gauges hold readings to. */
|
|
73
|
+
function finiteNumber(value: unknown): value is number {
|
|
74
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A finite reading, or `null` for anything else (missing, `null`, NaN, wrong type). */
|
|
78
|
+
function readingField(value: unknown): number | null {
|
|
79
|
+
return finiteNumber(value) ? value : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True for a non-null object we can read string-keyed fields off. */
|
|
83
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validates one NDJSON line against the host-metrics schema, returning the
|
|
89
|
+
* reading or `null` when the line is not one of ours.
|
|
90
|
+
*
|
|
91
|
+
* The contract (plan H2): the `schema` tag and a numeric `ts` are REQUIRED — a
|
|
92
|
+
* line that parses but is missing either is MALFORMED and dropped, never turned
|
|
93
|
+
* into an all-`null` sample that would read as "measured nothing". Each metric
|
|
94
|
+
* field, in contrast, is optional and independently `number | null`: absent,
|
|
95
|
+
* `null`, or a non-finite value all become `null` (a no-reading gauge), while a
|
|
96
|
+
* finite number rides through. Malformed JSON, a non-object, or an array all
|
|
97
|
+
* yield `null`. This function never throws.
|
|
98
|
+
*/
|
|
99
|
+
export function parseHostMetricsLine(line: string): HostReading | null {
|
|
100
|
+
let parsed: unknown;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(line);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (!isRecord(parsed)) return null;
|
|
107
|
+
if (parsed.schema !== HOST_METRICS_SCHEMA) return null;
|
|
108
|
+
if (!finiteNumber(parsed.ts)) return null;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ts: parsed.ts,
|
|
112
|
+
gpuUtil: readingField(parsed.gpuUtil),
|
|
113
|
+
gpuTempC: readingField(parsed.gpuTempC),
|
|
114
|
+
cpuUtil: readingField(parsed.cpuUtil),
|
|
115
|
+
cpuTempC: readingField(parsed.cpuTempC),
|
|
116
|
+
ramUsedGB: readingField(parsed.ramUsedGB),
|
|
117
|
+
ramTotalGB: readingField(parsed.ramTotalGB),
|
|
118
|
+
vramUsedGB: readingField(parsed.vramUsedGB),
|
|
119
|
+
vramTotalGB: readingField(parsed.vramTotalGB),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a `llama-server` `/props` body into the CONFIG rows the rail shows.
|
|
3
|
+
*
|
|
4
|
+
* `/props` arrives as `unknown` off the wire, so every field is validated before
|
|
5
|
+
* it is read — a missing or wrong-typed field renders as an em dash, never as
|
|
6
|
+
* `undefined` or `NaN`. Two server shapes are handled: the routed server Pi runs
|
|
7
|
+
* by default (`role: "router"`, with a model cap and an autoload flag), and a
|
|
8
|
+
* bare single-model server (`-m model.gguf`), which reports neither. Only the
|
|
9
|
+
* router shape is exercised against a live server; the single-model branch is
|
|
10
|
+
* built from llama.cpp's documented shape and unit-tested with a hand-authored
|
|
11
|
+
* fixture.
|
|
12
|
+
*
|
|
13
|
+
* This is a pure function so it can be tested directly against the captured real
|
|
14
|
+
* `/props` fixtures. Keep it free of Node and DOM APIs.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { listenAddress } from "./llama-connection.js";
|
|
18
|
+
import type { ConfigEntry } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** Shown wherever a value is absent or the wrong type. */
|
|
21
|
+
const MISSING = "—";
|
|
22
|
+
|
|
23
|
+
/** True for a non-null object we can read string-keyed fields off. */
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === "object" && value !== null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A non-empty string field, or `null` when absent/blank/wrong-typed. */
|
|
29
|
+
function readString(value: unknown): string | null {
|
|
30
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A finite-number field rendered as text, or the em dash when absent. */
|
|
34
|
+
function readCount(value: unknown): string {
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value) ? String(value) : MISSING;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A boolean flag as `on`/`off`, or the em dash when it is neither. */
|
|
39
|
+
function readToggle(value: unknown): string {
|
|
40
|
+
if (value === true) return "on";
|
|
41
|
+
if (value === false) return "off";
|
|
42
|
+
return MISSING;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The CONFIG rows for a `/props` body read from `baseUrl`. `listen` is derived
|
|
47
|
+
* from the connection, not the body, so it is present even when the body is
|
|
48
|
+
* empty. Router-only rows (`max models`, `autoload`) are emitted only in routed
|
|
49
|
+
* mode; a single-model server omits them rather than showing blanks.
|
|
50
|
+
*/
|
|
51
|
+
export function parseRouterConfig(props: unknown, baseUrl: string): ConfigEntry[] {
|
|
52
|
+
const record = isRecord(props) ? props : {};
|
|
53
|
+
const listen = listenAddress(baseUrl);
|
|
54
|
+
const build = readString(record.build_info);
|
|
55
|
+
const binary = build !== null ? `llama-server ${build}` : MISSING;
|
|
56
|
+
|
|
57
|
+
if (record.role === "router") {
|
|
58
|
+
return [
|
|
59
|
+
{ key: "mode", value: "routed" },
|
|
60
|
+
{ key: "engine", value: binary },
|
|
61
|
+
{ key: "address", value: listen },
|
|
62
|
+
{ key: "max models", value: readCount(record.max_instances) },
|
|
63
|
+
{ key: "autoload", value: readToggle(record.models_autoload) },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
{ key: "mode", value: "single model" },
|
|
69
|
+
{ key: "engine", value: binary },
|
|
70
|
+
{ key: "address", value: listen },
|
|
71
|
+
];
|
|
72
|
+
}
|