Files
llama-watch/docs/superpowers/plans/2026-08-14-llama-watch.md
T

2156 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: "6.5"`.
- 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 as `Authorization: Bearer <key>` when set.
- Key press on either action opens `<baseUrl>/ui` in the default browser via `streamDeck.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 outputs `com.bryce.llamawatch.sdPlugin/bin/plugin.js`), `npm test` runner available, placeholder plugin registers no actions yet.
- [ ] **Step 1: Create `package.json`**
```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`**
```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`**
```js
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`**
```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": "6.5" },
"OS": [{ "Platform": "mac", "MinimumVersion": "13" }],
"Nodejs": { "Version": "24" },
"UUID": "com.bryce.llamawatch"
}
```
- [ ] **Step 6: Create placeholder `src/plugin.ts`**
```ts
import streamDeck from "@elgato/streamdeck";
streamDeck.connect();
```
- [ ] **Step 7: Create placeholder action files**`src/actions/inflight-monitor.ts` and `src/actions/gpu-graph.ts`, each containing only:
```ts
export {};
```
- [ ] **Step 8: Create `tests/.gitkeep`** (empty file)
- [ ] **Step 9: Install dependencies and verify the build and test runner**
Run:
```bash
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**
```bash
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`
```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-NVFP4"), "Qwen3.8-27B-NVFP4");
assert.equal(shorten("DeepSeek-V4-Flash-0731"), "DeepSe…0731");
});
test("escapeXml escapes XML special characters", () => {
assert.equal(escapeXml(`A&B <C> "D" 'E'`), "A&amp;B &lt;C&gt; &quot;D&quot; &apos;E&apos;");
});
```
- [ ] **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`**
```ts
export const DEFAULT_BASE_URL = "http://localhost:9292";
export interface CfgSettings {
baseUrl?: string;
apiKey?: string;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
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)
```text
# 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`
```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`**
```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**
```bash
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: `LlamaSwapConfig` from `./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`, maps `data[]` to `{id, status: status.value}`.
- [ ] **Step 1: Implement `src/lib/llamaswap.ts`** (network module; verified via `npm run build` typecheck, and integration-tested in Task 12)
```ts
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**
```bash
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` + `GpuMetricKind` from `./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 | undefined`
- `getHistory(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`
```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 low = FIXTURE.replace(/} 100\n/g, "} 100\n").replace(/} 51\n/g, "} 51\n");
const poller = new MetricsPoller(CFG, 5000, stubFetch([low]));
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`**
```ts
import { fetchMetrics, type LlamaSwapConfig } from "./llamaswap";
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;
}
}
export { gpuInfos };
```
Note: `gpuInfos` is re-exported for `datasources.ts` convenience; if unused there, remove the re-export in Task 9.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
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`:
```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`:
```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`**
```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`**
```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**
```bash
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: `LlamaSwapConfig` from `./util`, `parseSse` + `SseMessage` from `./sse`, `FeedEvent`/`InflightRequest`/`ModelState` from `./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`
```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`**
```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**
```bash
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: `GpuMetricKind` from `./metrics-parser`, `ModelRuntimeState` from `./inflight-tracker`, `shorten`/`escapeXml` from `./util`.
- Produces:
- `svgDataUrl(svg: string): string`
- `renderInflight(opts: { modelName: string; state?: ModelRuntimeState; count: number; offline: boolean; pulse: boolean }): string`
- `renderGpuGraph(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 → green `IDLE`; `ready`+≥1 → red `ACTIVE` (opacity toggled by `pulse`); `loading` → amber `LOADING`; else → grey `OFF`. 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 · NAME` bottom; offline → grey `OFFLINE` frame.
- [ ] **Step 1: Write the failing test**`tests/render.test.ts`
```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`**
```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**
```bash
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` — wires `streamDeck.ui.onSendToPlugin` to answer `models` and `gpus` datasource requests.
- [ ] **Step 1: Implement `src/lib/runtime.ts`**
```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`**
```ts
import streamDeck from "@elgato/streamdeck";
import { fetchMetrics, fetchModels } from "./llamaswap";
import { gpuInfos, parseGpuMetrics } from "./metrics-parser";
import { cfgFromSettings, type CfgSettings } from "./util";
interface 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`.)
- [ ] **Step 4: Commit**
```bash
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` (for `system.openUrl`).
- Produces: two `SingletonAction` classes registered by UUIDs `com.bryce.llamawatch.inflight` and `com.bryce.llamawatch.gpu`.
- [ ] **Step 1: Implement `src/actions/inflight-monitor.ts`**
```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`**
```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**
```bash
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`/`gpus` datasources, and generated PNG icons.
- [ ] **Step 1: Replace `src/plugin.ts`**
```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`**
```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": "6.5" },
"OS": [{ "Platform": "mac", "MinimumVersion": "13" }],
"Nodejs": { "Version": "24" },
"UUID": "com.bryce.llamawatch"
}
```
- [ ] **Step 3: Download `sdpi-components.js` (v4) locally**
```bash
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`**
```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`**
```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
<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
<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
<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
<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
<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
<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`**
```bash
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**
```bash
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: `.streamDeckPlugin` package, plugin installed and verified live on the Stream Deck XL, README.
- [ ] **Step 1: Validate the manifest**
Run:
```bash
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:
```bash
streamdeck dev
streamdeck link com.bryce.llamawatch.sdPlugin
```
If `link` fails because the CLI cannot locate the app, copy the folder manually:
```bash
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 plugin `logs/` 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 green `IDLE`. While an OpenWebUI/chat request is running against that model, confirm it turns red `ACTIVE` and pulses. Press the key → browser opens `http://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` + metric `Utilization %`; confirm a graph line appears and updates every ~5s. Select metric `Power 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`**
```markdown
# 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
```bash
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**
```bash
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.