diff --git a/src/actions/inflight-monitor.ts b/src/actions/inflight-monitor.ts index ea4bd2e..f9cd128 100644 --- a/src/actions/inflight-monitor.ts +++ b/src/actions/inflight-monitor.ts @@ -21,8 +21,12 @@ type InflightState = { unsubscribe?: () => void; pulseTimer?: ReturnType; pulse: boolean; + history: number[]; + sampler?: ReturnType; }; +const HISTORY_LIMIT = 60; + @action({ UUID: "com.bryce.llamawatch.inflight" }) export class InflightMonitor extends SingletonAction { private states = new Map(); @@ -30,7 +34,7 @@ export class InflightMonitor extends SingletonAction { private stateFor(action: KeyAction): InflightState { let state = this.states.get(action.id); if (!state) { - state = { settings: {}, pulse: false }; + state = { settings: {}, pulse: false, history: [] }; this.states.set(action.id, state); } return state; @@ -43,6 +47,7 @@ export class InflightMonitor extends SingletonAction { state.action = ev.action; runtime.ensureConnections(cfgFromSettings(state.settings)); state.unsubscribe = runtime.subscribe(() => this.render(state)); + state.sampler = setInterval(() => this.sample(state), 1000); this.render(state); } @@ -50,6 +55,8 @@ export class InflightMonitor extends SingletonAction { const state = this.states.get(ev.action.id); if (!state) return; state.unsubscribe?.(); + if (state.sampler) clearInterval(state.sampler); + state.sampler = undefined; this.clearPulse(state); this.states.delete(ev.action.id); } @@ -59,6 +66,7 @@ export class InflightMonitor extends SingletonAction { const state = this.stateFor(ev.action); state.settings = ev.payload.settings; state.action = ev.action; + state.history = []; runtime.ensureConnections(cfgFromSettings(state.settings)); this.render(state); } @@ -82,6 +90,7 @@ export class InflightMonitor extends SingletonAction { count, offline, pulse: state.pulse, + history: state.history, }), ), ); @@ -101,4 +110,11 @@ export class InflightMonitor extends SingletonAction { state.pulseTimer = undefined; state.pulse = false; } + + private sample(state: InflightState): void { + const modelId = state.settings.modelId ?? ""; + state.history.push(runtime.tracker.count(modelId)); + if (state.history.length > HISTORY_LIMIT) state.history.shift(); + this.render(state); + } } diff --git a/src/lib/render.ts b/src/lib/render.ts index 77cf395..675a853 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -20,6 +20,7 @@ export interface InflightRenderOptions { count: number; offline: boolean; pulse: boolean; + history?: number[]; } export function renderInflight(opts: InflightRenderOptions): string { @@ -46,13 +47,26 @@ export function renderInflight(opts: InflightRenderOptions): string { 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 center = isActive ? `${opts.count}` : opts.state === "ready" ? "IDLE" : opts.state === "loading" ? "LOADING" : "OFF"; + const centerSize = isActive ? 24 : 16; const opacity = isActive && opts.pulse ? 0.55 : 1; - return frame(bg, [ - centerText(label, 38, 18, "bold", "#ffffff", opacity), - centerText(name, 64, 7, "normal", "#ffffff"), - ]); + const parts: string[] = [ + centerText(name, 12, 7, "normal", "#ffffff"), + centerText(center, 40, centerSize, "bold", "#ffffff", opacity), + ]; + + if (opts.state === "ready") { + const points = inflightSparkPoints(opts.history ?? []); + if (points.length > 1) { + parts.push( + ``, + ``, + ); + } + } + + return frame(bg, parts); } export interface GpuGraphRenderOptions { @@ -132,6 +146,22 @@ function chartPoints(history: number[], metric: GpuMetricKind): { x: number; y: }); } +function inflightSparkPoints(history: number[]): { x: number; y: number }[] { + const n = history.length; + if (n === 0) return []; + const left = 4; + const right = 68; + const top = 50; + const bottom = 63; + const yMax = Math.max(...history, 1); + 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 / yMax) * (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); diff --git a/tests/render.test.ts b/tests/render.test.ts index 4011e19..2a013c5 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -14,10 +14,31 @@ test("renderInflight: ready + count renders IDLE in green", () => { assert.doesNotMatch(svg, /ACTIVE/); }); -test("renderInflight: ready + count>0 renders ACTIVE in red", () => { +test("renderInflight: ready + count>0 renders the count number on red", () => { const svg = renderInflight({ modelName: "Qwen3.8-27B-NVFP4", state: "ready", count: 2, offline: false, pulse: false }); - assert.match(svg, /ACTIVE/); + assert.match(svg, /2/); assert.match(svg, /#8b2626/); + assert.doesNotMatch(svg, /ACTIVE/); + assert.doesNotMatch(svg, /IDLE/); +}); + +test("renderInflight: idle state does not show a count number", () => { + const svg = renderInflight({ modelName: "A", state: "ready", count: 0, offline: false, pulse: false }); + assert.match(svg, /IDLE/); + assert.doesNotMatch(svg, /font-size="24"/); +}); + +test("renderInflight: draws a count-trend spark when history has 2+ samples", () => { + const svg = renderInflight({ modelName: "A", state: "ready", count: 1, offline: false, pulse: false, history: [0, 1, 2, 1] }); + assert.match(svg, / { + const svg = renderInflight({ modelName: "A", state: "ready", count: 1, offline: false, pulse: false, history: [1] }); + assert.doesNotMatch(svg, / {