Files
llama-watch/src/lib/render.ts
T

258 lines
9.7 KiB
TypeScript

import { type GpuMetricKind } from "./metrics-parser";
import { type ModelRuntimeState } from "./inflight-tracker";
import { type UsageStats } from "./stats";
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;
history?: number[];
}
export function renderInflight(opts: InflightRenderOptions): string {
const name = escapeXml(shorten(opts.modelName === "all" ? "ALL MODELS" : opts.modelName));
if (opts.offline) {
return frame("#10131a", [
centerText("!!", 34, 20, "bold", "#e0e0e0"),
centerText("OFFLINE", 52, 9, "normal", "#bdbdbd"),
centerText(name, 64, 7, "normal", "#ffffff"),
]);
}
if (opts.modelName === "unset") {
return frame("#10131a", [
centerText("?", 36, 16, "bold", "#888888"),
centerText("NO MODEL", 54, 8, "normal", "#666666"),
]);
}
if (!opts.state) {
return frame("#10131a", [centerText("…", 36, 16, "bold", "#999999"), centerText(name, 60, 7, "normal", "#999999")]);
}
const isActive = opts.state === "ready" && opts.count > 0;
const center = isActive ? `${opts.count}` : opts.state === "ready" ? "IDLE" : opts.state === "loading" ? "LOADING" : "OFF";
const centerSize = isActive ? 24 : 16;
const parts: string[] = [
centerText(name, 12, 7, "normal", "#ffffff"),
centerText(center, 40, centerSize, "bold", "#ffffff"),
];
if (opts.state === "ready") {
const points = inflightSparkPoints(opts.history ?? []);
if (points.length > 1) {
parts.push(
`<path d="M ${points.map((p) => `${p.x},${p.y}`).join(" L ")} L ${points[points.length - 1].x},63 L ${points[0].x},63 Z" fill="#ffffff" opacity="0.18"/>`,
`<polyline points="${points.map((p) => `${p.x},${p.y}`).join(" ")}" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>`,
);
}
}
return frame("#10131a", parts);
}
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 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);
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>`;
}
export function formatCompact(n: number): string {
if (!Number.isFinite(n)) return "--";
if (n < 1000) return `${Math.round(n)}`;
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
export interface UsageRenderOptions {
modelName: string;
stats?: UsageStats;
primaryStat: "requests" | "input_tokens" | "output_tokens" | "gen_p95";
offline: boolean;
}
export function renderUsage(opts: UsageRenderOptions): string {
const name = escapeXml(shorten(opts.modelName === "all" ? "ALL MODELS" : opts.modelName));
if (opts.offline) {
return frame("#10131a", [
centerText("!!", 34, 20, "bold", "#e0e0e0"),
centerText("OFFLINE", 52, 9, "normal", "#bdbdbd"),
centerText(name, 64, 7, "normal", "#ffffff"),
]);
}
const s = opts.stats;
const big = s ? formatCompact(primaryValue(s, opts.primaryStat)) : "--";
const rows = usageRows(s, opts.primaryStat);
const parts: string[] = [
centerText(name, 10, 7, "normal", "#ffffff"),
centerText(big, 36, 24, "bold", "#ffffff"),
];
for (const row of rows) {
parts.push(
`<text x="4" y="${row.y}" font-family="Arial,sans-serif" font-size="7" fill="#8b93a5">${row.label}</text>`,
`<text x="68" y="${row.y}" text-anchor="end" font-family="Arial,sans-serif" font-size="7" fill="#ffffff">${row.value}</text>`,
);
}
return frame("#10131a", parts);
}
function primaryValue(s: UsageStats, primary: "requests" | "input_tokens" | "output_tokens" | "gen_p95"): number {
switch (primary) {
case "requests":
return s.totalRequests;
case "input_tokens":
return s.totalInputTokens;
case "output_tokens":
return s.totalOutputTokens;
case "gen_p95":
return s.genP95;
}
}
function usageRows(
s: UsageStats | undefined,
primary: "requests" | "input_tokens" | "output_tokens" | "gen_p95",
): { label: string; value: string; y: number }[] {
const items: { label: string; value: string }[] = [];
if (primary !== "requests") items.push({ label: "REQ", value: s ? formatCompact(s.totalRequests) : "--" });
if (primary !== "input_tokens") items.push({ label: "IN", value: s ? formatCompact(s.totalInputTokens) : "--" });
if (primary !== "output_tokens") items.push({ label: "OUT", value: s ? formatCompact(s.totalOutputTokens) : "--" });
if (primary !== "gen_p95") items.push({ label: "P95", value: s ? `${Math.round(s.genP95)} t/s` : "--" });
return items.map((it, i) => ({ ...it, y: 52 + i * 7 }));
}