feat: add SVG key renderers for inflight and GPU graph actions

This commit is contained in:
2026-08-14 10:44:06 -06:00
parent 79917cd054
commit 55e260c31b
2 changed files with 241 additions and 0 deletions
+163
View File
@@ -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<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>`;
}
+78
View File
@@ -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("<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/);
});