/** * The one place a `Snapshot` plus the operator's `UiState` becomes something * renderable. * * Every string, color, percentage and enabled/disabled decision on screen is * derived here, which is what lets the `ui/` modules stay a dumb translation of * view models into elements. Colors come out as CSS custom-property references * because the design assigns them per model and per threshold — the stylesheet * cannot know which. Keep this module free of Node and DOM APIs. */ import type { ConsentDrift, LaunchDrift } from "./drift.js"; import { barPercent, contextHeadroomColor, formatClock, formatClockSeconds, formatContextField, formatCount, formatFlashField, formatGpuLayersField, formatKvCacheField, formatLines, formatLogText, formatMemory, formatPercent, formatQuantField, formatSizeField, formatTemperature, formatTokenCount, formatTps, formatTypeField, formatUptime, NA, temperatureBarPercent, temperatureColor, } from "./format.js"; import { modelColor } from "./model-color.js"; import type { FamilyFilter, LevelFilter, TraceRef, UiState } from "./state.js"; import { LOG_BUFFER_LIMIT, visibleBuffer } from "./state.js"; import type { TemperaturePreference, TemperatureUnit } from "./temperature.js"; import type { ConfigEntry, LogFamily, LogKind, LogLevel, LogLine, ModelAction, ModelInfo, ServiceAction, SlotInfo, SlotState, Snapshot, } from "./types.js"; import { THROUGHPUT_HISTORY_SIZE, THROUGHPUT_SAMPLE_SECONDS } from "./types.js"; // A model's color is a stable hash of its id (embedders get a reserved hue); // re-exported here because it is part of this module's view-model surface. export { modelColor } from "./model-color.js"; /** * How many filtered lines reach the DOM. * * Set to the signal buffer's own size on purpose: with proxied requests hidden * — the default — the matched set can never exceed it, so the cap does not bite * and the console never posts a truncation banner while holding the whole * buffer. It bites exactly when proxy lines are shown, which is the one time * truncation is honest and expected. */ export const LOG_RENDER_LIMIT = 500; /** * How long a console with lines in it must go without a new matching line * before it reports itself quiet. * * A guess, and knowingly so — nothing measured argues for 60 s over 30 s or * 120 s. It only decides when a footer appears under content that is already * fully readable, so being wrong costs an operator nothing. */ export const QUIET_AFTER_MS = 60_000; /** Throughput tile: the bar reads full at this many tokens per second. */ const THROUGHPUT_FULL_SCALE = 120; const LEVEL_COLORS: Record = { DEBUG: "var(--text-muted)", INFO: "var(--info)", WARN: "var(--warning)", ERROR: "var(--error)", }; const LEVEL_FILTERS: LevelFilter[] = ["all", "INFO", "WARN", "ERROR"]; /** * The record-type chips. Four families and a reset, not the research's six: * proxied requests are already a toggle whose default is additive suppression * (which no member of a single-select set can express), and the launch-args * block is already a fold that collapses 31 rows to 1 without leaving the * scrollback. */ const FAMILY_FILTERS: FamilyFilter[] = ["any", "requests", "models", "startup", "other"]; /** * The literal the context-lost banner puts in the search box. * * A search, deliberately, and not a fourth filter axis: the token is in the * message text, the box visibly fills with it, the operator can edit or clear * it, and the existing count grammar reports the result honestly. It also * degrades perfectly — if llama.cpp renames the token, nothing matches, the * count is 0 and the banner never appears. */ export const CONTEXT_LOST_QUERY = "truncated = 1"; /** * How far apart two members of one trace may sit before they are two different * requests that happen to share an id. * * The measured maximum gap INSIDE a real request is 7 buffer lines (p99 = 5), * so 16 leaves better than a 2× margin over anything a genuine request has ever * produced while still catching a child that died and respawned on the same * ephemeral port a few seconds later — a reuse observed 20 lines apart, which a * wider threshold swallows whole and reports as one request. * * It errs toward splitting, and that is the right direction: a split trace * announces itself in the banner and shows the run that was clicked, while a * merged one silently presents two operators' requests as one. */ const TRACE_SPLIT_GAP = 16; /** Shown wherever a reading the source could not supply would otherwise print. */ const NO_READING = "—"; function tint(color: string, percent: number): string { return `color-mix(in srgb, ${color} ${percent}%, transparent)`; } /** * A KPI tile's value. The mock rounds its counters; a live source reports * whatever `llama-server` gave it, which can be fractional or missing, and * neither `61.837` nor `NaN` is something an operator can read at a glance. */ function countLabel(value: number): string { return Number.isFinite(value) ? String(Math.round(value)) : NO_READING; } /** A memory gauge's label, or a dash when either figure is not a reading. */ function memoryLabel(usedGB: number, totalGB: number, decimals: number): string { return Number.isFinite(usedGB) && Number.isFinite(totalGB) ? formatMemory(usedGB, totalGB, decimals) : NO_READING; } /** One button in the SERVICE block's control row. */ export interface ServiceControlVm { action: ServiceAction; /** `Restart`, or the optimistic verb (`Restarting…`) while it is in flight. */ label: string; /** True while this is the action awaiting its POST. */ busy: boolean; disabled: boolean; /** * Why the button is inert (`The service is already started.`), or `""` when * it is not. Rendered as its title and folded into the accessible name, so * the reason is never left to the greyed-out fill alone. */ disabledReason: string; /** True for the disruptive actions: the click opens the confirm strip. */ confirms: boolean; /** Danger-toned (stop, restart). Never the only signal — the verb says it. */ danger: boolean; /** `Restart the llama.cpp service` — the label alone is ambiguous out of context. */ ariaLabel: string; } /** The inline confirm strip for a disruptive action. */ export interface ServiceConfirmVm { action: ServiceAction; /** `Restart unloads gpt-oss-20b and drops in-flight requests.` */ consequence: string; /** The affirmative verb, e.g. `Restart`. */ confirmLabel: string; confirmAriaLabel: string; cancelLabel: string; } /** * The drift notice — the one surface both drift producers write to. * * It exists only when something is actually wrong. A compliant machine renders * NOTHING here: no "all good" badge, no reassurance. That is the point of the * whole check — the dashboard's silence has to mean something, so it may never * be spent on a machine Steward could not verify (a `unknown` launch check is * silent too, and the operator is told nothing rather than told it is fine). */ export interface DriftNoticeVm { /** * Identity of this exact mismatch. A dismissal is bound to it, so dismissing * "`--metrics` removed" cannot also hide "`--slots` removed" arriving later: * the key changes and the notice comes back. */ key: string; title: string; /** One line per thing that no longer matches; never empty. */ messages: string[]; /** What to do about it, in words, naming the command that does it. */ fix: string; dismissLabel: string; /** Says out loud that dismissing does not make the mismatch go away. */ dismissAriaLabel: string; /** The notice region's accessible name. */ ariaLabel: string; /** The whole notice as one sentence, for the polite status region. */ announcement: string; } /** The affordance shown in place of controls when none are configured. */ export interface ServiceSetupVm { label: string; /** Names the skill that configures control — the only way to get buttons. */ detail: string; command: string; } export interface ServiceControlsVm { /** One per consented action, in start/stop/restart order. Empty = unconfigured. */ buttons: ServiceControlVm[]; /** The single setup affordance, present only when {@link buttons} is empty. */ setup: ServiceSetupVm | null; /** The open confirm strip, or `null`. */ confirm: ServiceConfirmVm | null; /** `Restart failed — launchctl: permission denied`, or `null`. */ notice: string | null; /** True while any action is in flight: the whole row disables and reads busy. */ pending: boolean; } /** * What the status chip reports. * * Three values, and each one gets its own SHAPE in the stylesheet — filled disc, * ring, dotted ring — so the state survives a monochrome screen, a colour-blind * reader and a printout. Hue is the third signal here, never the only one. * * `unknown` is deliberately neutral rather than red: a machine Steward has not * been pointed at yet is the expected first state, not a failure. */ export type ServiceState = "up" | "down" | "unknown"; /** * The chip's word and dot colour per state, stated once. * * The colour reaches the DOT and stops there. The word is painted by the * stylesheet with a text token, because `--success` (2.75:1) and `--error` * (4.47:1) on the rail's panel ground both miss AA at the chip's 11.5px — and a * state word that cannot be read is not a readout. */ export const SERVICE_STATE_PRESENTATION: Record = { up: { label: "started", dotColor: "var(--success)" }, down: { label: "stopped", dotColor: "var(--error)" }, unknown: { label: "not connected", dotColor: "var(--text-muted)" }, }; export interface ServiceVm { /** Drives both the chip's word and its dot SHAPE. See {@link ServiceState}. */ state: ServiceState; /** `started` / `stopped` / `not connected` — the chip's word, and the signal. */ statusLabel: string; /** * The dot's colour, and only the dot's. The word's colour is fixed in the * stylesheet so it clears AA in both themes; see * {@link SERVICE_STATE_PRESENTATION}. */ statusDotColor: string; /** The theme control's current-state glyph: `◐` system, `☀` light, `☾` dark. */ themeGlyph: string; themeLabel: string; /** The start/stop/restart row, its confirm strip, and any failure notice. */ controls: ServiceControlsVm; /** * The config-drift notice, or `null` when there is nothing to report (or the * operator dismissed this exact one). It lives in this block because this is * where the router facts `steward.json` claims are rendered — the notice says * those facts have stopped being true. */ drift: DriftNoticeVm | null; /** * The router facts (role, binary, listen, …) folded in from what was the * separate CONFIG block. They render below the status as this block's third * zone, sourced from `/props` so the listen address and build have one home. */ config: ConfigEntry[]; } export interface GaugeVm { key: string; label: string; value: string; percent: number; color: string; /** * How the bar's track is drawn, so an empty bar cannot be mistaken for a real * reading. `solid` is a genuine figure (INCLUDING a real 0%); `hatched` means * "no reading" — the value dashed to `—` because a memory figure was `null` * or `NaN`; `dashed` is reserved for a future "last-seen" state and is not * produced yet. The value token (`—`) is the primary signal; the track * reinforces it, and is never color-only. */ track: "solid" | "hatched" | "dashed"; /** * True for the rows built from a temperature reading — the only rows a unit * choice relabels. * * It is a flag rather than a naming convention on {@link key} because the * unit control renders only when at least one of these rows exists, and a * control that provably changes nothing on screen is a label that is not * true. Set by `tempGauge` and by nothing else, so the question "is there * anything here to relabel?" cannot drift from the row set that answers it. */ temperature: boolean; } /** * The temperature-unit control in the HOST block's head. * * A text button rather than the theme control's cycling glyph: this control's * resolved value IS a two-character string, so the label can simply be it. It * carries no `aria-live` — it is a control, not a status, and its own value * cannot change without a press. */ export interface TemperatureControlVm { /** `auto` · `°C` · `°F` — the PREFERENCE, not the resolved unit. */ label: string; /** States the mode, what it resolves to, and what pressing it does. */ ariaLabel: string; /** Same sentence as {@link ariaLabel}: a pointer operator gets it on hover. */ title: string; /** The preference a press moves to. */ next: TemperaturePreference; } /** * One labeled cell of a model card's body grid: `Quant: 4-bit (Q4_0)`. The label * set and order are identical on every card; only the values change. `na` is set * when the value is the {@link NA} token, so the UI can dim an unconfirmed field * without re-parsing the string. */ export interface ModelFieldVm { label: string; value: string; na: boolean; } export interface ModelCardVm { id: string; short: string; /** * The card body: the same seven labeled fields on every card, in a fixed order * (`Type` last), each carrying its value or the `n/a` token when the fact is * not confirmed. Only `Type` is ever confirmed while unloaded. */ fields: ModelFieldVm[]; color: string; selected: boolean; cardBackground: string; cardBorder: string; buttonAction: ModelAction; buttonLabel: string; buttonBackground: string; buttonColor: string; buttonBorder: string; pending: boolean; } export interface PillVm { label: string; active: boolean; background: string; color: string; borderColor: string; } export interface KpiVm { key: string; label: string; value: string; unit: string; sub: string; color: string; percent: number; } export interface SparkBarVm { height: number; color: string; } export interface SparkVm { bars: SparkBarVm[]; /** `avg 61 · peak 98 tok/s` */ summary: string; /** Height of the dashed average rule, in percent of the plot area. */ averageLine: number; /** * The left end of the axis — how far back the oldest bar reaches, e.g. * `−134 s`. * * Measured, not nominal. Samples close on the browser's snapshot clock, so a * ~3 s cadence sampled every 1.6 s closes at ~3.2 s and a full 42-bar strip * spans about 134 seconds. A fixed `−2 min` printed under it would be off by * a measured 12%, on the one label whose whole job is to say which slice of * time the operator is looking at. */ axisStart: string; } export interface LevelChipVm extends PillVm { level: LevelFilter; /** * How many lines this chip would show — every OTHER filter applied and this * one lifted. So the number is a promise, and an empty ERROR chip is visibly * empty BEFORE it is pressed rather than after. * * A zero is never styled as reassurance: llama-server logs nothing at all for * a rejected request and reports a failed model load at INFO, so `ERROR 0` is * a fact about the file, not a health check. */ count: number; countLabel: string; /** `WARN — 2 lines`, so the count is not left to a bare numeral. */ ariaLabel: string; } /** How a row's model column is attributed. */ export type LineScope = "model" | "router" | "unknown"; /** The collapsed launch-argument block, rendered as one expandable row. */ export interface FoldVm { /** `seq` of the run's first line — the toggle key and the fold's identity. */ seq: number; /** Lines in the run actually held, which is what the label counts. */ count: number; /** Open right now, whether the operator asked or a query hit forced it. */ expanded: boolean; /** The operator's own setting — what the fold returns to once a query clears. */ sticky: boolean; /** The run reaches the front of the window, so older members may be gone. */ truncated: boolean; /** Hits inside the fold for the active query; 0 when there is no query. */ matches: number; /** True when a query hit forced it open, overriding the collapsed state. */ forced: boolean; /** `▸ 31 launch arguments`, plus the truncation and match clauses. */ label: string; /** The glyph is silence to a screen reader, so the name says the verb. */ ariaLabel: string; } /** * The task cell — a control, not a readout. `null` on an unframed line, on a * line whose port Steward never saw, and on `get_availabl` (task `-1`: no task * is attached yet, and inventing one would be the first mis-attribution in a * console built to avoid exactly that). */ export interface TaskCellVm { port: number; task: number; /** Stable identity for focus restoration across a repaint. */ key: string; /** `▸81259` collapsed, `▾81259` while this task is being traced. */ label: string; /** * Says what a task id IS, which the numeral cannot: llama-server's own handle * for the request, sparse, reused across children and not a request number. */ ariaLabel: string; /** True while this row's task is the one being traced. */ active: boolean; } /** * A trailing annotation on a row's message. * * Trailing, not leading: a leading badge shifts the message's left edge per row * and breaks the column rhythm that makes a log scannable. Severity is carried * in FORM — a glyph and a tinted ground with the text at full contrast — never * by an amber that is 2.16:1 on the light theme's console ground. */ export interface LogBadgeVm { key: "context-lost" | "cache" | "slot"; label: string; title: string; tone: "warn" | "neutral"; } export interface LogRowVm { /** * Row identity for the incremental patcher. A fold row and the first line of * its run share a `seq`, so `seq` alone cannot key the DOM: toggling a fold * would patch a `