feat: add SVG key renderers for inflight and GPU graph actions
This commit is contained in:
@@ -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>`;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user