Compare commits

..
10 Commits
17 changed files with 368 additions and 38 deletions
+18 -3
View File
@@ -4,8 +4,15 @@ A Stream Deck plugin (macOS) that monitors a llama-swap instance.
## Actions ## Actions
- **In-Flight Monitor** — per-model color state: `OFF` / `LOADING` / `IDLE` / `ACTIVE` (pulsing red). Powered by the real-time `/api/events` SSE feed. - **In-Flight Monitor** — per-model request activity. The default view shows
- **GPU Graph** — live line chart of a GPU metric (utilization %, VRAM %, temperature, power draw, fan speed) for one GPU or all GPUs, sampled every 5 s. the live in-flight request count with a 60-second activity spark (states:
`OFF` / `LOADING` / `IDLE` / count). Switch **Display** to **Usage stats**
to show request/token totals and the generation-speed P95 from
`/api/metrics/stats`, per model or across **All models**, with a pickable
primary stat. Powered by the real-time `/api/events` SSE feed.
- **GPU Graph** — live line chart of a GPU metric (utilization %, VRAM %,
temperature, power draw, fan speed) for one GPU, all GPUs, or an arbitrary
combination (e.g. GPU combination `0,2`), sampled every 5 s.
Pressing either key opens `http://<base-url>/ui` in your browser. Pressing either key opens `http://<base-url>/ui` in your browser.
@@ -28,7 +35,15 @@ npm test # unit tests (node:test + tsx)
## Configure ## Configure
Per-key settings: base URL (default `http://talos.milky.way:9292`), optional API key, and the model / GPU / metric to watch. The model and GPU dropdowns are populated live from the instance. Each action instance is independent, so you can place several In-Flight Monitor keys (one per model) and several GPU Graph keys (one per GPU × metric) on the same profile. All keys share a single connection to the configured llama-swap instance, which is re-established automatically if you change the base URL or API key on any key. Per-key settings: base URL (default `http://talos.milky.way:9292`), optional API key, and the model / GPU / metric to watch. The model and GPU dropdowns are populated live from the instance. Each action instance is independent, so you can place several In-Flight Monitor keys (one per model, or in either display mode) and several GPU Graph keys (one per GPU × metric, or per GPU combination) on the same profile. All keys share a single connection to the configured llama-swap instance, which is re-established automatically if you change the base URL or API key on any key.
## Notes
- Usage totals come from llama-swap's `/api/metrics/stats` and cover its
in-memory activity retention (default ~1000 most recent requests), not
lifetime counters. The P95 is a **tokens/sec** generation-speed percentile.
- Keys are SVG-rendered (`render.ts`), so no image assets or canvas are
needed at runtime.
## Marketplace ## Marketplace
@@ -14,6 +14,9 @@
<sdpi-item label="GPU"> <sdpi-item label="GPU">
<sdpi-select setting="gpuId" datasource="gpus" loading="Loading GPUs…" hot-reload default="all" placeholder="Select a GPU" /> <sdpi-select setting="gpuId" datasource="gpus" loading="Loading GPUs…" hot-reload default="all" placeholder="Select a GPU" />
</sdpi-item> </sdpi-item>
<sdpi-item label="GPU combination">
<sdpi-textfield setting="gpuCombo" placeholder="e.g. 0,2 (overrides GPU)" />
</sdpi-item>
<sdpi-item label="Metric"> <sdpi-item label="Metric">
<sdpi-select setting="metric" default="util_percent" placeholder="Select a metric"> <sdpi-select setting="metric" default="util_percent" placeholder="Select a metric">
<option value="util_percent">Utilization %</option> <option value="util_percent">Utilization %</option>
@@ -11,6 +11,20 @@
<sdpi-item label="API Key"> <sdpi-item label="API Key">
<sdpi-password setting="apiKey" placeholder="Optional" /> <sdpi-password setting="apiKey" placeholder="Optional" />
</sdpi-item> </sdpi-item>
<sdpi-item label="Display">
<sdpi-select setting="display" default="count" placeholder="Select a display mode">
<option value="count">Request count + activity</option>
<option value="usage">Usage stats</option>
</sdpi-select>
</sdpi-item>
<sdpi-item label="Primary stat (usage view)">
<sdpi-select setting="primaryStat" default="requests" placeholder="Select a primary stat">
<option value="requests">Requests</option>
<option value="input_tokens">Processed tokens</option>
<option value="output_tokens">Generated tokens</option>
<option value="gen_p95">Generation speed P95</option>
</sdpi-select>
</sdpi-item>
<sdpi-item label="Model"> <sdpi-item label="Model">
<sdpi-select setting="modelId" datasource="models" loading="Loading models…" hot-reload placeholder="Select a model" /> <sdpi-select setting="modelId" datasource="models" loading="Loading models…" hot-reload placeholder="Select a model" />
</sdpi-item> </sdpi-item>
+27 -4
View File
@@ -8,6 +8,7 @@ import streamDeck, {
type WillDisappearEvent, type WillDisappearEvent,
} from "@elgato/streamdeck"; } from "@elgato/streamdeck";
import { type GpuMetricKind } from "../lib/metrics-parser"; import { type GpuMetricKind } from "../lib/metrics-parser";
import { combineSeries } from "../lib/metrics-poller";
import { renderGpuGraph, svgDataUrl } from "../lib/render"; import { renderGpuGraph, svgDataUrl } from "../lib/render";
import { runtime } from "../lib/runtime"; import { runtime } from "../lib/runtime";
import { cfgFromSettings, type CfgSettings } from "../lib/util"; import { cfgFromSettings, type CfgSettings } from "../lib/util";
@@ -15,6 +16,7 @@ import { cfgFromSettings, type CfgSettings } from "../lib/util";
type GpuSettings = CfgSettings & { type GpuSettings = CfgSettings & {
gpuId?: string; gpuId?: string;
metric?: GpuMetricKind; metric?: GpuMetricKind;
gpuCombo?: string;
}; };
type GpuState = { type GpuState = {
@@ -71,12 +73,28 @@ export class GpuGraph extends SingletonAction<GpuSettings> {
const action = state.action; const action = state.action;
if (!action) return; if (!action) return;
const poller = runtime.pollerInstance; const poller = runtime.pollerInstance;
const gpuId = state.settings.gpuId ?? "all";
const metric = state.settings.metric ?? "util_percent"; const metric = state.settings.metric ?? "util_percent";
const value = poller?.getValue(gpuId, metric); const combo = parseCombo(state.settings.gpuCombo);
const history = poller?.getHistory(gpuId, metric) ?? [];
const offline = !poller || poller.isOffline(); const offline = !poller || poller.isOffline();
const gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId);
let value: number | undefined;
let history: number[] = [];
let gpuName: string;
if (combo) {
const known = new Set((poller?.gpus() ?? []).map((g) => g.id));
const ids = combo.filter((id) => known.has(id));
const histories = ids.map((id) => poller?.getHistory(id, metric) ?? []);
const combined = combineSeries(histories, metric);
value = combined.value;
history = combined.history;
gpuName = ids.length > 0 ? `GPU ${ids.join("+")}` : "NO GPUS";
} else {
const gpuId = state.settings.gpuId ?? "all";
value = poller?.getValue(gpuId, metric);
history = poller?.getHistory(gpuId, metric) ?? [];
gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId);
}
const svg = renderGpuGraph({ gpuName, metric, value, history, offline }); const svg = renderGpuGraph({ gpuName, metric, value, history, offline });
if (svg === state.lastSvg) return; if (svg === state.lastSvg) return;
state.lastSvg = svg; state.lastSvg = svg;
@@ -88,3 +106,8 @@ export class GpuGraph extends SingletonAction<GpuSettings> {
return gpu && gpu.name ? `GPU ${gpuId} · ${gpu.name}` : `GPU ${gpuId}`; return gpu && gpu.name ? `GPU ${gpuId} · ${gpu.name}` : `GPU ${gpuId}`;
} }
} }
function parseCombo(raw: string | undefined): string[] | undefined {
const ids = [...new Set((raw ?? "").split(",").map((s) => s.trim()).filter(Boolean))];
return ids.length > 0 ? ids : undefined;
}
+55 -10
View File
@@ -7,20 +7,24 @@ import streamDeck, {
type WillAppearEvent, type WillAppearEvent,
type WillDisappearEvent, type WillDisappearEvent,
} from "@elgato/streamdeck"; } from "@elgato/streamdeck";
import { renderInflight, svgDataUrl } from "../lib/render"; import { renderInflight, renderUsage, svgDataUrl } from "../lib/render";
import { runtime } from "../lib/runtime"; import { runtime } from "../lib/runtime";
import { cfgFromSettings, type CfgSettings } from "../lib/util"; import { cfgFromSettings, type CfgSettings } from "../lib/util";
type InflightSettings = CfgSettings & { type InflightSettings = CfgSettings & {
modelId?: string; modelId?: string;
display?: "count" | "usage";
primaryStat?: "requests" | "input_tokens" | "output_tokens" | "gen_p95";
}; };
type InflightState = { type InflightState = {
settings: InflightSettings; settings: InflightSettings;
action?: KeyAction<InflightSettings>; action?: KeyAction<InflightSettings>;
unsubscribe?: () => void; unsubscribe?: () => void;
unwatchStats?: () => void;
history: number[]; history: number[];
sampler?: ReturnType<typeof setInterval>; sampler?: ReturnType<typeof setInterval>;
lastSvg?: string;
}; };
const HISTORY_LIMIT = 60; const HISTORY_LIMIT = 60;
@@ -45,7 +49,11 @@ export class InflightMonitor extends SingletonAction<InflightSettings> {
state.action = ev.action; state.action = ev.action;
runtime.ensureConnections(cfgFromSettings(state.settings)); runtime.ensureConnections(cfgFromSettings(state.settings));
state.unsubscribe = runtime.subscribe(() => this.render(state)); state.unsubscribe = runtime.subscribe(() => this.render(state));
if (this.displayOf(state) === "usage") {
state.unwatchStats = runtime.watchStats(this.modelKeyOf(state));
} else {
state.sampler = setInterval(() => this.sample(state), 1000); state.sampler = setInterval(() => this.sample(state), 1000);
}
this.render(state); this.render(state);
} }
@@ -53,6 +61,8 @@ export class InflightMonitor extends SingletonAction<InflightSettings> {
const state = this.states.get(ev.action.id); const state = this.states.get(ev.action.id);
if (!state) return; if (!state) return;
state.unsubscribe?.(); state.unsubscribe?.();
state.unwatchStats?.();
state.unwatchStats = undefined;
if (state.sampler) clearInterval(state.sampler); if (state.sampler) clearInterval(state.sampler);
state.sampler = undefined; state.sampler = undefined;
this.states.delete(ev.action.id); this.states.delete(ev.action.id);
@@ -65,6 +75,15 @@ export class InflightMonitor extends SingletonAction<InflightSettings> {
state.action = ev.action; state.action = ev.action;
state.history = []; state.history = [];
runtime.ensureConnections(cfgFromSettings(state.settings)); runtime.ensureConnections(cfgFromSettings(state.settings));
state.unwatchStats?.();
state.unwatchStats = undefined;
if (state.sampler) clearInterval(state.sampler);
state.sampler = undefined;
if (this.displayOf(state) === "usage") {
state.unwatchStats = runtime.watchStats(this.modelKeyOf(state));
} else {
state.sampler = setInterval(() => this.sample(state), 1000);
}
this.render(state); this.render(state);
} }
@@ -72,29 +91,55 @@ export class InflightMonitor extends SingletonAction<InflightSettings> {
void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`); void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`);
} }
private displayOf(state: InflightState): "count" | "usage" {
return state.settings.display ?? "count";
}
private modelKeyOf(state: InflightState): string {
const modelId = state.settings.modelId ?? "";
return modelId === "" ? "all" : modelId;
}
private primaryStatOf(state: InflightState): "requests" | "input_tokens" | "output_tokens" | "gen_p95" {
return state.settings.primaryStat ?? "requests";
}
private render(state: InflightState): void { private render(state: InflightState): void {
const action = state.action; const action = state.action;
if (!action) return; if (!action) return;
let svg: string;
if (this.displayOf(state) === "usage") {
const modelKey = this.modelKeyOf(state);
svg = svgDataUrl(
renderUsage({
modelName: modelKey,
stats: runtime.getStats(modelKey),
primaryStat: this.primaryStatOf(state),
offline: runtime.offline,
}),
);
} else {
const modelId = state.settings.modelId ?? ""; const modelId = state.settings.modelId ?? "";
const trackerState = runtime.tracker.state(modelId); const trackerState = modelId === "all" ? "ready" : runtime.tracker.state(modelId);
const count = runtime.tracker.count(modelId); const count = modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId);
const offline = runtime.offline && modelId.length > 0; svg = svgDataUrl(
void action.setImage(
svgDataUrl(
renderInflight({ renderInflight({
modelName: modelId.length > 0 ? modelId : "unset", modelName: modelId === "" ? "unset" : modelId,
state: trackerState, state: trackerState,
count, count,
offline, offline: runtime.offline && modelId.length > 0,
history: state.history, history: state.history,
}), }),
),
); );
} }
if (svg === state.lastSvg) return;
state.lastSvg = svg;
void action.setImage(svg);
}
private sample(state: InflightState): void { private sample(state: InflightState): void {
const modelId = state.settings.modelId ?? ""; const modelId = state.settings.modelId ?? "";
state.history.push(runtime.tracker.count(modelId)); state.history.push(modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId));
if (state.history.length > HISTORY_LIMIT) state.history.shift(); if (state.history.length > HISTORY_LIMIT) state.history.shift();
this.render(state); this.render(state);
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export function registerDataSources(): void {
let items: DataSourceItem[] = []; let items: DataSourceItem[] = [];
try { try {
const models = await fetchModels(cfgFromSettings(settings)); const models = await fetchModels(cfgFromSettings(settings));
items = models.map((m) => ({ label: `${m.id} (${m.status})`, value: m.id })); items = [{ label: "All models", value: "all" }, ...models.map((m) => ({ label: `${m.id} (${m.status})`, value: m.id }))];
} catch { } catch {
items = []; items = [];
} }
+6
View File
@@ -8,6 +8,12 @@ export function decodeEvent(msg: SseMessage): FeedEvent | null {
if (!outer.data) return null; if (!outer.data) return null;
const inner = JSON.parse(outer.data) as Record<string, unknown>; const inner = JSON.parse(outer.data) as Record<string, unknown>;
if (outer.type === "activity") {
const id = typeof inner.id === "number" ? inner.id : Number.NaN;
if (Number.isFinite(id)) return { type: "activity", id };
return null;
}
if (outer.type === "inflight") { if (outer.type === "inflight") {
const operation = inner.operation as string; const operation = inner.operation as string;
if (operation === "remove") { if (operation === "remove") {
+5
View File
@@ -15,6 +15,7 @@ export interface ModelState {
export type FeedEvent = export type FeedEvent =
| { type: "inflight"; operation: "snapshot" | "add"; requests: InflightRequest[] } | { type: "inflight"; operation: "snapshot" | "add"; requests: InflightRequest[] }
| { type: "inflight"; operation: "remove"; id: string } | { type: "inflight"; operation: "remove"; id: string }
| { type: "activity"; id: number }
| { type: "modelStatus"; models: ModelState[] }; | { type: "modelStatus"; models: ModelState[] };
export type ModelRuntimeState = "stopped" | "loading" | "ready"; export type ModelRuntimeState = "stopped" | "loading" | "ready";
@@ -44,6 +45,10 @@ export class InflightTracker {
return n; return n;
} }
total(): number {
return this.requests.size;
}
state(modelId: string): ModelRuntimeState | undefined { state(modelId: string): ModelRuntimeState | undefined {
return this.states.get(modelId); return this.states.get(modelId);
} }
+12
View File
@@ -22,6 +22,18 @@ const AGGREGATE: Record<GpuMetricKind, (values: number[]) => number> = {
fan: avg, fan: avg,
}; };
export function combineSeries(histories: number[][], kind: GpuMetricKind): { value?: number; history: number[] } {
const n = Math.max(...histories.map((h) => h.length), 0);
if (n === 0) return { value: undefined, history: [] };
const history: number[] = [];
for (let i = 0; i < n; i++) {
const at = histories.map((h) => h[i]).filter((v): v is number => v !== undefined);
if (at.length === 0) continue;
history.push(AGGREGATE[kind](at));
}
return { value: history.length > 0 ? history[history.length - 1] : undefined, history };
}
function key(gpuId: string, kind: GpuMetricKind): string { function key(gpuId: string, kind: GpuMetricKind): string {
return `${gpuId}|${kind}`; return `${gpuId}|${kind}`;
} }
+68 -1
View File
@@ -1,5 +1,6 @@
import { type GpuMetricKind } from "./metrics-parser"; import { type GpuMetricKind } from "./metrics-parser";
import { type ModelRuntimeState } from "./inflight-tracker"; import { type ModelRuntimeState } from "./inflight-tracker";
import { type UsageStats } from "./stats";
import { escapeXml, shorten } from "./util"; import { escapeXml, shorten } from "./util";
export function svgDataUrl(svg: string): string { export function svgDataUrl(svg: string): string {
@@ -23,7 +24,7 @@ export interface InflightRenderOptions {
} }
export function renderInflight(opts: InflightRenderOptions): string { export function renderInflight(opts: InflightRenderOptions): string {
const name = escapeXml(shorten(opts.modelName)); const name = escapeXml(shorten(opts.modelName === "all" ? "ALL MODELS" : opts.modelName));
if (opts.offline) { if (opts.offline) {
return frame("#10131a", [ return frame("#10131a", [
@@ -188,3 +189,69 @@ function frame(bg: string, parts: string[]): string {
${parts.join("\n ")} ${parts.join("\n ")}
</svg>`; </svg>`;
} }
export function formatCompact(n: number): string {
if (!Number.isFinite(n)) return "--";
if (n < 1000) return `${Math.round(n)}`;
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
export interface UsageRenderOptions {
modelName: string;
stats?: UsageStats;
primaryStat: "requests" | "input_tokens" | "output_tokens" | "gen_p95";
offline: boolean;
}
export function renderUsage(opts: UsageRenderOptions): string {
const name = escapeXml(shorten(opts.modelName === "all" ? "ALL MODELS" : opts.modelName));
if (opts.offline) {
return frame("#10131a", [
centerText("!!", 34, 20, "bold", "#e0e0e0"),
centerText("OFFLINE", 52, 9, "normal", "#bdbdbd"),
centerText(name, 64, 7, "normal", "#ffffff"),
]);
}
const s = opts.stats;
const big = s ? formatCompact(primaryValue(s, opts.primaryStat)) : "--";
const rows = usageRows(s, opts.primaryStat);
const parts: string[] = [
centerText(name, 10, 7, "normal", "#ffffff"),
centerText(big, 36, 24, "bold", "#ffffff"),
];
for (const row of rows) {
parts.push(
`<text x="4" y="${row.y}" font-family="Arial,sans-serif" font-size="7" fill="#8b93a5">${row.label}</text>`,
`<text x="68" y="${row.y}" text-anchor="end" font-family="Arial,sans-serif" font-size="7" fill="#ffffff">${row.value}</text>`,
);
}
return frame("#10131a", parts);
}
function primaryValue(s: UsageStats, primary: "requests" | "input_tokens" | "output_tokens" | "gen_p95"): number {
switch (primary) {
case "requests":
return s.totalRequests;
case "input_tokens":
return s.totalInputTokens;
case "output_tokens":
return s.totalOutputTokens;
case "gen_p95":
return s.genP95;
}
}
function usageRows(
s: UsageStats | undefined,
primary: "requests" | "input_tokens" | "output_tokens" | "gen_p95",
): { label: string; value: string; y: number }[] {
const items: { label: string; value: string }[] = [];
if (primary !== "requests") items.push({ label: "REQ", value: s ? formatCompact(s.totalRequests) : "--" });
if (primary !== "input_tokens") items.push({ label: "IN", value: s ? formatCompact(s.totalInputTokens) : "--" });
if (primary !== "output_tokens") items.push({ label: "OUT", value: s ? formatCompact(s.totalOutputTokens) : "--" });
if (primary !== "gen_p95") items.push({ label: "P95", value: s ? `${Math.round(s.genP95)} t/s` : "--" });
return items.map((it, i) => ({ ...it, y: 52 + i * 7 }));
}
+18
View File
@@ -1,6 +1,8 @@
import { EventFeed } from "./event-feed"; import { EventFeed } from "./event-feed";
import { InflightTracker } from "./inflight-tracker"; import { InflightTracker } from "./inflight-tracker";
import { MetricsPoller } from "./metrics-poller"; import { MetricsPoller } from "./metrics-poller";
import { StatsCache } from "./stats-cache";
import { type UsageStats } from "./stats";
import { type LlamaSwapConfig } from "./util"; import { type LlamaSwapConfig } from "./util";
class Runtime { class Runtime {
@@ -9,6 +11,7 @@ class Runtime {
private cfg?: LlamaSwapConfig; private cfg?: LlamaSwapConfig;
private feed?: EventFeed; private feed?: EventFeed;
private poller?: MetricsPoller; private poller?: MetricsPoller;
private statsCache?: StatsCache;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
ensureConnections(cfg: LlamaSwapConfig): void { ensureConnections(cfg: LlamaSwapConfig): void {
@@ -18,11 +21,13 @@ class Runtime {
this.poller?.stop(); this.poller?.stop();
this.feed = undefined; this.feed = undefined;
this.poller = undefined; this.poller = undefined;
this.statsCache?.setConfig(cfg);
this.cfg = cfg; this.cfg = cfg;
} }
if (!this.feed) { if (!this.feed) {
this.feed = new EventFeed(cfg, (ev) => { this.feed = new EventFeed(cfg, (ev) => {
this.tracker.apply(ev); this.tracker.apply(ev);
if (ev.type === "activity") this.statsCache?.scheduleRefresh();
this.emit(); this.emit();
}); });
this.feed.setStatusHandler((connected) => { this.feed.setStatusHandler((connected) => {
@@ -36,12 +41,25 @@ class Runtime {
this.poller.on(() => this.emit()); this.poller.on(() => this.emit());
this.poller.start(); this.poller.start();
} }
if (!this.statsCache) {
this.statsCache = new StatsCache(cfg);
this.statsCache.onChange(() => this.emit());
}
} }
get pollerInstance(): MetricsPoller | undefined { get pollerInstance(): MetricsPoller | undefined {
return this.poller; return this.poller;
} }
getStats(modelId: string): UsageStats | undefined {
return this.statsCache?.get(modelId);
}
watchStats(modelId: string): () => void {
this.statsCache?.register(modelId);
return () => this.statsCache?.unregister(modelId);
}
subscribe(listener: () => void): () => void { subscribe(listener: () => void): () => void {
this.listeners.add(listener); this.listeners.add(listener);
return () => { return () => {
+21 -8
View File
@@ -7,7 +7,8 @@ const ACTIVITY_THROTTLE_MS = 2000;
export type FetchFn = (cfg: LlamaSwapConfig, modelId: string) => Promise<UsageStats | null>; export type FetchFn = (cfg: LlamaSwapConfig, modelId: string) => Promise<UsageStats | null>;
export class StatsCache { export class StatsCache {
private keys = new Set<string>(); private refs = new Map<string, number>();
private refreshing = false;
private values = new Map<string, UsageStats | undefined>(); private values = new Map<string, UsageStats | undefined>();
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private timer?: ReturnType<typeof setInterval>; private timer?: ReturnType<typeof setInterval>;
@@ -27,8 +28,7 @@ export class StatsCache {
} }
register(key: string): void { register(key: string): void {
if (this.keys.has(key)) return; this.refs.set(key, (this.refs.get(key) ?? 0) + 1);
this.keys.add(key);
if (!this.timer) { if (!this.timer) {
void this.refresh(); void this.refresh();
this.timer = setInterval(() => void this.refresh(), this.pollMs); this.timer = setInterval(() => void this.refresh(), this.pollMs);
@@ -36,11 +36,18 @@ export class StatsCache {
} }
unregister(key: string): void { unregister(key: string): void {
this.keys.delete(key); const count = (this.refs.get(key) ?? 0) - 1;
if (count <= 0) {
this.refs.delete(key);
this.values.delete(key); this.values.delete(key);
if (this.keys.size === 0 && this.timer) { } else {
clearInterval(this.timer); this.refs.set(key, count);
}
if (this.refs.size === 0) {
if (this.timer) clearInterval(this.timer);
this.timer = undefined; this.timer = undefined;
if (this.throttleTimer) clearTimeout(this.throttleTimer);
this.throttleTimer = undefined;
} }
} }
@@ -59,16 +66,22 @@ export class StatsCache {
} }
async refresh(): Promise<void> { async refresh(): Promise<void> {
this.lastRefresh = Date.now(); if (this.refreshing) return;
for (const key of this.keys) { this.refreshing = true;
try {
for (const key of this.refs.keys()) {
try { try {
const stats = await this.fetchFn(this.cfg, key); const stats = await this.fetchFn(this.cfg, key);
if (stats) this.values.set(key, stats); if (stats) this.values.set(key, stats);
} catch { } catch {
} }
} }
} finally {
this.refreshing = false;
this.lastRefresh = Date.now();
this.emit(); this.emit();
} }
}
onChange(listener: () => void): () => void { onChange(listener: () => void): () => void {
this.listeners.add(listener); this.listeners.add(listener);
+9
View File
@@ -117,3 +117,12 @@ test("decodeEvent handles inflight with a non-array requests field and no reques
assert.deepEqual(ev.requests, []); assert.deepEqual(ev.requests, []);
} }
}); });
test("decodeEvent parses an activity event with an id", () => {
const msg: SseMessage = {
event: "message",
data: JSON.stringify({ type: "activity", data: JSON.stringify({ id: 817 }) }),
};
const ev = decodeEvent(msg);
assert.deepEqual(ev, { type: "activity", id: 817 });
});
+10
View File
@@ -63,3 +63,13 @@ test("modelStatus normalizes states", () => {
assert.equal(tracker.state("D"), "stopped"); assert.equal(tracker.state("D"), "stopped");
assert.equal(tracker.state("missing"), undefined); assert.equal(tracker.state("missing"), undefined);
}); });
test("total sums in-flight requests across all models", () => {
const tracker = new InflightTracker();
assert.equal(tracker.total(), 0);
tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "1" }, { model: "B", id: "2" }] });
tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "3" }] });
assert.equal(tracker.total(), 3);
tracker.apply({ type: "inflight", operation: "remove", id: "1" });
assert.equal(tracker.total(), 2);
});
+29 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import { type LlamaSwapConfig } from "../src/lib/util"; import { type LlamaSwapConfig } from "../src/lib/util";
import { MetricsPoller } from "../src/lib/metrics-poller"; import { combineSeries, MetricsPoller } from "../src/lib/metrics-poller";
const FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8"); const FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8");
@@ -61,3 +61,31 @@ test("poller reports offline after a fetch failure and recovers", async () => {
assert.equal(poller.isOffline(), false); assert.equal(poller.isOffline(), false);
assert.ok(poller.getValue("0", "util_percent") !== undefined); assert.ok(poller.getValue("0", "util_percent") !== undefined);
}); });
test("combineSeries averages util and sums power", () => {
const util = combineSeries(
[
[10, 20, 30],
[20, 40, 60],
],
"util_percent",
);
assert.deepEqual(util, { value: 45, history: [15, 30, 45] });
const power = combineSeries(
[
[100, 200],
[150, 250],
],
"power",
);
assert.deepEqual(power, { value: 450, history: [250, 450] });
});
test("combineSeries takes the max temperature and skips gaps", () => {
const temp = combineSeries([[50, 70], [60]], "temperature");
assert.deepEqual(temp, { value: 70, history: [60, 70] });
const empty = combineSeries([[], []], "temperature");
assert.deepEqual(empty, { value: undefined, history: [] });
});
+35 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { renderGpuGraph, renderInflight, svgDataUrl } from "../src/lib/render"; import { renderGpuGraph, renderInflight, renderUsage, svgDataUrl, formatCompact } from "../src/lib/render";
test("svgDataUrl wraps an SVG as a base64 data URL", () => { test("svgDataUrl wraps an SVG as a base64 data URL", () => {
const url = svgDataUrl("<svg></svg>"); const url = svgDataUrl("<svg></svg>");
@@ -99,3 +99,37 @@ test("renderGpuGraph: single-point history avoids divide-by-zero", () => {
const svg = renderGpuGraph({ gpuName: "ALL GPUS", metric: "util_percent", value: 50, history: [50], offline: false }); const svg = renderGpuGraph({ gpuName: "ALL GPUS", metric: "util_percent", value: 50, history: [50], offline: false });
assert.match(svg, /<svg/); assert.match(svg, /<svg/);
}); });
test("formatCompact renders integers, k, and M", () => {
assert.equal(formatCompact(0), "0");
assert.equal(formatCompact(999), "999");
assert.equal(formatCompact(52100), "52.1k");
assert.equal(formatCompact(1200000), "1.2M");
});
test("renderUsage shows the primary stat big and the rest small", () => {
const svg = renderUsage({
modelName: "DeepSeek-V4-Flash-0731",
stats: { totalRequests: 1950, totalInputTokens: 312177540, totalOutputTokens: 889193, genP95: 378.86 },
primaryStat: "gen_p95",
offline: false,
});
assert.match(svg, />379</);
assert.match(svg, /REQ/);
assert.match(svg, /IN/);
assert.match(svg, /OUT/);
assert.match(svg, /312.2M/);
assert.match(svg, /#10131a/);
});
test("renderUsage renders -- when stats are unknown and OFFLINE when offline", () => {
const none = renderUsage({ modelName: "A", stats: undefined, primaryStat: "requests", offline: false });
assert.match(none, />--</);
const off = renderUsage({ modelName: "A", stats: undefined, primaryStat: "requests", offline: true });
assert.match(off, /OFFLINE/);
});
test("renderUsage labels the aggregate view ALL MODELS", () => {
const svg = renderUsage({ modelName: "all", stats: { totalRequests: 1, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }, primaryStat: "requests", offline: false });
assert.match(svg, /ALL MODELS/);
});
+28
View File
@@ -83,3 +83,31 @@ test("fetch failure keeps the last-known value", async () => {
assert.equal(cache.get("a"), undefined); assert.equal(cache.get("a"), undefined);
cache.unregister("a"); cache.unregister("a");
}); });
test("ref-counted register: a sibling unregister keeps the key active", async () => {
const calls = { count: 0 };
const cache = new StatsCache(cfg, stubFetch({ totalRequests: 5, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }, calls), 10000, 30);
cache.register("all");
await flush();
assert.equal(cache.get("all")!.totalRequests, 5);
cache.register("all");
cache.unregister("all");
await flush();
assert.equal(cache.get("all")!.totalRequests, 5);
cache.unregister("all");
assert.equal(cache.get("all"), undefined);
});
test("setConfig keeps registrations but clears cached values", async () => {
const cache = new StatsCache(cfg, async (_c, key) => ({ totalRequests: 5, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }), 10000, 30);
cache.register("all");
await flush();
assert.equal(cache.get("all")!.totalRequests, 5);
cache.setConfig({ baseUrl: "http://new" });
assert.equal(cache.get("all"), undefined);
cache.scheduleRefresh();
await flush();
await new Promise((r) => setTimeout(r, 60));
assert.equal(cache.get("all")!.totalRequests, 5);
cache.unregister("all");
});