diff --git a/src/actions/gpu-graph.ts b/src/actions/gpu-graph.ts index 17c4369..b8c9b91 100644 --- a/src/actions/gpu-graph.ts +++ b/src/actions/gpu-graph.ts @@ -87,7 +87,7 @@ export class GpuGraph extends SingletonAction { const combined = combineSeries(histories, metric); value = combined.value; history = combined.history; - gpuName = `GPU ${ids.join("+")}`; + gpuName = ids.length > 0 ? `GPU ${ids.join("+")}` : "NO GPUS"; } else { const gpuId = state.settings.gpuId ?? "all"; value = poller?.getValue(gpuId, metric); @@ -108,9 +108,6 @@ export class GpuGraph extends SingletonAction { } function parseCombo(raw: string | undefined): string[] | undefined { - const ids = (raw ?? "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const ids = [...new Set((raw ?? "").split(",").map((s) => s.trim()).filter(Boolean))]; return ids.length > 0 ? ids : undefined; } diff --git a/src/actions/inflight-monitor.ts b/src/actions/inflight-monitor.ts index e8443e2..d4afcd9 100644 --- a/src/actions/inflight-monitor.ts +++ b/src/actions/inflight-monitor.ts @@ -24,6 +24,7 @@ type InflightState = { unwatchStats?: () => void; history: number[]; sampler?: ReturnType; + lastSvg?: string; }; const HISTORY_LIMIT = 60; @@ -106,38 +107,34 @@ export class InflightMonitor extends SingletonAction { private render(state: InflightState): void { const action = state.action; if (!action) return; + let svg: string; if (this.displayOf(state) === "usage") { const modelKey = this.modelKeyOf(state); - const offline = runtime.offline; - void action.setImage( - svgDataUrl( - renderUsage({ - modelName: modelKey, - stats: runtime.getStats(modelKey), - primaryStat: this.primaryStatOf(state), - offline, - }), - ), + svg = svgDataUrl( + renderUsage({ + modelName: modelKey, + stats: runtime.getStats(modelKey), + primaryStat: this.primaryStatOf(state), + offline: runtime.offline, + }), ); - return; - } - - const modelId = state.settings.modelId ?? ""; - const trackerState = modelId === "all" ? "ready" : runtime.tracker.state(modelId); - const count = modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId); - const offline = runtime.offline && modelId.length > 0; - const modelName = modelId === "" ? "unset" : modelId; - void action.setImage( - svgDataUrl( + } else { + const modelId = state.settings.modelId ?? ""; + const trackerState = modelId === "all" ? "ready" : runtime.tracker.state(modelId); + const count = modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId); + svg = svgDataUrl( renderInflight({ - modelName, + modelName: modelId === "" ? "unset" : modelId, state: trackerState, count, - offline, + offline: runtime.offline && modelId.length > 0, history: state.history, }), - ), - ); + ); + } + if (svg === state.lastSvg) return; + state.lastSvg = svg; + void action.setImage(svg); } private sample(state: InflightState): void { diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts index 2257c74..ad89877 100644 --- a/src/lib/runtime.ts +++ b/src/lib/runtime.ts @@ -12,7 +12,6 @@ class Runtime { private feed?: EventFeed; private poller?: MetricsPoller; private statsCache?: StatsCache; - private statsUnsub?: () => void; private listeners = new Set<() => void>(); ensureConnections(cfg: LlamaSwapConfig): void { @@ -22,9 +21,7 @@ class Runtime { this.poller?.stop(); this.feed = undefined; this.poller = undefined; - this.statsUnsub?.(); - this.statsCache = undefined; - this.statsUnsub = undefined; + this.statsCache?.setConfig(cfg); this.cfg = cfg; } if (!this.feed) { @@ -46,7 +43,7 @@ class Runtime { } if (!this.statsCache) { this.statsCache = new StatsCache(cfg); - this.statsUnsub = this.statsCache.onChange(() => this.emit()); + this.statsCache.onChange(() => this.emit()); } } diff --git a/src/lib/stats-cache.ts b/src/lib/stats-cache.ts index 2a62592..b5e51af 100644 --- a/src/lib/stats-cache.ts +++ b/src/lib/stats-cache.ts @@ -7,7 +7,8 @@ const ACTIVITY_THROTTLE_MS = 2000; export type FetchFn = (cfg: LlamaSwapConfig, modelId: string) => Promise; export class StatsCache { - private keys = new Set(); + private refs = new Map(); + private refreshing = false; private values = new Map(); private listeners = new Set<() => void>(); private timer?: ReturnType; @@ -27,8 +28,7 @@ export class StatsCache { } register(key: string): void { - if (this.keys.has(key)) return; - this.keys.add(key); + this.refs.set(key, (this.refs.get(key) ?? 0) + 1); if (!this.timer) { void this.refresh(); this.timer = setInterval(() => void this.refresh(), this.pollMs); @@ -36,11 +36,18 @@ export class StatsCache { } unregister(key: string): void { - this.keys.delete(key); - this.values.delete(key); - if (this.keys.size === 0 && this.timer) { - clearInterval(this.timer); + const count = (this.refs.get(key) ?? 0) - 1; + if (count <= 0) { + this.refs.delete(key); + this.values.delete(key); + } else { + this.refs.set(key, count); + } + if (this.refs.size === 0) { + if (this.timer) clearInterval(this.timer); this.timer = undefined; + if (this.throttleTimer) clearTimeout(this.throttleTimer); + this.throttleTimer = undefined; } } @@ -59,15 +66,21 @@ export class StatsCache { } async refresh(): Promise { - this.lastRefresh = Date.now(); - for (const key of this.keys) { - try { - const stats = await this.fetchFn(this.cfg, key); - if (stats) this.values.set(key, stats); - } catch { + if (this.refreshing) return; + this.refreshing = true; + try { + for (const key of this.refs.keys()) { + try { + const stats = await this.fetchFn(this.cfg, key); + if (stats) this.values.set(key, stats); + } catch { + } } + } finally { + this.refreshing = false; + this.lastRefresh = Date.now(); + this.emit(); } - this.emit(); } onChange(listener: () => void): () => void { diff --git a/tests/stats-cache.test.ts b/tests/stats-cache.test.ts index d3e8fb4..01fbf97 100644 --- a/tests/stats-cache.test.ts +++ b/tests/stats-cache.test.ts @@ -83,3 +83,26 @@ test("fetch failure keeps the last-known value", async () => { assert.equal(cache.get("a"), undefined); cache.unregister("a"); }); + +test("ref-counted register: a sibling unregister keeps the key active", async () => { + const calls = { count: 0 }; + const cache = new StatsCache(cfg, stubFetch({ totalRequests: 5, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }, calls), 10000, 30); + cache.register("all"); + cache.register("all"); + cache.unregister("all"); + await flush(); + assert.equal(cache.get("all")!.totalRequests, 5); + assert.equal(calls.count, 1); + cache.unregister("all"); + assert.equal(cache.get("all"), undefined); +}); + +test("setConfig keeps registrations but clears cached values", async () => { + const cache = new StatsCache(cfg, async (_c, key) => ({ totalRequests: 5, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }), 10000, 30); + cache.register("all"); + await flush(); + assert.equal(cache.get("all")!.totalRequests, 5); + cache.setConfig({ baseUrl: "http://new" }); + assert.equal(cache.get("all"), undefined); + cache.unregister("all"); +});