258 lines
9.7 KiB
TypeScript
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 }));
|
|
}
|