@jmcombs/pi-steward 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- package/ui/steward.css +2008 -0
|
@@ -0,0 +1,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
|
+
};
|