Compare commits
10
Commits
ce372568e4
...
20faac1905
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20faac1905 | ||
|
|
ca5fcf444d | ||
|
|
d52a0596ed | ||
|
|
9e847940d3 | ||
|
|
e79def7e16 | ||
|
|
7ab9fd791c | ||
|
|
b23eaac153 | ||
|
|
47e08f8953 | ||
|
|
fd1ff8e53e | ||
|
|
e961ed35aa |
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user