proxy,ui: add performance monitoring with Prometheus metrics (#743)
Add a comprehensive performance monitoring system that collects CPU, memory, swap, load average, network IO, and GPU stats. Provides both a REST API for the UI and a Prometheus /metrics endpoint. Backend changes: - New internal/perf package with configurable interval-based stats collection - GPU monitoring via LACT (Unix socket) and nvidia-smi fallback on Linux - Ring buffer (internal/ring) for time-series stat storage - Prometheus /metrics endpoint with all system and GPU metrics - Moved LogMonitor to internal/logmon package - New PerformanceConfig for hot-reloadable monitoring settings - REST /api/performance endpoint replacing SSE streaming UI changes: - New Performance page with real-time charts for CPU, memory, GPU, and network - Reusable PerformanceChart component - LLAMA_SWAP_URL environment variable support - Improved capture dialog display Other: - Example Grafana dashboard for Prometheus metrics - monitor-test standalone binary - Config schema and example updates fixes #596
This commit is contained in:
Generated
+19
@@ -8,6 +8,7 @@
|
||||
"name": "ui-svelte",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"chart.js": "4.5.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-svelte": "^0.563.0",
|
||||
@@ -120,6 +121,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
@@ -1096,6 +1103,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"chart.js": "4.5.1",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import LogViewer from "./routes/LogViewer.svelte";
|
||||
import Models from "./routes/Models.svelte";
|
||||
import Activity from "./routes/Activity.svelte";
|
||||
import Performance from "./routes/Performance.svelte";
|
||||
import Playground from "./routes/Playground.svelte";
|
||||
import PlaygroundStub from "./routes/PlaygroundStub.svelte";
|
||||
import { enableAPIEvents } from "./stores/api";
|
||||
@@ -16,6 +17,7 @@
|
||||
"/models": Models,
|
||||
"/logs": LogViewer,
|
||||
"/activity": Activity,
|
||||
"/performance": Performance,
|
||||
"*": PlaygroundStub,
|
||||
};
|
||||
|
||||
|
||||
@@ -427,6 +427,14 @@
|
||||
<button onclick={() => dialogEl?.close()} class="btn"> Close </button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center p-12">
|
||||
<p class="text-lg text-txtsecondary">Capture not found</p>
|
||||
<p class="text-sm text-txtsecondary mt-1">The capture may have expired or was never recorded.</p>
|
||||
<div class="mt-4">
|
||||
<button onclick={() => dialogEl?.close()} class="btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -84,6 +84,16 @@
|
||||
>
|
||||
Logs
|
||||
</a>
|
||||
<a
|
||||
href="/performance"
|
||||
use:link
|
||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||
class:font-semibold={isActive("/performance", $currentRoute)}
|
||||
class:underline={isActive("/performance", $currentRoute)}
|
||||
class:underline-offset-4={isActive("/performance", $currentRoute)}
|
||||
>
|
||||
Performance
|
||||
</a>
|
||||
<button onclick={toggleTheme} title="Toggle theme">
|
||||
{#if $isDarkMode}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import { isDarkMode } from "../stores/theme";
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
interface Dataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
labels: string[];
|
||||
datasets: Dataset[];
|
||||
yMin?: number;
|
||||
yMax?: number;
|
||||
yLabel?: string;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
let { title, labels, datasets, yMin, yMax, yLabel, showLegend = true }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart;
|
||||
|
||||
function getChartColors(dark: boolean) {
|
||||
return {
|
||||
grid: dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)",
|
||||
tick: dark ? "#9ca3af" : "#6b7280",
|
||||
legend: dark ? "#d1d5db" : "#374151",
|
||||
tooltipBg: dark ? "#1f2937" : "#ffffff",
|
||||
tooltipText: dark ? "#f3f4f6" : "#111827",
|
||||
tooltipBorder: dark ? "#374151" : "#e5e7eb",
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptions(dark: boolean) {
|
||||
const colors = getChartColors(dark);
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
interaction: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: showLegend,
|
||||
position: "top" as const,
|
||||
labels: {
|
||||
color: colors.legend,
|
||||
usePointStyle: true,
|
||||
pointStyle: "circle" as const,
|
||||
padding: 12,
|
||||
font: { size: 11 },
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: title,
|
||||
color: colors.legend,
|
||||
font: { size: 14, weight: "bold" as const },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: colors.tooltipBg,
|
||||
titleColor: colors.tooltipText,
|
||||
bodyColor: colors.tooltipText,
|
||||
borderColor: colors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
bounds: "data" as const,
|
||||
offset: false,
|
||||
ticks: { color: colors.tick, maxRotation: 0, font: { size: 10 }, maxTicksLimit: 10 },
|
||||
grid: { color: colors.grid },
|
||||
},
|
||||
y: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
ticks: { color: colors.tick, font: { size: 10 } },
|
||||
grid: { color: colors.grid },
|
||||
title: yLabel
|
||||
? { display: true, text: yLabel, color: colors.tick }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
chart = new Chart(canvas, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: [...labels],
|
||||
datasets: datasets.map((ds) => ({
|
||||
label: ds.label,
|
||||
data: [...ds.data],
|
||||
borderColor: ds.borderColor,
|
||||
backgroundColor: ds.borderColor + "20",
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false,
|
||||
})),
|
||||
},
|
||||
options: buildOptions($isDarkMode),
|
||||
});
|
||||
|
||||
return () => {
|
||||
chart.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!chart) return;
|
||||
const _dark = $isDarkMode;
|
||||
chart.options = buildOptions(_dark);
|
||||
chart.update("none");
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!chart) return;
|
||||
const _l = labels;
|
||||
const _d = datasets;
|
||||
chart.data.labels = [..._l];
|
||||
chart.data.datasets = _d.map((ds) => ({
|
||||
label: ds.label,
|
||||
data: [...ds.data],
|
||||
borderColor: ds.borderColor,
|
||||
backgroundColor: ds.borderColor + "20",
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: false,
|
||||
}));
|
||||
chart.update("none");
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card p-4 h-[300px]">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
@@ -50,8 +50,48 @@ export interface InFlightStats {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface NetIOStat {
|
||||
name: string;
|
||||
bytes_recv: number;
|
||||
bytes_sent: number;
|
||||
}
|
||||
|
||||
export interface SysStat {
|
||||
timestamp: string;
|
||||
cpu_util_per_core: number[];
|
||||
mem_total_mb: number;
|
||||
mem_used_mb: number;
|
||||
mem_free_mb: number;
|
||||
swap_total_mb: number;
|
||||
swap_used_mb: number;
|
||||
load_avg_1: number;
|
||||
load_avg_5: number;
|
||||
load_avg_15: number;
|
||||
net_io: NetIOStat[];
|
||||
}
|
||||
|
||||
export interface GpuStat {
|
||||
timestamp: string;
|
||||
id: number;
|
||||
name: string;
|
||||
uuid: string;
|
||||
temp_c: number;
|
||||
vram_temp_c: number;
|
||||
gpu_util_pct: number;
|
||||
mem_util_pct: number;
|
||||
mem_used_mb: number;
|
||||
mem_total_mb: number;
|
||||
fan_speed_pct: number;
|
||||
power_draw_w: number;
|
||||
}
|
||||
|
||||
export interface PerformanceResponse {
|
||||
sys_stats: SysStat[];
|
||||
gpu_stats: GpuStat[];
|
||||
}
|
||||
|
||||
export interface APIEventEnvelope {
|
||||
type: "modelStatus" | "logData" | "metrics" | "inflight";
|
||||
type: "modelStatus" | "logData" | "metrics" | "inflight" | "perfsys" | "perfgpu";
|
||||
data: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,10 +129,8 @@
|
||||
loadingCaptureId = id;
|
||||
const capture = await getCapture(id);
|
||||
loadingCaptureId = null;
|
||||
if (capture) {
|
||||
selectedCapture = capture;
|
||||
dialogOpen = true;
|
||||
}
|
||||
selectedCapture = capture;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fetchPerformance } from "../stores/api";
|
||||
import { persistentStore } from "../stores/persistent";
|
||||
import type { SysStat, GpuStat } from "../lib/types";
|
||||
import PerformanceChart from "../components/PerformanceChart.svelte";
|
||||
|
||||
const COLORS = [
|
||||
"#3b82f6",
|
||||
"#ef4444",
|
||||
"#10b981",
|
||||
"#f59e0b",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#06b6d4",
|
||||
"#84cc16",
|
||||
"#f97316",
|
||||
"#14b8a6",
|
||||
"#a855f7",
|
||||
"#e11d48",
|
||||
"#0ea5e9",
|
||||
"#eab308",
|
||||
"#d946ef",
|
||||
"#22d3ee",
|
||||
];
|
||||
|
||||
const WINDOWS = [
|
||||
{ label: "5 min", ms: 5 * 60 * 1000 },
|
||||
{ label: "15 min", ms: 15 * 60 * 1000 },
|
||||
{ label: "1 hr", ms: 60 * 60 * 1000 },
|
||||
] as const;
|
||||
|
||||
const INTERVALS = [
|
||||
{ label: "Off", ms: 0 },
|
||||
{ label: "5s", ms: 5000 },
|
||||
{ label: "10s", ms: 10000 },
|
||||
{ label: "30s", ms: 30000 },
|
||||
{ label: "60s", ms: 60000 },
|
||||
] as const;
|
||||
|
||||
let selectedWindow = persistentStore("perf-window", 0);
|
||||
let selectedInterval = persistentStore("perf-refresh-interval", 0);
|
||||
let sysData = $state<SysStat[]>([]);
|
||||
let gpuData = $state<GpuStat[]>([]);
|
||||
let refreshing = $state(false);
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let visible = $state(true);
|
||||
let mounted = $state(false);
|
||||
|
||||
function cutoffTime(): number {
|
||||
return Date.now() - WINDOWS[$selectedWindow].ms;
|
||||
}
|
||||
|
||||
function formatDelta(ts: string, refTime: number): string {
|
||||
const diffMs = new Date(ts).getTime() - refTime;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const absSec = Math.abs(diffSec);
|
||||
const sign = diffSec <= 0 ? "-" : "+";
|
||||
if (absSec < 60) return `${sign}${absSec}s`;
|
||||
const min = Math.floor(absSec / 60);
|
||||
const sec = absSec % 60;
|
||||
if (sec === 0) return `${sign}${min}m`;
|
||||
return `${sign}${min}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const sysLabels = $derived.by(() => {
|
||||
const stats = filteredSysStats;
|
||||
if (stats.length === 0) return [];
|
||||
const refTime = new Date(stats[stats.length - 1].timestamp).getTime();
|
||||
return stats.map((s) => formatDelta(s.timestamp, refTime));
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
const resp = await fetchPerformance();
|
||||
if (resp) {
|
||||
sysData = resp.sys_stats ?? [];
|
||||
gpuData = resp.gpu_stats ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIncremental() {
|
||||
const lastTs = sysData.length > 0 ? sysData[sysData.length - 1].timestamp : undefined;
|
||||
const resp = await fetchPerformance(lastTs);
|
||||
if (resp) {
|
||||
const newSys = resp.sys_stats ?? [];
|
||||
const newGpu = resp.gpu_stats ?? [];
|
||||
if (newSys.length > 0) {
|
||||
sysData = [...sysData, ...newSys];
|
||||
}
|
||||
if (newGpu.length > 0) {
|
||||
gpuData = [...gpuData, ...newGpu];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
const ms = INTERVALS[$selectedInterval].ms;
|
||||
if (ms <= 0) return;
|
||||
pollTimer = setInterval(() => {
|
||||
if (visible) {
|
||||
loadIncremental();
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
visible = !document.hidden;
|
||||
if (visible && mounted) {
|
||||
loadAll().then(() => startPolling());
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function handleIntervalChange(i: number) {
|
||||
$selectedInterval = i;
|
||||
if (visible && mounted) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
refreshing = true;
|
||||
await loadIncremental();
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
loadAll().then(() => startPolling());
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
stopPolling();
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
});
|
||||
|
||||
// --- System charts (filtered by time window) ---
|
||||
|
||||
const filteredSysStats = $derived(sysData.filter((s) => new Date(s.timestamp).getTime() >= cutoffTime()));
|
||||
|
||||
const cpuDatasets = $derived.by(() => {
|
||||
const stats = filteredSysStats;
|
||||
if (stats.length === 0) return [];
|
||||
const coreCount = stats[0].cpu_util_per_core.length;
|
||||
const datasets = [];
|
||||
for (let i = 0; i < coreCount; i++) {
|
||||
datasets.push({
|
||||
label: `Core ${i}`,
|
||||
data: stats.map((s) => s.cpu_util_per_core[i]),
|
||||
borderColor: COLORS[i % COLORS.length],
|
||||
});
|
||||
}
|
||||
return datasets;
|
||||
});
|
||||
|
||||
const memSwapDatasets = $derived.by(() => {
|
||||
const stats = filteredSysStats;
|
||||
if (stats.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
label: "Memory Used %",
|
||||
data: stats.map((s) => (s.mem_used_mb / s.mem_total_mb) * 100),
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
{
|
||||
label: "Swap Used %",
|
||||
data: stats.map((s) => (s.swap_total_mb > 0 ? (s.swap_used_mb / s.swap_total_mb) * 100 : 0)),
|
||||
borderColor: "#8b5cf6",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const latestMemSwap = $derived.by(() => {
|
||||
const stats = filteredSysStats;
|
||||
if (stats.length === 0) return null;
|
||||
const s = stats[stats.length - 1];
|
||||
return {
|
||||
mem_total_mb: s.mem_total_mb,
|
||||
mem_used_mb: s.mem_used_mb,
|
||||
mem_used_pct: ((s.mem_used_mb / s.mem_total_mb) * 100).toFixed(1),
|
||||
swap_total_mb: s.swap_total_mb,
|
||||
swap_used_mb: s.swap_used_mb,
|
||||
swap_used_pct: s.swap_total_mb > 0 ? ((s.swap_used_mb / s.swap_total_mb) * 100).toFixed(1) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const loadDatasets = $derived.by(() => {
|
||||
const stats = filteredSysStats;
|
||||
if (stats.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
label: "1 min",
|
||||
data: stats.map((s) => s.load_avg_1),
|
||||
borderColor: "#10b981",
|
||||
},
|
||||
{
|
||||
label: "5 min",
|
||||
data: stats.map((s) => s.load_avg_5),
|
||||
borderColor: "#f59e0b",
|
||||
},
|
||||
{
|
||||
label: "15 min",
|
||||
data: stats.map((s) => s.load_avg_15),
|
||||
borderColor: "#ef4444",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const netBandwidthDatasets = $derived.by(() => {
|
||||
const stats = filteredSysStats;
|
||||
if (stats.length < 2) return [];
|
||||
|
||||
const ifaceNames = new Set<string>();
|
||||
for (const s of stats) {
|
||||
for (const n of s.net_io ?? []) {
|
||||
ifaceNames.add(n.name);
|
||||
}
|
||||
}
|
||||
|
||||
const interfaces = [...ifaceNames].sort();
|
||||
if (interfaces.length === 0) return [];
|
||||
|
||||
const datasets: { label: string; data: number[]; borderColor: string }[] = [];
|
||||
let colorIdx = 0;
|
||||
|
||||
for (const iface of interfaces) {
|
||||
const recvData: number[] = [];
|
||||
const sentData: number[] = [];
|
||||
|
||||
for (let i = 1; i < stats.length; i++) {
|
||||
const prev = stats[i - 1];
|
||||
const curr = stats[i];
|
||||
const prevIO = (prev.net_io ?? []).find((n) => n.name === iface);
|
||||
const currIO = (curr.net_io ?? []).find((n) => n.name === iface);
|
||||
|
||||
if (!prevIO || !currIO) {
|
||||
recvData.push(0);
|
||||
sentData.push(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dtMs = new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime();
|
||||
if (dtMs <= 0) {
|
||||
recvData.push(0);
|
||||
sentData.push(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dtSec = dtMs / 1000;
|
||||
recvData.push((((currIO.bytes_recv - prevIO.bytes_recv) / dtSec) * 8) / 1_000_000);
|
||||
sentData.push((((currIO.bytes_sent - prevIO.bytes_sent) / dtSec) * 8) / 1_000_000);
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
label: `${iface} in`,
|
||||
data: recvData,
|
||||
borderColor: COLORS[colorIdx % COLORS.length],
|
||||
});
|
||||
colorIdx++;
|
||||
datasets.push({
|
||||
label: `${iface} out`,
|
||||
data: sentData,
|
||||
borderColor: COLORS[colorIdx % COLORS.length],
|
||||
});
|
||||
colorIdx++;
|
||||
}
|
||||
|
||||
return datasets;
|
||||
});
|
||||
|
||||
const netBandwidthLabels = $derived.by(() => {
|
||||
const stats = filteredSysStats;
|
||||
if (stats.length < 2) return [];
|
||||
const refTime = new Date(stats[stats.length - 1].timestamp).getTime();
|
||||
return stats.slice(1).map((s) => formatDelta(s.timestamp, refTime));
|
||||
});
|
||||
|
||||
// --- GPU charts (filtered by time window) ---
|
||||
|
||||
const filteredGpuStats = $derived(gpuData.filter((g) => new Date(g.timestamp).getTime() >= cutoffTime()));
|
||||
|
||||
const hasGpuData = $derived(gpuData.length > 0);
|
||||
|
||||
const gpuLabels = $derived.by(() => {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
const stats = filteredGpuStats;
|
||||
if (stats.length === 0) return [];
|
||||
const refTime = new Date(stats[stats.length - 1].timestamp).getTime();
|
||||
for (const g of stats) {
|
||||
const label = formatDelta(g.timestamp, refTime);
|
||||
if (!seen.has(label)) {
|
||||
seen.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
});
|
||||
|
||||
function buildGpuDatasets(
|
||||
stats: GpuStat[],
|
||||
field: keyof Pick<GpuStat, "gpu_util_pct" | "mem_util_pct" | "temp_c" | "vram_temp_c" | "power_draw_w">,
|
||||
) {
|
||||
if (stats.length === 0) return [];
|
||||
|
||||
const byId = new Map<number, { name: string; values: number[] }>();
|
||||
for (const g of stats) {
|
||||
if (!byId.has(g.id)) {
|
||||
byId.set(g.id, { name: g.name, values: [] });
|
||||
}
|
||||
byId.get(g.id)!.values.push(g[field] as number);
|
||||
}
|
||||
|
||||
const datasets = [];
|
||||
let colorIdx = 0;
|
||||
for (const [id, entry] of byId) {
|
||||
datasets.push({
|
||||
label: entry.name || `GPU ${id}`,
|
||||
data: entry.values,
|
||||
borderColor: COLORS[colorIdx % COLORS.length],
|
||||
});
|
||||
colorIdx++;
|
||||
}
|
||||
return datasets;
|
||||
}
|
||||
|
||||
const gpuUtilDatasets = $derived(buildGpuDatasets(filteredGpuStats, "gpu_util_pct"));
|
||||
const gpuMemDatasets = $derived(buildGpuDatasets(filteredGpuStats, "mem_util_pct"));
|
||||
const gpuTempDatasets = $derived(buildGpuDatasets(filteredGpuStats, "temp_c"));
|
||||
const gpuVramTempDatasets = $derived(buildGpuDatasets(filteredGpuStats, "vram_temp_c"));
|
||||
const gpuPowerDatasets = $derived(buildGpuDatasets(filteredGpuStats, "power_draw_w"));
|
||||
const hasVramTemp = $derived(filteredGpuStats.some((g) => g.vram_temp_c > 0));
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-txtmain">Performance (Experimental)</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each WINDOWS as win, i}
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
class:bg-primary={$selectedWindow === i}
|
||||
class:text-btn-primary-text={$selectedWindow === i}
|
||||
onclick={() => ($selectedWindow = i)}
|
||||
>
|
||||
{win.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-txtsecondary mr-1">Refresh:</span>
|
||||
{#each INTERVALS as intv, i}
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
class:bg-primary={$selectedInterval === i}
|
||||
class:text-btn-primary-text={$selectedInterval === i}
|
||||
onclick={() => handleIntervalChange(i)}
|
||||
>
|
||||
{intv.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="btn btn--sm p-1" title="Refresh" onclick={manualRefresh} disabled={refreshing}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-4 h-4"
|
||||
class:animate-spin={refreshing}
|
||||
>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-txtsecondary">
|
||||
This is an experimental feature. Please see <a class="underline hover:text-txtmain" href="https://github.com/mostlygeek/llama-swap/issues/596">issue 596</a> for instructions.
|
||||
</p>
|
||||
|
||||
<!-- GPU Section -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-txtmain">GPU</h3>
|
||||
{#if !hasGpuData}
|
||||
<p class="text-txtsecondary card p-4">No GPU data available</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<PerformanceChart
|
||||
title="GPU Utilization (%)"
|
||||
labels={gpuLabels}
|
||||
datasets={gpuUtilDatasets}
|
||||
yMin={0}
|
||||
yMax={100}
|
||||
yLabel="%"
|
||||
/>
|
||||
<PerformanceChart
|
||||
title="GPU Memory Utilization (%)"
|
||||
labels={gpuLabels}
|
||||
datasets={gpuMemDatasets}
|
||||
yMin={0}
|
||||
yMax={100}
|
||||
yLabel="%"
|
||||
/>
|
||||
<PerformanceChart
|
||||
title="GPU Temperature (°C)"
|
||||
labels={gpuLabels}
|
||||
datasets={gpuTempDatasets}
|
||||
yMin={0}
|
||||
yLabel="°C"
|
||||
/>
|
||||
{#if hasVramTemp}
|
||||
<PerformanceChart
|
||||
title="GPU VRAM Temperature (°C)"
|
||||
labels={gpuLabels}
|
||||
datasets={gpuVramTempDatasets}
|
||||
yMin={0}
|
||||
yLabel="°C"
|
||||
/>
|
||||
{/if}
|
||||
<PerformanceChart
|
||||
title="GPU Power Draw (W)"
|
||||
labels={gpuLabels}
|
||||
datasets={gpuPowerDatasets}
|
||||
yMin={0}
|
||||
yLabel="W"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- System Section -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-txtmain">System</h3>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<PerformanceChart
|
||||
title="CPU Utilization (%)"
|
||||
labels={sysLabels}
|
||||
datasets={cpuDatasets}
|
||||
yMin={0}
|
||||
yMax={100}
|
||||
yLabel="%"
|
||||
showLegend={false}
|
||||
/>
|
||||
<div>
|
||||
<PerformanceChart
|
||||
title="Memory & Swap Usage (%)"
|
||||
labels={sysLabels}
|
||||
datasets={memSwapDatasets}
|
||||
yMin={0}
|
||||
yMax={100}
|
||||
yLabel="%"
|
||||
/>
|
||||
{#if latestMemSwap}
|
||||
<div class="flex items-center justify-center gap-4 text-xs text-txtsecondary mt-1 px-4">
|
||||
<span
|
||||
>Mem: <span class="text-txtmain font-medium"
|
||||
>{latestMemSwap.mem_used_mb.toLocaleString()} / {latestMemSwap.mem_total_mb.toLocaleString()} MB ({latestMemSwap.mem_used_pct}%)</span
|
||||
></span
|
||||
>
|
||||
{#if latestMemSwap.swap_used_pct !== null}
|
||||
<span
|
||||
>Swap: <span class="text-txtmain font-medium"
|
||||
>{latestMemSwap.swap_used_mb.toLocaleString()} / {latestMemSwap.swap_total_mb.toLocaleString()} MB ({latestMemSwap.swap_used_pct}%)</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<PerformanceChart title="Load Average" labels={sysLabels} datasets={loadDatasets} yMin={0} />
|
||||
{#if netBandwidthDatasets.length > 0}
|
||||
<PerformanceChart
|
||||
title="Network Bandwidth (Mbit/s)"
|
||||
labels={netBandwidthLabels}
|
||||
datasets={netBandwidthDatasets}
|
||||
yMin={0}
|
||||
yLabel="Mbit/s"
|
||||
showLegend={false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
APIEventEnvelope,
|
||||
ReqRespCapture,
|
||||
InFlightStats,
|
||||
PerformanceResponse,
|
||||
} from "../lib/types";
|
||||
import { connectionState } from "./theme";
|
||||
|
||||
@@ -204,3 +205,17 @@ export async function getCapture(id: number): Promise<ReqRespCapture | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPerformance(after?: string): Promise<PerformanceResponse | null> {
|
||||
try {
|
||||
const url = after ? `/api/performance?after=${encodeURIComponent(after)}` : "/api/performance";
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch performance data:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,11 @@ export default defineConfig({
|
||||
// on the public internet for dev?! haha.
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080", // Proxy API calls to Go backend during development
|
||||
"/logs": "http://localhost:8080",
|
||||
"/upstream": "http://localhost:8080",
|
||||
"/unload": "http://localhost:8080",
|
||||
"/v1": "http://localhost:8080",
|
||||
"/sdapi": "http://localhost:8080",
|
||||
},
|
||||
proxy: Object.fromEntries(
|
||||
["/api", "/logs", "/upstream", "/unload", "/v1", "/sdapi"].map((path) => [
|
||||
path,
|
||||
process.env.LLAMA_SWAP_URL ?? "http://localhost:8080",
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user