@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/select.ts
ADDED
|
@@ -0,0 +1,2923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a `Snapshot` plus the operator's `UiState` becomes something
|
|
3
|
+
* renderable.
|
|
4
|
+
*
|
|
5
|
+
* Every string, color, percentage and enabled/disabled decision on screen is
|
|
6
|
+
* derived here, which is what lets the `ui/` modules stay a dumb translation of
|
|
7
|
+
* view models into elements. Colors come out as CSS custom-property references
|
|
8
|
+
* because the design assigns them per model and per threshold — the stylesheet
|
|
9
|
+
* cannot know which. Keep this module free of Node and DOM APIs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ConsentDrift, LaunchDrift } from "./drift.js";
|
|
13
|
+
import {
|
|
14
|
+
barPercent,
|
|
15
|
+
contextHeadroomColor,
|
|
16
|
+
formatClock,
|
|
17
|
+
formatClockSeconds,
|
|
18
|
+
formatContextField,
|
|
19
|
+
formatCount,
|
|
20
|
+
formatFlashField,
|
|
21
|
+
formatGpuLayersField,
|
|
22
|
+
formatKvCacheField,
|
|
23
|
+
formatLines,
|
|
24
|
+
formatLogText,
|
|
25
|
+
formatMemory,
|
|
26
|
+
formatPercent,
|
|
27
|
+
formatQuantField,
|
|
28
|
+
formatSizeField,
|
|
29
|
+
formatTemperature,
|
|
30
|
+
formatTokenCount,
|
|
31
|
+
formatTps,
|
|
32
|
+
formatTypeField,
|
|
33
|
+
formatUptime,
|
|
34
|
+
NA,
|
|
35
|
+
temperatureBarPercent,
|
|
36
|
+
temperatureColor,
|
|
37
|
+
} from "./format.js";
|
|
38
|
+
import { modelColor } from "./model-color.js";
|
|
39
|
+
import type { FamilyFilter, LevelFilter, TraceRef, UiState } from "./state.js";
|
|
40
|
+
import { LOG_BUFFER_LIMIT, visibleBuffer } from "./state.js";
|
|
41
|
+
import type { TemperaturePreference, TemperatureUnit } from "./temperature.js";
|
|
42
|
+
import type {
|
|
43
|
+
ConfigEntry,
|
|
44
|
+
LogFamily,
|
|
45
|
+
LogKind,
|
|
46
|
+
LogLevel,
|
|
47
|
+
LogLine,
|
|
48
|
+
ModelAction,
|
|
49
|
+
ModelInfo,
|
|
50
|
+
ServiceAction,
|
|
51
|
+
SlotInfo,
|
|
52
|
+
SlotState,
|
|
53
|
+
Snapshot,
|
|
54
|
+
} from "./types.js";
|
|
55
|
+
import { THROUGHPUT_HISTORY_SIZE, THROUGHPUT_SAMPLE_SECONDS } from "./types.js";
|
|
56
|
+
|
|
57
|
+
// A model's color is a stable hash of its id (embedders get a reserved hue);
|
|
58
|
+
// re-exported here because it is part of this module's view-model surface.
|
|
59
|
+
export { modelColor } from "./model-color.js";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How many filtered lines reach the DOM.
|
|
63
|
+
*
|
|
64
|
+
* Set to the signal buffer's own size on purpose: with proxied requests hidden
|
|
65
|
+
* — the default — the matched set can never exceed it, so the cap does not bite
|
|
66
|
+
* and the console never posts a truncation banner while holding the whole
|
|
67
|
+
* buffer. It bites exactly when proxy lines are shown, which is the one time
|
|
68
|
+
* truncation is honest and expected.
|
|
69
|
+
*/
|
|
70
|
+
export const LOG_RENDER_LIMIT = 500;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How long a console with lines in it must go without a new matching line
|
|
74
|
+
* before it reports itself quiet.
|
|
75
|
+
*
|
|
76
|
+
* A guess, and knowingly so — nothing measured argues for 60 s over 30 s or
|
|
77
|
+
* 120 s. It only decides when a footer appears under content that is already
|
|
78
|
+
* fully readable, so being wrong costs an operator nothing.
|
|
79
|
+
*/
|
|
80
|
+
export const QUIET_AFTER_MS = 60_000;
|
|
81
|
+
|
|
82
|
+
/** Throughput tile: the bar reads full at this many tokens per second. */
|
|
83
|
+
const THROUGHPUT_FULL_SCALE = 120;
|
|
84
|
+
|
|
85
|
+
const LEVEL_COLORS: Record<LogLevel, string> = {
|
|
86
|
+
DEBUG: "var(--text-muted)",
|
|
87
|
+
INFO: "var(--info)",
|
|
88
|
+
WARN: "var(--warning)",
|
|
89
|
+
ERROR: "var(--error)",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const LEVEL_FILTERS: LevelFilter[] = ["all", "INFO", "WARN", "ERROR"];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The record-type chips. Four families and a reset, not the research's six:
|
|
96
|
+
* proxied requests are already a toggle whose default is additive suppression
|
|
97
|
+
* (which no member of a single-select set can express), and the launch-args
|
|
98
|
+
* block is already a fold that collapses 31 rows to 1 without leaving the
|
|
99
|
+
* scrollback.
|
|
100
|
+
*/
|
|
101
|
+
const FAMILY_FILTERS: FamilyFilter[] = ["any", "requests", "models", "startup", "other"];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The literal the context-lost banner puts in the search box.
|
|
105
|
+
*
|
|
106
|
+
* A search, deliberately, and not a fourth filter axis: the token is in the
|
|
107
|
+
* message text, the box visibly fills with it, the operator can edit or clear
|
|
108
|
+
* it, and the existing count grammar reports the result honestly. It also
|
|
109
|
+
* degrades perfectly — if llama.cpp renames the token, nothing matches, the
|
|
110
|
+
* count is 0 and the banner never appears.
|
|
111
|
+
*/
|
|
112
|
+
export const CONTEXT_LOST_QUERY = "truncated = 1";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How far apart two members of one trace may sit before they are two different
|
|
116
|
+
* requests that happen to share an id.
|
|
117
|
+
*
|
|
118
|
+
* The measured maximum gap INSIDE a real request is 7 buffer lines (p99 = 5),
|
|
119
|
+
* so 16 leaves better than a 2× margin over anything a genuine request has ever
|
|
120
|
+
* produced while still catching a child that died and respawned on the same
|
|
121
|
+
* ephemeral port a few seconds later — a reuse observed 20 lines apart, which a
|
|
122
|
+
* wider threshold swallows whole and reports as one request.
|
|
123
|
+
*
|
|
124
|
+
* It errs toward splitting, and that is the right direction: a split trace
|
|
125
|
+
* announces itself in the banner and shows the run that was clicked, while a
|
|
126
|
+
* merged one silently presents two operators' requests as one.
|
|
127
|
+
*/
|
|
128
|
+
const TRACE_SPLIT_GAP = 16;
|
|
129
|
+
|
|
130
|
+
/** Shown wherever a reading the source could not supply would otherwise print. */
|
|
131
|
+
const NO_READING = "—";
|
|
132
|
+
|
|
133
|
+
function tint(color: string, percent: number): string {
|
|
134
|
+
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A KPI tile's value. The mock rounds its counters; a live source reports
|
|
139
|
+
* whatever `llama-server` gave it, which can be fractional or missing, and
|
|
140
|
+
* neither `61.837` nor `NaN` is something an operator can read at a glance.
|
|
141
|
+
*/
|
|
142
|
+
function countLabel(value: number): string {
|
|
143
|
+
return Number.isFinite(value) ? String(Math.round(value)) : NO_READING;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A memory gauge's label, or a dash when either figure is not a reading. */
|
|
147
|
+
function memoryLabel(usedGB: number, totalGB: number, decimals: number): string {
|
|
148
|
+
return Number.isFinite(usedGB) && Number.isFinite(totalGB)
|
|
149
|
+
? formatMemory(usedGB, totalGB, decimals)
|
|
150
|
+
: NO_READING;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One button in the SERVICE block's control row. */
|
|
154
|
+
export interface ServiceControlVm {
|
|
155
|
+
action: ServiceAction;
|
|
156
|
+
/** `Restart`, or the optimistic verb (`Restarting…`) while it is in flight. */
|
|
157
|
+
label: string;
|
|
158
|
+
/** True while this is the action awaiting its POST. */
|
|
159
|
+
busy: boolean;
|
|
160
|
+
disabled: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Why the button is inert (`The service is already started.`), or `""` when
|
|
163
|
+
* it is not. Rendered as its title and folded into the accessible name, so
|
|
164
|
+
* the reason is never left to the greyed-out fill alone.
|
|
165
|
+
*/
|
|
166
|
+
disabledReason: string;
|
|
167
|
+
/** True for the disruptive actions: the click opens the confirm strip. */
|
|
168
|
+
confirms: boolean;
|
|
169
|
+
/** Danger-toned (stop, restart). Never the only signal — the verb says it. */
|
|
170
|
+
danger: boolean;
|
|
171
|
+
/** `Restart the llama.cpp service` — the label alone is ambiguous out of context. */
|
|
172
|
+
ariaLabel: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The inline confirm strip for a disruptive action. */
|
|
176
|
+
export interface ServiceConfirmVm {
|
|
177
|
+
action: ServiceAction;
|
|
178
|
+
/** `Restart unloads gpt-oss-20b and drops in-flight requests.` */
|
|
179
|
+
consequence: string;
|
|
180
|
+
/** The affirmative verb, e.g. `Restart`. */
|
|
181
|
+
confirmLabel: string;
|
|
182
|
+
confirmAriaLabel: string;
|
|
183
|
+
cancelLabel: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The drift notice — the one surface both drift producers write to.
|
|
188
|
+
*
|
|
189
|
+
* It exists only when something is actually wrong. A compliant machine renders
|
|
190
|
+
* NOTHING here: no "all good" badge, no reassurance. That is the point of the
|
|
191
|
+
* whole check — the dashboard's silence has to mean something, so it may never
|
|
192
|
+
* be spent on a machine Steward could not verify (a `unknown` launch check is
|
|
193
|
+
* silent too, and the operator is told nothing rather than told it is fine).
|
|
194
|
+
*/
|
|
195
|
+
export interface DriftNoticeVm {
|
|
196
|
+
/**
|
|
197
|
+
* Identity of this exact mismatch. A dismissal is bound to it, so dismissing
|
|
198
|
+
* "`--metrics` removed" cannot also hide "`--slots` removed" arriving later:
|
|
199
|
+
* the key changes and the notice comes back.
|
|
200
|
+
*/
|
|
201
|
+
key: string;
|
|
202
|
+
title: string;
|
|
203
|
+
/** One line per thing that no longer matches; never empty. */
|
|
204
|
+
messages: string[];
|
|
205
|
+
/** What to do about it, in words, naming the command that does it. */
|
|
206
|
+
fix: string;
|
|
207
|
+
dismissLabel: string;
|
|
208
|
+
/** Says out loud that dismissing does not make the mismatch go away. */
|
|
209
|
+
dismissAriaLabel: string;
|
|
210
|
+
/** The notice region's accessible name. */
|
|
211
|
+
ariaLabel: string;
|
|
212
|
+
/** The whole notice as one sentence, for the polite status region. */
|
|
213
|
+
announcement: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The affordance shown in place of controls when none are configured. */
|
|
217
|
+
export interface ServiceSetupVm {
|
|
218
|
+
label: string;
|
|
219
|
+
/** Names the skill that configures control — the only way to get buttons. */
|
|
220
|
+
detail: string;
|
|
221
|
+
command: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface ServiceControlsVm {
|
|
225
|
+
/** One per consented action, in start/stop/restart order. Empty = unconfigured. */
|
|
226
|
+
buttons: ServiceControlVm[];
|
|
227
|
+
/** The single setup affordance, present only when {@link buttons} is empty. */
|
|
228
|
+
setup: ServiceSetupVm | null;
|
|
229
|
+
/** The open confirm strip, or `null`. */
|
|
230
|
+
confirm: ServiceConfirmVm | null;
|
|
231
|
+
/** `Restart failed — launchctl: permission denied`, or `null`. */
|
|
232
|
+
notice: string | null;
|
|
233
|
+
/** True while any action is in flight: the whole row disables and reads busy. */
|
|
234
|
+
pending: boolean;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What the status chip reports.
|
|
239
|
+
*
|
|
240
|
+
* Three values, and each one gets its own SHAPE in the stylesheet — filled disc,
|
|
241
|
+
* ring, dotted ring — so the state survives a monochrome screen, a colour-blind
|
|
242
|
+
* reader and a printout. Hue is the third signal here, never the only one.
|
|
243
|
+
*
|
|
244
|
+
* `unknown` is deliberately neutral rather than red: a machine Steward has not
|
|
245
|
+
* been pointed at yet is the expected first state, not a failure.
|
|
246
|
+
*/
|
|
247
|
+
export type ServiceState = "up" | "down" | "unknown";
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The chip's word and dot colour per state, stated once.
|
|
251
|
+
*
|
|
252
|
+
* The colour reaches the DOT and stops there. The word is painted by the
|
|
253
|
+
* stylesheet with a text token, because `--success` (2.75:1) and `--error`
|
|
254
|
+
* (4.47:1) on the rail's panel ground both miss AA at the chip's 11.5px — and a
|
|
255
|
+
* state word that cannot be read is not a readout.
|
|
256
|
+
*/
|
|
257
|
+
export const SERVICE_STATE_PRESENTATION: Record<ServiceState, { label: string; dotColor: string }> =
|
|
258
|
+
{
|
|
259
|
+
up: { label: "started", dotColor: "var(--success)" },
|
|
260
|
+
down: { label: "stopped", dotColor: "var(--error)" },
|
|
261
|
+
unknown: { label: "not connected", dotColor: "var(--text-muted)" },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export interface ServiceVm {
|
|
265
|
+
/** Drives both the chip's word and its dot SHAPE. See {@link ServiceState}. */
|
|
266
|
+
state: ServiceState;
|
|
267
|
+
/** `started` / `stopped` / `not connected` — the chip's word, and the signal. */
|
|
268
|
+
statusLabel: string;
|
|
269
|
+
/**
|
|
270
|
+
* The dot's colour, and only the dot's. The word's colour is fixed in the
|
|
271
|
+
* stylesheet so it clears AA in both themes; see
|
|
272
|
+
* {@link SERVICE_STATE_PRESENTATION}.
|
|
273
|
+
*/
|
|
274
|
+
statusDotColor: string;
|
|
275
|
+
/** The theme control's current-state glyph: `◐` system, `☀` light, `☾` dark. */
|
|
276
|
+
themeGlyph: string;
|
|
277
|
+
themeLabel: string;
|
|
278
|
+
/** The start/stop/restart row, its confirm strip, and any failure notice. */
|
|
279
|
+
controls: ServiceControlsVm;
|
|
280
|
+
/**
|
|
281
|
+
* The config-drift notice, or `null` when there is nothing to report (or the
|
|
282
|
+
* operator dismissed this exact one). It lives in this block because this is
|
|
283
|
+
* where the router facts `steward.json` claims are rendered — the notice says
|
|
284
|
+
* those facts have stopped being true.
|
|
285
|
+
*/
|
|
286
|
+
drift: DriftNoticeVm | null;
|
|
287
|
+
/**
|
|
288
|
+
* The router facts (role, binary, listen, …) folded in from what was the
|
|
289
|
+
* separate CONFIG block. They render below the status as this block's third
|
|
290
|
+
* zone, sourced from `/props` so the listen address and build have one home.
|
|
291
|
+
*/
|
|
292
|
+
config: ConfigEntry[];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface GaugeVm {
|
|
296
|
+
key: string;
|
|
297
|
+
label: string;
|
|
298
|
+
value: string;
|
|
299
|
+
percent: number;
|
|
300
|
+
color: string;
|
|
301
|
+
/**
|
|
302
|
+
* How the bar's track is drawn, so an empty bar cannot be mistaken for a real
|
|
303
|
+
* reading. `solid` is a genuine figure (INCLUDING a real 0%); `hatched` means
|
|
304
|
+
* "no reading" — the value dashed to `—` because a memory figure was `null`
|
|
305
|
+
* or `NaN`; `dashed` is reserved for a future "last-seen" state and is not
|
|
306
|
+
* produced yet. The value token (`—`) is the primary signal; the track
|
|
307
|
+
* reinforces it, and is never color-only.
|
|
308
|
+
*/
|
|
309
|
+
track: "solid" | "hatched" | "dashed";
|
|
310
|
+
/**
|
|
311
|
+
* True for the rows built from a temperature reading — the only rows a unit
|
|
312
|
+
* choice relabels.
|
|
313
|
+
*
|
|
314
|
+
* It is a flag rather than a naming convention on {@link key} because the
|
|
315
|
+
* unit control renders only when at least one of these rows exists, and a
|
|
316
|
+
* control that provably changes nothing on screen is a label that is not
|
|
317
|
+
* true. Set by `tempGauge` and by nothing else, so the question "is there
|
|
318
|
+
* anything here to relabel?" cannot drift from the row set that answers it.
|
|
319
|
+
*/
|
|
320
|
+
temperature: boolean;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The temperature-unit control in the HOST block's head.
|
|
325
|
+
*
|
|
326
|
+
* A text button rather than the theme control's cycling glyph: this control's
|
|
327
|
+
* resolved value IS a two-character string, so the label can simply be it. It
|
|
328
|
+
* carries no `aria-live` — it is a control, not a status, and its own value
|
|
329
|
+
* cannot change without a press.
|
|
330
|
+
*/
|
|
331
|
+
export interface TemperatureControlVm {
|
|
332
|
+
/** `auto` · `°C` · `°F` — the PREFERENCE, not the resolved unit. */
|
|
333
|
+
label: string;
|
|
334
|
+
/** States the mode, what it resolves to, and what pressing it does. */
|
|
335
|
+
ariaLabel: string;
|
|
336
|
+
/** Same sentence as {@link ariaLabel}: a pointer operator gets it on hover. */
|
|
337
|
+
title: string;
|
|
338
|
+
/** The preference a press moves to. */
|
|
339
|
+
next: TemperaturePreference;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One labeled cell of a model card's body grid: `Quant: 4-bit (Q4_0)`. The label
|
|
344
|
+
* set and order are identical on every card; only the values change. `na` is set
|
|
345
|
+
* when the value is the {@link NA} token, so the UI can dim an unconfirmed field
|
|
346
|
+
* without re-parsing the string.
|
|
347
|
+
*/
|
|
348
|
+
export interface ModelFieldVm {
|
|
349
|
+
label: string;
|
|
350
|
+
value: string;
|
|
351
|
+
na: boolean;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface ModelCardVm {
|
|
355
|
+
id: string;
|
|
356
|
+
short: string;
|
|
357
|
+
/**
|
|
358
|
+
* The card body: the same seven labeled fields on every card, in a fixed order
|
|
359
|
+
* (`Type` last), each carrying its value or the `n/a` token when the fact is
|
|
360
|
+
* not confirmed. Only `Type` is ever confirmed while unloaded.
|
|
361
|
+
*/
|
|
362
|
+
fields: ModelFieldVm[];
|
|
363
|
+
color: string;
|
|
364
|
+
selected: boolean;
|
|
365
|
+
cardBackground: string;
|
|
366
|
+
cardBorder: string;
|
|
367
|
+
buttonAction: ModelAction;
|
|
368
|
+
buttonLabel: string;
|
|
369
|
+
buttonBackground: string;
|
|
370
|
+
buttonColor: string;
|
|
371
|
+
buttonBorder: string;
|
|
372
|
+
pending: boolean;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface PillVm {
|
|
376
|
+
label: string;
|
|
377
|
+
active: boolean;
|
|
378
|
+
background: string;
|
|
379
|
+
color: string;
|
|
380
|
+
borderColor: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface KpiVm {
|
|
384
|
+
key: string;
|
|
385
|
+
label: string;
|
|
386
|
+
value: string;
|
|
387
|
+
unit: string;
|
|
388
|
+
sub: string;
|
|
389
|
+
color: string;
|
|
390
|
+
percent: number;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface SparkBarVm {
|
|
394
|
+
height: number;
|
|
395
|
+
color: string;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export interface SparkVm {
|
|
399
|
+
bars: SparkBarVm[];
|
|
400
|
+
/** `avg 61 · peak 98 tok/s` */
|
|
401
|
+
summary: string;
|
|
402
|
+
/** Height of the dashed average rule, in percent of the plot area. */
|
|
403
|
+
averageLine: number;
|
|
404
|
+
/**
|
|
405
|
+
* The left end of the axis — how far back the oldest bar reaches, e.g.
|
|
406
|
+
* `−134 s`.
|
|
407
|
+
*
|
|
408
|
+
* Measured, not nominal. Samples close on the browser's snapshot clock, so a
|
|
409
|
+
* ~3 s cadence sampled every 1.6 s closes at ~3.2 s and a full 42-bar strip
|
|
410
|
+
* spans about 134 seconds. A fixed `−2 min` printed under it would be off by
|
|
411
|
+
* a measured 12%, on the one label whose whole job is to say which slice of
|
|
412
|
+
* time the operator is looking at.
|
|
413
|
+
*/
|
|
414
|
+
axisStart: string;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface LevelChipVm extends PillVm {
|
|
418
|
+
level: LevelFilter;
|
|
419
|
+
/**
|
|
420
|
+
* How many lines this chip would show — every OTHER filter applied and this
|
|
421
|
+
* one lifted. So the number is a promise, and an empty ERROR chip is visibly
|
|
422
|
+
* empty BEFORE it is pressed rather than after.
|
|
423
|
+
*
|
|
424
|
+
* A zero is never styled as reassurance: llama-server logs nothing at all for
|
|
425
|
+
* a rejected request and reports a failed model load at INFO, so `ERROR 0` is
|
|
426
|
+
* a fact about the file, not a health check.
|
|
427
|
+
*/
|
|
428
|
+
count: number;
|
|
429
|
+
countLabel: string;
|
|
430
|
+
/** `WARN — 2 lines`, so the count is not left to a bare numeral. */
|
|
431
|
+
ariaLabel: string;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** How a row's model column is attributed. */
|
|
435
|
+
export type LineScope = "model" | "router" | "unknown";
|
|
436
|
+
|
|
437
|
+
/** The collapsed launch-argument block, rendered as one expandable row. */
|
|
438
|
+
export interface FoldVm {
|
|
439
|
+
/** `seq` of the run's first line — the toggle key and the fold's identity. */
|
|
440
|
+
seq: number;
|
|
441
|
+
/** Lines in the run actually held, which is what the label counts. */
|
|
442
|
+
count: number;
|
|
443
|
+
/** Open right now, whether the operator asked or a query hit forced it. */
|
|
444
|
+
expanded: boolean;
|
|
445
|
+
/** The operator's own setting — what the fold returns to once a query clears. */
|
|
446
|
+
sticky: boolean;
|
|
447
|
+
/** The run reaches the front of the window, so older members may be gone. */
|
|
448
|
+
truncated: boolean;
|
|
449
|
+
/** Hits inside the fold for the active query; 0 when there is no query. */
|
|
450
|
+
matches: number;
|
|
451
|
+
/** True when a query hit forced it open, overriding the collapsed state. */
|
|
452
|
+
forced: boolean;
|
|
453
|
+
/** `▸ 31 launch arguments`, plus the truncation and match clauses. */
|
|
454
|
+
label: string;
|
|
455
|
+
/** The glyph is silence to a screen reader, so the name says the verb. */
|
|
456
|
+
ariaLabel: string;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The task cell — a control, not a readout. `null` on an unframed line, on a
|
|
461
|
+
* line whose port Steward never saw, and on `get_availabl` (task `-1`: no task
|
|
462
|
+
* is attached yet, and inventing one would be the first mis-attribution in a
|
|
463
|
+
* console built to avoid exactly that).
|
|
464
|
+
*/
|
|
465
|
+
export interface TaskCellVm {
|
|
466
|
+
port: number;
|
|
467
|
+
task: number;
|
|
468
|
+
/** Stable identity for focus restoration across a repaint. */
|
|
469
|
+
key: string;
|
|
470
|
+
/** `▸81259` collapsed, `▾81259` while this task is being traced. */
|
|
471
|
+
label: string;
|
|
472
|
+
/**
|
|
473
|
+
* Says what a task id IS, which the numeral cannot: llama-server's own handle
|
|
474
|
+
* for the request, sparse, reused across children and not a request number.
|
|
475
|
+
*/
|
|
476
|
+
ariaLabel: string;
|
|
477
|
+
/** True while this row's task is the one being traced. */
|
|
478
|
+
active: boolean;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* A trailing annotation on a row's message.
|
|
483
|
+
*
|
|
484
|
+
* Trailing, not leading: a leading badge shifts the message's left edge per row
|
|
485
|
+
* and breaks the column rhythm that makes a log scannable. Severity is carried
|
|
486
|
+
* in FORM — a glyph and a tinted ground with the text at full contrast — never
|
|
487
|
+
* by an amber that is 2.16:1 on the light theme's console ground.
|
|
488
|
+
*/
|
|
489
|
+
export interface LogBadgeVm {
|
|
490
|
+
key: "context-lost" | "cache" | "slot";
|
|
491
|
+
label: string;
|
|
492
|
+
title: string;
|
|
493
|
+
tone: "warn" | "neutral";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export interface LogRowVm {
|
|
497
|
+
/**
|
|
498
|
+
* Row identity for the incremental patcher. A fold row and the first line of
|
|
499
|
+
* its run share a `seq`, so `seq` alone cannot key the DOM: toggling a fold
|
|
500
|
+
* would patch a `<button>`'s text into a `<div>` and leave the wrong element
|
|
501
|
+
* on screen.
|
|
502
|
+
*/
|
|
503
|
+
key: string;
|
|
504
|
+
seq: number;
|
|
505
|
+
time: string;
|
|
506
|
+
level: LogLevel;
|
|
507
|
+
model: string;
|
|
508
|
+
modelColor: string;
|
|
509
|
+
/** Why the model cell reads what it does, or `""` for a plain model name. */
|
|
510
|
+
modelTitle: string;
|
|
511
|
+
scope: LineScope;
|
|
512
|
+
kind: LogKind;
|
|
513
|
+
/** The trace entry point, or `null` when this row has no task to trace. */
|
|
514
|
+
task: TaskCellVm | null;
|
|
515
|
+
/**
|
|
516
|
+
* The pipe frame this row's task cell stands in for, or `""`. Carried so an
|
|
517
|
+
* export can write the file's own line back; never painted.
|
|
518
|
+
*/
|
|
519
|
+
frameRaw: string;
|
|
520
|
+
message: string;
|
|
521
|
+
/** Absent enrichments render NOTHING — not an empty box, not a dash. */
|
|
522
|
+
badges: LogBadgeVm[];
|
|
523
|
+
/** True while this row is part of the open trace. */
|
|
524
|
+
traced: boolean;
|
|
525
|
+
/** True for a line rendered inside an expanded fold. */
|
|
526
|
+
folded: boolean;
|
|
527
|
+
/** Non-null only on the fold row itself. */
|
|
528
|
+
fold: FoldVm | null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The toolbar's proxied-request toggle. */
|
|
532
|
+
export interface ProxyToggleVm {
|
|
533
|
+
/** `▸ 1,203 proxied` / `▸ proxied` / `▾ proxied shown`. */
|
|
534
|
+
label: string;
|
|
535
|
+
/** Tracks SHOWN, so pressed means the extra lines are in. */
|
|
536
|
+
pressed: boolean;
|
|
537
|
+
/** Says what the chip has no room for: why these are hidden by default. */
|
|
538
|
+
ariaLabel: string;
|
|
539
|
+
title: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** One record-type chip. The same component as a level chip, a different axis. */
|
|
543
|
+
export interface FamilyChipVm extends PillVm {
|
|
544
|
+
family: FamilyFilter;
|
|
545
|
+
/** What pressing it yields — every OTHER filter applied and this axis lifted. */
|
|
546
|
+
count: number;
|
|
547
|
+
countLabel: string;
|
|
548
|
+
ariaLabel: string;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export interface ToolbarVm {
|
|
552
|
+
activeModelLabel: string;
|
|
553
|
+
activeModelBackground: string;
|
|
554
|
+
activeModelColor: string;
|
|
555
|
+
familyChips: FamilyChipVm[];
|
|
556
|
+
levelChips: LevelChipVm[];
|
|
557
|
+
proxyToggle: ProxyToggleVm;
|
|
558
|
+
query: string;
|
|
559
|
+
lineCountLabel: string;
|
|
560
|
+
paused: boolean;
|
|
561
|
+
pauseLabel: string;
|
|
562
|
+
pauseBackground: string;
|
|
563
|
+
pauseColor: string;
|
|
564
|
+
pauseBorder: string;
|
|
565
|
+
copyLabel: string;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Everything the console and toolbar count, in one place. */
|
|
569
|
+
export interface LogCountsVm {
|
|
570
|
+
/** Lines in the painted window — never more than {@link LOG_RENDER_LIMIT}. */
|
|
571
|
+
rendered: number;
|
|
572
|
+
/** Lines passing the whole filter stack, before the render cap. */
|
|
573
|
+
matched: number;
|
|
574
|
+
/** Lines in the buffer the console is reading (frozen while paused). */
|
|
575
|
+
buffered: number;
|
|
576
|
+
/**
|
|
577
|
+
* Lines excluded ONLY by the proxy toggle — already past the model scope, the
|
|
578
|
+
* level chip and the query. That makes the number a promise: this many more
|
|
579
|
+
* rows appear if the toggle is pressed.
|
|
580
|
+
*/
|
|
581
|
+
hiddenProxy: number;
|
|
582
|
+
/** Proxy lines currently shown, for the toggle's pressed-state name. */
|
|
583
|
+
proxyShown: number;
|
|
584
|
+
/** Router-wide lines an active model scope suppressed. */
|
|
585
|
+
hiddenRouter: number;
|
|
586
|
+
/** Matching lines that live inside an args fold, expanded or not. */
|
|
587
|
+
folded: number;
|
|
588
|
+
/** The buffer has evicted at least one signal line this session. */
|
|
589
|
+
bufferDropped: boolean;
|
|
590
|
+
/** The render cap bit: {@link matched} exceeds {@link LOG_RENDER_LIMIT}. */
|
|
591
|
+
renderCapped: boolean;
|
|
592
|
+
/** Per-chip counts, each with the other filters applied and its own lifted. */
|
|
593
|
+
levels: Record<LevelFilter, number>;
|
|
594
|
+
/** The same promise on the second axis. */
|
|
595
|
+
families: Record<FamilyFilter, number>;
|
|
596
|
+
/**
|
|
597
|
+
* Matched lines carrying a pipe frame. Drives the task column's existence —
|
|
598
|
+
* over the MATCHED SET, not the painted window, so it flips at most once per
|
|
599
|
+
* session instead of on every scroll.
|
|
600
|
+
*/
|
|
601
|
+
framed: number;
|
|
602
|
+
/**
|
|
603
|
+
* Buffered lines that said `truncated = 1`. Counted over the buffer, not the
|
|
604
|
+
* matched set, because the banner it drives says "of the N buffered" and the
|
|
605
|
+
* two numbers have to be about the same population.
|
|
606
|
+
*/
|
|
607
|
+
contextLost: number;
|
|
608
|
+
/** Lines in the open trace, or 0 when nothing is being traced. */
|
|
609
|
+
traced: number;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Which single truth the console is telling, first match wins. The order is
|
|
614
|
+
* load-bearing: `empty-filtered` precedes `cold` so a filter that excludes
|
|
615
|
+
* everything cannot masquerade as a cold start, and `paused` precedes `quiet`
|
|
616
|
+
* because a frozen buffer necessarily goes stale and calling that "quiet" would
|
|
617
|
+
* be a lie about the router.
|
|
618
|
+
*/
|
|
619
|
+
export type ConsoleState =
|
|
620
|
+
| "no-source"
|
|
621
|
+
| "file-missing"
|
|
622
|
+
| "reconnecting"
|
|
623
|
+
| "stopped"
|
|
624
|
+
| "paused"
|
|
625
|
+
// A trace outranks `empty-filtered` — it ignores the filters, so a filter
|
|
626
|
+
// that matches nothing says nothing about what a trace is showing — but not
|
|
627
|
+
// the four source-health states, which are facts about the source itself.
|
|
628
|
+
| "tracing"
|
|
629
|
+
| "empty-filtered"
|
|
630
|
+
| "cold"
|
|
631
|
+
| "quiet"
|
|
632
|
+
| "streaming";
|
|
633
|
+
|
|
634
|
+
export type ConsoleTone = "info" | "warn" | "muted" | "trace";
|
|
635
|
+
|
|
636
|
+
/** What a notice or banner offers to do about itself. */
|
|
637
|
+
export interface ConsoleActionVm {
|
|
638
|
+
label: string;
|
|
639
|
+
kind: "clear-filters" | "show-all-models" | "exit-trace" | "query-truncated";
|
|
640
|
+
ariaLabel: string;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** The full-height notice that takes the place of rows (or sits above them). */
|
|
644
|
+
export interface ConsoleNoticeVm {
|
|
645
|
+
state: ConsoleState;
|
|
646
|
+
/** Decoration. Never load-bearing — the title carries the meaning. */
|
|
647
|
+
glyph: string;
|
|
648
|
+
title: string;
|
|
649
|
+
detail: string;
|
|
650
|
+
tone: ConsoleTone;
|
|
651
|
+
action: ConsoleActionVm | null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** A strip that coexists with rows, at the top or the bottom of the console. */
|
|
655
|
+
export interface ConsoleBannerVm {
|
|
656
|
+
key: string;
|
|
657
|
+
placement: "top" | "bottom";
|
|
658
|
+
tone: ConsoleTone;
|
|
659
|
+
text: string;
|
|
660
|
+
/** Extra sentences under the strip, or `""` when the strip says it all. */
|
|
661
|
+
detail: string;
|
|
662
|
+
action: ConsoleActionVm | null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** The open trace, as its banner renders it. */
|
|
666
|
+
export interface TraceVm {
|
|
667
|
+
port: number;
|
|
668
|
+
task: number;
|
|
669
|
+
/**
|
|
670
|
+
* The model, from the port map. `""` when the port is unmapped — never
|
|
671
|
+
* invented, and never read off the neighbouring `proxy_reques` line, which is
|
|
672
|
+
* the only line that names a model and carries no task id.
|
|
673
|
+
*/
|
|
674
|
+
modelLabel: string;
|
|
675
|
+
count: number;
|
|
676
|
+
/** `▾ tracing task 81259 · port 53691 · gpt-oss-20b · 9 lines`, plus guards. */
|
|
677
|
+
title: string;
|
|
678
|
+
/** The sentences that answer "what IS a task id", said where it matters. */
|
|
679
|
+
detail: string;
|
|
680
|
+
backLabel: string;
|
|
681
|
+
backAriaLabel: string;
|
|
682
|
+
/** The earliest member sits at the front of a buffer that has dropped lines. */
|
|
683
|
+
partial: boolean;
|
|
684
|
+
/** The port was reused and another request shares this id; only one is shown. */
|
|
685
|
+
splitRuns: boolean;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export interface ConsoleVm {
|
|
689
|
+
state: ConsoleState;
|
|
690
|
+
lines: LogRowVm[];
|
|
691
|
+
notice: ConsoleNoticeVm | null;
|
|
692
|
+
banners: ConsoleBannerVm[];
|
|
693
|
+
paused: boolean;
|
|
694
|
+
/** The open trace, or `null`. */
|
|
695
|
+
trace: TraceVm | null;
|
|
696
|
+
/**
|
|
697
|
+
* Whether the row grid carries a task column at all. False collapses it —
|
|
698
|
+
* an idle router showing eleven banner lines gets the original four-column
|
|
699
|
+
* grid back, not 64px of dead space.
|
|
700
|
+
*/
|
|
701
|
+
showTaskColumn: boolean;
|
|
702
|
+
/** Lines that arrived behind a frozen buffer, so Pause can say what it costs. */
|
|
703
|
+
frozenBehind: number;
|
|
704
|
+
/** The visually-hidden heading inside the region. */
|
|
705
|
+
heading: string;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export interface SlotDotVm {
|
|
709
|
+
id: number;
|
|
710
|
+
/**
|
|
711
|
+
* `unknown` where the lane's occupancy was never established — a child that
|
|
712
|
+
* has only just spawned, or a stream Steward lost track of. It is not a
|
|
713
|
+
* synonym for idle and must never be rendered as one.
|
|
714
|
+
*/
|
|
715
|
+
state: SlotState;
|
|
716
|
+
/**
|
|
717
|
+
* Context fill, 0–100, for a mini bar: `promptTokens / ctxTotal`. `null` when
|
|
718
|
+
* either half is unmeasured, so nothing draws an empty bar for a lane whose
|
|
719
|
+
* fill nobody reported.
|
|
720
|
+
*/
|
|
721
|
+
headroomPct: number | null;
|
|
722
|
+
/** `27 / 40k ctx · 5 decoded`, `40k ctx · idle`, or `40k ctx · state unknown`. */
|
|
723
|
+
detail: string;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export interface SlotGroupVm {
|
|
727
|
+
modelId: string;
|
|
728
|
+
/** The model's short name. */
|
|
729
|
+
modelLabel: string;
|
|
730
|
+
modelColor: string;
|
|
731
|
+
/** Slots in this group that are processing. */
|
|
732
|
+
busy: number;
|
|
733
|
+
/** Slots in this group whose occupancy could not be established. */
|
|
734
|
+
unknown: number;
|
|
735
|
+
/** Slots in this group. */
|
|
736
|
+
total: number;
|
|
737
|
+
/** `2/4 busy`, plus ` · 1 unknown` when a lane could not be spoken for. */
|
|
738
|
+
summary: string;
|
|
739
|
+
/** `63 t/s` while the model is generating; `""` when idle or the rate is unknown. */
|
|
740
|
+
rateLabel: string;
|
|
741
|
+
/**
|
|
742
|
+
* The busiest lane's context fill, 0–100 — the group's overflow signal — or
|
|
743
|
+
* `null` when no lane reported one.
|
|
744
|
+
*/
|
|
745
|
+
peakPct: number | null;
|
|
746
|
+
/**
|
|
747
|
+
* `61%`, the peak as a label, or `""` when there is no peak to show. Shown
|
|
748
|
+
* only on a busy chip; the number is never color-only.
|
|
749
|
+
*/
|
|
750
|
+
peakLabel: string;
|
|
751
|
+
/** Threshold color for {@link peakLabel}: tertiary, then warning, then error. */
|
|
752
|
+
peakColor: string;
|
|
753
|
+
slots: SlotDotVm[];
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export interface SlotsVm {
|
|
757
|
+
/** One group per loaded model, in models order. */
|
|
758
|
+
groups: SlotGroupVm[];
|
|
759
|
+
/** True when no model is loaded (no groups). */
|
|
760
|
+
empty: boolean;
|
|
761
|
+
emptyLabel: string;
|
|
762
|
+
/** `3 of 8 busy · peak 92% ctx`; the peak clause is dropped when nothing is busy. */
|
|
763
|
+
totalSummary: string;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export interface DashboardVm {
|
|
767
|
+
service: ServiceVm;
|
|
768
|
+
gauges: GaugeVm[];
|
|
769
|
+
/**
|
|
770
|
+
* The HOST block's unit control, or `null` when no gauge in {@link gauges} is
|
|
771
|
+
* a temperature row. See {@link TemperatureControlVm}.
|
|
772
|
+
*/
|
|
773
|
+
temperature: TemperatureControlVm | null;
|
|
774
|
+
models: ModelCardVm[];
|
|
775
|
+
allLogsPill: PillVm;
|
|
776
|
+
kpis: KpiVm[];
|
|
777
|
+
spark: SparkVm;
|
|
778
|
+
toolbar: ToolbarVm;
|
|
779
|
+
console: ConsoleVm;
|
|
780
|
+
logCounts: LogCountsVm;
|
|
781
|
+
/**
|
|
782
|
+
* Every matching buffered line, folds expanded, with no render cap — what
|
|
783
|
+
* Copy and Download write out. Deliberately NOT the painted rows: those stop
|
|
784
|
+
* at {@link LOG_RENDER_LIMIT} and hide the launch arguments behind a fold,
|
|
785
|
+
* which is the single most likely thing an operator is copying.
|
|
786
|
+
*/
|
|
787
|
+
exportLines: LogRowVm[];
|
|
788
|
+
slots: SlotsVm;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** The button verb for each action, and its in-flight form. */
|
|
792
|
+
const SERVICE_LABELS: Record<ServiceAction, string> = {
|
|
793
|
+
start: "Start",
|
|
794
|
+
stop: "Stop",
|
|
795
|
+
restart: "Restart",
|
|
796
|
+
};
|
|
797
|
+
const SERVICE_BUSY_LABELS: Record<ServiceAction, string> = {
|
|
798
|
+
start: "Starting…",
|
|
799
|
+
stop: "Stopping…",
|
|
800
|
+
restart: "Restarting…",
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
/** The order the control row renders, whatever order the source listed them in. */
|
|
804
|
+
const SERVICE_ACTION_ORDER: readonly ServiceAction[] = ["start", "stop", "restart"];
|
|
805
|
+
|
|
806
|
+
/** `a`, `a and b`, `a, b and c` — never a bare comma list. */
|
|
807
|
+
function joinNames(names: string[]): string {
|
|
808
|
+
if (names.length <= 1) return names[0] ?? "";
|
|
809
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* What a disruptive action will actually cost, naming the real models that are
|
|
814
|
+
* loaded right now. Vague warnings get clicked through; "Restart unloads
|
|
815
|
+
* gpt-oss-20b and drops in-flight requests" does not. With nothing loaded there
|
|
816
|
+
* is no model to name and the strip says so rather than inventing a stake.
|
|
817
|
+
*/
|
|
818
|
+
function serviceConsequence(action: ServiceAction, snapshot: Snapshot): string {
|
|
819
|
+
const verb = SERVICE_LABELS[action];
|
|
820
|
+
const loaded = snapshot.models
|
|
821
|
+
.filter((model) => model.status === "active" || model.status === "resident")
|
|
822
|
+
.map((model) => model.short);
|
|
823
|
+
|
|
824
|
+
if (loaded.length === 0) {
|
|
825
|
+
return `${verb} drops any in-flight requests. No model is loaded right now.`;
|
|
826
|
+
}
|
|
827
|
+
// The comma before "and drops" keeps the two clauses apart once the model
|
|
828
|
+
// list itself contains an "and".
|
|
829
|
+
const separator = loaded.length > 1 ? "," : "";
|
|
830
|
+
return `${verb} unloads ${joinNames(loaded)}${separator} and drops in-flight requests.`;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The SERVICE control row.
|
|
835
|
+
*
|
|
836
|
+
* Which actions exist is config, not a guess: the snapshot carries exactly the
|
|
837
|
+
* actions `steward.json` declares a consented command for. None configured is
|
|
838
|
+
* its own state — one setup affordance naming `/steward_initialize`, never
|
|
839
|
+
* three dead buttons that would 400 if pressed. An action that cannot apply
|
|
840
|
+
* right now (starting a started service) disables WITH a reason, so the
|
|
841
|
+
* greyed-out fill is never the only thing carrying it.
|
|
842
|
+
*/
|
|
843
|
+
function selectServiceControls(snapshot: Snapshot, ui: UiState): ServiceControlsVm {
|
|
844
|
+
const running = snapshot.service.running;
|
|
845
|
+
const pending = ui.pendingService !== null;
|
|
846
|
+
const available = SERVICE_ACTION_ORDER.filter((action) =>
|
|
847
|
+
snapshot.service.controls.includes(action),
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
const buttons: ServiceControlVm[] = available.map((action) => {
|
|
851
|
+
const busy = ui.pendingService === action;
|
|
852
|
+
// Restart stays live while stopped — it is the one command a machine that
|
|
853
|
+
// only consented to `restart` has, and launchd/systemd both start from it.
|
|
854
|
+
const reason =
|
|
855
|
+
running && action === "start"
|
|
856
|
+
? "The service is already started."
|
|
857
|
+
: !running && action === "stop"
|
|
858
|
+
? "The service is already stopped."
|
|
859
|
+
: "";
|
|
860
|
+
const label = busy ? SERVICE_BUSY_LABELS[action] : SERVICE_LABELS[action];
|
|
861
|
+
return {
|
|
862
|
+
action,
|
|
863
|
+
label,
|
|
864
|
+
busy,
|
|
865
|
+
disabled: pending || reason !== "",
|
|
866
|
+
disabledReason: reason,
|
|
867
|
+
confirms: action !== "start",
|
|
868
|
+
danger: action !== "start",
|
|
869
|
+
ariaLabel:
|
|
870
|
+
reason === ""
|
|
871
|
+
? `${label} the llama.cpp service`
|
|
872
|
+
: `${label} the llama.cpp service. ${reason}`,
|
|
873
|
+
};
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// The strip stands only while its own button would still act. Anything that
|
|
877
|
+
// disables that button — a poll showing the service already stopped, an
|
|
878
|
+
// action going out, the config dropping the command — closes the strip too,
|
|
879
|
+
// so it can never sit there stating a consequence that has become false
|
|
880
|
+
// ("unloads gpt-oss-20b" after the service died on its own) over an Accept
|
|
881
|
+
// that would still POST.
|
|
882
|
+
const opener = buttons.find((button) => button.action === ui.confirmService);
|
|
883
|
+
const confirming = opener !== undefined && !opener.disabled ? opener.action : null;
|
|
884
|
+
|
|
885
|
+
const failure = ui.serviceFailure;
|
|
886
|
+
return {
|
|
887
|
+
buttons,
|
|
888
|
+
setup:
|
|
889
|
+
buttons.length > 0
|
|
890
|
+
? null
|
|
891
|
+
: {
|
|
892
|
+
label: "Service control is not set up.",
|
|
893
|
+
detail:
|
|
894
|
+
"Steward runs only the start, stop and restart commands this machine has declared and you have approved.",
|
|
895
|
+
command: "/steward_initialize",
|
|
896
|
+
},
|
|
897
|
+
confirm:
|
|
898
|
+
confirming === null
|
|
899
|
+
? null
|
|
900
|
+
: {
|
|
901
|
+
action: confirming,
|
|
902
|
+
consequence: serviceConsequence(confirming, snapshot),
|
|
903
|
+
confirmLabel: SERVICE_LABELS[confirming],
|
|
904
|
+
confirmAriaLabel: `Confirm: ${SERVICE_LABELS[confirming].toLowerCase()} the llama.cpp service`,
|
|
905
|
+
cancelLabel: "Cancel",
|
|
906
|
+
},
|
|
907
|
+
// The command's own words, not a paraphrase: "permission denied" tells the
|
|
908
|
+
// operator what to fix, where "something went wrong" tells them nothing.
|
|
909
|
+
notice:
|
|
910
|
+
failure === null
|
|
911
|
+
? null
|
|
912
|
+
: failure.detail === null || failure.detail === ""
|
|
913
|
+
? `${SERVICE_LABELS[failure.action]} failed.`
|
|
914
|
+
: `${SERVICE_LABELS[failure.action]} failed — ${failure.detail}`,
|
|
915
|
+
pending,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** How many flag groups a notice names before it summarises the rest. */
|
|
920
|
+
const DRIFT_LIST_LIMIT = 3;
|
|
921
|
+
|
|
922
|
+
/** The Pi command that re-detects this machine and rewrites `steward.json`. */
|
|
923
|
+
const SETUP_COMMAND = "/steward_initialize";
|
|
924
|
+
|
|
925
|
+
/** Human-readable action names for the consent-drift sentence. */
|
|
926
|
+
const CONTROL_NAMES: Record<ServiceAction, string> = {
|
|
927
|
+
start: "start",
|
|
928
|
+
stop: "stop",
|
|
929
|
+
restart: "restart",
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
/** `--metrics and --slots`, or `--a, --b, --c and 4 more` past the limit. */
|
|
933
|
+
function driftList(groups: readonly string[]): string {
|
|
934
|
+
if (groups.length <= DRIFT_LIST_LIMIT) return joinNames([...groups]);
|
|
935
|
+
const head = groups.slice(0, DRIFT_LIST_LIMIT).join(", ");
|
|
936
|
+
return `${head} and ${groups.length - DRIFT_LIST_LIMIT} more`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The launch-argv half of the notice: one line naming exactly what changed.
|
|
941
|
+
* Only a `drifted` verdict speaks. `clean` says nothing (a compliant machine is
|
|
942
|
+
* not nagged), and `unknown` says nothing either — Steward could not check, so
|
|
943
|
+
* it has nothing to report and does not pretend otherwise.
|
|
944
|
+
*/
|
|
945
|
+
function launchDriftMessages(launch: LaunchDrift): string[] {
|
|
946
|
+
if (launch.status !== "drifted") return [];
|
|
947
|
+
const messages: string[] = [];
|
|
948
|
+
const changes: string[] = [];
|
|
949
|
+
if (launch.removed.length > 0) changes.push(`${driftList(launch.removed)} removed`);
|
|
950
|
+
if (launch.added.length > 0) changes.push(`${driftList(launch.added)} added`);
|
|
951
|
+
if (changes.length > 0) {
|
|
952
|
+
messages.push(`Launch flags changed since setup: ${changes.join(", ")}.`);
|
|
953
|
+
}
|
|
954
|
+
if (launch.program !== null) {
|
|
955
|
+
// Spelled out rather than arrowed: an arrow glyph is silence to a screen
|
|
956
|
+
// reader, and this line is the whole content of the alert.
|
|
957
|
+
messages.push(
|
|
958
|
+
`The server binary changed since setup: it was ${launch.program.recorded}, and is now ${launch.program.observed}.`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
return messages;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* The consent half: a command `steward.json` declares that the operator has not
|
|
966
|
+
* approved. Steward refusing to run it is the gate working as designed — but an
|
|
967
|
+
* inert panel with no explanation looks exactly like one that was never set up,
|
|
968
|
+
* so the reason is said out loud.
|
|
969
|
+
*/
|
|
970
|
+
function consentDriftMessages(consent: ConsentDrift): string[] {
|
|
971
|
+
const messages: string[] = [];
|
|
972
|
+
if (consent.hostCollector) {
|
|
973
|
+
messages.push(
|
|
974
|
+
"The host-metrics collector is declared but not approved, so no host readings are being collected.",
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
if (consent.controls.length > 0) {
|
|
978
|
+
const names = consent.controls.map((action) => CONTROL_NAMES[action]);
|
|
979
|
+
messages.push(
|
|
980
|
+
names.length === 1
|
|
981
|
+
? `The ${names[0]} command is declared but not approved, so it is not offered.`
|
|
982
|
+
: `The ${joinNames(names)} commands are declared but not approved, so they are not offered.`,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
return messages;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* The drift notice, or `null` when there is nothing honest to say.
|
|
990
|
+
*
|
|
991
|
+
* Dismissal is bound to {@link DriftNoticeVm.key} and lives in memory only: the
|
|
992
|
+
* notice returns on the next reload, and immediately if what drifted changes.
|
|
993
|
+
* A mismatch that is still there must never be hidden by a click the operator
|
|
994
|
+
* made ten minutes ago — the whole feature is a promise that silence means
|
|
995
|
+
* compliance.
|
|
996
|
+
*/
|
|
997
|
+
function selectDrift(snapshot: Snapshot, ui: UiState): DriftNoticeVm | null {
|
|
998
|
+
const { launch, consent } = snapshot.drift;
|
|
999
|
+
const messages = [...launchDriftMessages(launch), ...consentDriftMessages(consent)];
|
|
1000
|
+
if (messages.length === 0) return null;
|
|
1001
|
+
|
|
1002
|
+
const key = [
|
|
1003
|
+
launch.status === "drifted"
|
|
1004
|
+
? `launch:-${launch.removed.join("\u0000")}:+${launch.added.join("\u0000")}:${
|
|
1005
|
+
launch.program === null ? "" : launch.program.observed
|
|
1006
|
+
}`
|
|
1007
|
+
: "",
|
|
1008
|
+
consent.hostCollector ? "collector" : "",
|
|
1009
|
+
consent.controls.length > 0 ? `controls:${consent.controls.join(",")}` : "",
|
|
1010
|
+
].join("|");
|
|
1011
|
+
if (ui.dismissedDrift === key) return null;
|
|
1012
|
+
|
|
1013
|
+
const fix = `Re-run ${SETUP_COMMAND} to re-detect this machine.`;
|
|
1014
|
+
return {
|
|
1015
|
+
key,
|
|
1016
|
+
title: "Configuration drift",
|
|
1017
|
+
messages,
|
|
1018
|
+
fix,
|
|
1019
|
+
dismissLabel: "Dismiss",
|
|
1020
|
+
dismissAriaLabel:
|
|
1021
|
+
"Dismiss the configuration drift notice. It returns while the mismatch is still there.",
|
|
1022
|
+
ariaLabel: "Configuration drift",
|
|
1023
|
+
announcement: `Configuration drift. ${messages.join(" ")} ${fix}`,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** What the polite status region should say about drift, and the new watermark. */
|
|
1028
|
+
export interface DriftAnnouncement {
|
|
1029
|
+
/** The sentence to announce, or `null` when nothing has changed. */
|
|
1030
|
+
message: string | null;
|
|
1031
|
+
/** The key to remember, so the same notice is not announced twice. */
|
|
1032
|
+
key: string | null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Decides whether a drift notice is worth announcing.
|
|
1037
|
+
*
|
|
1038
|
+
* The snapshot poll runs every 1.6 s and drift persists across all of them, so
|
|
1039
|
+
* announcing per repaint would turn a screen reader into a metronome. The notice
|
|
1040
|
+
* is announced when it is NEW — a different mismatch, or the first one after a
|
|
1041
|
+
* clean stretch — and stays silent otherwise. Losing the notice resets the
|
|
1042
|
+
* watermark, so a mismatch that returns is announced again.
|
|
1043
|
+
*/
|
|
1044
|
+
export function driftAnnouncement(
|
|
1045
|
+
notice: DriftNoticeVm | null,
|
|
1046
|
+
lastKey: string | null,
|
|
1047
|
+
): DriftAnnouncement {
|
|
1048
|
+
if (notice === null) return { message: null, key: null };
|
|
1049
|
+
if (notice.key === lastKey) return { message: null, key: lastKey };
|
|
1050
|
+
return { message: notice.announcement, key: notice.key };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function selectService(snapshot: Snapshot, ui: UiState): ServiceVm {
|
|
1054
|
+
const { service } = snapshot;
|
|
1055
|
+
// Two of the three states are reachable from a reachability probe. `unknown`
|
|
1056
|
+
// belongs to a machine that was never pointed at a server at all, which is a
|
|
1057
|
+
// fact the snapshot does not carry yet; the chip renders it correctly the day
|
|
1058
|
+
// it does.
|
|
1059
|
+
const state: ServiceState = service.running ? "up" : "down";
|
|
1060
|
+
const presentation = SERVICE_STATE_PRESENTATION[state];
|
|
1061
|
+
// The glyph reports the current mode, not the destination — with three states
|
|
1062
|
+
// "next" is ambiguous — and the label names both so the change is announced.
|
|
1063
|
+
const themeGlyph = ui.theme === "system" ? "◐" : ui.theme === "light" ? "☀" : "☾";
|
|
1064
|
+
const themeLabel =
|
|
1065
|
+
ui.theme === "system"
|
|
1066
|
+
? "Theme: System (matches your OS). Switch to light."
|
|
1067
|
+
: ui.theme === "light"
|
|
1068
|
+
? "Theme: Light. Switch to dark."
|
|
1069
|
+
: "Theme: Dark. Switch to system.";
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
state,
|
|
1073
|
+
statusLabel: presentation.label,
|
|
1074
|
+
statusDotColor: presentation.dotColor,
|
|
1075
|
+
themeGlyph,
|
|
1076
|
+
themeLabel,
|
|
1077
|
+
controls: selectServiceControls(snapshot, ui),
|
|
1078
|
+
drift: selectDrift(snapshot, ui),
|
|
1079
|
+
config: snapshot.config,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* A memory gauge (VRAM, RAM, or Unified). `solid` when both figures are real,
|
|
1085
|
+
* else the value dashes and the track hatches — an empty bar for a reading the
|
|
1086
|
+
* host could not supply, told apart from a genuine 0%.
|
|
1087
|
+
*/
|
|
1088
|
+
function memoryGauge(
|
|
1089
|
+
key: string,
|
|
1090
|
+
label: string,
|
|
1091
|
+
usedGB: number,
|
|
1092
|
+
totalGB: number,
|
|
1093
|
+
decimals: number,
|
|
1094
|
+
color: string,
|
|
1095
|
+
): GaugeVm {
|
|
1096
|
+
const read = Number.isFinite(usedGB) && Number.isFinite(totalGB);
|
|
1097
|
+
return {
|
|
1098
|
+
key,
|
|
1099
|
+
label,
|
|
1100
|
+
value: memoryLabel(usedGB, totalGB, decimals),
|
|
1101
|
+
percent: barPercent(totalGB === 0 ? 0 : usedGB / totalGB),
|
|
1102
|
+
color,
|
|
1103
|
+
track: read ? "solid" : "hatched",
|
|
1104
|
+
temperature: false,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* A utilisation gauge (GPU, CPU). A real 0% is a reading and stays `solid`; only
|
|
1110
|
+
* a non-finite figure the source could not supply hatches.
|
|
1111
|
+
*/
|
|
1112
|
+
function utilGauge(key: string, label: string, util: number, color: string): GaugeVm {
|
|
1113
|
+
return {
|
|
1114
|
+
key,
|
|
1115
|
+
label,
|
|
1116
|
+
value: formatPercent(util),
|
|
1117
|
+
percent: barPercent(util),
|
|
1118
|
+
color,
|
|
1119
|
+
track: Number.isFinite(util) ? "solid" : "hatched",
|
|
1120
|
+
temperature: false,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* A temperature gauge. Only ever built from a finite reading, so always solid.
|
|
1126
|
+
*
|
|
1127
|
+
* The unit reaches the LABEL and nothing else: the bar's position and the
|
|
1128
|
+
* threshold color are computed from the Celsius reading whichever unit is on
|
|
1129
|
+
* screen, so switching units moves the text and moves nothing else.
|
|
1130
|
+
*/
|
|
1131
|
+
function tempGauge(key: string, label: string, celsius: number, unit: TemperatureUnit): GaugeVm {
|
|
1132
|
+
return {
|
|
1133
|
+
key,
|
|
1134
|
+
label,
|
|
1135
|
+
value: formatTemperature(celsius, unit),
|
|
1136
|
+
percent: temperatureBarPercent(celsius),
|
|
1137
|
+
color: temperatureColor(celsius),
|
|
1138
|
+
track: "solid",
|
|
1139
|
+
temperature: true,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* The HOST block's gauge set, laid out for the machine's memory topology. A
|
|
1145
|
+
* `discrete` box shows the VRAM+RAM pair; a `unified` one (Apple Silicon) shares
|
|
1146
|
+
* one pool and cannot read a VRAM total — so it shows a single Unified RAM gauge
|
|
1147
|
+
* and NEVER an invented VRAM ceiling. Both share the GPU/CPU util and temperature
|
|
1148
|
+
* rows.
|
|
1149
|
+
*
|
|
1150
|
+
* The temperature rows are labelled in `ui.temperatureUnit` — the operator's
|
|
1151
|
+
* browser region, resolved before it got here. It is a labelling choice only;
|
|
1152
|
+
* nothing about which gauges exist, or where their bars sit, depends on it.
|
|
1153
|
+
*/
|
|
1154
|
+
function selectGauges(snapshot: Snapshot, ui: UiState): GaugeVm[] {
|
|
1155
|
+
const m = snapshot.metrics;
|
|
1156
|
+
const gauges: GaugeVm[] =
|
|
1157
|
+
snapshot.memoryTopology === "unified"
|
|
1158
|
+
? // Unified memory takes the place of the VRAM+RAM pair; there is no
|
|
1159
|
+
// separate VRAM figure to show, so no VRAM gauge is built at all.
|
|
1160
|
+
[
|
|
1161
|
+
memoryGauge(
|
|
1162
|
+
"unified-memory",
|
|
1163
|
+
"Unified RAM",
|
|
1164
|
+
m.ramUsedGB,
|
|
1165
|
+
m.ramTotalGB,
|
|
1166
|
+
1,
|
|
1167
|
+
"var(--latte-teal)",
|
|
1168
|
+
),
|
|
1169
|
+
]
|
|
1170
|
+
: [memoryGauge("vram", "VRAM", m.vramUsedGB, m.vramTotalGB, 1, "var(--latte-teal)")];
|
|
1171
|
+
|
|
1172
|
+
gauges.push(utilGauge("gpu", "GPU", m.gpuUtil, "var(--latte-mauve)"));
|
|
1173
|
+
|
|
1174
|
+
// Temperatures come from host sensors, not llama.cpp. Where the platform
|
|
1175
|
+
// cannot supply them the design drops the row rather than plotting a zero,
|
|
1176
|
+
// and a reading that is not a number is no more of a reading than `null`.
|
|
1177
|
+
if (m.gpuTempC !== null && Number.isFinite(m.gpuTempC)) {
|
|
1178
|
+
gauges.push(tempGauge("gpu-temp", "GPU temp", m.gpuTempC, ui.temperatureUnit));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Discrete machines carry a separate RAM gauge after the temps; unified
|
|
1182
|
+
// machines already accounted for RAM in the single Unified RAM gauge.
|
|
1183
|
+
if (snapshot.memoryTopology === "discrete") {
|
|
1184
|
+
gauges.push(memoryGauge("ram", "RAM", m.ramUsedGB, m.ramTotalGB, 0, "var(--accent)"));
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
gauges.push(utilGauge("cpu", "CPU", m.cpuUtil, "var(--latte-peach)"));
|
|
1188
|
+
|
|
1189
|
+
if (m.cpuTempC !== null && Number.isFinite(m.cpuTempC)) {
|
|
1190
|
+
gauges.push(tempGauge("cpu-temp", "CPU temp", m.cpuTempC, ui.temperatureUnit));
|
|
1191
|
+
}
|
|
1192
|
+
return gauges;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** The unit each preference resolves a press to, in a fixed three-stop cycle. */
|
|
1196
|
+
const TEMPERATURE_NEXT: Record<TemperaturePreference, TemperaturePreference> = {
|
|
1197
|
+
auto: "celsius",
|
|
1198
|
+
celsius: "fahrenheit",
|
|
1199
|
+
fahrenheit: "auto",
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
/** The two-character token an operator reads a temperature in. */
|
|
1203
|
+
function unitSymbol(unit: TemperatureUnit): string {
|
|
1204
|
+
return unit === "celsius" ? "°C" : "°F";
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* The HOST block's temperature-unit control, or `null` when this machine
|
|
1209
|
+
* reports no temperature at all.
|
|
1210
|
+
*
|
|
1211
|
+
* Absent rather than disabled: `selectGauges` drops a temperature row when the
|
|
1212
|
+
* sensor is missing, so on such a machine the control would provably change
|
|
1213
|
+
* nothing on screen — and a row of dead controls reads as broken while an
|
|
1214
|
+
* absent one reads as "there is nothing here", which is the truth.
|
|
1215
|
+
*
|
|
1216
|
+
* The label is the PREFERENCE (`auto`, not `°C auto`): the resolved unit is
|
|
1217
|
+
* literally visible on the gauge one line below, so the four characters are
|
|
1218
|
+
* spent saying the thing the gauges cannot.
|
|
1219
|
+
*/
|
|
1220
|
+
function selectTemperatureControl(
|
|
1221
|
+
gauges: readonly GaugeVm[],
|
|
1222
|
+
ui: UiState,
|
|
1223
|
+
): TemperatureControlVm | null {
|
|
1224
|
+
if (!gauges.some((gauge) => gauge.temperature)) return null;
|
|
1225
|
+
const preference = ui.temperaturePreference;
|
|
1226
|
+
const next = TEMPERATURE_NEXT[preference];
|
|
1227
|
+
const sentence =
|
|
1228
|
+
preference === "auto"
|
|
1229
|
+
? `Temperature unit: automatic — ${unitSymbol(ui.temperatureUnit)} from your browser region. Switch to always °C.`
|
|
1230
|
+
: preference === "celsius"
|
|
1231
|
+
? "Temperature unit: always °C. Switch to always °F."
|
|
1232
|
+
: "Temperature unit: always °F. Switch to automatic.";
|
|
1233
|
+
return {
|
|
1234
|
+
label: preference === "auto" ? "auto" : unitSymbol(preference),
|
|
1235
|
+
ariaLabel: sentence,
|
|
1236
|
+
title: sentence,
|
|
1237
|
+
next,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function selectModels(snapshot: Snapshot, ui: UiState): ModelCardVm[] {
|
|
1242
|
+
return snapshot.models.map((model) => {
|
|
1243
|
+
const color = modelColor(model.id, model.embedding);
|
|
1244
|
+
const selected = ui.filterModel === model.id;
|
|
1245
|
+
const loaded = model.status !== "unloaded";
|
|
1246
|
+
const pendingAction = ui.pendingModels[model.id];
|
|
1247
|
+
// The card shows the loading state whenever the model is mid-transition,
|
|
1248
|
+
// whether this operator started it (a local pending flag) or the polled
|
|
1249
|
+
// status arrived already `loading`/`downloading` (someone else did).
|
|
1250
|
+
const statusPending = model.status === "loading" || model.status === "downloading";
|
|
1251
|
+
|
|
1252
|
+
// A model's facts — quant, size, context, tuning — are only known for
|
|
1253
|
+
// certain once it is loaded and reporting them; before that they are
|
|
1254
|
+
// inference (a filename) or intent (launch args), not confirmed, so every
|
|
1255
|
+
// field but `Type` reads `n/a` and every unloaded card reads the same. The
|
|
1256
|
+
// type is confirmed even unloaded (the router reports the modalities), so it
|
|
1257
|
+
// always carries a real value.
|
|
1258
|
+
const confirmed = model.status === "active" || model.status === "resident";
|
|
1259
|
+
|
|
1260
|
+
// The card body: a fixed label set in a fixed order (`Type` last). `na`
|
|
1261
|
+
// rides along so the view can dim a field without re-reading its string.
|
|
1262
|
+
const fields: ModelFieldVm[] = [
|
|
1263
|
+
{ label: "Quant", value: formatQuantField(model.quant, confirmed) },
|
|
1264
|
+
{ label: "Size", value: formatSizeField(model.sizeGB, confirmed) },
|
|
1265
|
+
{ label: "Context", value: formatContextField(model.ctx, confirmed) },
|
|
1266
|
+
{ label: "GPU Layers", value: formatGpuLayersField(model.gpuLayers, confirmed) },
|
|
1267
|
+
{ label: "Flash", value: formatFlashField(model.flashAttn, confirmed) },
|
|
1268
|
+
{ label: "KV Cache", value: formatKvCacheField(model.kvCache, confirmed) },
|
|
1269
|
+
{ label: "Type", value: formatTypeField(model.embedding) },
|
|
1270
|
+
].map((f) => ({ ...f, na: f.value === NA }));
|
|
1271
|
+
|
|
1272
|
+
return {
|
|
1273
|
+
id: model.id,
|
|
1274
|
+
short: model.short,
|
|
1275
|
+
fields,
|
|
1276
|
+
color,
|
|
1277
|
+
selected,
|
|
1278
|
+
cardBackground: selected ? tint(color, 10) : "var(--surface-page)",
|
|
1279
|
+
cardBorder: selected ? tint(color, 50) : "var(--border)",
|
|
1280
|
+
buttonAction: loaded ? "unload" : "load",
|
|
1281
|
+
// The button is the only place a transition is announced (the header says
|
|
1282
|
+
// nothing now). It distinguishes a download — which pulls weights over the
|
|
1283
|
+
// network and can run minutes — from a load into VRAM, since the wait is so
|
|
1284
|
+
// different; an operator-started action shows its optimistic verb first.
|
|
1285
|
+
buttonLabel:
|
|
1286
|
+
pendingAction === "unload"
|
|
1287
|
+
? "Unloading…"
|
|
1288
|
+
: model.status === "downloading"
|
|
1289
|
+
? "Downloading…"
|
|
1290
|
+
: pendingAction === "load" || statusPending
|
|
1291
|
+
? "Loading…"
|
|
1292
|
+
: loaded
|
|
1293
|
+
? "Unload"
|
|
1294
|
+
: "Load",
|
|
1295
|
+
buttonBackground: loaded ? "var(--surface-page)" : "var(--accent)",
|
|
1296
|
+
buttonColor: loaded ? "var(--error)" : "var(--accent-fg)",
|
|
1297
|
+
buttonBorder: loaded ? tint("var(--error)", 40) : "var(--accent)",
|
|
1298
|
+
pending: pendingAction !== undefined || statusPending,
|
|
1299
|
+
};
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function selectKpis(snapshot: Snapshot, now: number): KpiVm[] {
|
|
1304
|
+
const { service } = snapshot;
|
|
1305
|
+
const running = service.running;
|
|
1306
|
+
const uptime = service.startedAt === null ? "—" : formatUptime(now - service.startedAt);
|
|
1307
|
+
// Port lives in the `address` fact now (the Steward block); the tile owns
|
|
1308
|
+
// uptime and pid, so it does not repeat the port.
|
|
1309
|
+
const pid = service.pid === null ? "no process" : `pid ${service.pid}`;
|
|
1310
|
+
|
|
1311
|
+
// The requests tile reports live counts (in flight, queued) — llama.cpp has
|
|
1312
|
+
// no request-rate metric. The bar fills as the slots fill.
|
|
1313
|
+
//
|
|
1314
|
+
// Either figure can be genuinely unavailable, and each for its own reason. In
|
|
1315
|
+
// flight is `null` when a slot's occupancy is unknown, so the true count can
|
|
1316
|
+
// only be bounded below. Queued has no log line behind it at all, so a Steward
|
|
1317
|
+
// reading occupancy from the log — which is every Steward with a log source —
|
|
1318
|
+
// simply cannot know it. Both print a dash and neither prints a `0`, because
|
|
1319
|
+
// "none" and "we cannot tell" are the two answers an operator most needs to
|
|
1320
|
+
// tell apart on this tile.
|
|
1321
|
+
const inFlight = running ? snapshot.requestsInFlight : 0;
|
|
1322
|
+
const queued = running ? snapshot.requestsQueued : 0;
|
|
1323
|
+
const slotTotal = snapshot.slots.length;
|
|
1324
|
+
const requestsFill =
|
|
1325
|
+
inFlight === null ? 0 : slotTotal > 0 ? inFlight / slotTotal : inFlight > 0 ? 1 : 0;
|
|
1326
|
+
const throughput = running ? snapshot.throughputTps : 0;
|
|
1327
|
+
// The window is what the tile is allowed to CLAIM. A source that measured one
|
|
1328
|
+
// says how long it was, and the tile names it; a source that only sampled a
|
|
1329
|
+
// rate gauge reports none, and the tile says nothing about a span it does not
|
|
1330
|
+
// have. A stopped server is not measuring anything either way.
|
|
1331
|
+
const throughputWindow = running ? snapshot.throughputWindowSeconds : null;
|
|
1332
|
+
|
|
1333
|
+
return [
|
|
1334
|
+
{
|
|
1335
|
+
key: "service",
|
|
1336
|
+
label: "service",
|
|
1337
|
+
value: running ? uptime : "stopped",
|
|
1338
|
+
unit: running ? "uptime" : "",
|
|
1339
|
+
sub: running ? pid : "no process",
|
|
1340
|
+
color: running ? "var(--success)" : "var(--error)",
|
|
1341
|
+
percent: running ? 100 : 0,
|
|
1342
|
+
},
|
|
1343
|
+
{
|
|
1344
|
+
key: "requests",
|
|
1345
|
+
label: "requests",
|
|
1346
|
+
value: inFlight === null ? NO_READING : String(inFlight),
|
|
1347
|
+
unit: "in flight",
|
|
1348
|
+
sub: queued === null ? "queued n/a" : `${queued} queued`,
|
|
1349
|
+
color: "var(--accent)",
|
|
1350
|
+
percent: barPercent(requestsFill),
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
key: "throughput",
|
|
1354
|
+
label: "throughput",
|
|
1355
|
+
// A dash here means no window has been measured — nothing has closed yet,
|
|
1356
|
+
// or the log stream broke and the span cannot be vouched for. That is a
|
|
1357
|
+
// different thing from `0`, which is a window that elapsed with nothing
|
|
1358
|
+
// generated in it, so the two must not share a glyph.
|
|
1359
|
+
value: throughput === null ? NO_READING : countLabel(throughput),
|
|
1360
|
+
unit: "tok/s",
|
|
1361
|
+
// Generation, never prefill — and over the span the figure actually
|
|
1362
|
+
// covers, because "tok/s" beside a live tile reads as "right now" unless
|
|
1363
|
+
// the tile says otherwise. It is the same span the strip below plots.
|
|
1364
|
+
sub:
|
|
1365
|
+
throughputWindow === null
|
|
1366
|
+
? "generation, all slots"
|
|
1367
|
+
: `generation, last ${Math.round(throughputWindow)} s`,
|
|
1368
|
+
color: "var(--latte-mauve)",
|
|
1369
|
+
percent: barPercent((throughput ?? 0) / THROUGHPUT_FULL_SCALE),
|
|
1370
|
+
},
|
|
1371
|
+
];
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function selectSpark(snapshot: Snapshot): SparkVm {
|
|
1375
|
+
const samples = snapshot.throughputHistory;
|
|
1376
|
+
// Bars are plotted against the window's own peak, not a fixed ceiling, so a
|
|
1377
|
+
// quiet two minutes still shows shape instead of a flat line at the bottom.
|
|
1378
|
+
const peak = samples.reduce((hi, v) => Math.max(hi, v), 1);
|
|
1379
|
+
const average =
|
|
1380
|
+
samples.length === 0 ? 0 : samples.reduce((total, v) => total + v, 0) / samples.length;
|
|
1381
|
+
const newest = samples.length - 1;
|
|
1382
|
+
|
|
1383
|
+
// A source that measured its window says how far back the bars reach. One that
|
|
1384
|
+
// only sampled a rate gauge measured none, so the axis falls back to the span
|
|
1385
|
+
// the strip is built to hold — the only claim available there.
|
|
1386
|
+
const window = snapshot.throughputWindowSeconds;
|
|
1387
|
+
const nominal = THROUGHPUT_HISTORY_SIZE * THROUGHPUT_SAMPLE_SECONDS;
|
|
1388
|
+
|
|
1389
|
+
return {
|
|
1390
|
+
bars: samples.map((value, index) => ({
|
|
1391
|
+
height: barPercent(value / peak),
|
|
1392
|
+
color: index === newest ? "var(--accent)" : tint("var(--accent)", 38),
|
|
1393
|
+
})),
|
|
1394
|
+
summary: `avg ${Math.round(average)} · peak ${Math.round(peak)} tok/s`,
|
|
1395
|
+
averageLine: barPercent(average / peak),
|
|
1396
|
+
axisStart: `−${Math.round(window ?? nominal)} s`,
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Who wrote a line, from the operator's point of view.
|
|
1402
|
+
*
|
|
1403
|
+
* A missing `origin` means the source predates the field, and every measured
|
|
1404
|
+
* unattributed line was router-wide — so an unattributed line defaults to
|
|
1405
|
+
* `router`, and the dash is reserved for a source that positively says a CHILD
|
|
1406
|
+
* wrote it and Steward still could not name the model. Those are two different
|
|
1407
|
+
* facts and the console must not spend one glyph on both.
|
|
1408
|
+
*/
|
|
1409
|
+
function lineScope(line: LogLine): LineScope {
|
|
1410
|
+
if (line.modelId !== null) return "model";
|
|
1411
|
+
return line.origin === "child" ? "unknown" : "router";
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const ROUTER_TITLE = "router-wide — this line is not about any one model";
|
|
1415
|
+
const UNKNOWN_TITLE =
|
|
1416
|
+
"model unknown — this line came from a child process on a port Steward has not mapped yet";
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* One line after the model scope, with every filter axis staged as a boolean.
|
|
1420
|
+
*
|
|
1421
|
+
* Staging them rather than applying them in sequence is what makes two axes of
|
|
1422
|
+
* honest chip counts possible: one walk over the grouped set can lift any one
|
|
1423
|
+
* axis and leave the rest applied, which is exactly what a chip's number
|
|
1424
|
+
* promises.
|
|
1425
|
+
*/
|
|
1426
|
+
interface StagedLine {
|
|
1427
|
+
line: LogLine;
|
|
1428
|
+
scope: LineScope;
|
|
1429
|
+
kind: LogKind;
|
|
1430
|
+
family: LogFamily;
|
|
1431
|
+
/** True when the active query matches, or when there is no query. */
|
|
1432
|
+
hit: boolean;
|
|
1433
|
+
/** False only for a proxy line while the toggle is off. */
|
|
1434
|
+
shown: boolean;
|
|
1435
|
+
/** Passes the level chip as it is set right now. */
|
|
1436
|
+
levelPass: boolean;
|
|
1437
|
+
/** Passes the record-type chip as it is set right now. */
|
|
1438
|
+
familyPass: boolean;
|
|
1439
|
+
/** `seq` of the args run this belongs to, or `null` for a standalone line. */
|
|
1440
|
+
runId: number | null;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/** A line that survived the whole filter stack, with its fold membership. */
|
|
1444
|
+
interface KeptLine {
|
|
1445
|
+
line: LogLine;
|
|
1446
|
+
scope: LineScope;
|
|
1447
|
+
kind: LogKind;
|
|
1448
|
+
/** `seq` of the args run this belongs to, or `null` for a standalone line. */
|
|
1449
|
+
runId: number | null;
|
|
1450
|
+
hit: boolean;
|
|
1451
|
+
/** True while this row belongs to the open trace. */
|
|
1452
|
+
traced: boolean;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** The text a query is matched against: the file's line, frame included. */
|
|
1456
|
+
function lineText(line: LogLine): string {
|
|
1457
|
+
return `${line.frame?.raw ?? ""}${line.message}`;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** The trace key, composed at the call site rather than stored on the record. */
|
|
1461
|
+
export function taskKey(port: number, task: number): string {
|
|
1462
|
+
return `${port}:${task}`;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** Everything one pass over the buffer produces. */
|
|
1466
|
+
interface LogSelection {
|
|
1467
|
+
rows: LogRowVm[];
|
|
1468
|
+
/** The open trace's membership and guards, or `null`. */
|
|
1469
|
+
trace: TraceSelection | null;
|
|
1470
|
+
exportLines: LogRowVm[];
|
|
1471
|
+
counts: LogCountsVm;
|
|
1472
|
+
/** Arrival stamp of the newest matching line, or `null` when none match. */
|
|
1473
|
+
newestMatchedAt: number | null;
|
|
1474
|
+
/**
|
|
1475
|
+
* Arrival stamp of the newest line in the BUFFER, matching or not. The gap
|
|
1476
|
+
* between this and {@link newestMatchedAt} is how the console knows the
|
|
1477
|
+
* difference between a router that has gone silent and a filter that is
|
|
1478
|
+
* hiding everything the router is currently saying.
|
|
1479
|
+
*/
|
|
1480
|
+
newestBufferedAt: number | null;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
interface RowContext {
|
|
1484
|
+
shorts: Map<string, ModelInfo>;
|
|
1485
|
+
/** The model the console is scoped to, or `null`. */
|
|
1486
|
+
scoped: string | null;
|
|
1487
|
+
/** The open trace, so a row can say whether its task is the one being traced. */
|
|
1488
|
+
trace: TraceRef | null;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* The trace entry point for a row, or `null`.
|
|
1493
|
+
*
|
|
1494
|
+
* `null` for an unframed line, for a line whose port Steward never saw (half
|
|
1495
|
+
* the trace key is missing, and a trace on the other half would be a
|
|
1496
|
+
* mis-attribution), and for `get_availabl`, which is `task -1` in 217/217
|
|
1497
|
+
* measured cases because no task is attached yet. That last one joins its trace
|
|
1498
|
+
* by adjacency instead, and the banner says so.
|
|
1499
|
+
*/
|
|
1500
|
+
function taskCell(line: LogLine, trace: TraceRef | null): TaskCellVm | null {
|
|
1501
|
+
const frame = line.frame;
|
|
1502
|
+
const port = line.port;
|
|
1503
|
+
if (frame === undefined || port === undefined || frame.task < 0) return null;
|
|
1504
|
+
const active = trace !== null && trace.port === port && trace.task === frame.task;
|
|
1505
|
+
return {
|
|
1506
|
+
port,
|
|
1507
|
+
task: frame.task,
|
|
1508
|
+
key: taskKey(port, frame.task),
|
|
1509
|
+
// `▸` already means "press to reveal more" in this console and `▾` means
|
|
1510
|
+
// "showing"; the task cell reuses the args fold's vocabulary exactly.
|
|
1511
|
+
label: `${active ? "▾" : "▸"}${frame.task}`,
|
|
1512
|
+
ariaLabel: `Trace task ${frame.task} on port ${port}. This is llama-server's own handle for the request, not a request number.`,
|
|
1513
|
+
active,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* A row's trailing annotations. Every one is a nullable enrichment: an absent
|
|
1519
|
+
* reading renders nothing at all, so a llama.cpp that renames a payload costs
|
|
1520
|
+
* one missing badge and never a row.
|
|
1521
|
+
*/
|
|
1522
|
+
function logBadges(line: LogLine, model: ModelInfo | undefined): LogBadgeVm[] {
|
|
1523
|
+
const badges: LogBadgeVm[] = [];
|
|
1524
|
+
// Only ever for `= 1`. A `truncated: no` badge on 217 of 217 rows is the
|
|
1525
|
+
// definition of crying wolf: it trains the eye to skip the exact pixel where
|
|
1526
|
+
// the real thing will appear.
|
|
1527
|
+
if (line.contextLost === true) {
|
|
1528
|
+
badges.push({
|
|
1529
|
+
key: "context-lost",
|
|
1530
|
+
label: "▲ context lost",
|
|
1531
|
+
title:
|
|
1532
|
+
"A context shift discarded the front of this conversation before the reply was written.",
|
|
1533
|
+
tone: "warn",
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
// The one extraction that TRANSLATES rather than repeats: an operator knows
|
|
1537
|
+
// what "cache 47%" means and does not know what `sim_best = 0.473` means.
|
|
1538
|
+
if (line.cacheHit !== undefined && Number.isFinite(line.cacheHit)) {
|
|
1539
|
+
const percent = Math.round(line.cacheHit * 100);
|
|
1540
|
+
badges.push({
|
|
1541
|
+
key: "cache",
|
|
1542
|
+
label: `cache ${percent}%`,
|
|
1543
|
+
title: `${percent}% of this prompt was already in the slot's KV cache, so only the rest had to be prefilled.`,
|
|
1544
|
+
tone: "neutral",
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
// Revealed by the model's own `--parallel`, which the snapshot already
|
|
1548
|
+
// carries — an authoritative rule rather than one inferred from the values
|
|
1549
|
+
// seen so far. On a one-slot server every slot id is 0 and a column of zeros
|
|
1550
|
+
// teaches an operator that the column is meaningless.
|
|
1551
|
+
const parallel = model?.parallel ?? null;
|
|
1552
|
+
if (line.frame !== undefined && parallel !== null && parallel > 1) {
|
|
1553
|
+
badges.push({
|
|
1554
|
+
key: "slot",
|
|
1555
|
+
label: `slot ${line.frame.slot}`,
|
|
1556
|
+
title: `Decode slot ${line.frame.slot} of this model's ${parallel}.`,
|
|
1557
|
+
tone: "neutral",
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
return badges;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function logRow(
|
|
1564
|
+
ctx: RowContext,
|
|
1565
|
+
entry: KeptLine,
|
|
1566
|
+
key: string,
|
|
1567
|
+
message: string,
|
|
1568
|
+
folded: boolean,
|
|
1569
|
+
fold: FoldVm | null,
|
|
1570
|
+
): LogRowVm {
|
|
1571
|
+
const { line, scope, kind } = entry;
|
|
1572
|
+
const model = line.modelId === null ? undefined : ctx.shorts.get(line.modelId);
|
|
1573
|
+
return {
|
|
1574
|
+
key,
|
|
1575
|
+
seq: line.seq,
|
|
1576
|
+
time: formatClock(line.ts),
|
|
1577
|
+
level: line.level,
|
|
1578
|
+
// The word is the signal. A screen reader reads "router", which is the
|
|
1579
|
+
// truth, where it would read an em dash as nothing at all.
|
|
1580
|
+
model:
|
|
1581
|
+
scope === "router"
|
|
1582
|
+
? "router"
|
|
1583
|
+
: scope === "unknown"
|
|
1584
|
+
? NO_READING
|
|
1585
|
+
: (model?.short ?? line.modelId ?? NO_READING),
|
|
1586
|
+
// Once the console is scoped to one model the color carries no information,
|
|
1587
|
+
// so the column recedes to plain muted text.
|
|
1588
|
+
modelColor:
|
|
1589
|
+
scope === "unknown"
|
|
1590
|
+
? "var(--text-subtle)"
|
|
1591
|
+
: scope !== "model" || ctx.scoped !== null || model === undefined
|
|
1592
|
+
? "var(--text-muted)"
|
|
1593
|
+
: modelColor(model.id, model.embedding),
|
|
1594
|
+
modelTitle: scope === "router" ? ROUTER_TITLE : scope === "unknown" ? UNKNOWN_TITLE : "",
|
|
1595
|
+
scope,
|
|
1596
|
+
kind,
|
|
1597
|
+
// A fold row stands for a whole args run, which is unframed and carries no
|
|
1598
|
+
// enrichment of its own; both come back empty on their own merits.
|
|
1599
|
+
task: taskCell(line, ctx.trace),
|
|
1600
|
+
frameRaw: line.frame?.raw ?? "",
|
|
1601
|
+
badges: fold === null ? logBadges(line, model) : [],
|
|
1602
|
+
traced: entry.traced,
|
|
1603
|
+
message,
|
|
1604
|
+
folded,
|
|
1605
|
+
fold,
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function foldLabel(
|
|
1610
|
+
expanded: boolean,
|
|
1611
|
+
count: number,
|
|
1612
|
+
truncated: boolean,
|
|
1613
|
+
matches: number,
|
|
1614
|
+
query: string,
|
|
1615
|
+
): string {
|
|
1616
|
+
const noun = `launch argument${count === 1 ? "" : "s"}`;
|
|
1617
|
+
// "dropped", never "trimmed" and never "rotated": launchd appends across
|
|
1618
|
+
// router restarts, so nothing here ever rotates.
|
|
1619
|
+
const cut = truncated ? " (older lines dropped)" : "";
|
|
1620
|
+
const hits = matches > 0 ? ` · ${formatCount(matches)} match "${query}"` : "";
|
|
1621
|
+
return `${expanded ? "▾" : "▸"} ${formatCount(count)} ${noun}${cut}${hits}`;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* Marks the contiguous launch-argument runs, in place.
|
|
1626
|
+
*
|
|
1627
|
+
* Membership is decided BEFORE the query, the level chip and the record-type
|
|
1628
|
+
* chip, on purpose: 31 argument lines are one artifact — the exact launch
|
|
1629
|
+
* command — and a search for `ctx-size` that returned 2 of them and hid the
|
|
1630
|
+
* other 29 would answer a question nobody asked.
|
|
1631
|
+
*
|
|
1632
|
+
* The scan walks the SHOWN lines only, so a suppressed proxy line interleaved
|
|
1633
|
+
* with a spawn cannot split one launch command into two folds.
|
|
1634
|
+
*/
|
|
1635
|
+
function groupRuns(staged: readonly StagedLine[]): void {
|
|
1636
|
+
let runId: number | null = null;
|
|
1637
|
+
let inRun = false;
|
|
1638
|
+
for (const entry of staged) {
|
|
1639
|
+
if (!entry.shown) continue;
|
|
1640
|
+
if (entry.kind !== "args") {
|
|
1641
|
+
inRun = false;
|
|
1642
|
+
runId = null;
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
if (!inRun) runId = entry.line.seq;
|
|
1646
|
+
entry.runId = runId;
|
|
1647
|
+
inRun = true;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* The membership of one trace, and the two things that can be wrong with it.
|
|
1653
|
+
*
|
|
1654
|
+
* Both guards are surfaced rather than silently handled: a partial trace and a
|
|
1655
|
+
* reused port are things the operator has to know, and a console that quietly
|
|
1656
|
+
* showed half a request would be worse than one that says it is showing half.
|
|
1657
|
+
*/
|
|
1658
|
+
export interface TraceSelection {
|
|
1659
|
+
members: LogLine[];
|
|
1660
|
+
/** The earliest member is the oldest line held, and the buffer HAS evicted. */
|
|
1661
|
+
partial: boolean;
|
|
1662
|
+
/** The id belongs to more than one run on this port; only one is shown. */
|
|
1663
|
+
splitRuns: boolean;
|
|
1664
|
+
/** The model the port maps to, or `null` when Steward never mapped it. */
|
|
1665
|
+
modelId: string | null;
|
|
1666
|
+
/** True when the first member joined by adjacency rather than by task id. */
|
|
1667
|
+
adjacent: boolean;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* Every line one llama-server task wrote, in file order.
|
|
1672
|
+
*
|
|
1673
|
+
* Keyed on `(port, task)`. The opening `get_availabl` line carries `task -1` —
|
|
1674
|
+
* no task is attached when the slot is chosen — so it joins by ADJACENCY, and
|
|
1675
|
+
* only when the line immediately before the earliest member is on the same
|
|
1676
|
+
* port, framed, and carries `-1`. Anything else and the trace simply starts at
|
|
1677
|
+
* `launch_slot_`: the rule breaks by omitting a line, which is the safe
|
|
1678
|
+
* direction for a console built to avoid mis-attribution.
|
|
1679
|
+
*
|
|
1680
|
+
* Deliberately NOT joined: the `proxy_reques` line. It is the only line that
|
|
1681
|
+
* names the model and it carries no task id, so joining it would be adjacency
|
|
1682
|
+
* at 1.25 lines/second of Steward's own polling. The model comes from the port
|
|
1683
|
+
* map instead, or the banner names no model at all.
|
|
1684
|
+
*/
|
|
1685
|
+
export function selectTrace(
|
|
1686
|
+
buffer: readonly LogLine[],
|
|
1687
|
+
trace: TraceRef,
|
|
1688
|
+
/** The buffer has evicted a signal line this session — see {@link TraceSelection.partial}. */
|
|
1689
|
+
bufferDropped: boolean,
|
|
1690
|
+
): TraceSelection {
|
|
1691
|
+
const matches: number[] = [];
|
|
1692
|
+
buffer.forEach((line, index) => {
|
|
1693
|
+
if (line.port === trace.port && line.frame?.task === trace.task) matches.push(index);
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
// An id can belong to two different requests: ports are ephemeral, and a
|
|
1697
|
+
// child that died and respawned on the same one starts its task counter at 0
|
|
1698
|
+
// again. Members more than a request's width apart are therefore two runs,
|
|
1699
|
+
// and merging them would present one operator's request as another's — the
|
|
1700
|
+
// exact mis-attribution this console is built to avoid. `anchorSeq` says
|
|
1701
|
+
// which one was actually clicked, so only that run is shown.
|
|
1702
|
+
const runs: number[][] = [];
|
|
1703
|
+
for (const index of matches) {
|
|
1704
|
+
const current = runs.at(-1);
|
|
1705
|
+
const previous = current?.at(-1);
|
|
1706
|
+
if (current === undefined || previous === undefined || index - previous > TRACE_SPLIT_GAP) {
|
|
1707
|
+
runs.push([index]);
|
|
1708
|
+
} else {
|
|
1709
|
+
current.push(index);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// The run holding the row that opened the trace. If that row has since been
|
|
1714
|
+
// evicted there is nothing to disambiguate with, so the newest run wins —
|
|
1715
|
+
// still one request, never a merge of two.
|
|
1716
|
+
const chosen =
|
|
1717
|
+
runs.find((run) => run.some((index) => buffer[index]?.seq === trace.anchorSeq)) ??
|
|
1718
|
+
runs.at(-1) ??
|
|
1719
|
+
[];
|
|
1720
|
+
const indices = [...chosen];
|
|
1721
|
+
|
|
1722
|
+
const first = indices[0];
|
|
1723
|
+
let adjacent = false;
|
|
1724
|
+
if (first !== undefined && first > 0) {
|
|
1725
|
+
const before = buffer[first - 1];
|
|
1726
|
+
if (before !== undefined && before.port === trace.port && before.frame?.task === -1) {
|
|
1727
|
+
indices.unshift(first - 1);
|
|
1728
|
+
adjacent = true;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const members = indices
|
|
1733
|
+
.map((index) => buffer[index])
|
|
1734
|
+
.filter((line): line is LogLine => line !== undefined);
|
|
1735
|
+
|
|
1736
|
+
return {
|
|
1737
|
+
members,
|
|
1738
|
+
// Only true when lines were ACTUALLY evicted. A trace that simply starts at
|
|
1739
|
+
// the front of a buffer nothing has fallen out of is complete, and saying
|
|
1740
|
+
// otherwise would be a warning about a loss that never happened.
|
|
1741
|
+
partial: indices[0] === 0 && bufferDropped,
|
|
1742
|
+
splitRuns: runs.length > 1,
|
|
1743
|
+
// Read off the traced lines themselves, never off "some line that used this
|
|
1744
|
+
// port once": a child that respawned on a reused port would otherwise have
|
|
1745
|
+
// the banner name the model that USED to be there.
|
|
1746
|
+
modelId: members.find((line) => line.modelId !== null)?.modelId ?? null,
|
|
1747
|
+
adjacent,
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* The whole log pipeline for one repaint: stage every axis, group the
|
|
1753
|
+
* launch-argument runs, count honestly, and cap what reaches the DOM.
|
|
1754
|
+
*
|
|
1755
|
+
* Two filter axes mean the counts cannot be a sequence of narrowing passes.
|
|
1756
|
+
* Each is staged as a boolean on the line, the runs are grouped once, and a
|
|
1757
|
+
* single walk lifts one axis at a time — so `levels[WARN]` is what pressing
|
|
1758
|
+
* WARN yields with the record-type chip still applied, `families[models]` is
|
|
1759
|
+
* what pressing `models` yields with the level chip still applied, and
|
|
1760
|
+
* `hiddenProxy` is what the toggle would reveal with both applied. Every one of
|
|
1761
|
+
* those numbers is literally true under every combination of the others.
|
|
1762
|
+
*/
|
|
1763
|
+
function selectLog(snapshot: Snapshot, ui: UiState): LogSelection {
|
|
1764
|
+
const ctx: RowContext = {
|
|
1765
|
+
shorts: new Map(snapshot.models.map((m) => [m.id, m])),
|
|
1766
|
+
scoped: ui.filterModel,
|
|
1767
|
+
trace: ui.trace,
|
|
1768
|
+
};
|
|
1769
|
+
const rawQuery = ui.query.trim();
|
|
1770
|
+
const query = rawQuery.toLowerCase();
|
|
1771
|
+
const buffer = visibleBuffer(ui);
|
|
1772
|
+
|
|
1773
|
+
let hiddenRouter = 0;
|
|
1774
|
+
let contextLost = 0;
|
|
1775
|
+
const staged: StagedLine[] = [];
|
|
1776
|
+
|
|
1777
|
+
for (const line of buffer) {
|
|
1778
|
+
// Counted over the whole buffer, matching or not: the banner it drives says
|
|
1779
|
+
// "of the N buffered", and both halves of that sentence have to be about
|
|
1780
|
+
// the same population.
|
|
1781
|
+
if (line.contextLost === true) contextLost += 1;
|
|
1782
|
+
const scope = lineScope(line);
|
|
1783
|
+
if (ui.filterModel !== null && line.modelId !== ui.filterModel) {
|
|
1784
|
+
// Scoping to one model silently removes the boot banner, the preset
|
|
1785
|
+
// catalogue and the launch arguments, because none of them belong to a
|
|
1786
|
+
// model. Count them so the console can say so instead.
|
|
1787
|
+
if (scope === "router") hiddenRouter += 1;
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
const kind = line.kind ?? "event";
|
|
1791
|
+
const family = line.family ?? "other";
|
|
1792
|
+
staged.push({
|
|
1793
|
+
line,
|
|
1794
|
+
scope,
|
|
1795
|
+
kind,
|
|
1796
|
+
family,
|
|
1797
|
+
// Matched against the file's own line, frame included: the task id is
|
|
1798
|
+
// visible on the row, so it has to be findable in the box.
|
|
1799
|
+
hit: query === "" || lineText(line).toLowerCase().includes(query),
|
|
1800
|
+
shown: kind !== "proxy" || ui.showProxy,
|
|
1801
|
+
levelPass: ui.filterLevel === "all" || line.level === ui.filterLevel,
|
|
1802
|
+
familyPass: ui.filterFamily === "any" || family === ui.filterFamily,
|
|
1803
|
+
runId: null,
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
groupRuns(staged);
|
|
1808
|
+
|
|
1809
|
+
// A run is one artifact, so a hit anywhere in it counts for all of it.
|
|
1810
|
+
const runHit = new Map<number, boolean>();
|
|
1811
|
+
for (const entry of staged) {
|
|
1812
|
+
if (entry.runId === null) continue;
|
|
1813
|
+
runHit.set(entry.runId, (runHit.get(entry.runId) ?? false) || entry.hit);
|
|
1814
|
+
}
|
|
1815
|
+
for (const entry of staged) {
|
|
1816
|
+
if (entry.runId !== null) entry.hit = query === "" || (runHit.get(entry.runId) ?? false);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
const levels: Record<LevelFilter, number> = { all: 0, DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0 };
|
|
1820
|
+
const families: Record<FamilyFilter, number> = {
|
|
1821
|
+
any: 0,
|
|
1822
|
+
requests: 0,
|
|
1823
|
+
models: 0,
|
|
1824
|
+
startup: 0,
|
|
1825
|
+
other: 0,
|
|
1826
|
+
};
|
|
1827
|
+
const kept: KeptLine[] = [];
|
|
1828
|
+
let hiddenProxy = 0;
|
|
1829
|
+
let proxyShown = 0;
|
|
1830
|
+
let folded = 0;
|
|
1831
|
+
let framed = 0;
|
|
1832
|
+
|
|
1833
|
+
for (const entry of staged) {
|
|
1834
|
+
if (!entry.hit) continue;
|
|
1835
|
+
if (!entry.shown) {
|
|
1836
|
+
// Dropped ONLY for being proxy traffic — already past the model scope,
|
|
1837
|
+
// the query, the level chip and the record-type chip. That is what makes
|
|
1838
|
+
// the toggle's number a promise rather than an upper bound.
|
|
1839
|
+
if (entry.levelPass && entry.familyPass) hiddenProxy += 1;
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1842
|
+
if (entry.familyPass) {
|
|
1843
|
+
levels.all += 1;
|
|
1844
|
+
levels[entry.line.level] += 1;
|
|
1845
|
+
}
|
|
1846
|
+
if (entry.levelPass) {
|
|
1847
|
+
families.any += 1;
|
|
1848
|
+
families[entry.family] += 1;
|
|
1849
|
+
}
|
|
1850
|
+
if (!entry.levelPass || !entry.familyPass) continue;
|
|
1851
|
+
kept.push({
|
|
1852
|
+
line: entry.line,
|
|
1853
|
+
scope: entry.scope,
|
|
1854
|
+
kind: entry.kind,
|
|
1855
|
+
runId: entry.runId,
|
|
1856
|
+
hit: true,
|
|
1857
|
+
traced: false,
|
|
1858
|
+
});
|
|
1859
|
+
if (entry.kind === "proxy") proxyShown += 1;
|
|
1860
|
+
if (entry.runId !== null) folded += 1;
|
|
1861
|
+
if (entry.line.frame !== undefined) framed += 1;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const matched = kept.length;
|
|
1865
|
+
const renderCapped = matched > LOG_RENDER_LIMIT;
|
|
1866
|
+
const cutAt = renderCapped ? matched - LOG_RENDER_LIMIT : 0;
|
|
1867
|
+
const painted = renderCapped ? kept.slice(cutAt) : kept;
|
|
1868
|
+
/** The oldest line the buffer still holds — the boundary an eviction cut at. */
|
|
1869
|
+
const oldestHeld = buffer[0]?.seq;
|
|
1870
|
+
|
|
1871
|
+
const rows: LogRowVm[] = [];
|
|
1872
|
+
for (let i = 0; i < painted.length; ) {
|
|
1873
|
+
const entry = painted[i];
|
|
1874
|
+
if (entry === undefined) {
|
|
1875
|
+
i += 1;
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
if (entry.runId === null) {
|
|
1879
|
+
rows.push(logRow(ctx, entry, String(entry.line.seq), entry.line.message, false, null));
|
|
1880
|
+
i += 1;
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
let end = i;
|
|
1885
|
+
while (end < painted.length && painted[end]?.runId === entry.runId) end += 1;
|
|
1886
|
+
const run = painted.slice(i, end);
|
|
1887
|
+
const matches =
|
|
1888
|
+
query === ""
|
|
1889
|
+
? 0
|
|
1890
|
+
: run.filter((member) => lineText(member.line).toLowerCase().includes(query)).length;
|
|
1891
|
+
// A hit inside a collapsed fold opens it and says why, without touching
|
|
1892
|
+
// what the operator set: clearing the query puts the fold back.
|
|
1893
|
+
const forced = matches > 0;
|
|
1894
|
+
const sticky = ui.expandedArgs[entry.runId] === true;
|
|
1895
|
+
const expanded = sticky || forced;
|
|
1896
|
+
// Truncation is a fact about THIS run, not about the buffer. Only the front
|
|
1897
|
+
// of the window can have lost members, and only two things can have taken
|
|
1898
|
+
// them: the render slice cutting into the middle of the run, or the buffer
|
|
1899
|
+
// evicting the members that came before its oldest held line. Anything
|
|
1900
|
+
// looser puts "(older lines dropped)" on a complete 31-of-31 launch
|
|
1901
|
+
// command, which is the exact false claim this label exists to make.
|
|
1902
|
+
const cutByRender = i === 0 && cutAt > 0 && kept[cutAt - 1]?.runId === entry.runId;
|
|
1903
|
+
const cutByBuffer =
|
|
1904
|
+
i === 0 && ui.bufferDropped && oldestHeld !== undefined && entry.line.seq === oldestHeld;
|
|
1905
|
+
const truncated = cutByRender || cutByBuffer;
|
|
1906
|
+
const count = run.length;
|
|
1907
|
+
const noun = `launch argument${count === 1 ? "" : "s"}`;
|
|
1908
|
+
const fold: FoldVm = {
|
|
1909
|
+
seq: entry.runId,
|
|
1910
|
+
count,
|
|
1911
|
+
expanded,
|
|
1912
|
+
sticky,
|
|
1913
|
+
truncated,
|
|
1914
|
+
matches,
|
|
1915
|
+
forced,
|
|
1916
|
+
label: foldLabel(expanded, count, truncated, matches, rawQuery),
|
|
1917
|
+
// No model is named: an args line is router-wide and carries no model id,
|
|
1918
|
+
// and reading one out of the neighbouring spawn line would be an invented
|
|
1919
|
+
// attribution — the one thing the model column exists to avoid.
|
|
1920
|
+
//
|
|
1921
|
+
// A fold a query holds open cannot be named "Hide …": pressing it would
|
|
1922
|
+
// change nothing visible, only what happens once the query clears. So it
|
|
1923
|
+
// says what the press actually does.
|
|
1924
|
+
ariaLabel: !forced
|
|
1925
|
+
? `${expanded ? "Hide" : "Show"} the ${formatCount(count)} ${noun}`
|
|
1926
|
+
: sticky
|
|
1927
|
+
? `${formatCount(count)} ${noun}, open because the search matches inside them. Collapse them once the search is cleared.`
|
|
1928
|
+
: `${formatCount(count)} ${noun}, open because the search matches inside them. Keep them open once the search is cleared.`,
|
|
1929
|
+
};
|
|
1930
|
+
rows.push(logRow(ctx, entry, `fold:${entry.runId}`, fold.label, false, fold));
|
|
1931
|
+
if (expanded) {
|
|
1932
|
+
for (const member of run) {
|
|
1933
|
+
rows.push(logRow(ctx, member, String(member.line.seq), member.line.message, true, null));
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
i = end;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// The trace branch, taken AFTER the counts and BEFORE the rows are handed
|
|
1940
|
+
// out: membership ignores every filter, but the chips keep reporting the
|
|
1941
|
+
// filtered buffer so they stay live and honest while a trace is open. That is
|
|
1942
|
+
// what lets every control stay enabled — pressing one exits the trace and
|
|
1943
|
+
// applies itself, and its number was already true.
|
|
1944
|
+
let trace: TraceSelection | null = null;
|
|
1945
|
+
if (ui.trace !== null) {
|
|
1946
|
+
trace = selectTrace(buffer, ui.trace, ui.bufferDropped);
|
|
1947
|
+
rows.length = 0;
|
|
1948
|
+
for (const line of trace.members.slice(-LOG_RENDER_LIMIT)) {
|
|
1949
|
+
rows.push(
|
|
1950
|
+
logRow(
|
|
1951
|
+
ctx,
|
|
1952
|
+
{
|
|
1953
|
+
line,
|
|
1954
|
+
scope: lineScope(line),
|
|
1955
|
+
kind: line.kind ?? "event",
|
|
1956
|
+
runId: null,
|
|
1957
|
+
hit: true,
|
|
1958
|
+
traced: true,
|
|
1959
|
+
},
|
|
1960
|
+
String(line.seq),
|
|
1961
|
+
line.message,
|
|
1962
|
+
false,
|
|
1963
|
+
null,
|
|
1964
|
+
),
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
return {
|
|
1970
|
+
rows,
|
|
1971
|
+
trace,
|
|
1972
|
+
exportLines: kept.map((entry) =>
|
|
1973
|
+
logRow(ctx, entry, String(entry.line.seq), entry.line.message, false, null),
|
|
1974
|
+
),
|
|
1975
|
+
counts: {
|
|
1976
|
+
rendered: painted.length,
|
|
1977
|
+
matched,
|
|
1978
|
+
buffered: buffer.length,
|
|
1979
|
+
hiddenProxy,
|
|
1980
|
+
proxyShown,
|
|
1981
|
+
hiddenRouter,
|
|
1982
|
+
folded,
|
|
1983
|
+
bufferDropped: ui.bufferDropped,
|
|
1984
|
+
renderCapped,
|
|
1985
|
+
levels,
|
|
1986
|
+
families,
|
|
1987
|
+
framed,
|
|
1988
|
+
contextLost,
|
|
1989
|
+
traced: trace === null ? 0 : trace.members.length,
|
|
1990
|
+
},
|
|
1991
|
+
newestMatchedAt: kept.at(-1)?.line.ts ?? null,
|
|
1992
|
+
newestBufferedAt: buffer.at(-1)?.ts ?? null,
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/** The filters an operator has actually set, in the order the toolbar shows them. */
|
|
1997
|
+
function activeFilters(ui: UiState, shorts: Map<string, ModelInfo>): string[] {
|
|
1998
|
+
const clauses: string[] = [];
|
|
1999
|
+
if (ui.filterModel !== null) clauses.push(shorts.get(ui.filterModel)?.short ?? ui.filterModel);
|
|
2000
|
+
if (ui.filterFamily !== "any") clauses.push(ui.filterFamily);
|
|
2001
|
+
if (ui.filterLevel !== "all") clauses.push(ui.filterLevel);
|
|
2002
|
+
const query = ui.query.trim();
|
|
2003
|
+
if (query !== "") clauses.push(`"${query}"`);
|
|
2004
|
+
return clauses;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
const CLEAR_FILTERS: ConsoleActionVm = {
|
|
2008
|
+
label: "Clear filters",
|
|
2009
|
+
kind: "clear-filters",
|
|
2010
|
+
ariaLabel: "Clear the model, record type, level and search filters",
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
const SHOW_ALL_MODELS: ConsoleActionVm = {
|
|
2014
|
+
label: "show all",
|
|
2015
|
+
kind: "show-all-models",
|
|
2016
|
+
ariaLabel: "Show every model's lines, including the router-wide ones",
|
|
2017
|
+
};
|
|
2018
|
+
|
|
2019
|
+
const EXIT_TRACE: ConsoleActionVm = {
|
|
2020
|
+
label: "back",
|
|
2021
|
+
kind: "exit-trace",
|
|
2022
|
+
ariaLabel: "Close the trace and go back to the filtered log",
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
const SHOW_CONTEXT_LOST: ConsoleActionVm = {
|
|
2026
|
+
label: "show them",
|
|
2027
|
+
kind: "query-truncated",
|
|
2028
|
+
// Named as the search it is, so nobody expects a fourth filter chip to appear.
|
|
2029
|
+
ariaLabel: `Search the log for ${CONTEXT_LOST_QUERY}`,
|
|
2030
|
+
};
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* The strip that makes `truncated = 1` findable.
|
|
2034
|
+
*
|
|
2035
|
+
* The level is NOT rewritten to make it findable — llama-server says INFO and
|
|
2036
|
+
* the console says INFO, because an operator who has learned that the ERROR
|
|
2037
|
+
* chip means "llama-server said E" must be able to keep believing that. So the
|
|
2038
|
+
* badge alone would be findable only by scrolling, and this is the strip that
|
|
2039
|
+
* fixes it.
|
|
2040
|
+
*
|
|
2041
|
+
* It appears only when the count is above zero. There is no "0 requests lost
|
|
2042
|
+
* context" state: that would be a health affirmation about a signal the console
|
|
2043
|
+
* can only see a 500-line window of, which is why the copy says "buffered"
|
|
2044
|
+
* rather than implying a total.
|
|
2045
|
+
*/
|
|
2046
|
+
export function truncatedBanner(counts: LogCountsVm): ConsoleBannerVm | null {
|
|
2047
|
+
if (counts.contextLost <= 0) return null;
|
|
2048
|
+
// "lines", because lines are what is counted. `buffered` is a LINE count, and
|
|
2049
|
+
// a 500-line window is roughly 20 requests — so "500 buffered requests" would
|
|
2050
|
+
// be false by a factor of 25, in the one strip whose whole job is to be
|
|
2051
|
+
// trusted. The noun also has to agree with the number it sits against, which
|
|
2052
|
+
// is the denominator.
|
|
2053
|
+
const noun = counts.buffered === 1 ? "line" : "lines";
|
|
2054
|
+
return {
|
|
2055
|
+
key: "context-lost",
|
|
2056
|
+
placement: "top",
|
|
2057
|
+
tone: "warn",
|
|
2058
|
+
text: `▲ ${formatCount(counts.contextLost)} of the ${formatCount(
|
|
2059
|
+
counts.buffered,
|
|
2060
|
+
)} buffered ${noun} said the reply lost context`,
|
|
2061
|
+
detail: "A context shift discarded the front of the conversation before the reply was written.",
|
|
2062
|
+
action: SHOW_CONTEXT_LOST,
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/** The open trace, as its banner reads it. */
|
|
2067
|
+
function selectTraceVm(
|
|
2068
|
+
trace: TraceRef,
|
|
2069
|
+
selection: TraceSelection,
|
|
2070
|
+
shorts: Map<string, ModelInfo>,
|
|
2071
|
+
): TraceVm {
|
|
2072
|
+
const modelLabel =
|
|
2073
|
+
selection.modelId === null ? "" : (shorts.get(selection.modelId)?.short ?? selection.modelId);
|
|
2074
|
+
const model = modelLabel === "" ? "" : ` · ${modelLabel}`;
|
|
2075
|
+
// Both guards are said out loud rather than handled quietly. The rows are the
|
|
2076
|
+
// same either way; what changes is whether the operator knows.
|
|
2077
|
+
const partial = selection.partial ? " · earliest lines dropped from the buffer" : "";
|
|
2078
|
+
// Two requests share this id because the port was reused. Only the one that
|
|
2079
|
+
// was clicked is shown — merging them would be a mis-attribution — and the
|
|
2080
|
+
// banner says so rather than letting a short trace look like the whole story.
|
|
2081
|
+
const split = selection.splitRuns
|
|
2082
|
+
? ` · another request on port ${trace.port} shares this id — showing only the one you opened`
|
|
2083
|
+
: "";
|
|
2084
|
+
// "in file order" closes off sorting; there is no header a reader could take
|
|
2085
|
+
// for a sort control, and task ids are not monotonic anyway (a deferred task
|
|
2086
|
+
// is allocated its id at enqueue and logs later with a lower one).
|
|
2087
|
+
const adjacency = selection.adjacent
|
|
2088
|
+
? " The first line has no task id of its own — it is attached by position."
|
|
2089
|
+
: "";
|
|
2090
|
+
return {
|
|
2091
|
+
port: trace.port,
|
|
2092
|
+
task: trace.task,
|
|
2093
|
+
modelLabel,
|
|
2094
|
+
count: selection.members.length,
|
|
2095
|
+
title: `▾ tracing task ${trace.task} · port ${trace.port}${model} · ${formatLines(
|
|
2096
|
+
selection.members.length,
|
|
2097
|
+
)}${partial}${split}`,
|
|
2098
|
+
detail:
|
|
2099
|
+
"Every line llama-server wrote for this request, in file order. Filters do not apply inside a trace. Task ids are llama-server's internal handles: sparse, reused across children, and not in order." +
|
|
2100
|
+
adjacency,
|
|
2101
|
+
backLabel: EXIT_TRACE.label,
|
|
2102
|
+
backAriaLabel: EXIT_TRACE.ariaLabel,
|
|
2103
|
+
partial: selection.partial,
|
|
2104
|
+
splitRuns: selection.splitRuns,
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** Paths macOS's `tmp_cleaner` unlinks after three untouched days. */
|
|
2109
|
+
const TMP_PREFIXES = ["/tmp/", "/private/tmp/"];
|
|
2110
|
+
|
|
2111
|
+
/**
|
|
2112
|
+
* The file-unlinked copy. Warn-toned and never called an error: nothing failed,
|
|
2113
|
+
* the tailer holds its offset, and it re-opens the file the moment it returns.
|
|
2114
|
+
* The path is named verbatim — that is what makes this diagnosable, and it is
|
|
2115
|
+
* how the operator confirms Steward is watching what they think it is.
|
|
2116
|
+
*/
|
|
2117
|
+
function fileMissingCopy(path: string | null): { title: string; detail: string } {
|
|
2118
|
+
const named = path === null ? "The log file Steward was watching" : path;
|
|
2119
|
+
const temporary = path !== null && TMP_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
2120
|
+
const why = temporary
|
|
2121
|
+
? " macOS clears /tmp files that have gone untouched for three days, so a router that was stopped that long has no log left."
|
|
2122
|
+
: "";
|
|
2123
|
+
const fix = temporary ? " Fix it for good: point llama-server's log somewhere outside /tmp." : "";
|
|
2124
|
+
return {
|
|
2125
|
+
title: "The log file is gone",
|
|
2126
|
+
detail: `${named} no longer exists.${why} Steward is still watching that path and picks the log back up the moment llama-server writes to it again.${fix}`,
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function consoleState(
|
|
2131
|
+
snapshot: Snapshot,
|
|
2132
|
+
ui: UiState,
|
|
2133
|
+
counts: LogCountsVm,
|
|
2134
|
+
newestMatchedAt: number | null,
|
|
2135
|
+
now: number,
|
|
2136
|
+
): ConsoleState {
|
|
2137
|
+
if (ui.logSource === "unavailable") return "no-source";
|
|
2138
|
+
if (ui.logSource === "missing") return "file-missing";
|
|
2139
|
+
if (ui.logStream === "reconnecting") return "reconnecting";
|
|
2140
|
+
if (!snapshot.service.running) return "stopped";
|
|
2141
|
+
if (ui.paused) return "paused";
|
|
2142
|
+
// Above `empty-filtered`: a trace ignores the filters, so a filter stack that
|
|
2143
|
+
// matches nothing says nothing about what the trace is showing.
|
|
2144
|
+
if (ui.trace !== null) return "tracing";
|
|
2145
|
+
if (counts.matched === 0 && counts.buffered > 0) return "empty-filtered";
|
|
2146
|
+
if (counts.buffered === 0) return "cold";
|
|
2147
|
+
// `LogLine.ts` is Steward's ARRIVAL stamp, so the newest matching row's stamp
|
|
2148
|
+
// already is "when the last visible line arrived". No extra bookkeeping.
|
|
2149
|
+
if (newestMatchedAt !== null && now - newestMatchedAt > QUIET_AFTER_MS) return "quiet";
|
|
2150
|
+
return "streaming";
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* The empty-filtered notice.
|
|
2155
|
+
*
|
|
2156
|
+
* The ERROR chip gets its own words. An empty ERROR console is the single
|
|
2157
|
+
* easiest place in this dashboard to imply "all clear", and it would be false:
|
|
2158
|
+
* llama-server writes nothing at all for a rejected request and reports a
|
|
2159
|
+
* failed model load at INFO.
|
|
2160
|
+
*/
|
|
2161
|
+
function emptyFilteredNotice(ui: UiState, counts: LogCountsVm, clauses: string[]): ConsoleNoticeVm {
|
|
2162
|
+
const buffered = formatLines(counts.buffered);
|
|
2163
|
+
const proxyClause =
|
|
2164
|
+
counts.hiddenProxy > 0
|
|
2165
|
+
? ` ${formatCount(counts.hiddenProxy)} proxied lines are hidden by the toolbar toggle.`
|
|
2166
|
+
: "";
|
|
2167
|
+
|
|
2168
|
+
if (ui.filterLevel === "ERROR") {
|
|
2169
|
+
const others = clauses.filter((clause) => clause !== "ERROR");
|
|
2170
|
+
return {
|
|
2171
|
+
state: "empty-filtered",
|
|
2172
|
+
glyph: "",
|
|
2173
|
+
title:
|
|
2174
|
+
others.length === 0
|
|
2175
|
+
? `No ERROR lines among the ${formatCount(counts.buffered)} buffered.`
|
|
2176
|
+
: `No ERROR lines match ${others.join(" + ")}.`,
|
|
2177
|
+
detail:
|
|
2178
|
+
`This is not a health check: llama-server logs nothing at all for rejected requests, and reports a failed model load at INFO. A clean console is not a clean server.` +
|
|
2179
|
+
(others.length === 0 ? "" : ` ${buffered} are buffered.`) +
|
|
2180
|
+
proxyClause,
|
|
2181
|
+
tone: "muted",
|
|
2182
|
+
action: CLEAR_FILTERS,
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
return {
|
|
2187
|
+
state: "empty-filtered",
|
|
2188
|
+
glyph: "",
|
|
2189
|
+
title:
|
|
2190
|
+
clauses.length === 0 ? "No lines are showing." : `No lines match ${clauses.join(" + ")}.`,
|
|
2191
|
+
detail: `${buffered} are buffered.${proxyClause}`,
|
|
2192
|
+
tone: "muted",
|
|
2193
|
+
action: clauses.length === 0 ? null : CLEAR_FILTERS,
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/**
|
|
2198
|
+
* The console's notices and strips.
|
|
2199
|
+
*
|
|
2200
|
+
* Exactly one state wins, but notices and strips are not the same thing: a
|
|
2201
|
+
* state that has real lines under it gets a strip and keeps the lines (those
|
|
2202
|
+
* eleven banner lines are what the operator opened the console to read), and
|
|
2203
|
+
* only a state with nothing readable takes the whole panel. The truncation and
|
|
2204
|
+
* scope strips are orthogonal to all of it and stack on top.
|
|
2205
|
+
*/
|
|
2206
|
+
function selectConsole(
|
|
2207
|
+
snapshot: Snapshot,
|
|
2208
|
+
ui: UiState,
|
|
2209
|
+
selection: LogSelection,
|
|
2210
|
+
now: number,
|
|
2211
|
+
shorts: Map<string, ModelInfo>,
|
|
2212
|
+
): ConsoleVm {
|
|
2213
|
+
const counts = selection.counts;
|
|
2214
|
+
const state = consoleState(snapshot, ui, counts, selection.newestMatchedAt, now);
|
|
2215
|
+
const banners: ConsoleBannerVm[] = [];
|
|
2216
|
+
let notice: ConsoleNoticeVm | null = null;
|
|
2217
|
+
const proxyClause =
|
|
2218
|
+
counts.hiddenProxy > 0 ? ` · ${formatCount(counts.hiddenProxy)} proxied lines hidden` : "";
|
|
2219
|
+
const frozenBehind =
|
|
2220
|
+
ui.paused && ui.frozen !== null ? Math.max(0, ui.log.length - ui.frozen.length) : 0;
|
|
2221
|
+
const trace =
|
|
2222
|
+
ui.trace === null || selection.trace === null
|
|
2223
|
+
? null
|
|
2224
|
+
: selectTraceVm(ui.trace, selection.trace, shorts);
|
|
2225
|
+
|
|
2226
|
+
if (trace !== null) {
|
|
2227
|
+
banners.push({
|
|
2228
|
+
key: "trace",
|
|
2229
|
+
placement: "top",
|
|
2230
|
+
tone: "trace",
|
|
2231
|
+
text: trace.title,
|
|
2232
|
+
detail: trace.detail,
|
|
2233
|
+
action: EXIT_TRACE,
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
if (state === "reconnecting") {
|
|
2238
|
+
banners.push({
|
|
2239
|
+
key: "reconnecting",
|
|
2240
|
+
placement: "top",
|
|
2241
|
+
tone: "muted",
|
|
2242
|
+
text: "◌ Reconnecting to the log stream…",
|
|
2243
|
+
detail: "The lines below are the last Steward read; nothing has been cleared.",
|
|
2244
|
+
action: null,
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
if (state === "paused") {
|
|
2249
|
+
banners.push({
|
|
2250
|
+
key: "paused",
|
|
2251
|
+
placement: "top",
|
|
2252
|
+
tone: "warn",
|
|
2253
|
+
text:
|
|
2254
|
+
frozenBehind > 0
|
|
2255
|
+
? `⏸ Paused · buffer frozen · ${formatLines(frozenBehind)} arrived behind it`
|
|
2256
|
+
: "⏸ Paused · buffer frozen",
|
|
2257
|
+
detail: "",
|
|
2258
|
+
action: null,
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// Everything below describes the FILTERED view, and filters do not apply
|
|
2263
|
+
// inside a trace — so while one is open these strips would be claims about a
|
|
2264
|
+
// console nobody is looking at.
|
|
2265
|
+
const contextLostStrip = trace === null ? truncatedBanner(counts) : null;
|
|
2266
|
+
if (contextLostStrip !== null) banners.push(contextLostStrip);
|
|
2267
|
+
|
|
2268
|
+
if (trace === null && (counts.renderCapped || counts.bufferDropped)) {
|
|
2269
|
+
const shown = `showing the latest ${formatCount(counts.rendered)} of ${formatCount(
|
|
2270
|
+
counts.matched,
|
|
2271
|
+
)} matching`;
|
|
2272
|
+
const dropped = `older lines dropped from the ${LOG_BUFFER_LIMIT}-line buffer`;
|
|
2273
|
+
banners.push({
|
|
2274
|
+
key: "truncation",
|
|
2275
|
+
placement: "top",
|
|
2276
|
+
tone: "muted",
|
|
2277
|
+
text:
|
|
2278
|
+
counts.renderCapped && counts.bufferDropped
|
|
2279
|
+
? `${shown} · ${dropped}`
|
|
2280
|
+
: counts.renderCapped
|
|
2281
|
+
? `${shown} lines`
|
|
2282
|
+
: `${LOG_BUFFER_LIMIT}-line buffer · older lines dropped`,
|
|
2283
|
+
detail: "",
|
|
2284
|
+
action: null,
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
if (trace === null && ui.filterModel !== null && counts.hiddenRouter > 0) {
|
|
2289
|
+
const label = shorts.get(ui.filterModel)?.short ?? ui.filterModel;
|
|
2290
|
+
banners.push({
|
|
2291
|
+
key: "scope",
|
|
2292
|
+
placement: "top",
|
|
2293
|
+
tone: "muted",
|
|
2294
|
+
text: `scoped to ${label} · ${formatCount(counts.hiddenRouter)} router-wide lines hidden`,
|
|
2295
|
+
detail: "",
|
|
2296
|
+
action: SHOW_ALL_MODELS,
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
switch (state) {
|
|
2301
|
+
case "no-source":
|
|
2302
|
+
// `unavailable` covers two different failures and they need opposite
|
|
2303
|
+
// copy. With NO path, nothing was ever wired up and the lines on screen
|
|
2304
|
+
// are the fallback's simulation. With a path, a real log was found and
|
|
2305
|
+
// could not be read (a permission or I/O problem) — the lines are the
|
|
2306
|
+
// server's, and telling that operator they are looking at a simulation
|
|
2307
|
+
// would be exactly the false claim this console exists to prevent.
|
|
2308
|
+
notice =
|
|
2309
|
+
ui.logSourcePath === null
|
|
2310
|
+
? {
|
|
2311
|
+
state,
|
|
2312
|
+
glyph: "⚠",
|
|
2313
|
+
title: "No log source connected",
|
|
2314
|
+
detail:
|
|
2315
|
+
"Steward is running but has not been pointed at a llama-server log. Ask your agent to wire up log streaming — this console fills on its own once it is." +
|
|
2316
|
+
(counts.buffered > 0
|
|
2317
|
+
? " The lines below are Steward's built-in simulation, not this machine's log."
|
|
2318
|
+
: ""),
|
|
2319
|
+
tone: "warn",
|
|
2320
|
+
action: null,
|
|
2321
|
+
}
|
|
2322
|
+
: {
|
|
2323
|
+
state,
|
|
2324
|
+
glyph: "⚠",
|
|
2325
|
+
title: "The log file cannot be read",
|
|
2326
|
+
detail:
|
|
2327
|
+
// The server's own words, not a paraphrase: "(EACCES)" tells
|
|
2328
|
+
// the operator what to fix where "something went wrong" does not.
|
|
2329
|
+
`${ui.logSourceDetail ?? `${ui.logSourcePath} could not be read`}. Steward keeps trying, and picks the log back up as soon as it can read it.` +
|
|
2330
|
+
(counts.buffered > 0
|
|
2331
|
+
? " The lines below are what it read before that stopped working."
|
|
2332
|
+
: ""),
|
|
2333
|
+
tone: "warn",
|
|
2334
|
+
action: null,
|
|
2335
|
+
};
|
|
2336
|
+
break;
|
|
2337
|
+
case "file-missing": {
|
|
2338
|
+
const copy = fileMissingCopy(ui.logSourcePath);
|
|
2339
|
+
if (counts.buffered === 0) {
|
|
2340
|
+
notice = {
|
|
2341
|
+
state,
|
|
2342
|
+
glyph: "⚠",
|
|
2343
|
+
title: copy.title,
|
|
2344
|
+
detail: copy.detail,
|
|
2345
|
+
tone: "warn",
|
|
2346
|
+
action: null,
|
|
2347
|
+
};
|
|
2348
|
+
} else {
|
|
2349
|
+
banners.push({
|
|
2350
|
+
key: "file-missing",
|
|
2351
|
+
placement: "bottom",
|
|
2352
|
+
tone: "warn",
|
|
2353
|
+
text: `⚠ ${copy.title}`,
|
|
2354
|
+
detail: copy.detail,
|
|
2355
|
+
action: null,
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
break;
|
|
2359
|
+
}
|
|
2360
|
+
case "stopped": {
|
|
2361
|
+
const newest = ui.log.at(-1)?.ts ?? null;
|
|
2362
|
+
if (counts.buffered === 0) {
|
|
2363
|
+
notice = {
|
|
2364
|
+
state,
|
|
2365
|
+
glyph: "⏹",
|
|
2366
|
+
title: "The service is stopped",
|
|
2367
|
+
detail:
|
|
2368
|
+
"llama-server is not running, so nothing is being written. This console fills again as soon as it is started.",
|
|
2369
|
+
tone: "muted",
|
|
2370
|
+
action: null,
|
|
2371
|
+
};
|
|
2372
|
+
} else {
|
|
2373
|
+
banners.push({
|
|
2374
|
+
key: "stopped",
|
|
2375
|
+
placement: "bottom",
|
|
2376
|
+
tone: "muted",
|
|
2377
|
+
text: `⏹ Service stopped · log is idle${
|
|
2378
|
+
newest === null ? "" : ` · last line ${formatClockSeconds(newest)}`
|
|
2379
|
+
}`,
|
|
2380
|
+
detail: "",
|
|
2381
|
+
action: null,
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
case "empty-filtered":
|
|
2387
|
+
notice = emptyFilteredNotice(ui, counts, activeFilters(ui, shorts));
|
|
2388
|
+
break;
|
|
2389
|
+
case "cold":
|
|
2390
|
+
notice = {
|
|
2391
|
+
state,
|
|
2392
|
+
glyph: "◷",
|
|
2393
|
+
title: "Waiting for the first line",
|
|
2394
|
+
detail:
|
|
2395
|
+
"Connected to the log. A running router writes its startup banner immediately, so this should fill within a second. If this notice stays, Steward is watching the wrong file.",
|
|
2396
|
+
tone: "muted",
|
|
2397
|
+
action: null,
|
|
2398
|
+
};
|
|
2399
|
+
break;
|
|
2400
|
+
case "quiet": {
|
|
2401
|
+
// "Quiet" is a claim about the ROUTER, and the console can only make it
|
|
2402
|
+
// about what it is showing. Anything the operator's own filters are
|
|
2403
|
+
// holding back gets said out loud, or the word claims a silence that is
|
|
2404
|
+
// not happening — the proxy toggle is only the commonest case of this,
|
|
2405
|
+
// not the only one.
|
|
2406
|
+
const arriving =
|
|
2407
|
+
selection.newestBufferedAt !== null &&
|
|
2408
|
+
counts.matched < counts.buffered &&
|
|
2409
|
+
now - selection.newestBufferedAt <= QUIET_AFTER_MS;
|
|
2410
|
+
const filtering =
|
|
2411
|
+
ui.filterModel !== null || ui.filterLevel !== "all" || ui.query.trim() !== "";
|
|
2412
|
+
const filteredClause =
|
|
2413
|
+
arriving && filtering ? " · lines are still arriving that the filter hides" : "";
|
|
2414
|
+
banners.push({
|
|
2415
|
+
key: "quiet",
|
|
2416
|
+
placement: "bottom",
|
|
2417
|
+
tone: "muted",
|
|
2418
|
+
text: `▪ quiet · ${formatLines(counts.matched)} · nothing new since ${formatClockSeconds(
|
|
2419
|
+
selection.newestMatchedAt ?? 0,
|
|
2420
|
+
)}${proxyClause}${filteredClause}`,
|
|
2421
|
+
detail: "",
|
|
2422
|
+
action: null,
|
|
2423
|
+
});
|
|
2424
|
+
break;
|
|
2425
|
+
}
|
|
2426
|
+
default:
|
|
2427
|
+
break;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
return {
|
|
2431
|
+
state,
|
|
2432
|
+
lines: selection.rows,
|
|
2433
|
+
notice,
|
|
2434
|
+
banners,
|
|
2435
|
+
paused: ui.paused,
|
|
2436
|
+
trace,
|
|
2437
|
+
// A trace is nothing but framed rows, so the column always exists inside
|
|
2438
|
+
// one. Outside, it is keyed on the whole matched set rather than the
|
|
2439
|
+
// painted window, so it flips at most once per session and never on scroll.
|
|
2440
|
+
showTaskColumn: trace !== null || counts.framed > 0,
|
|
2441
|
+
frozenBehind,
|
|
2442
|
+
// The stamp is Steward's arrival time, not llama-server's own elapsed
|
|
2443
|
+
// counter, and nothing on screen says so to a screen reader otherwise.
|
|
2444
|
+
heading: "Server log — times are local arrival time",
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** What the polite status region should say about the console, and the watermark. */
|
|
2449
|
+
export interface ConsoleAnnouncement {
|
|
2450
|
+
message: string | null;
|
|
2451
|
+
key: string | null;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/**
|
|
2455
|
+
* Announces a console STATE change, once.
|
|
2456
|
+
*
|
|
2457
|
+
* The snapshot poll runs every 1.6 s and the render clock every second, so a
|
|
2458
|
+
* message that is not gated on a transition would turn the status region into a
|
|
2459
|
+
* metronome. Individual lines are never announced — not even errors: a burst of
|
|
2460
|
+
* them would be as hostile as announcing INFO, and the console is a
|
|
2461
|
+
* read-on-demand scrollback by design.
|
|
2462
|
+
*/
|
|
2463
|
+
export function consoleAnnouncement(
|
|
2464
|
+
console: ConsoleVm,
|
|
2465
|
+
path: string | null,
|
|
2466
|
+
lastKey: string | null,
|
|
2467
|
+
): ConsoleAnnouncement {
|
|
2468
|
+
const key = console.state === "file-missing" ? `file-missing:${path ?? ""}` : console.state;
|
|
2469
|
+
if (key === lastKey) return { message: null, key: lastKey };
|
|
2470
|
+
|
|
2471
|
+
const healthy = console.state === "streaming" || console.state === "cold";
|
|
2472
|
+
const message =
|
|
2473
|
+
console.state === "no-source"
|
|
2474
|
+
? "No log source connected."
|
|
2475
|
+
: console.state === "file-missing"
|
|
2476
|
+
? `The log file ${path ?? "Steward was watching"} is gone. Steward is still watching for it.`
|
|
2477
|
+
: console.state === "reconnecting"
|
|
2478
|
+
? "Log stream lost. Reconnecting…"
|
|
2479
|
+
: console.state === "stopped"
|
|
2480
|
+
? "Service stopped; log is idle."
|
|
2481
|
+
: console.state === "quiet"
|
|
2482
|
+
? "Log quiet — no new lines for a minute."
|
|
2483
|
+
: // Pause, filters and folds are announced by the handlers that
|
|
2484
|
+
// caused them, so this channel stays silent for those.
|
|
2485
|
+
healthy && lastKey === "reconnecting"
|
|
2486
|
+
? "Log stream connected."
|
|
2487
|
+
: null;
|
|
2488
|
+
return { message, key };
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/** The honest count clause: what is shown, out of what, and what is held back. */
|
|
2492
|
+
function lineCountLabel(ui: UiState, counts: LogCountsVm): string {
|
|
2493
|
+
// Inside a trace the count is about the trace, not the filter stack — the
|
|
2494
|
+
// filters are not applying, so reporting them would be a lie by omission.
|
|
2495
|
+
if (ui.trace !== null) {
|
|
2496
|
+
return `tracing task ${ui.trace.task} · ${formatLines(counts.traced)}`;
|
|
2497
|
+
}
|
|
2498
|
+
// Proxy suppression alone is not "filtering" — the operator did not ask for
|
|
2499
|
+
// it, so the count keeps its plain form and the suppression gets its own
|
|
2500
|
+
// clause rather than silently shrinking the numerator.
|
|
2501
|
+
const filtering =
|
|
2502
|
+
ui.filterModel !== null ||
|
|
2503
|
+
ui.filterLevel !== "all" ||
|
|
2504
|
+
ui.filterFamily !== "any" ||
|
|
2505
|
+
ui.query.trim() !== "";
|
|
2506
|
+
const head = counts.renderCapped
|
|
2507
|
+
? `showing ${formatCount(counts.rendered)} of ${formatCount(counts.matched)}`
|
|
2508
|
+
: filtering
|
|
2509
|
+
? `${formatCount(counts.matched)} of ${formatCount(counts.buffered)}`
|
|
2510
|
+
: formatLines(counts.matched);
|
|
2511
|
+
const tail = counts.hiddenProxy > 0 ? ` · ${formatCount(counts.hiddenProxy)} proxied hidden` : "";
|
|
2512
|
+
return `${head}${tail}`;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
/**
|
|
2516
|
+
* The proxied-request toggle.
|
|
2517
|
+
*
|
|
2518
|
+
* The chip has room for the class, the count and the fact that it is
|
|
2519
|
+
* reversible; it does not have room for WHY they are hidden, so that goes in
|
|
2520
|
+
* the accessible name and the title — which is where an operator who wonders
|
|
2521
|
+
* will look. `aria-pressed` tracks SHOWN.
|
|
2522
|
+
*/
|
|
2523
|
+
function selectProxyToggle(ui: UiState, counts: LogCountsVm): ProxyToggleVm {
|
|
2524
|
+
if (ui.showProxy) {
|
|
2525
|
+
const name = `Hide proxied request lines. ${formatCount(counts.proxyShown)} of the ${formatCount(
|
|
2526
|
+
counts.matched,
|
|
2527
|
+
)} lines shown are proxied requests, most of them Steward's own status polling.`;
|
|
2528
|
+
return { label: "▾ proxied shown", pressed: true, ariaLabel: name, title: name };
|
|
2529
|
+
}
|
|
2530
|
+
const why =
|
|
2531
|
+
"Hidden by default: Steward's own status polling produces about 1.3 of these per second per loaded model.";
|
|
2532
|
+
const name =
|
|
2533
|
+
counts.hiddenProxy > 0
|
|
2534
|
+
? `Show ${formatCount(counts.hiddenProxy)} proxied request lines. ${why}`
|
|
2535
|
+
: `Show proxied request lines. None are hidden by the current filters. ${why}`;
|
|
2536
|
+
return {
|
|
2537
|
+
label: counts.hiddenProxy > 0 ? `▸ ${formatCount(counts.hiddenProxy)} proxied` : "▸ proxied",
|
|
2538
|
+
pressed: false,
|
|
2539
|
+
ariaLabel: name,
|
|
2540
|
+
title: name,
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Both reset chips read `any`, under a group label that names the axis. Two
|
|
2545
|
+
// adjacent groups whose reset both read `all` and both show the same number at
|
|
2546
|
+
// rest look like one duplicated control; `any kind` / `any level` does not.
|
|
2547
|
+
const LEVEL_CHIP_NAMES: Record<LevelFilter, string> = {
|
|
2548
|
+
all: "Any level",
|
|
2549
|
+
DEBUG: "Debug",
|
|
2550
|
+
INFO: "Info",
|
|
2551
|
+
WARN: "Warnings",
|
|
2552
|
+
ERROR: "Errors",
|
|
2553
|
+
};
|
|
2554
|
+
|
|
2555
|
+
/**
|
|
2556
|
+
* What each record-type chip is FOR, in the accessible name — including
|
|
2557
|
+
* `other`'s, which is the one that says out loud that it is the drift alarm.
|
|
2558
|
+
*/
|
|
2559
|
+
const FAMILY_CHIP_NAMES: Record<FamilyFilter, string> = {
|
|
2560
|
+
any: "Any kind",
|
|
2561
|
+
requests: "Requests",
|
|
2562
|
+
models: "Models",
|
|
2563
|
+
startup: "Startup",
|
|
2564
|
+
other:
|
|
2565
|
+
"Other — lines Steward could not classify. A new llama-server message shape lands here and stays visible",
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2568
|
+
const FAMILY_CHIP_LABELS: Record<FamilyFilter, string> = {
|
|
2569
|
+
any: "any",
|
|
2570
|
+
requests: "requests",
|
|
2571
|
+
models: "models",
|
|
2572
|
+
startup: "startup",
|
|
2573
|
+
other: "other",
|
|
2574
|
+
};
|
|
2575
|
+
|
|
2576
|
+
function selectFamilyChips(ui: UiState, counts: LogCountsVm): FamilyChipVm[] {
|
|
2577
|
+
return FAMILY_FILTERS.map((family) => {
|
|
2578
|
+
const active = ui.filterFamily === family;
|
|
2579
|
+
const count = counts.families[family];
|
|
2580
|
+
return {
|
|
2581
|
+
family,
|
|
2582
|
+
label: FAMILY_CHIP_LABELS[family],
|
|
2583
|
+
count,
|
|
2584
|
+
countLabel: formatCount(count),
|
|
2585
|
+
ariaLabel: `${FAMILY_CHIP_NAMES[family]} — ${formatLines(count)}`,
|
|
2586
|
+
active,
|
|
2587
|
+
background: active ? tint("var(--accent)", 18) : "var(--surface-page)",
|
|
2588
|
+
// Same reasoning as the level chips: the hue stays in the tint and the
|
|
2589
|
+
// border, where it decorates, and never carries the meaning.
|
|
2590
|
+
color: active ? "var(--text-primary)" : "var(--text-secondary)",
|
|
2591
|
+
borderColor: active ? tint("var(--accent)", 45) : "var(--border)",
|
|
2592
|
+
};
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
function selectToolbar(
|
|
2597
|
+
ui: UiState,
|
|
2598
|
+
counts: LogCountsVm,
|
|
2599
|
+
activeModel: ModelInfo | undefined,
|
|
2600
|
+
): ToolbarVm {
|
|
2601
|
+
const activeColor =
|
|
2602
|
+
activeModel === undefined ? "var(--accent)" : modelColor(activeModel.id, activeModel.embedding);
|
|
2603
|
+
|
|
2604
|
+
return {
|
|
2605
|
+
activeModelLabel: activeModel?.short ?? ui.filterModel ?? "all models",
|
|
2606
|
+
activeModelBackground: tint(activeColor, activeModel === undefined ? 16 : 18),
|
|
2607
|
+
activeModelColor: activeColor,
|
|
2608
|
+
familyChips: selectFamilyChips(ui, counts),
|
|
2609
|
+
levelChips: LEVEL_FILTERS.map((level) => {
|
|
2610
|
+
const color = level === "all" ? "var(--accent)" : LEVEL_COLORS[level];
|
|
2611
|
+
const active = ui.filterLevel === level;
|
|
2612
|
+
const count = counts.levels[level];
|
|
2613
|
+
return {
|
|
2614
|
+
level,
|
|
2615
|
+
label: level === "all" ? "any" : level,
|
|
2616
|
+
count,
|
|
2617
|
+
countLabel: formatCount(count),
|
|
2618
|
+
ariaLabel: `${LEVEL_CHIP_NAMES[level]} — ${formatLines(count)}`,
|
|
2619
|
+
active,
|
|
2620
|
+
background: active ? tint(color, 18) : "var(--surface-page)",
|
|
2621
|
+
// The hue stays in the tint and the border, where it decorates and
|
|
2622
|
+
// carries nothing. No level colour clears AA as an 11.5px label on the
|
|
2623
|
+
// chip's own tinted ground in the light theme.
|
|
2624
|
+
color: active ? "var(--text-primary)" : "var(--text-tertiary)",
|
|
2625
|
+
borderColor: active ? tint(color, 45) : "var(--border)",
|
|
2626
|
+
};
|
|
2627
|
+
}),
|
|
2628
|
+
proxyToggle: selectProxyToggle(ui, counts),
|
|
2629
|
+
query: ui.query,
|
|
2630
|
+
lineCountLabel: lineCountLabel(ui, counts),
|
|
2631
|
+
paused: ui.paused,
|
|
2632
|
+
pauseLabel: ui.paused ? "Resume" : "Pause",
|
|
2633
|
+
pauseBackground: ui.paused ? tint("var(--warning)", 18) : "var(--surface-page)",
|
|
2634
|
+
pauseColor: ui.paused ? "var(--warning)" : "var(--text-secondary)",
|
|
2635
|
+
pauseBorder: ui.paused ? tint("var(--warning)", 45) : "var(--border)",
|
|
2636
|
+
copyLabel: ui.copied ? "Copied" : "Copy",
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/** One slot's dot view-model, honouring the service being down. */
|
|
2641
|
+
function selectSlotDot(slot: SlotInfo, running: boolean): SlotDotVm {
|
|
2642
|
+
// A slot cannot be working if the service is stopped, whatever the last event
|
|
2643
|
+
// said — a stopped server has nothing running in any lane, so a stopped
|
|
2644
|
+
// service reads idle across the board.
|
|
2645
|
+
const state: SlotState = running ? slot.state : "idle";
|
|
2646
|
+
const ctx =
|
|
2647
|
+
slot.ctxTotal === null ? `${NO_READING} ctx` : `${formatTokenCount(slot.ctxTotal)} ctx`;
|
|
2648
|
+
// The context fill needs both halves measured. Missing either one leaves no
|
|
2649
|
+
// percentage rather than a 0% bar, which would read as an empty lane.
|
|
2650
|
+
const headroomPct =
|
|
2651
|
+
slot.promptTokens === null || slot.ctxTotal === null || slot.ctxTotal <= 0
|
|
2652
|
+
? null
|
|
2653
|
+
: barPercent(slot.promptTokens / slot.ctxTotal);
|
|
2654
|
+
const held = slot.promptTokens === null ? NO_READING : String(slot.promptTokens);
|
|
2655
|
+
const decoded = slot.decoded === null ? NO_READING : String(slot.decoded);
|
|
2656
|
+
return {
|
|
2657
|
+
id: slot.id,
|
|
2658
|
+
state,
|
|
2659
|
+
headroomPct,
|
|
2660
|
+
detail:
|
|
2661
|
+
state === "processing"
|
|
2662
|
+
? `${held} / ${ctx} · ${decoded} decoded`
|
|
2663
|
+
: state === "idle"
|
|
2664
|
+
? `${ctx} · idle`
|
|
2665
|
+
: `${ctx} · state unknown`,
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function selectSlots(snapshot: Snapshot): SlotsVm {
|
|
2670
|
+
const running = snapshot.service.running;
|
|
2671
|
+
// Slots arrive flat but belong to one model each; bucket them by model so a
|
|
2672
|
+
// group can be built per loaded model, in the order MODELS lists them.
|
|
2673
|
+
const byModel = new Map<string, SlotInfo[]>();
|
|
2674
|
+
for (const slot of snapshot.slots) {
|
|
2675
|
+
const bucket = byModel.get(slot.modelId);
|
|
2676
|
+
if (bucket === undefined) byModel.set(slot.modelId, [slot]);
|
|
2677
|
+
else bucket.push(slot);
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
const groups: SlotGroupVm[] = [];
|
|
2681
|
+
for (const model of snapshot.models) {
|
|
2682
|
+
if (model.status !== "active" && model.status !== "resident") continue;
|
|
2683
|
+
const modelSlots = byModel.get(model.id);
|
|
2684
|
+
// A loaded model whose /slots read was dropped contributes no slots and so
|
|
2685
|
+
// no group, rather than an empty one.
|
|
2686
|
+
if (modelSlots === undefined || modelSlots.length === 0) continue;
|
|
2687
|
+
|
|
2688
|
+
const dots = modelSlots.map((slot) => selectSlotDot(slot, running));
|
|
2689
|
+
const busy = dots.filter((dot) => dot.state === "processing").length;
|
|
2690
|
+
// Lanes we cannot speak for are counted and said out loud. Folding them into
|
|
2691
|
+
// the idle remainder would make `1/4 busy` look like a measurement when
|
|
2692
|
+
// three of those four lanes were never established.
|
|
2693
|
+
const unknown = dots.filter((dot) => dot.state === "unknown").length;
|
|
2694
|
+
// The busiest lane is the overflow signal — one full lane matters even when
|
|
2695
|
+
// the others are empty — so the group reduces to its max fill, not a mean.
|
|
2696
|
+
// A group where no lane reported a fill has no peak, rather than a peak of 0.
|
|
2697
|
+
const peakPct = dots.reduce<number | null>(
|
|
2698
|
+
(hi, dot) => (dot.headroomPct === null ? hi : Math.max(hi ?? 0, dot.headroomPct)),
|
|
2699
|
+
null,
|
|
2700
|
+
);
|
|
2701
|
+
// The rate belongs to a model that is actually generating; an idle model, or
|
|
2702
|
+
// one whose child was launched without `--metrics`, has none to show.
|
|
2703
|
+
const rateLabel =
|
|
2704
|
+
model.status === "active" && model.tokensPerSecond !== null
|
|
2705
|
+
? formatTps(model.tokensPerSecond)
|
|
2706
|
+
: "";
|
|
2707
|
+
groups.push({
|
|
2708
|
+
modelId: model.id,
|
|
2709
|
+
modelLabel: model.short,
|
|
2710
|
+
modelColor: modelColor(model.id, model.embedding),
|
|
2711
|
+
busy,
|
|
2712
|
+
unknown,
|
|
2713
|
+
total: dots.length,
|
|
2714
|
+
summary: `${busy}/${dots.length} busy${unknown > 0 ? ` · ${unknown} unknown` : ""}`,
|
|
2715
|
+
rateLabel,
|
|
2716
|
+
peakPct,
|
|
2717
|
+
peakLabel: peakPct === null ? "" : `${peakPct}%`,
|
|
2718
|
+
peakColor: contextHeadroomColor(peakPct ?? 0),
|
|
2719
|
+
slots: dots,
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
const busyTotal = groups.reduce((sum, group) => sum + group.busy, 0);
|
|
2724
|
+
const slotTotal = groups.reduce((sum, group) => sum + group.total, 0);
|
|
2725
|
+
const unknownTotal = groups.reduce((sum, group) => sum + group.unknown, 0);
|
|
2726
|
+
// Worst-case fill across the lanes that are actually working; an idle group
|
|
2727
|
+
// holds no context, so it never sets the peak, and a working group that never
|
|
2728
|
+
// reported a fill contributes none rather than dragging the peak down to 0.
|
|
2729
|
+
const peak = groups.reduce<number | null>(
|
|
2730
|
+
(hi, group) =>
|
|
2731
|
+
group.busy > 0 && group.peakPct !== null ? Math.max(hi ?? 0, group.peakPct) : hi,
|
|
2732
|
+
null,
|
|
2733
|
+
);
|
|
2734
|
+
const peakClause = busyTotal > 0 && peak !== null ? ` · peak ${peak}% ctx` : "";
|
|
2735
|
+
const unknownClause = unknownTotal > 0 ? ` · ${unknownTotal} unknown` : "";
|
|
2736
|
+
return {
|
|
2737
|
+
groups,
|
|
2738
|
+
empty: groups.length === 0,
|
|
2739
|
+
emptyLabel: "no models loaded",
|
|
2740
|
+
totalSummary: `${busyTotal} of ${slotTotal} busy${peakClause}${unknownClause}`,
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
/** Builds the whole dashboard view model for one repaint. */
|
|
2745
|
+
export function selectDashboard(snapshot: Snapshot, ui: UiState, now: number): DashboardVm {
|
|
2746
|
+
const activeModel =
|
|
2747
|
+
ui.filterModel === null ? undefined : snapshot.models.find((m) => m.id === ui.filterModel);
|
|
2748
|
+
const selection = selectLog(snapshot, ui);
|
|
2749
|
+
const shorts = new Map(snapshot.models.map((m) => [m.id, m]));
|
|
2750
|
+
const allSelected = ui.filterModel === null;
|
|
2751
|
+
// Built once: whether the unit control renders is a question about this exact
|
|
2752
|
+
// gauge set, so the two must not be derived from two separate passes.
|
|
2753
|
+
const gauges = selectGauges(snapshot, ui);
|
|
2754
|
+
|
|
2755
|
+
return {
|
|
2756
|
+
service: selectService(snapshot, ui),
|
|
2757
|
+
gauges,
|
|
2758
|
+
temperature: selectTemperatureControl(gauges, ui),
|
|
2759
|
+
models: selectModels(snapshot, ui),
|
|
2760
|
+
allLogsPill: {
|
|
2761
|
+
label: "all logs",
|
|
2762
|
+
active: allSelected,
|
|
2763
|
+
background: allSelected ? tint("var(--accent)", 16) : "var(--surface-page)",
|
|
2764
|
+
color: allSelected ? "var(--accent)" : "var(--text-tertiary)",
|
|
2765
|
+
borderColor: allSelected ? tint("var(--accent)", 45) : "var(--border)",
|
|
2766
|
+
},
|
|
2767
|
+
kpis: selectKpis(snapshot, now),
|
|
2768
|
+
spark: selectSpark(snapshot),
|
|
2769
|
+
toolbar: selectToolbar(ui, selection.counts, activeModel),
|
|
2770
|
+
console: selectConsole(snapshot, ui, selection, now, shorts),
|
|
2771
|
+
logCounts: selection.counts,
|
|
2772
|
+
exportLines: selection.exportLines,
|
|
2773
|
+
slots: selectSlots(snapshot),
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
/**
|
|
2778
|
+
* The copy/download payload: every MATCHING BUFFERED line with the folds
|
|
2779
|
+
* expanded, not the painted rows.
|
|
2780
|
+
*
|
|
2781
|
+
* The painted window stops at {@link LOG_RENDER_LIMIT} and shows the launch
|
|
2782
|
+
* arguments as a single fold row, so exporting what is on screen would hand an
|
|
2783
|
+
* operator diagnosing a bad launch a truncated dump with the launch command
|
|
2784
|
+
* itself replaced by the words "31 launch arguments".
|
|
2785
|
+
*
|
|
2786
|
+
* Each row writes `frameRaw + message`, so the message half is byte-identical
|
|
2787
|
+
* to the file's line — the frame the task column took out goes back in. That
|
|
2788
|
+
* guarantee is what makes relocating the frame a relocation.
|
|
2789
|
+
*/
|
|
2790
|
+
export function selectLogText(vm: DashboardVm): string {
|
|
2791
|
+
return formatLogText(vm.exportLines);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/**
|
|
2795
|
+
* What was actually written out, said out loud. The folded and suppressed
|
|
2796
|
+
* clauses are the point: an export that silently differs from the screen is
|
|
2797
|
+
* the kind of quiet inaccuracy this console exists to avoid.
|
|
2798
|
+
*/
|
|
2799
|
+
export function selectLogExportSummary(counts: LogCountsVm): string {
|
|
2800
|
+
const folded =
|
|
2801
|
+
counts.folded > 0
|
|
2802
|
+
? `, including ${formatCount(counts.folded)} folded launch argument${
|
|
2803
|
+
counts.folded === 1 ? "" : "s"
|
|
2804
|
+
}`
|
|
2805
|
+
: "";
|
|
2806
|
+
const hidden =
|
|
2807
|
+
counts.hiddenProxy > 0
|
|
2808
|
+
? ` ${formatCount(counts.hiddenProxy)} proxied line${
|
|
2809
|
+
counts.hiddenProxy === 1 ? " was" : "s were"
|
|
2810
|
+
} hidden.`
|
|
2811
|
+
: "";
|
|
2812
|
+
return `${formatLines(counts.matched)}${folded}.${hidden}`;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/**
|
|
2816
|
+
* Where focus should land after a console repaint destroyed the control that
|
|
2817
|
+
* had it.
|
|
2818
|
+
*
|
|
2819
|
+
* Three interactions remove their own control as a direct result of being
|
|
2820
|
+
* activated — toggling a fold rebuilds the row list, "Clear filters" empties
|
|
2821
|
+
* the notice, "show all" drops the scope banner — and a keyboard operator would
|
|
2822
|
+
* otherwise be dropped at the top of the document, thousands of log lines from
|
|
2823
|
+
* what they were doing. The DOM plumbing lives in `console.ts`; the DECISION
|
|
2824
|
+
* lives here so it can be reasoned about and tested without a browser.
|
|
2825
|
+
*/
|
|
2826
|
+
export type FocusRestore =
|
|
2827
|
+
| { target: "fold"; key: string }
|
|
2828
|
+
| { target: "task"; key: string }
|
|
2829
|
+
| { target: "back" }
|
|
2830
|
+
| { target: "region" | "none" };
|
|
2831
|
+
|
|
2832
|
+
export interface FocusRestoreInput {
|
|
2833
|
+
/** Focus was inside the console when the repaint began. */
|
|
2834
|
+
wasInside: boolean;
|
|
2835
|
+
/** Focus was inside AND the browser has since moved it. */
|
|
2836
|
+
moved: boolean;
|
|
2837
|
+
/** This repaint opened a trace. */
|
|
2838
|
+
enteringTrace: boolean;
|
|
2839
|
+
/** This repaint closed one. */
|
|
2840
|
+
leavingTrace: boolean;
|
|
2841
|
+
/** The key of the fold row that had focus, or `null` for anything else. */
|
|
2842
|
+
foldKey: string | null;
|
|
2843
|
+
/** The key of the task cell that had focus, or `null` for anything else. */
|
|
2844
|
+
taskKey: string | null;
|
|
2845
|
+
/** The `(port, task)` key of the trace being left, or `null`. */
|
|
2846
|
+
exitingTaskKey: string | null;
|
|
2847
|
+
/** The row keys present after the repaint. */
|
|
2848
|
+
keys: readonly string[];
|
|
2849
|
+
/** The task-cell keys present after the repaint. */
|
|
2850
|
+
taskKeys: readonly string[];
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
export function consoleFocusRestore(input: FocusRestoreInput): FocusRestore {
|
|
2854
|
+
// Entering a trace replaces the whole row set, so the cell that was pressed
|
|
2855
|
+
// is gone. Focus parks on `[ back ]` — the safe choice, and the same pattern
|
|
2856
|
+
// the service block's confirm strip uses.
|
|
2857
|
+
if (input.enteringTrace && input.wasInside) return { target: "back" };
|
|
2858
|
+
// Leaving one puts the operator back on the task cell they came from, so the
|
|
2859
|
+
// trace can be re-opened with one press. Only when focus was inside the
|
|
2860
|
+
// console: a chip that closed the trace as a side effect keeps its own focus,
|
|
2861
|
+
// which is why no control has to be disabled while tracing.
|
|
2862
|
+
if (input.leavingTrace && input.wasInside) {
|
|
2863
|
+
return input.exitingTaskKey !== null && input.taskKeys.includes(input.exitingTaskKey)
|
|
2864
|
+
? { target: "task", key: input.exitingTaskKey }
|
|
2865
|
+
: { target: "region" };
|
|
2866
|
+
}
|
|
2867
|
+
if (!input.moved) return { target: "none" };
|
|
2868
|
+
// The equivalent control first: a fold that was toggled still exists under
|
|
2869
|
+
// the same key, and landing back on it is the only outcome that lets an
|
|
2870
|
+
// operator press it twice.
|
|
2871
|
+
if (input.foldKey !== null && input.keys.includes(input.foldKey)) {
|
|
2872
|
+
return { target: "fold", key: input.foldKey };
|
|
2873
|
+
}
|
|
2874
|
+
if (input.taskKey !== null && input.taskKeys.includes(input.taskKey)) {
|
|
2875
|
+
return { target: "task", key: input.taskKey };
|
|
2876
|
+
}
|
|
2877
|
+
return { target: "region" };
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
/**
|
|
2881
|
+
* How many genuinely new lines are sitting below a console the operator has
|
|
2882
|
+
* scrolled away from.
|
|
2883
|
+
*
|
|
2884
|
+
* Fold rows are excluded: a fold row shares its `seq` with the first line of
|
|
2885
|
+
* its run, so counting rows rather than lines over-reports by one for every
|
|
2886
|
+
* expanded fold on screen.
|
|
2887
|
+
*/
|
|
2888
|
+
export function countNewLines(rows: readonly LogRowVm[], sinceSeq: number): number {
|
|
2889
|
+
return rows.reduce(
|
|
2890
|
+
(total, row) => (row.fold === null && row.seq > sinceSeq ? total + 1 : total),
|
|
2891
|
+
0,
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* What toggling an args fold did, for the polite region.
|
|
2897
|
+
*
|
|
2898
|
+
* While a query is holding the fold open the press changes nothing visible —
|
|
2899
|
+
* only where the fold lands once the query clears — so the two directions must
|
|
2900
|
+
* not share a sentence.
|
|
2901
|
+
*/
|
|
2902
|
+
export function foldAnnouncement(count: number, forced: boolean, sticky: boolean): string {
|
|
2903
|
+
const noun = `launch argument${count === 1 ? "" : "s"}`;
|
|
2904
|
+
if (forced) {
|
|
2905
|
+
return `${formatCount(count)} ${noun} will stay ${
|
|
2906
|
+
sticky ? "open" : "collapsed"
|
|
2907
|
+
} when the search is cleared.`;
|
|
2908
|
+
}
|
|
2909
|
+
return `${formatCount(count)} ${noun} ${sticky ? "shown" : "hidden"}.`;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
/**
|
|
2913
|
+
* The one-off announcement for the buffer having evicted lines. It carries the
|
|
2914
|
+
* same two forms the banner does, so it can never read "showing the latest 500
|
|
2915
|
+
* of 500" on a console the render cap never touched.
|
|
2916
|
+
*/
|
|
2917
|
+
export function truncationAnnouncement(counts: LogCountsVm): string {
|
|
2918
|
+
return counts.renderCapped
|
|
2919
|
+
? `Older lines dropped from the buffer; showing the latest ${formatCount(
|
|
2920
|
+
counts.rendered,
|
|
2921
|
+
)} of ${formatCount(counts.matched)}.`
|
|
2922
|
+
: `Older lines dropped from the ${LOG_BUFFER_LIMIT}-line buffer.`;
|
|
2923
|
+
}
|