73 KiB
llama-watch Stream Deck Plugin — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a local-install Stream Deck plugin ("llama-watch") with two key actions — an in-flight request monitor per model and a live GPU metric graph — backed by a llama-swap instance at http://localhost:9292.
Architecture: A Node.js/TypeScript plugin running as a process launched by the Stream Deck app (SDK v2, @elgato/streamdeck@2.1.1). It maintains a persistent SSE connection to llama-swap /api/events (real-time in-flight + model status) and a 5s polling loop over /metrics (Prometheus text). Pure-logic modules (parser, tracker, poller, SSE parser, SVG renderer) are unit-tested with node:test + tsx. Key images are rendered as dynamic SVG strings passed to KeyAction.setImage — no canvas dependency.
Tech Stack: TypeScript, @elgato/streamdeck@^2.1.1, Rollup (@rollup/plugin-typescript, terser), @elgato/cli@^1.8.1 for validate/pack, tsx for tests, Node 24 runtime declared in the manifest (Stream Deck bundles it; the SDK requires ≥24). Icons generated from hand-written SVGs via macOS sips.
Global Constraints
- Plugin UUID:
com.bryce.llamawatch(reverse-DNS; lowercase alphanumeric, hyphens, periods only). - Action UUIDs:
com.bryce.llamawatch.inflight,com.bryce.llamawatch.gpu. - Manifest:
SDKVersion: 2,Nodejs.Version: "24", macOS only (OS: [{Platform: "mac", MinimumVersion: "13"}]),Software.MinimumVersion: "7.1". (Verified against@elgato/schemas: Nodejs"24"requires Software ≥ 7.1; the user's Stream Deck app is 7.4.2.) - Dependency versions pinned per the official hello-world sample:
@elgato/streamdeck@^2.1.1,@elgato/cli@^1.8.1,@rollup/plugin-commonjs@^28.0.2,@rollup/plugin-node-resolve@^16.0.0,@rollup/plugin-terser@^0.4.4,@rollup/plugin-typescript@^12.1.2,@tsconfig/node20@^20.1.4,@types/node@22.13.0,rollup@^4.32.1,tslib@^2.8.1,typescript@^5.7.3,tsx@^4.19.2. - Poll interval for
/metrics: fixed 5000 ms; ring buffer 60 samples (~5 min). - Metrics (from
llamaswap_gpu_*Prometheus series):util_percent,memory_util_percent,temperature,power,fan. - Aggregates for GPU selector
"all": util/memory/fan = average, temperature = max, power = sum. - Default base URL:
http://localhost:9292. No hardcoded URLs beyond the default. Optional API key sent asAuthorization: Bearer <key>when set. - Key press on either action opens
<baseUrl>/uiin the default browser viastreamDeck.system.openUrl. - No Marketplace submission in scope; assets are prepared marketplace-ready (plugin PNG 256/512, SVG action icons, category icon).
- No code comments. Follow the no-comments rule strictly.
- Existing conventions from the official sample: entry
src/plugin.ts, output${PLUGIN}.sdPlugin/bin/plugin.js, extension-less relative TS imports, ESM ("type": "module").
Task 1: Scaffold project structure
Files:
- Create:
package.json - Create:
tsconfig.json - Create:
rollup.config.mjs - Create:
com.bryce.llamawatch.sdPlugin/manifest.json(placeholder manifest) - Create:
src/plugin.ts(placeholder) - Create:
src/actions/inflight-monitor.ts(placeholder),src/actions/gpu-graph.ts(placeholder) - Create:
tests/.gitkeep - Create:
.gitignore
Interfaces:
-
Consumes: nothing.
-
Produces: working
npm run build(rollup outputscom.bryce.llamawatch.sdPlugin/bin/plugin.js),npm testrunner available, placeholder plugin registers no actions yet. -
Step 1: Create
package.json
{
"name": "llama-watch",
"version": "1.0.0",
"description": "Stream Deck plugin to monitor llama-swap requests and GPU metrics.",
"private": true,
"type": "module",
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w --watch.onEnd=\"streamdeck restart com.bryce.llamawatch\"",
"test": "tsx --test tests/"
},
"devDependencies": {
"@elgato/cli": "^1.8.1",
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.2",
"@tsconfig/node20": "^20.1.4",
"@types/node": "22.13.0",
"rollup": "^4.32.1",
"tslib": "^2.8.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
},
"dependencies": {
"@elgato/streamdeck": "^2.1.1"
}
}
- Step 2: Create
tsconfig.json
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"customConditions": ["node"],
"noImplicitOverride": true,
"module": "ES2022",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
- Step 3: Create
rollup.config.mjs
import commonjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";
import path from "node:path";
import url from "node:url";
const isWatching = !!process.env.ROLLUP_WATCH;
const sdPlugin = "com.bryce.llamawatch.sdPlugin";
const config = {
input: "src/plugin.ts",
output: {
file: `${sdPlugin}/bin/plugin.js`,
sourcemap: isWatching,
sourcemapPathTransform: (relativeSourcePath, sourcemapPath) => {
return url.pathToFileURL(path.resolve(path.dirname(sourcemapPath), relativeSourcePath)).href;
},
},
plugins: [
{
name: "watch-externals",
buildStart: function () {
this.addWatchFile(`${sdPlugin}/manifest.json`);
},
},
typescript({ mapRoot: isWatching ? "./" : undefined }),
nodeResolve({ browser: false, exportConditions: ["node"], preferBuiltins: true }),
commonjs(),
!isWatching && terser(),
{
name: "emit-module-package-file",
generateBundle() {
this.emitFile({ fileName: "package.json", source: `{ "type": "module" }`, type: "asset" });
},
},
],
};
export default config;
- Step 4: Create
.gitignore
node_modules/
*.streamDeckPlugin
.DS_Store
- Step 5: Create placeholder
com.bryce.llamawatch.sdPlugin/manifest.json
{
"$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
"Name": "llama-watch",
"Version": "1.0.0.0",
"Author": "Bryce Zuccaro",
"Actions": [],
"Category": "llama-watch",
"CodePath": "bin/plugin.js",
"Description": "Monitor llama-swap: in-flight requests per model and live GPU metric graphs.",
"Icon": "imgs/plugin/icon",
"SDKVersion": 2,
"Software": { "MinimumVersion": "7.1" },
"OS": [{ "Platform": "mac", "MinimumVersion": "13" }],
"Nodejs": { "Version": "24" },
"UUID": "com.bryce.llamawatch"
}
- Step 6: Create placeholder
src/plugin.ts
import streamDeck from "@elgato/streamdeck";
streamDeck.connect();
- Step 7: Create placeholder action files —
src/actions/inflight-monitor.tsandsrc/actions/gpu-graph.ts, each containing only:
export {};
-
Step 8: Create
tests/.gitkeep(empty file) -
Step 9: Install dependencies and verify the build and test runner
Run:
npm install
npm run build
npm test
Expected: npm run build produces com.bryce.llamawatch.sdPlugin/bin/plugin.js; npm test exits 0 (no test files yet).
- Step 10: Commit
git add -A
git commit -m "chore: scaffold llama-watch Stream Deck plugin"
Task 2: util.ts — shared helpers and config
Files:
- Create:
src/lib/util.ts - Test:
tests/util.test.ts
Interfaces:
-
Consumes: nothing.
-
Produces:
DEFAULT_BASE_URL,CfgSettings,LlamaSwapConfig,cfgFromSettings(settings): LlamaSwapConfig,normalizeBaseUrl(s): string,shorten(name, max?): string,escapeXml(s): string. -
Step 1: Write the failing test —
tests/util.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import {
cfgFromSettings,
escapeXml,
normalizeBaseUrl,
shorten,
} from "../src/lib/util";
test("normalizeBaseUrl strips trailing slashes", () => {
assert.equal(normalizeBaseUrl("http://localhost:9292/"), "http://localhost:9292");
assert.equal(normalizeBaseUrl("http://localhost:9292"), "http://localhost:9292");
});
test("cfgFromSettings defaults baseUrl and omits empty apiKey", () => {
const cfg = cfgFromSettings({});
assert.equal(cfg.baseUrl, "http://localhost:9292");
assert.equal(cfg.apiKey, undefined);
assert.deepEqual(cfgFromSettings({ baseUrl: "http://x/", apiKey: "abc" }), {
baseUrl: "http://x",
apiKey: "abc",
});
});
test("shorten keeps short names and truncates long ones", () => {
assert.equal(shorten("Qwen3.8-27B"), "Qwen3.8-27B");
assert.equal(shorten("DeepSeek-V4-Flash-0731"), "DeepSeek-…0731");
});
test("escapeXml escapes XML special characters", () => {
assert.equal(escapeXml(`A&B <C> "D" 'E'`), "A&B <C> "D" 'E'");
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — module ../src/lib/util not found.
- Step 3: Implement
src/lib/util.ts
import type { JsonObject } from "@elgato/utils";
export const DEFAULT_BASE_URL = "http://localhost:9292";
export type CfgSettings = {
baseUrl?: string;
apiKey?: string;
} & JsonObject;
export interface LlamaSwapConfig {
baseUrl: string;
apiKey?: string;
}
export function cfgFromSettings(settings: CfgSettings): LlamaSwapConfig {
return {
baseUrl: normalizeBaseUrl(settings.baseUrl ?? DEFAULT_BASE_URL),
apiKey: settings.apiKey || undefined,
};
}
export function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
export function shorten(name: string, max = 14): string {
if (name.length <= max) return name;
const head = Math.floor((max - 1) * 0.7);
return `${name.slice(0, head)}…${name.slice(name.length - (max - head - 1))}`;
}
export function escapeXml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS.
- Step 5: Commit
git add -A
git commit -m "feat: add shared util helpers and config"
Task 3: metrics-parser.ts — Prometheus text parser
Files:
- Create:
src/lib/metrics-parser.ts - Create:
tests/fixtures/metrics.txt - Test:
tests/metrics-parser.test.ts
Interfaces:
-
Consumes: nothing.
-
Produces:
type GpuMetricKind = "util_percent" | "memory_util_percent" | "temperature" | "power" | "fan"interface GpuSample { id: string; name: string; kind: GpuMetricKind; value: number }parsePrometheus(text: string): { metric: string; labels: Record<string, string>; value: number }[]parseGpuMetrics(text: string): GpuSample[]gpuInfos(samples: GpuSample[]): { id: string; name: string }[](deduped, sorted by numeric id)
-
Step 1: Create the fixture —
tests/fixtures/metrics.txt(trimmed capture from localhost:9292)
# HELP llamaswap_gpu_util_percent GPU utilization percent (0-100)
# TYPE llamaswap_gpu_util_percent gauge
llamaswap_gpu_util_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 100
llamaswap_gpu_util_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 100
llamaswap_gpu_util_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 100
# HELP llamaswap_gpu_memory_util_percent GPU memory utilization percent (0-100)
# TYPE llamaswap_gpu_memory_util_percent gauge
llamaswap_gpu_memory_util_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 98.41347676402383
llamaswap_gpu_memory_util_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 98.41245517790922
llamaswap_gpu_memory_util_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 97.02211181648113
# HELP llamaswap_gpu_temperature_celsius GPU temperature in Celsius
# TYPE llamaswap_gpu_temperature_celsius gauge
llamaswap_gpu_temperature_celsius{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 51
llamaswap_gpu_temperature_celsius{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 55
llamaswap_gpu_temperature_celsius{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 64
# HELP llamaswap_gpu_power_draw_watts GPU power draw in watts
# TYPE llamaswap_gpu_power_draw_watts gauge
llamaswap_gpu_power_draw_watts{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 316.56
llamaswap_gpu_power_draw_watts{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 336.17
llamaswap_gpu_power_draw_watts{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 377.28
# HELP llamaswap_gpu_fan_speed_percent GPU fan speed percent (0-100)
# TYPE llamaswap_gpu_fan_speed_percent gauge
llamaswap_gpu_fan_speed_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 51
llamaswap_gpu_fan_speed_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 62
llamaswap_gpu_fan_speed_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 89
- Step 2: Write the failing test —
tests/metrics-parser.test.ts
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { gpuInfos, parseGpuMetrics, parsePrometheus } from "../src/lib/metrics-parser";
const FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8");
test("parsePrometheus parses a gauge with labels", () => {
const samples = parsePrometheus(`# TYPE llamaswap_gpu_util_percent gauge\nllamaswap_gpu_util_percent{id="0",name="RTX 5090"} 42.5\n`);
assert.equal(samples.length, 1);
assert.equal(samples[0].metric, "llamaswap_gpu_util_percent");
assert.deepEqual(samples[0].labels, { id: "0", name: "RTX 5090" });
assert.equal(samples[0].value, 42.5);
});
test("parsePrometheus ignores comments and blank lines", () => {
const samples = parsePrometheus("# HELP x y\n# TYPE x gauge\n\n");
assert.equal(samples.length, 0);
});
test("parseGpuMetrics extracts all five kinds for three GPUs from the fixture", () => {
const samples = parseGpuMetrics(FIXTURE);
assert.equal(samples.length, 15);
const kinds = new Set(samples.map((s) => s.kind));
assert.deepEqual([...kinds].sort(), ["fan", "memory_util_percent", "power", "temperature", "util_percent"]);
const power0 = samples.find((s) => s.kind === "power" && s.id === "0");
assert.ok(power0);
assert.equal(power0.value, 316.56);
});
test("gpuInfos dedupes, preserves names, sorts numerically by id", () => {
const infos = gpuInfos(parseGpuMetrics(FIXTURE));
assert.deepEqual(infos.map((i) => i.id), ["0", "1", "2"]);
assert.equal(infos[2].name, "NVIDIA GeForce RTX 5090");
});
- Step 3: Run test to verify it fails
Run: npm test
Expected: FAIL — module not found.
- Step 4: Implement
src/lib/metrics-parser.ts
export interface PromSample {
metric: string;
labels: Record<string, string>;
value: number;
}
const LINE_RE = /^([A-Za-z_:][A-Za-z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)$/;
export function parsePrometheus(text: string): PromSample[] {
const samples: PromSample[] = [];
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const m = LINE_RE.exec(line);
if (!m) continue;
const labels: Record<string, string> = {};
if (m[2]) {
for (const pair of m[2].split(",")) {
const eq = pair.indexOf("=");
if (eq === -1) continue;
const value = pair.slice(eq + 1).trim();
labels[pair.slice(0, eq).trim()] = value.replace(/^"(.*)"$/, "$1");
}
}
samples.push({ metric: m[1], labels, value: parseFloat(m[3]) });
}
return samples;
}
export type GpuMetricKind = "util_percent" | "memory_util_percent" | "temperature" | "power" | "fan";
export const GPU_SERIES: Record<string, GpuMetricKind> = {
llamaswap_gpu_util_percent: "util_percent",
llamaswap_gpu_memory_util_percent: "memory_util_percent",
llamaswap_gpu_temperature_celsius: "temperature",
llamaswap_gpu_power_draw_watts: "power",
llamaswap_gpu_fan_speed_percent: "fan",
};
export interface GpuSample {
id: string;
name: string;
kind: GpuMetricKind;
value: number;
}
export function parseGpuMetrics(text: string): GpuSample[] {
const out: GpuSample[] = [];
for (const s of parsePrometheus(text)) {
const kind = GPU_SERIES[s.metric];
if (!kind) continue;
const id = s.labels["id"];
if (id === undefined) continue;
out.push({ id, name: s.labels["name"] ?? "", kind, value: s.value });
}
return out;
}
export function gpuInfos(samples: GpuSample[]): { id: string; name: string }[] {
const byId = new Map<string, string>();
for (const s of samples) {
if (!byId.has(s.id)) byId.set(s.id, s.name);
}
return [...byId.entries()]
.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10))
.map(([id, name]) => ({ id, name }));
}
- Step 5: Run test to verify it passes
Run: npm test
Expected: PASS.
- Step 6: Commit
git add -A
git commit -m "feat: add Prometheus metrics parser for llama-swap GPU series"
Task 4: llamaswap.ts — HTTP client
Files:
- Create:
src/lib/llamaswap.ts
Interfaces:
-
Consumes:
LlamaSwapConfigfrom./util. -
Produces:
interface ModelInfo { id: string; status: string }fetchMetrics(cfg: LlamaSwapConfig): Promise<string>— GET<baseUrl>/metrics, throws on non-2xx or timeout (5s).fetchModels(cfg: LlamaSwapConfig): Promise<ModelInfo[]>— GET<baseUrl>/v1/models, mapsdata[]to{id, status: status.value}.
-
Step 1: Implement
src/lib/llamaswap.ts(network module; verified vianpm run buildtypecheck, and integration-tested in Task 12)
import { type LlamaSwapConfig } from "./util";
const TIMEOUT_MS = 5000;
export interface ModelInfo {
id: string;
status: string;
}
function headersFor(cfg: LlamaSwapConfig): Record<string, string> {
const headers: Record<string, string> = {};
if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`;
return headers;
}
export async function fetchMetrics(cfg: LlamaSwapConfig): Promise<string> {
const res = await fetch(`${cfg.baseUrl}/metrics`, {
headers: headersFor(cfg),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`metrics HTTP ${res.status}`);
return await res.text();
}
export async function fetchModels(cfg: LlamaSwapConfig): Promise<ModelInfo[]> {
const res = await fetch(`${cfg.baseUrl}/v1/models`, {
headers: headersFor(cfg),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`models HTTP ${res.status}`);
const body = (await res.json()) as { data?: { id: string; status?: { value?: string } }[] };
return (body.data ?? []).map((m) => ({ id: m.id, status: m.status?.value ?? "unknown" }));
}
- Step 2: Verify it typechecks in the bundle
Run: npm run build
Expected: build succeeds (rollup/typescript compiles without error).
- Step 3: Commit
git add -A
git commit -m "feat: add llama-swap HTTP client"
Task 5: metrics-poller.ts — polling, ring buffers, aggregates
Files:
- Create:
src/lib/metrics-poller.ts - Test:
tests/metrics-poller.test.ts
Interfaces:
-
Consumes:
fetchMetrics+LlamaSwapConfig(default fetchFn),parseGpuMetrics+GpuMetricKindfrom./metrics-parser. -
Produces:
class MetricsPoller { constructor(cfg: LlamaSwapConfig, intervalMs?: number, fetchFn?: (cfg: LlamaSwapConfig) => Promise<string>) }start(): void,stop(): void,tick(): Promise<void>(public for tests)on(listener: () => void): () => void(returns unsubscribe)getValue(gpuId: string, kind: GpuMetricKind): number | undefinedgetHistory(gpuId: string, kind: GpuMetricKind): number[](copy of ring)gpus(): { id: string; name: string }[]isOffline(): boolean
-
Step 1: Write the failing test —
tests/metrics-poller.test.ts
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { type LlamaSwapConfig } from "../src/lib/util";
import { MetricsPoller } from "../src/lib/metrics-poller";
import { parseGpuMetrics } from "../src/lib/metrics-parser";
const FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8");
const FIXTURE2 = FIXTURE
.replace(/llamaswap_gpu_util_percent\{id="0"[^}]*\} 100/, 'llamaswap_gpu_util_percent{id="0",name="RTX PRO 6000"} 30')
.replace(/llamaswap_gpu_util_percent\{id="1"[^}]*\} 100/, 'llamaswap_gpu_util_percent{id="1",name="RTX PRO 6000"} 50')
.replace(/llamaswap_gpu_util_percent\{id="2"[^}]*\} 100/, 'llamaswap_gpu_util_percent{id="2",name="RTX 5090"} 80');
function stubFetch(bodies: string[]) {
let i = 0;
return async () => bodies[Math.min(i++, bodies.length - 1)];
}
const CFG: LlamaSwapConfig = { baseUrl: "http://test" };
test("poller stores per-GPU history and computes aggregates", async () => {
const poller = new MetricsPoller(CFG, 5000, stubFetch([FIXTURE, FIXTURE2]));
await poller.tick();
await poller.tick();
assert.equal(poller.getValue("0", "util_percent"), 30);
assert.deepEqual(poller.getHistory("0", "util_percent"), [100, 30]);
const allUtil = poller.getValue("all", "util_percent");
assert.ok(allUtil !== undefined);
assert.ok(Math.abs(allUtil - (30 + 50 + 80) / 3) < 1e-9);
const allTemp = poller.getValue("all", "temperature");
assert.equal(allTemp, 64);
const allPower = poller.getValue("all", "power");
assert.ok(allPower !== undefined);
assert.ok(Math.abs(allPower - (316.56 + 336.17 + 377.28)) < 1e-9);
assert.deepEqual(poller.gpus().map((g) => g.id), ["0", "1", "2"]);
assert.equal(poller.gpus()[2].name, "NVIDIA GeForce RTX 5090");
});
test("poller ring buffer caps at 60 samples", async () => {
const poller = new MetricsPoller(CFG, 5000, stubFetch([FIXTURE]));
for (let i = 0; i < 70; i++) await poller.tick();
assert.equal(poller.getHistory("0", "fan").length, 60);
});
test("poller reports offline after a fetch failure and recovers", async () => {
let fail = true;
const fetchFn = async () => {
if (fail) throw new Error("boom");
return FIXTURE;
};
const poller = new MetricsPoller(CFG, 5000, fetchFn);
await poller.tick();
assert.equal(poller.isOffline(), true);
fail = false;
await poller.tick();
assert.equal(poller.isOffline(), false);
assert.ok(poller.getValue("0", "util_percent") !== undefined);
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — module not found.
- Step 3: Implement
src/lib/metrics-poller.ts
import { fetchMetrics } from "./llamaswap";
import { type LlamaSwapConfig } from "./util";
import { gpuInfos, parseGpuMetrics, type GpuMetricKind, type GpuSample } from "./metrics-parser";
const RING_SIZE = 60;
function avg(values: number[]): number {
return values.reduce((a, b) => a + b, 0) / (values.length || 1);
}
function max(values: number[]): number {
return values.reduce((a, b) => Math.max(a, b), -Infinity);
}
function sum(values: number[]): number {
return values.reduce((a, b) => a + b, 0);
}
const AGGREGATE: Record<GpuMetricKind, (values: number[]) => number> = {
util_percent: avg,
memory_util_percent: avg,
temperature: max,
power: sum,
fan: avg,
};
function key(gpuId: string, kind: GpuMetricKind): string {
return `${gpuId}|${kind}`;
}
type FetchFn = (cfg: LlamaSwapConfig) => Promise<string>;
export class MetricsPoller {
private buffers = new Map<string, number[]>();
private gpuName = new Map<string, string>();
private gpuIds: string[] = [];
private listeners = new Set<() => void>();
private timer?: ReturnType<typeof setInterval>;
private lastError?: string;
constructor(
private cfg: LlamaSwapConfig,
private intervalMs = 5000,
private fetchFn: FetchFn = fetchMetrics,
) {}
start(): void {
void this.tick();
this.timer = setInterval(() => void this.tick(), this.intervalMs);
}
stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
}
on(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async tick(): Promise<void> {
try {
const text = await this.fetchFn(this.cfg);
this.apply(parseGpuMetrics(text));
this.lastError = undefined;
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
} finally {
for (const listener of this.listeners) listener();
}
}
private apply(samples: GpuSample[]): void {
const byKind = new Map<GpuMetricKind, Map<string, number>>();
for (const s of samples) {
if (!byKind.has(s.kind)) byKind.set(s.kind, new Map());
byKind.get(s.kind)!.set(s.id, s.value);
if (!this.gpuName.has(s.id)) this.gpuName.set(s.id, s.name);
}
this.gpuIds = [...this.gpuName.keys()].sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
for (const [kind, values] of byKind) {
for (const [id, value] of values) this.push(key(id, kind), value);
const all = [...values.values()];
if (all.length > 0) this.push(key("all", kind), AGGREGATE[kind](all));
}
}
private push(k: string, value: number): void {
let ring = this.buffers.get(k);
if (!ring) {
ring = [];
this.buffers.set(k, ring);
}
ring.push(value);
if (ring.length > RING_SIZE) ring.shift();
}
getValue(gpuId: string, kind: GpuMetricKind): number | undefined {
const ring = this.buffers.get(key(gpuId, kind));
return ring ? ring[ring.length - 1] : undefined;
}
getHistory(gpuId: string, kind: GpuMetricKind): number[] {
const ring = this.buffers.get(key(gpuId, kind));
return ring ? [...ring] : [];
}
gpus(): { id: string; name: string }[] {
return this.gpuIds.map((id) => ({ id, name: this.gpuName.get(id) ?? "" }));
}
isOffline(): boolean {
return this.lastError !== undefined;
}
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS.
- Step 5: Commit
git add -A
git commit -m "feat: add metrics poller with ring buffers and GPU aggregates"
Task 6: sse.ts + inflight-tracker.ts — SSE parsing and request tracking
Files:
- Create:
src/lib/sse.ts - Create:
src/lib/inflight-tracker.ts - Test:
tests/sse.test.ts - Test:
tests/inflight-tracker.test.ts
Interfaces:
-
Consumes: nothing.
-
Produces:
sse.ts:interface SseMessage { event?: string; data: string },parseSse(chunk: string): { messages: SseMessage[]; rest: string }.inflight-tracker.ts:interface InflightRequest { id?: string; model: string; req_path?: string; method?: string; timestamp?: string; elapsed_ms?: number }interface ModelState { id: string; state: string }type FeedEvent = { type: "inflight"; operation: "snapshot" | "add" | "remove"; requests: InflightRequest[] } | { type: "modelStatus"; models: ModelState[] }type ModelRuntimeState = "stopped" | "loading" | "ready"class InflightTracker { apply(event: FeedEvent): void; count(modelId: string): number; state(modelId: string): ModelRuntimeState | undefined }
-
Step 1: Write the failing tests
tests/sse.test.ts:
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseSse } from "../src/lib/sse";
test("parseSse extracts event + data blocks separated by blank lines", () => {
const { messages, rest } = parseSse('event:message\ndata:{"a":1}\n\nevent:message\ndata:hello\n\n');
assert.equal(rest, "");
assert.equal(messages.length, 2);
assert.equal(messages[0].event, "message");
assert.equal(messages[0].data, '{"a":1}');
assert.equal(messages[1].data, "hello");
});
test("parseSse keeps a trailing partial event in rest", () => {
const { messages, rest } = parseSse("event:message\ndata:par");
assert.equal(messages.length, 0);
assert.equal(rest, "event:message\ndata:par");
});
test("parseSse joins multi-line data fields with newline", () => {
const { messages } = parseSse("data:line1\ndata:line2\n\n");
assert.equal(messages.length, 1);
assert.equal(messages[0].data, "line1\nline2");
});
tests/inflight-tracker.test.ts:
import assert from "node:assert/strict";
import { test } from "node:test";
import { InflightTracker, type FeedEvent } from "../src/lib/inflight-tracker";
test("snapshot rebuilds counts for all in-flight requests", () => {
const tracker = new InflightTracker();
tracker.apply({
type: "inflight",
operation: "snapshot",
requests: [
{ model: "A", id: "1" },
{ model: "A", id: "2" },
{ model: "B", id: "3" },
],
});
assert.equal(tracker.count("A"), 2);
assert.equal(tracker.count("B"), 1);
assert.equal(tracker.count("C"), 0);
});
test("add and remove adjust per-model counts", () => {
const tracker = new InflightTracker();
tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "1" }] });
tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "2" }] });
assert.equal(tracker.count("A"), 2);
tracker.apply({ type: "inflight", operation: "remove", requests: [{ model: "A", id: "1" }] });
assert.equal(tracker.count("A"), 1);
tracker.apply({ type: "inflight", operation: "remove", requests: [{ model: "A", id: "2" }] });
assert.equal(tracker.count("A"), 0);
tracker.apply({ type: "inflight", operation: "remove", requests: [{ model: "A", id: "9" }] });
assert.equal(tracker.count("A"), 0);
});
test("modelStatus normalizes states", () => {
const tracker = new InflightTracker();
tracker.apply({
type: "modelStatus",
models: [
{ id: "A", state: "ready" },
{ id: "B", state: "loading" },
{ id: "C", state: "stopped" },
{ id: "D", state: "weird" },
],
});
assert.equal(tracker.state("A"), "ready");
assert.equal(tracker.state("B"), "loading");
assert.equal(tracker.state("C"), "stopped");
assert.equal(tracker.state("D"), "stopped");
assert.equal(tracker.state("missing"), undefined);
});
- Step 2: Run tests to verify they fail
Run: npm test
Expected: FAIL — modules not found.
- Step 3: Implement
src/lib/sse.ts
export interface SseMessage {
event?: string;
data: string;
}
export function parseSse(chunk: string): { messages: SseMessage[]; rest: string } {
const messages: SseMessage[] = [];
let rest = chunk;
while (true) {
const idx = rest.indexOf("\n\n");
if (idx === -1) break;
const block = rest.slice(0, idx);
rest = rest.slice(idx + 2);
const message = toMessage(block);
if (message && message.data.length > 0) messages.push(message);
}
return { messages, rest };
}
function toMessage(block: string): SseMessage | null {
let event: string | undefined;
const dataLines: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return null;
return { event, data: dataLines.join("\n") };
}
- Step 4: Implement
src/lib/inflight-tracker.ts
export interface InflightRequest {
id?: string;
model: string;
req_path?: string;
method?: string;
timestamp?: string;
elapsed_ms?: number;
}
export interface ModelState {
id: string;
state: string;
}
export type FeedEvent =
| { type: "inflight"; operation: "snapshot" | "add" | "remove"; requests: InflightRequest[] }
| { type: "modelStatus"; models: ModelState[] };
export type ModelRuntimeState = "stopped" | "loading" | "ready";
export class InflightTracker {
private counts = new Map<string, number>();
private states = new Map<string, ModelRuntimeState>();
apply(event: FeedEvent): void {
if (event.type === "inflight") {
if (event.operation === "snapshot") {
const counts = new Map<string, number>();
for (const r of event.requests) counts.set(r.model, (counts.get(r.model) ?? 0) + 1);
this.counts = counts;
} else if (event.operation === "add") {
for (const r of event.requests) this.counts.set(r.model, (this.counts.get(r.model) ?? 0) + 1);
} else if (event.operation === "remove") {
for (const r of event.requests) {
const c = this.counts.get(r.model) ?? 0;
if (c <= 1) this.counts.delete(r.model);
else this.counts.set(r.model, c - 1);
}
}
} else if (event.type === "modelStatus") {
for (const m of event.models) this.states.set(m.id, normalizeState(m.state));
}
}
count(modelId: string): number {
return this.counts.get(modelId) ?? 0;
}
state(modelId: string): ModelRuntimeState | undefined {
return this.states.get(modelId);
}
}
function normalizeState(state: string): ModelRuntimeState {
if (state === "ready") return "ready";
if (state === "loading") return "loading";
return "stopped";
}
- Step 5: Run tests to verify they pass
Run: npm test
Expected: PASS.
- Step 6: Commit
git add -A
git commit -m "feat: add SSE parser and in-flight request tracker"
Task 7: event-feed.ts — SSE client and event decoding
Files:
- Create:
src/lib/event-feed.ts - Test:
tests/event-feed.test.ts
Interfaces:
-
Consumes:
LlamaSwapConfigfrom./util,parseSse+SseMessagefrom./sse,FeedEvent/InflightRequest/ModelStatefrom./inflight-tracker. -
Produces:
decodeEvent(msg: SseMessage): FeedEvent | null(exported for tests)class EventFeed { constructor(cfg: LlamaSwapConfig, onEvent: (ev: FeedEvent) => void); setStatusHandler(fn: (connected: boolean) => void): void; start(): void; stop(): void }- Reconnect with exponential backoff 1s → 30s; self-heals from
snapshot.
-
Step 1: Write the failing test —
tests/event-feed.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { decodeEvent } from "../src/lib/event-feed";
import type { SseMessage } from "../src/lib/sse";
test("decodeEvent parses an inflight snapshot", () => {
const msg: SseMessage = {
event: "message",
data: JSON.stringify({
type: "inflight",
data: JSON.stringify({
operation: "snapshot",
requests: [{ id: "14", model: "Qwen3.8-27B-NVFP4", req_path: "/v1/chat/completions", elapsed_ms: 165510 }],
}),
}),
};
const ev = decodeEvent(msg);
assert.ok(ev);
assert.equal(ev.type, "inflight");
if (ev.type === "inflight") {
assert.equal(ev.operation, "snapshot");
assert.equal(ev.requests.length, 1);
assert.equal(ev.requests[0].model, "Qwen3.8-27B-NVFP4");
assert.equal(ev.requests[0].elapsed_ms, 165510);
}
});
test("decodeEvent parses an inflight add with a singular request field", () => {
const msg: SseMessage = {
event: "message",
data: JSON.stringify({
type: "inflight",
data: JSON.stringify({ operation: "add", request: { id: "9", model: "A" } }),
}),
};
const ev = decodeEvent(msg);
assert.ok(ev && ev.type === "inflight");
if (ev && ev.type === "inflight") {
assert.equal(ev.operation, "add");
assert.equal(ev.requests[0].model, "A");
}
});
test("decodeEvent parses modelStatus", () => {
const msg: SseMessage = {
event: "message",
data: JSON.stringify({
type: "modelStatus",
data: JSON.stringify([
{ id: "DeepSeek-V4-Flash-0731", state: "ready" },
{ id: "Qwen3.8-27B-NVFP4", state: "stopped" },
]),
}),
};
const ev = decodeEvent(msg);
assert.ok(ev && ev.type === "modelStatus");
if (ev && ev.type === "modelStatus") {
assert.equal(ev.models.length, 2);
assert.equal(ev.models[0].state, "ready");
}
});
test("decodeEvent returns null for unrelated or malformed events", () => {
assert.equal(decodeEvent({ event: "message", data: JSON.stringify({ type: "logData", data: "{}" }) }), null);
assert.equal(decodeEvent({ event: "message", data: "not json" }), null);
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — module not found.
- Step 3: Implement
src/lib/event-feed.ts
import { type LlamaSwapConfig } from "./util";
import { parseSse, type SseMessage } from "./sse";
import { type FeedEvent, type InflightRequest, type ModelState } from "./inflight-tracker";
export function decodeEvent(msg: SseMessage): FeedEvent | null {
try {
const outer = JSON.parse(msg.data) as { type?: string; data?: string };
if (!outer.data) return null;
const inner = JSON.parse(outer.data) as Record<string, unknown>;
if (outer.type === "inflight") {
const requests: InflightRequest[] =
(inner.requests as InflightRequest[]) ?? (inner.request ? [inner.request as InflightRequest] : []);
return {
type: "inflight",
operation: inner.operation as "snapshot" | "add" | "remove",
requests,
};
}
if (outer.type === "modelStatus") {
return { type: "modelStatus", models: inner as unknown as ModelState[] };
}
return null;
} catch {
return null;
}
}
const MAX_RETRY_MS = 30000;
const INITIAL_RETRY_MS = 1000;
export class EventFeed {
private controller?: AbortController;
private closed = false;
private retryMs = INITIAL_RETRY_MS;
private statusHandler?: (connected: boolean) => void;
constructor(
private cfg: LlamaSwapConfig,
private onEvent: (ev: FeedEvent) => void,
) {}
setStatusHandler(fn: (connected: boolean) => void): void {
this.statusHandler = fn;
}
start(): void {
void this.connect();
}
stop(): void {
this.closed = true;
this.controller?.abort();
}
private async connect(): Promise<void> {
while (!this.closed) {
this.controller = new AbortController();
try {
const headers: Record<string, string> = { Accept: "text/event-stream" };
if (this.cfg.apiKey) headers["Authorization"] = `Bearer ${this.cfg.apiKey}`;
const res = await fetch(`${this.cfg.baseUrl}/api/events`, {
headers,
signal: this.controller.signal,
});
if (!res.ok || !res.body) throw new Error(`events HTTP ${res.status}`);
this.statusHandler?.(true);
this.retryMs = INITIAL_RETRY_MS;
await this.readStream(res.body.getReader());
} catch {
if (this.closed) return;
}
this.statusHandler?.(false);
if (this.closed) return;
await sleep(this.retryMs);
this.retryMs = Math.min(this.retryMs * 2, MAX_RETRY_MS);
}
}
private async readStream(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
const decoder = new TextDecoder();
let buffer = "";
try {
while (!this.closed) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { messages, rest } = parseSse(buffer);
buffer = rest;
for (const msg of messages) {
const ev = decodeEvent(msg);
if (ev) this.onEvent(ev);
}
}
} finally {
reader.releaseLock();
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS.
- Step 5: Commit
git add -A
git commit -m "feat: add SSE event feed for llama-swap api/events"
Task 8: render.ts — SVG key rendering
Files:
- Create:
src/lib/render.ts - Test:
tests/render.test.ts
Interfaces:
- Consumes:
GpuMetricKindfrom./metrics-parser,ModelRuntimeStatefrom./inflight-tracker,shorten/escapeXmlfrom./util. - Produces:
svgDataUrl(svg: string): stringrenderInflight(opts: { modelName: string; state?: ModelRuntimeState; count: number; offline: boolean; pulse: boolean }): stringrenderGpuGraph(opts: { gpuName: string; metric: GpuMetricKind; value: number | undefined; history: number[]; offline: boolean }): string
Rendering rules:
-
Inflight: offline → grey
OFFLINE; no model (modelName === "unset") → dark?/NO MODEL; unknown state → grey…;ready+0 → greenIDLE;ready+≥1 → redACTIVE(opacity toggled bypulse);loading→ amberLOADING; else → greyOFF. Model name at the bottom. -
GPU: dark chart area (x 3..69, y 20..55); % metrics scale 0..100, power scales to
niceCeil(max*1.1);<polyline>line + gradient<path>fill under curve; value label top (severity-colored),METRIC · NAMEbottom; offline → greyOFFLINEframe. -
Step 1: Write the failing test —
tests/render.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderGpuGraph, renderInflight, svgDataUrl } from "../src/lib/render";
test("svgDataUrl wraps an SVG as a base64 data URL", () => {
const url = svgDataUrl("<svg></svg>");
assert.match(url, /^data:image\/svg\+xml;base64,/);
});
test("renderInflight: ready + count renders IDLE in green", () => {
const svg = renderInflight({ modelName: "Qwen3.8-27B-NVFP4", state: "ready", count: 0, offline: false, pulse: false });
assert.match(svg, /IDLE/);
assert.match(svg, /#1e6b34/);
assert.doesNotMatch(svg, /ACTIVE/);
});
test("renderInflight: ready + count>0 renders ACTIVE in red", () => {
const svg = renderInflight({ modelName: "Qwen3.8-27B-NVFP4", state: "ready", count: 2, offline: false, pulse: false });
assert.match(svg, /ACTIVE/);
assert.match(svg, /#8b2626/);
});
test("renderInflight: pulse toggles opacity while active", () => {
const a = renderInflight({ modelName: "A", state: "ready", count: 1, offline: false, pulse: false });
const b = renderInflight({ modelName: "A", state: "ready", count: 1, offline: false, pulse: true });
assert.notEqual(a, b);
assert.match(b, /opacity="0.55"/);
});
test("renderInflight: offline and no-model states", () => {
const offline = renderInflight({ modelName: "A", state: "ready", count: 0, offline: true, pulse: false });
assert.match(offline, /OFFLINE/);
const noModel = renderInflight({ modelName: "unset", state: undefined, count: 0, offline: false, pulse: false });
assert.match(noModel, /NO MODEL/);
});
test("renderInflight: loading renders LOADING in amber", () => {
const svg = renderInflight({ modelName: "A", state: "loading", count: 0, offline: false, pulse: false });
assert.match(svg, /LOADING/);
assert.match(svg, /#8a6d1d/);
});
test("renderGpuGraph: draws a polyline, value, and metric label", () => {
const svg = renderGpuGraph({
gpuName: "GPU 2 · RTX 5090",
metric: "util_percent",
value: 67,
history: [0, 20, 40, 67],
offline: false,
});
assert.match(svg, /<polyline/);
assert.match(svg, /67%/);
assert.match(svg, /UTIL/);
assert.match(svg, /GPU 2/);
});
test("renderGpuGraph: power draws a watts value", () => {
const svg = renderGpuGraph({
gpuName: "ALL GPUS",
metric: "power",
value: 1030,
history: [900, 1000, 1030],
offline: false,
});
assert.match(svg, /1030W/);
assert.match(svg, /PWR/);
assert.match(svg, /ALL GPUS/);
});
test("renderGpuGraph: offline frame", () => {
const svg = renderGpuGraph({ gpuName: "ALL GPUS", metric: "util_percent", value: undefined, history: [], offline: true });
assert.match(svg, /OFFLINE/);
});
test("renderGpuGraph: single-point history avoids divide-by-zero", () => {
const svg = renderGpuGraph({ gpuName: "ALL GPUS", metric: "util_percent", value: 50, history: [50], offline: false });
assert.match(svg, /<svg/);
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — module not found.
- Step 3: Implement
src/lib/render.ts
import { type GpuMetricKind } from "./metrics-parser";
import { type ModelRuntimeState } from "./inflight-tracker";
import { escapeXml, shorten } from "./util";
export function svgDataUrl(svg: string): string {
return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
}
const METRIC_LABEL: Record<GpuMetricKind, string> = {
util_percent: "UTIL",
memory_util_percent: "VRAM",
temperature: "TEMP",
power: "PWR",
fan: "FAN",
};
export interface InflightRenderOptions {
modelName: string;
state?: ModelRuntimeState;
count: number;
offline: boolean;
pulse: boolean;
}
export function renderInflight(opts: InflightRenderOptions): string {
const name = escapeXml(shorten(opts.modelName));
if (opts.offline) {
return frame("#3a3a3a", [
centerText("!!", 34, 20, "bold", "#e0e0e0"),
centerText("OFFLINE", 52, 9, "normal", "#bdbdbd"),
centerText(name, 64, 7, "normal", "#ffffff"),
]);
}
if (opts.modelName === "unset") {
return frame("#222222", [
centerText("?", 36, 16, "bold", "#888888"),
centerText("NO MODEL", 54, 8, "normal", "#666666"),
]);
}
if (!opts.state) {
return frame("#222222", [centerText("…", 36, 16, "bold", "#999999"), centerText(name, 60, 7, "normal", "#999999")]);
}
const isActive = opts.state === "ready" && opts.count > 0;
const bg = opts.state === "ready" ? (isActive ? "#8b2626" : "#1e6b34") : opts.state === "loading" ? "#8a6d1d" : "#3a3a3a";
const label = opts.state === "ready" ? (isActive ? "ACTIVE" : "IDLE") : opts.state === "loading" ? "LOADING" : "OFF";
const opacity = isActive && opts.pulse ? 0.55 : 1;
return frame(bg, [
centerText(label, 38, 18, "bold", "#ffffff", opacity),
centerText(name, 64, 7, "normal", "#ffffff"),
]);
}
export interface GpuGraphRenderOptions {
gpuName: string;
metric: GpuMetricKind;
value: number | undefined;
history: number[];
offline: boolean;
}
export function renderGpuGraph(opts: GpuGraphRenderOptions): string {
const label = escapeXml(shorten(opts.gpuName));
if (opts.offline) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
<rect width="72" height="72" fill="#3a3a3a"/>
<text x="36" y="30" text-anchor="middle" font-family="Arial,sans-serif" font-size="18" font-weight="bold" fill="#e0e0e0">!!</text>
<text x="36" y="46" text-anchor="middle" font-family="Arial,sans-serif" font-size="9" fill="#bdbdbd">OFFLINE</text>
<text x="36" y="62" text-anchor="middle" font-family="Arial,sans-serif" font-size="7" fill="#ffffff" opacity="0.9">${label}</text>
</svg>`;
}
const color = opts.value === undefined ? "#666666" : severityColor(opts.metric, opts.value);
const valueText = opts.value === undefined ? "--" : `${Math.round(opts.value)}${unit(opts.metric)}`;
const points = chartPoints(opts.history, opts.metric);
const line =
points.length > 1
? `<polyline points="${points.map((p) => `${p.x},${p.y}`).join(" ")}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`
: "";
const area =
points.length > 1
? `<path d="M ${points.map((p) => `${p.x},${p.y}`).join(" L ")} L ${points[points.length - 1].x},55 L ${points[0].x},55 Z" fill="url(#grad)" opacity="0.35"/>`
: "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="72" height="72" fill="#10131a"/>
<rect x="1" y="18" width="70" height="40" fill="none" stroke="#242b38" stroke-width="1"/>
<line x1="1" y1="55" x2="71" y2="55" stroke="#242b38" stroke-width="1"/>
${area}
${line}
<text x="36" y="11" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="${color}">${valueText}</text>
<text x="36" y="67" text-anchor="middle" font-family="Arial,sans-serif" font-size="7" fill="#8b93a5">${METRIC_LABEL[opts.metric]} · ${label}</text>
</svg>`;
}
function unit(metric: GpuMetricKind): string {
return metric === "temperature" ? "°C" : metric === "power" ? "W" : "%";
}
function severityColor(metric: GpuMetricKind, value: number): string {
if (metric === "temperature") return value >= 80 ? "#e0453a" : value >= 60 ? "#d9a02a" : "#3fae5a";
if (metric === "power") return value >= 300 ? "#e0453a" : value >= 150 ? "#d9a02a" : "#3fae5a";
return value >= 85 ? "#e0453a" : value >= 50 ? "#d9a02a" : "#3fae5a";
}
function chartPoints(history: number[], metric: GpuMetricKind): { x: number; y: number }[] {
const n = history.length;
if (n === 0) return [];
const left = 3;
const right = 69;
const top = 20;
const bottom = 55;
const yMax = yScaleMax(metric, history);
return history.map((v, i) => {
const x = n === 1 ? (left + right) / 2 : left + ((right - left) * i) / (n - 1);
const clamped = Math.max(0, Math.min(v, yMax));
const y = bottom - ((clamped - 0) / (yMax - 0 || 1)) * (bottom - top);
return { x: round1(x), y: round1(y) };
});
}
function yScaleMax(metric: GpuMetricKind, history: number[]): number {
if (metric !== "power") return 100;
const maxValue = Math.max(...history);
if (!isFinite(maxValue) || maxValue <= 0) return 100;
return niceCeil(maxValue * 1.1);
}
function niceCeil(value: number): number {
const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
for (const m of [1, 2, 2.5, 5, 10]) {
if (m * magnitude >= value) return m * magnitude;
}
return value;
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function centerText(text: string, y: number, size: number, weight: "normal" | "bold", fill: string, opacity?: number): string {
return `<text x="36" y="${y}" text-anchor="middle" font-family="Arial,sans-serif" font-size="${size}" font-weight="${weight}" fill="${fill}"${opacity === undefined ? "" : ` opacity="${opacity}"`}>${text}</text>`;
}
function frame(bg: string, parts: string[]): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
<rect width="72" height="72" fill="${bg}"/>
${parts.join("\n ")}
</svg>`;
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS.
- Step 5: Commit
git add -A
git commit -m "feat: add SVG key renderers for inflight and GPU graph actions"
Task 9: runtime.ts + datasources.ts — shared state and PI data sourcing
Files:
- Create:
src/lib/runtime.ts - Create:
src/lib/datasources.ts
Interfaces:
-
Consumes:
EventFeed,InflightTracker,MetricsPoller,LlamaSwapConfig,fetchModels,fetchMetrics,gpuInfos,parseGpuMetrics,cfgFromSettings,CfgSettings. -
Produces:
runtime: { tracker: InflightTracker; offline: boolean; ensureConnections(cfg): void; pollerInstance?: MetricsPoller; subscribe(listener): () => void }registerDataSources(): void— wiresstreamDeck.ui.onSendToPluginto answermodelsandgpusdatasource requests.
-
Step 1: Implement
src/lib/runtime.ts
import { EventFeed } from "./event-feed";
import { InflightTracker } from "./inflight-tracker";
import { MetricsPoller } from "./metrics-poller";
import { type LlamaSwapConfig } from "./util";
class Runtime {
readonly tracker = new InflightTracker();
offline = true;
private feed?: EventFeed;
private poller?: MetricsPoller;
private listeners = new Set<() => void>();
ensureConnections(cfg: LlamaSwapConfig): void {
if (!this.feed) {
this.feed = new EventFeed(cfg, (ev) => {
this.tracker.apply(ev);
this.emit();
});
this.feed.setStatusHandler((connected) => {
this.offline = !connected;
this.emit();
});
this.feed.start();
}
if (!this.poller) {
this.poller = new MetricsPoller(cfg);
this.poller.on(() => this.emit());
this.poller.start();
}
}
get pollerInstance(): MetricsPoller | undefined {
return this.poller;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private emit(): void {
for (const listener of this.listeners) listener();
}
}
export const runtime = new Runtime();
- Step 2: Implement
src/lib/datasources.ts
import streamDeck from "@elgato/streamdeck";
import { fetchMetrics, fetchModels } from "./llamaswap";
import { gpuInfos, parseGpuMetrics } from "./metrics-parser";
import { cfgFromSettings, type CfgSettings } from "./util";
type DataSourceItem = { label: string; value: string };
export function registerDataSources(): void {
streamDeck.ui.onSendToPlugin(async (ev) => {
const request = ev.payload as { event?: string } | undefined;
const event = request?.event;
if (!event) return;
const settings = await ev.action.getSettings<CfgSettings>();
if (event === "models") {
let items: DataSourceItem[] = [];
try {
const models = await fetchModels(cfgFromSettings(settings));
items = models.map((m) => ({ label: `${m.id} (${m.status})`, value: m.id }));
} catch {
items = [];
}
await streamDeck.ui.sendToPropertyInspector({ event, items });
return;
}
if (event === "gpus") {
let items: DataSourceItem[] = [];
try {
const text = await fetchMetrics(cfgFromSettings(settings));
const gpus = gpuInfos(parseGpuMetrics(text));
items = [
{ label: "All GPUs", value: "all" },
...gpus.map((g) => ({ label: `GPU ${g.id} · ${g.name}`, value: g.id })),
];
} catch {
items = [{ label: "All GPUs", value: "all" }];
}
await streamDeck.ui.sendToPropertyInspector({ event, items });
}
});
}
- Step 3: Verify it typechecks
Run: npm run build
Expected: build succeeds. (Note: import streamDeck default export + streamDeck.ui.onSendToPlugin — if the compiler reports a different method name, consult node_modules/@elgato/streamdeck/dist/plugin/ui.d.ts and adjust; the public API is onSendToPlugin / sendToPropertyInspector. Also note: DataSourceItem must be a type alias, not an interface — interfaces without an index signature aren't assignable to JsonObject/JsonValue.)
- Step 4: Commit
git add -A
git commit -m "feat: add shared runtime and PI datasource handlers"
Task 10: Actions — InflightMonitor and GpuGraph
Files:
- Create:
src/actions/inflight-monitor.ts - Create:
src/actions/gpu-graph.ts
Interfaces:
-
Consumes:
runtime,renderInflight/renderGpuGraph/svgDataUrl,cfgFromSettings/CfgSettings,GpuMetricKind,streamDeck(forsystem.openUrl). -
Produces: two
SingletonActionclasses registered by UUIDscom.bryce.llamawatch.inflightandcom.bryce.llamawatch.gpu. -
Step 1: Implement
src/actions/inflight-monitor.ts
import streamDeck, {
action,
type DidReceiveSettingsEvent,
type KeyAction,
type KeyDownEvent,
SingletonAction,
type WillAppearEvent,
} from "@elgato/streamdeck";
import { renderInflight, svgDataUrl } from "../lib/render";
import { runtime } from "../lib/runtime";
import { cfgFromSettings, type CfgSettings } from "../lib/util";
type InflightSettings = CfgSettings & {
modelId?: string;
};
@action({ UUID: "com.bryce.llamawatch.inflight" })
export class InflightMonitor extends SingletonAction<InflightSettings> {
private settings: InflightSettings = {};
private action?: KeyAction<InflightSettings>;
private unsubscribe?: () => void;
private pulseTimer?: ReturnType<typeof setInterval>;
private pulse = false;
override onWillAppear(ev: WillAppearEvent<InflightSettings>): void {
if (!ev.action.isKey()) return;
this.settings = ev.payload.settings;
this.action = ev.action;
runtime.ensureConnections(cfgFromSettings(this.settings));
this.unsubscribe = runtime.subscribe(() => this.render());
this.render();
}
override onWillDisappear(): void {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.action = undefined;
this.clearPulse();
}
override onDidReceiveSettings(ev: DidReceiveSettingsEvent<InflightSettings>): void {
if (!ev.action.isKey()) return;
this.settings = ev.payload.settings;
this.action = ev.action;
runtime.ensureConnections(cfgFromSettings(this.settings));
this.render();
}
override onKeyDown(ev: KeyDownEvent<InflightSettings>): void {
void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`);
}
private render(): void {
const action = this.action;
if (!action) return;
const modelId = this.settings.modelId ?? "";
const state = runtime.tracker.state(modelId);
const count = runtime.tracker.count(modelId);
const offline = runtime.offline && modelId.length > 0;
void action.setImage(
svgDataUrl(
renderInflight({
modelName: modelId.length > 0 ? modelId : "unset",
state,
count,
offline,
pulse: this.pulse,
}),
),
);
if (state === "ready" && count > 0 && !this.pulseTimer) {
this.pulseTimer = setInterval(() => {
this.pulse = !this.pulse;
this.render();
}, 500);
} else if (!(state === "ready" && count > 0) && this.pulseTimer) {
this.clearPulse();
}
}
private clearPulse(): void {
if (this.pulseTimer) clearInterval(this.pulseTimer);
this.pulseTimer = undefined;
this.pulse = false;
}
}
- Step 2: Implement
src/actions/gpu-graph.ts
import streamDeck, {
action,
type DidReceiveSettingsEvent,
type KeyAction,
type KeyDownEvent,
SingletonAction,
type WillAppearEvent,
} from "@elgato/streamdeck";
import { type GpuMetricKind } from "../lib/metrics-parser";
import { renderGpuGraph, svgDataUrl } from "../lib/render";
import { runtime } from "../lib/runtime";
import { cfgFromSettings, type CfgSettings } from "../lib/util";
type GpuSettings = CfgSettings & {
gpuId?: string;
metric?: GpuMetricKind;
};
@action({ UUID: "com.bryce.llamawatch.gpu" })
export class GpuGraph extends SingletonAction<GpuSettings> {
private settings: GpuSettings = {};
private action?: KeyAction<GpuSettings>;
private unsubscribe?: () => void;
private lastSvg?: string;
override onWillAppear(ev: WillAppearEvent<GpuSettings>): void {
if (!ev.action.isKey()) return;
this.settings = ev.payload.settings;
this.action = ev.action;
runtime.ensureConnections(cfgFromSettings(this.settings));
this.unsubscribe = runtime.subscribe(() => this.render());
this.render();
}
override onWillDisappear(): void {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.action = undefined;
}
override onDidReceiveSettings(ev: DidReceiveSettingsEvent<GpuSettings>): void {
if (!ev.action.isKey()) return;
this.settings = ev.payload.settings;
this.action = ev.action;
this.render();
}
override onKeyDown(ev: KeyDownEvent<GpuSettings>): void {
void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`);
}
private render(): void {
const action = this.action;
if (!action) return;
const poller = runtime.pollerInstance;
const gpuId = this.settings.gpuId ?? "all";
const metric = this.settings.metric ?? "util_percent";
const value = poller?.getValue(gpuId, metric);
const history = poller?.getHistory(gpuId, metric) ?? [];
const offline = !poller || poller.isOffline();
const gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId);
const svg = renderGpuGraph({ gpuName, metric, value, history, offline });
if (svg === this.lastSvg) return;
this.lastSvg = svg;
void action.setImage(svgDataUrl(svg));
}
private gpuLabel(gpuId: string): string {
const gpu = runtime.pollerInstance?.gpus().find((g) => g.id === gpuId);
return gpu && gpu.name ? `GPU ${gpuId} · ${gpu.name}` : `GPU ${gpuId}`;
}
}
- Step 3: Verify it typechecks
Run: npm run build
Expected: build succeeds.
- Step 4: Commit
git add -A
git commit -m "feat: implement inflight monitor and GPU graph actions"
Task 11: Entry point, manifest actions, property inspectors, assets
Files:
- Modify:
src/plugin.ts - Modify:
com.bryce.llamawatch.sdPlugin/manifest.json - Create:
com.bryce.llamawatch.sdPlugin/ui/inflight.html - Create:
com.bryce.llamawatch.sdPlugin/ui/gpu.html - Create:
com.bryce.llamawatch.sdPlugin/ui/sdpi-components.js(downloaded, v4) - Create:
com.bryce.llamawatch.sdPlugin/imgs/plugin/icon.svg(256×256 source) - Create:
com.bryce.llamawatch.sdPlugin/imgs/plugin/category-icon.svg - Create:
com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/icon.svg(monochrome) - Create:
com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/key.svg - Create:
com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/icon.svg(monochrome) - Create:
com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/key.svg
Interfaces:
-
Consumes: action classes and
registerDataSources. -
Produces: a fully manifest-defined plugin (both actions), compiled entry, PI pages wired to the
models/gpusdatasources, and generated PNG icons. -
Step 1: Replace
src/plugin.ts
import streamDeck from "@elgato/streamdeck";
import { GpuGraph } from "./actions/gpu-graph";
import { InflightMonitor } from "./actions/inflight-monitor";
import { registerDataSources } from "./lib/datasources";
streamDeck.actions.registerAction(new InflightMonitor());
streamDeck.actions.registerAction(new GpuGraph());
registerDataSources();
streamDeck.connect();
- Step 2: Replace
com.bryce.llamawatch.sdPlugin/manifest.json
{
"$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
"Name": "llama-watch",
"Version": "1.0.0.0",
"Author": "Bryce Zuccaro",
"Actions": [
{
"Name": "In-Flight Monitor",
"UUID": "com.bryce.llamawatch.inflight",
"Icon": "imgs/actions/inflight/icon",
"Tooltip": "Shows whether a model has a request in flight",
"PropertyInspectorPath": "ui/inflight.html",
"Controllers": ["Keypad"],
"States": [{ "Image": "imgs/actions/inflight/key", "ShowTitle": false }]
},
{
"Name": "GPU Graph",
"UUID": "com.bryce.llamawatch.gpu",
"Icon": "imgs/actions/gpu/icon",
"Tooltip": "Live GPU metric graph",
"PropertyInspectorPath": "ui/gpu.html",
"Controllers": ["Keypad"],
"States": [{ "Image": "imgs/actions/gpu/key", "ShowTitle": false }]
}
],
"Category": "llama-watch",
"CategoryIcon": "imgs/plugin/category-icon",
"CodePath": "bin/plugin.js",
"Description": "Monitor llama-swap: in-flight requests per model and live GPU metric graphs.",
"Icon": "imgs/plugin/icon",
"SDKVersion": 2,
"Software": { "MinimumVersion": "7.1" },
"OS": [{ "Platform": "mac", "MinimumVersion": "13" }],
"Nodejs": { "Version": "24" },
"UUID": "com.bryce.llamawatch"
}
- Step 3: Download
sdpi-components.js(v4) locally
curl -sL https://sdpi-components.dev/releases/v4/sdpi-components.js -o com.bryce.llamawatch.sdPlugin/ui/sdpi-components.js
Expected: file is non-empty and starts with a JS comment/IIFE.
- Step 4: Create
com.bryce.llamawatch.sdPlugin/ui/inflight.html
<!doctype html>
<html>
<head lang="en">
<meta charset="utf-8" />
<script src="sdpi-components.js"></script>
</head>
<body>
<sdpi-item label="Base URL">
<sdpi-textfield setting="baseUrl" value="http://localhost:9292" placeholder="http://host:port" />
</sdpi-item>
<sdpi-item label="API Key">
<sdpi-password setting="apiKey" placeholder="Optional" />
</sdpi-item>
<sdpi-item label="Model">
<sdpi-select setting="modelId" datasource="models" loading="Loading models…" hot-reload placeholder="Select a model" />
</sdpi-item>
</body>
</html>
- Step 5: Create
com.bryce.llamawatch.sdPlugin/ui/gpu.html
<!doctype html>
<html>
<head lang="en">
<meta charset="utf-8" />
<script src="sdpi-components.js"></script>
</head>
<body>
<sdpi-item label="Base URL">
<sdpi-textfield setting="baseUrl" value="http://localhost:9292" placeholder="http://host:port" />
</sdpi-item>
<sdpi-item label="API Key">
<sdpi-password setting="apiKey" placeholder="Optional" />
</sdpi-item>
<sdpi-item label="GPU">
<sdpi-select setting="gpuId" datasource="gpus" loading="Loading GPUs…" hot-reload default="all" placeholder="Select a GPU" />
</sdpi-item>
<sdpi-item label="Metric">
<sdpi-select setting="metric" default="util_percent" placeholder="Select a metric">
<option value="util_percent">Utilization %</option>
<option value="memory_util_percent">VRAM %</option>
<option value="temperature">Temperature</option>
<option value="power">Power draw</option>
<option value="fan">Fan speed</option>
</sdpi-select>
</sdpi-item>
</body>
</html>
- Step 6: Create the SVG assets
com.bryce.llamawatch.sdPlugin/imgs/plugin/icon.svg (256×256, graph glyph on dark rounded square):
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="48" fill="#0e1117"/>
<polyline points="40,190 90,150 130,170 190,80 216,60" fill="none" stroke="#3fae5a" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="216" cy="60" r="16" fill="#e0453a"/>
</svg>
com.bryce.llamawatch.sdPlugin/imgs/plugin/category-icon.svg (monochrome white, 56×56):
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 56 56">
<polyline points="8,42 20,32 28,38 42,16 48,10" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/icon.svg (monochrome white, activity pulse):
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<polyline points="1,10 5,10 7,4 10,16 12,10 19,10" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/key.svg (72×72 default key):
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
<rect width="72" height="72" fill="#222222"/>
<polyline points="10,50 22,50 28,30 36,60 42,50 62,50" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/icon.svg (monochrome white, chip):
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<rect x="4" y="4" width="12" height="12" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/>
<rect x="8" y="8" width="4" height="4" fill="#ffffff"/>
<path d="M9 1v3M13 1v3M9 16v3M13 16v3M1 9h3M1 13h3M16 9h3M16 13h3" stroke="#ffffff" stroke-width="2" stroke-linecap="round"/>
</svg>
com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/key.svg (72×72 default key):
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
<rect width="72" height="72" fill="#222222"/>
<rect x="14" y="14" width="44" height="44" rx="6" fill="none" stroke="#ffffff" stroke-width="4"/>
<rect x="30" y="30" width="12" height="12" fill="#ffffff"/>
<path d="M32 2v8M40 2v8M32 62v8M40 62v8M2 32h8M2 40h8M62 32h8M62 40h8" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
</svg>
- Step 7: Generate the plugin PNG icon (256 + 512 @2x) with
sips
cd com.bryce.llamawatch.sdPlugin/imgs/plugin
sips -s format png icon.svg --out icon.png
sips -z 512 512 icon.png --out icon@2x.png
Expected: icon.png is 256×256, icon@2x.png is 512×512.
- Step 8: Build and verify
Run: npm run build
Expected: build succeeds and com.bryce.llamawatch.sdPlugin/bin/plugin.js exists.
- Step 9: Commit
git add -A
git commit -m "feat: wire entry point, manifest, property inspectors, and assets"
Task 12: Validate, install, and manually verify on the device
Files:
- Create:
README.md
Interfaces:
-
Consumes: the built plugin folder.
-
Produces:
.streamDeckPluginpackage, plugin installed and verified live on the Stream Deck XL, README. -
Step 1: Validate the manifest
Run:
streamdeck validate com.bryce.llamawatch.sdPlugin
Expected: validation passes (fix any reported issues, e.g. missing icon paths).
- Step 2: Enable developer mode (if needed) and install the plugin
Try the CLI link first:
streamdeck dev
streamdeck link com.bryce.llamawatch.sdPlugin
If link fails because the CLI cannot locate the app, copy the folder manually:
cp -R com.bryce.llamawatch.sdPlugin "$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/"
(The Stream Deck app at /Applications/Elgato Stream Deck.app auto-loads plugins from that directory.) Then verify with streamdeck list or by restarting the app.
-
Step 3: Verify the plugin loads
-
Restart the Stream Deck app (or
streamdeck restart com.bryce.llamawatch). -
Confirm the action list contains "In-Flight Monitor" and "GPU Graph" under the "llama-watch" category.
-
Check
~/Library/Logs/StreamDeck(or the pluginlogs/dir) for errors if the actions don't appear. -
Step 4: Manual verification on the device (Stream Deck XL)
-
Place an In-Flight Monitor action and pick model
DeepSeek-V4-Flash-0731; confirm it renders greenIDLE. While an OpenWebUI/chat request is running against that model, confirm it turns redACTIVEand pulses. Press the key → browser openshttp://localhost:9292/ui. -
Place a GPU Graph action; GPU dropdown should list "All GPUs" plus the three GPUs (RTX PRO 6000 ×2, RTX 5090). Select GPU
2+ metricUtilization %; confirm a graph line appears and updates every ~5s. Select metricPower draw; confirm watts value + auto-scaled chart. Press → opens/ui. -
Stop the llama-swap instance (or use a bogus base URL on a spare key) and confirm buttons show
OFFLINE; restore and confirm auto-recovery. -
Step 5: Write
README.md
# llama-watch
A Stream Deck plugin (macOS) that monitors a llama-swap instance.
## Actions
- **In-Flight Monitor** — per-model color state: `OFF` / `LOADING` / `IDLE` / `ACTIVE` (pulsing red). 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 or all GPUs, sampled every 5 s.
Pressing either key opens `http://<base-url>/ui` in your browser.
## Install
Double-click the packaged `.streamDeckPlugin`, or run:
```bash
npm install
npm run build
streamdeck validate com.bryce.llamawatch.sdPlugin
Develop
npm run watch # hot-reload while the Stream Deck app is running
npm test # unit tests (node:test + tsx)
Configure
Per-key settings: base URL (default http://localhost:9292), optional API key, and the model / GPU / metric to watch. The model and GPU dropdowns are populated live from the instance.
Marketplace
Assets are prepared for eventual submission (imgs/plugin/icon.png 256/512, action icons, category icon). The plugin only stores its settings locally; it reads GPU metrics and request status from the user's own llama-swap server and sends nothing elsewhere.
- [ ] **Step 6: Package the distributable**
Run:
```bash
streamdeck pack com.bryce.llamawatch.sdPlugin
Expected: com.bryce.llamawatch.streamDeckPlugin created. Double-clicking it installs the plugin.
- Step 7: Commit
git add -A
git commit -m "docs: add README; verify plugin install and rendering on device"
Self-Review
Spec coverage:
- Feature 1 (in-flight per model) → Tasks 6, 7 (tracker + feed), Task 10 (action), Task 11 (PI model dropdown). ✔
- Feature 2 (GPU graphs, all five metrics + aggregates) → Tasks 3, 5 (parser/poller/aggregates), Task 8 (renderer), Task 10 (action), Task 11 (PI GPU+metric dropdowns). ✔
- 5s poll, 60-sample buffer → Task 5. ✔
- 144×72 rendering / SVG, DPR 2 → SVG viewBox 72 +
setImage; device is Stream Deck XL (DPR 2), SVG scales crisply. ✔ - IDLE/ACTIVE color state, pulse → Task 8/10. ✔
- Press opens
/ui→ Task 10 (streamDeck.system.openUrl). ✔ - Error handling (offline, reconnect backoff, missed-poll skip, per-button isolation) → Task 5/7/9/10. ✔
- Marketplace-ready assets → Task 11/12. ✔
Placeholder scan: No TBD/TODO; all code blocks complete; no "similar to Task N" references; every interface symbol is defined in a task.
Type consistency: GpuMetricKind, FeedEvent, ModelRuntimeState, LlamaSwapConfig, cfgFromSettings, runtime, renderInflight, renderGpuGraph names are identical across tasks. KeyAction<T> used consistently for stored action refs; streamDeck.system.openUrl and streamDeck.ui.onSendToPlugin/sendToPropertyInspector match @elgato/streamdeck@2.1.1 (verified against its .d.ts). No lint/typecheck config beyond the sample's tsconfig — npm run build is the type gate.