fix: support multiple keys per action and harden runtime/parsing

This commit is contained in:
2026-08-14 10:59:25 -06:00
parent 7e3377f932
commit 733bc2b4f9
10 changed files with 180 additions and 87 deletions
+40 -22
View File
@@ -5,6 +5,7 @@ import streamDeck, {
type KeyDownEvent, type KeyDownEvent,
SingletonAction, SingletonAction,
type WillAppearEvent, type WillAppearEvent,
type WillDisappearEvent,
} from "@elgato/streamdeck"; } from "@elgato/streamdeck";
import { type GpuMetricKind } from "../lib/metrics-parser"; import { type GpuMetricKind } from "../lib/metrics-parser";
import { renderGpuGraph, svgDataUrl } from "../lib/render"; import { renderGpuGraph, svgDataUrl } from "../lib/render";
@@ -16,52 +17,69 @@ type GpuSettings = CfgSettings & {
metric?: GpuMetricKind; metric?: GpuMetricKind;
}; };
type GpuState = {
settings: GpuSettings;
action?: KeyAction<GpuSettings>;
unsubscribe?: () => void;
lastSvg?: string;
};
@action({ UUID: "com.bryce.llamawatch.gpu" }) @action({ UUID: "com.bryce.llamawatch.gpu" })
export class GpuGraph extends SingletonAction<GpuSettings> { export class GpuGraph extends SingletonAction<GpuSettings> {
private settings: GpuSettings = {}; private states = new Map<string, GpuState>();
private action?: KeyAction<GpuSettings>;
private unsubscribe?: () => void; private stateFor(action: KeyAction<GpuSettings>): GpuState {
private lastSvg?: string; let state = this.states.get(action.id);
if (!state) {
state = { settings: {} };
this.states.set(action.id, state);
}
return state;
}
override onWillAppear(ev: WillAppearEvent<GpuSettings>): void { override onWillAppear(ev: WillAppearEvent<GpuSettings>): void {
if (!ev.action.isKey()) return; if (!ev.action.isKey()) return;
this.settings = ev.payload.settings; const state = this.stateFor(ev.action);
this.action = ev.action; state.settings = ev.payload.settings;
runtime.ensureConnections(cfgFromSettings(this.settings)); state.action = ev.action;
this.unsubscribe = runtime.subscribe(() => this.render()); runtime.ensureConnections(cfgFromSettings(state.settings));
this.render(); state.unsubscribe = runtime.subscribe(() => this.render(state));
this.render(state);
} }
override onWillDisappear(): void { override onWillDisappear(ev: WillDisappearEvent<GpuSettings>): void {
this.unsubscribe?.(); const state = this.states.get(ev.action.id);
this.unsubscribe = undefined; if (!state) return;
this.action = undefined; state.unsubscribe?.();
this.states.delete(ev.action.id);
} }
override onDidReceiveSettings(ev: DidReceiveSettingsEvent<GpuSettings>): void { override onDidReceiveSettings(ev: DidReceiveSettingsEvent<GpuSettings>): void {
if (!ev.action.isKey()) return; if (!ev.action.isKey()) return;
this.settings = ev.payload.settings; const state = this.stateFor(ev.action);
this.action = ev.action; state.settings = ev.payload.settings;
this.render(); state.action = ev.action;
runtime.ensureConnections(cfgFromSettings(state.settings));
this.render(state);
} }
override onKeyDown(ev: KeyDownEvent<GpuSettings>): void { override onKeyDown(ev: KeyDownEvent<GpuSettings>): void {
void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`); void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`);
} }
private render(): void { private render(state: GpuState): void {
const action = this.action; const action = state.action;
if (!action) return; if (!action) return;
const poller = runtime.pollerInstance; const poller = runtime.pollerInstance;
const gpuId = this.settings.gpuId ?? "all"; const gpuId = state.settings.gpuId ?? "all";
const metric = this.settings.metric ?? "util_percent"; const metric = state.settings.metric ?? "util_percent";
const value = poller?.getValue(gpuId, metric); const value = poller?.getValue(gpuId, metric);
const history = poller?.getHistory(gpuId, metric) ?? []; const history = poller?.getHistory(gpuId, metric) ?? [];
const offline = !poller || poller.isOffline(); const offline = !poller || poller.isOffline();
const gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId); const gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId);
const svg = renderGpuGraph({ gpuName, metric, value, history, offline }); const svg = renderGpuGraph({ gpuName, metric, value, history, offline });
if (svg === this.lastSvg) return; if (svg === state.lastSvg) return;
this.lastSvg = svg; state.lastSvg = svg;
void action.setImage(svgDataUrl(svg)); void action.setImage(svgDataUrl(svg));
} }
+52 -35
View File
@@ -5,6 +5,7 @@ import streamDeck, {
type KeyDownEvent, type KeyDownEvent,
SingletonAction, SingletonAction,
type WillAppearEvent, type WillAppearEvent,
type WillDisappearEvent,
} from "@elgato/streamdeck"; } from "@elgato/streamdeck";
import { renderInflight, svgDataUrl } from "../lib/render"; import { renderInflight, svgDataUrl } from "../lib/render";
import { runtime } from "../lib/runtime"; import { runtime } from "../lib/runtime";
@@ -14,74 +15,90 @@ type InflightSettings = CfgSettings & {
modelId?: string; modelId?: string;
}; };
type InflightState = {
settings: InflightSettings;
action?: KeyAction<InflightSettings>;
unsubscribe?: () => void;
pulseTimer?: ReturnType<typeof setInterval>;
pulse: boolean;
};
@action({ UUID: "com.bryce.llamawatch.inflight" }) @action({ UUID: "com.bryce.llamawatch.inflight" })
export class InflightMonitor extends SingletonAction<InflightSettings> { export class InflightMonitor extends SingletonAction<InflightSettings> {
private settings: InflightSettings = {}; private states = new Map<string, InflightState>();
private action?: KeyAction<InflightSettings>;
private unsubscribe?: () => void; private stateFor(action: KeyAction<InflightSettings>): InflightState {
private pulseTimer?: ReturnType<typeof setInterval>; let state = this.states.get(action.id);
private pulse = false; if (!state) {
state = { settings: {}, pulse: false };
this.states.set(action.id, state);
}
return state;
}
override onWillAppear(ev: WillAppearEvent<InflightSettings>): void { override onWillAppear(ev: WillAppearEvent<InflightSettings>): void {
if (!ev.action.isKey()) return; if (!ev.action.isKey()) return;
this.settings = ev.payload.settings; const state = this.stateFor(ev.action);
this.action = ev.action; state.settings = ev.payload.settings;
runtime.ensureConnections(cfgFromSettings(this.settings)); state.action = ev.action;
this.unsubscribe = runtime.subscribe(() => this.render()); runtime.ensureConnections(cfgFromSettings(state.settings));
this.render(); state.unsubscribe = runtime.subscribe(() => this.render(state));
this.render(state);
} }
override onWillDisappear(): void { override onWillDisappear(ev: WillDisappearEvent<InflightSettings>): void {
this.unsubscribe?.(); const state = this.states.get(ev.action.id);
this.unsubscribe = undefined; if (!state) return;
this.action = undefined; state.unsubscribe?.();
this.clearPulse(); this.clearPulse(state);
this.states.delete(ev.action.id);
} }
override onDidReceiveSettings(ev: DidReceiveSettingsEvent<InflightSettings>): void { override onDidReceiveSettings(ev: DidReceiveSettingsEvent<InflightSettings>): void {
if (!ev.action.isKey()) return; if (!ev.action.isKey()) return;
this.settings = ev.payload.settings; const state = this.stateFor(ev.action);
this.action = ev.action; state.settings = ev.payload.settings;
runtime.ensureConnections(cfgFromSettings(this.settings)); state.action = ev.action;
this.render(); runtime.ensureConnections(cfgFromSettings(state.settings));
this.render(state);
} }
override onKeyDown(ev: KeyDownEvent<InflightSettings>): void { override onKeyDown(ev: KeyDownEvent<InflightSettings>): void {
void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`); void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`);
} }
private render(): void { private render(state: InflightState): void {
const action = this.action; const action = state.action;
if (!action) return; if (!action) return;
const modelId = this.settings.modelId ?? ""; const modelId = state.settings.modelId ?? "";
const state = runtime.tracker.state(modelId); const trackerState = runtime.tracker.state(modelId);
const count = runtime.tracker.count(modelId); const count = runtime.tracker.count(modelId);
const offline = runtime.offline && modelId.length > 0; const offline = runtime.offline && modelId.length > 0;
void action.setImage( void action.setImage(
svgDataUrl( svgDataUrl(
renderInflight({ renderInflight({
modelName: modelId.length > 0 ? modelId : "unset", modelName: modelId.length > 0 ? modelId : "unset",
state, state: trackerState,
count, count,
offline, offline,
pulse: this.pulse, pulse: state.pulse,
}), }),
), ),
); );
if (state === "ready" && count > 0 && !this.pulseTimer) { if (trackerState === "ready" && count > 0 && !state.pulseTimer) {
this.pulseTimer = setInterval(() => { state.pulseTimer = setInterval(() => {
this.pulse = !this.pulse; state.pulse = !state.pulse;
this.render(); this.render(state);
}, 500); }, 500);
} else if (!(state === "ready" && count > 0) && this.pulseTimer) { } else if (!(trackerState === "ready" && count > 0) && state.pulseTimer) {
this.clearPulse(); this.clearPulse(state);
} }
} }
private clearPulse(): void { private clearPulse(state: InflightState): void {
if (this.pulseTimer) clearInterval(this.pulseTimer); if (state.pulseTimer) clearInterval(state.pulseTimer);
this.pulseTimer = undefined; state.pulseTimer = undefined;
this.pulse = false; state.pulse = false;
} }
} }
+29 -26
View File
@@ -7,36 +7,39 @@ type DataSourceItem = { label: string; value: string };
export function registerDataSources(): void { export function registerDataSources(): void {
streamDeck.ui.onSendToPlugin(async (ev) => { streamDeck.ui.onSendToPlugin(async (ev) => {
const request = ev.payload as { event?: string } | undefined; try {
const event = request?.event; const request = ev.payload as { event?: string } | undefined;
if (!event) return; const event = request?.event;
const settings = await ev.action.getSettings<CfgSettings>(); if (!event) return;
const settings = await ev.action.getSettings<CfgSettings>();
if (event === "models") { if (event === "models") {
let items: DataSourceItem[] = []; let items: DataSourceItem[] = [];
try { try {
const models = await fetchModels(cfgFromSettings(settings)); const models = await fetchModels(cfgFromSettings(settings));
items = models.map((m) => ({ label: `${m.id} (${m.status})`, value: m.id })); items = models.map((m) => ({ label: `${m.id} (${m.status})`, value: m.id }));
} catch { } catch {
items = []; items = [];
}
await streamDeck.ui.sendToPropertyInspector({ event, items });
return;
} }
await streamDeck.ui.sendToPropertyInspector({ event, items });
return;
}
if (event === "gpus") { if (event === "gpus") {
let items: DataSourceItem[] = []; let items: DataSourceItem[] = [];
try { try {
const text = await fetchMetrics(cfgFromSettings(settings)); const text = await fetchMetrics(cfgFromSettings(settings));
const gpus = gpuInfos(parseGpuMetrics(text)); const gpus = gpuInfos(parseGpuMetrics(text));
items = [ items = [
{ label: "All GPUs", value: "all" }, { label: "All GPUs", value: "all" },
...gpus.map((g) => ({ label: `GPU ${g.id} · ${g.name}`, value: g.id })), ...gpus.map((g) => ({ label: `GPU ${g.id} · ${g.name}`, value: g.id })),
]; ];
} catch { } catch {
items = [{ label: "All GPUs", value: "all" }]; items = [{ label: "All GPUs", value: "all" }];
}
await streamDeck.ui.sendToPropertyInspector({ event, items });
} }
await streamDeck.ui.sendToPropertyInspector({ event, items }); } catch {
} }
}); });
} }
+8 -2
View File
@@ -9,8 +9,13 @@ export function decodeEvent(msg: SseMessage): FeedEvent | null {
const inner = JSON.parse(outer.data) as Record<string, unknown>; const inner = JSON.parse(outer.data) as Record<string, unknown>;
if (outer.type === "inflight") { if (outer.type === "inflight") {
const requests: InflightRequest[] = const requests: InflightRequest[] = Array.isArray(inner.requests)
(inner.requests as InflightRequest[]) ?? (inner.request ? [inner.request as InflightRequest] : []); ? (inner.requests as InflightRequest[])
: Array.isArray(inner.request)
? (inner.request as InflightRequest[])
: inner.request && typeof inner.request === "object"
? [inner.request as InflightRequest]
: [];
return { return {
type: "inflight", type: "inflight",
operation: inner.operation as "snapshot" | "add" | "remove", operation: inner.operation as "snapshot" | "add" | "remove",
@@ -19,6 +24,7 @@ export function decodeEvent(msg: SseMessage): FeedEvent | null {
} }
if (outer.type === "modelStatus") { if (outer.type === "modelStatus") {
if (!Array.isArray(inner)) return null;
return { type: "modelStatus", models: inner as unknown as ModelState[] }; return { type: "modelStatus", models: inner as unknown as ModelState[] };
} }
+6 -1
View File
@@ -1,6 +1,6 @@
import { fetchMetrics } from "./llamaswap"; import { fetchMetrics } from "./llamaswap";
import { type LlamaSwapConfig } from "./util"; import { type LlamaSwapConfig } from "./util";
import { gpuInfos, parseGpuMetrics, type GpuMetricKind, type GpuSample } from "./metrics-parser"; import { parseGpuMetrics, type GpuMetricKind, type GpuSample } from "./metrics-parser";
const RING_SIZE = 60; const RING_SIZE = 60;
@@ -34,6 +34,7 @@ export class MetricsPoller {
private gpuIds: string[] = []; private gpuIds: string[] = [];
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private timer?: ReturnType<typeof setInterval>; private timer?: ReturnType<typeof setInterval>;
private ticking = false;
private lastError?: string; private lastError?: string;
constructor( constructor(
@@ -43,6 +44,7 @@ export class MetricsPoller {
) {} ) {}
start(): void { start(): void {
if (this.timer) return;
void this.tick(); void this.tick();
this.timer = setInterval(() => void this.tick(), this.intervalMs); this.timer = setInterval(() => void this.tick(), this.intervalMs);
} }
@@ -60,6 +62,8 @@ export class MetricsPoller {
} }
async tick(): Promise<void> { async tick(): Promise<void> {
if (this.ticking) return;
this.ticking = true;
try { try {
const text = await this.fetchFn(this.cfg); const text = await this.fetchFn(this.cfg);
this.apply(parseGpuMetrics(text)); this.apply(parseGpuMetrics(text));
@@ -67,6 +71,7 @@ export class MetricsPoller {
} catch (err) { } catch (err) {
this.lastError = err instanceof Error ? err.message : String(err); this.lastError = err instanceof Error ? err.message : String(err);
} finally { } finally {
this.ticking = false;
for (const listener of this.listeners) listener(); for (const listener of this.listeners) listener();
} }
} }
+9
View File
@@ -6,11 +6,20 @@ import { type LlamaSwapConfig } from "./util";
class Runtime { class Runtime {
readonly tracker = new InflightTracker(); readonly tracker = new InflightTracker();
offline = true; offline = true;
private cfg?: LlamaSwapConfig;
private feed?: EventFeed; private feed?: EventFeed;
private poller?: MetricsPoller; private poller?: MetricsPoller;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
ensureConnections(cfg: LlamaSwapConfig): void { ensureConnections(cfg: LlamaSwapConfig): void {
const changed = !this.cfg || this.cfg.baseUrl !== cfg.baseUrl || this.cfg.apiKey !== cfg.apiKey;
if (changed) {
this.feed?.stop();
this.poller?.stop();
this.feed = undefined;
this.poller = undefined;
this.cfg = cfg;
}
if (!this.feed) { if (!this.feed) {
this.feed = new EventFeed(cfg, (ev) => { this.feed = new EventFeed(cfg, (ev) => {
this.tracker.apply(ev); this.tracker.apply(ev);
+1
View File
@@ -4,6 +4,7 @@ export interface SseMessage {
} }
export function parseSse(chunk: string): { messages: SseMessage[]; rest: string } { export function parseSse(chunk: string): { messages: SseMessage[]; rest: string } {
chunk = chunk.replace(/\r\n/g, "\n");
const messages: SseMessage[] = []; const messages: SseMessage[] = [];
let rest = chunk; let rest = chunk;
while (true) { while (true) {
+27
View File
@@ -64,3 +64,30 @@ 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: JSON.stringify({ type: "logData", data: "{}" }) }), null);
assert.equal(decodeEvent({ event: "message", data: "not json" }), null); assert.equal(decodeEvent({ event: "message", data: "not json" }), null);
}); });
test("decodeEvent returns null when modelStatus data is not an array", () => {
const msg: SseMessage = {
event: "message",
data: JSON.stringify({
type: "modelStatus",
data: JSON.stringify({ id: "DeepSeek-V4-Flash-0731", state: "ready" }),
}),
};
assert.equal(decodeEvent(msg), null);
});
test("decodeEvent handles inflight with a non-array requests field and no request field", () => {
const msg: SseMessage = {
event: "message",
data: JSON.stringify({
type: "inflight",
data: JSON.stringify({ operation: "snapshot", requests: "oops" }),
}),
};
const ev = decodeEvent(msg);
assert.ok(ev && ev.type === "inflight");
if (ev && ev.type === "inflight") {
assert.equal(ev.operation, "snapshot");
assert.deepEqual(ev.requests, []);
}
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import { type LlamaSwapConfig } from "../src/lib/util"; import { type LlamaSwapConfig } from "../src/lib/util";
import { MetricsPoller } from "../src/lib/metrics-poller"; import { 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 FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8");
const FIXTURE2 = FIXTURE const FIXTURE2 = FIXTURE
+7
View File
@@ -22,3 +22,10 @@ test("parseSse joins multi-line data fields with newline", () => {
assert.equal(messages.length, 1); assert.equal(messages.length, 1);
assert.equal(messages[0].data, "line1\nline2"); assert.equal(messages[0].data, "line1\nline2");
}); });
test("parseSse normalizes CRLF-delimited events", () => {
const { messages, rest } = parseSse("data:hello\r\n\r\n");
assert.equal(rest, "");
assert.equal(messages.length, 1);
assert.equal(messages[0].data, "hello");
});