diff --git a/src/lib/render.ts b/src/lib/render.ts new file mode 100644 index 0000000..77cf395 --- /dev/null +++ b/src/lib/render.ts @@ -0,0 +1,163 @@ +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 = { + 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 ` + + !! + OFFLINE + ${label} +`; + } + + 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 + ? `` + : ""; + + const area = + points.length > 1 + ? `` + : ""; + + return ` + + + + + + + + + + ${area} + ${line} + ${valueText} + ${METRIC_LABEL[opts.metric]} · ${label} +`; +} + +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}`; +} + +function frame(bg: string, parts: string[]): string { + return ` + + ${parts.join("\n ")} +`; +} diff --git a/tests/render.test.ts b/tests/render.test.ts new file mode 100644 index 0000000..4011e19 --- /dev/null +++ b/tests/render.test.ts @@ -0,0 +1,78 @@ +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(""); + 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, / { + 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, /