Restore bottom performance history graphs

This commit is contained in:
Hermes Agent
2026-08-29 10:31:10 +10:00
parent e1f0589c3c
commit f914dd0eca
6 changed files with 105 additions and 7 deletions
+2
View File
@@ -15,6 +15,8 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat.
- Chat is the default view when the Ollama Models plugin opens
- Chat uses a conversation rail, central message timeline, and dedicated model/runtime controls
- Messages support safe Markdown-style emphasis, fenced code blocks, copy, and retry actions
- Runtime operations and historical performance graphs are restored below chat: CPU usage/load, system memory, GPU usage/VRAM, disk, swap, resident Ollama weights, and resident model count
- Performance samples are retained locally for the last 120 one-second telemetry snapshots
- The catalog is loaded separately from live status with server-side search, filters, and pagination
- Natural composer behavior: Enter sends; Shift+Enter creates a new line
- Paste images directly into the composer and drag/drop images, PDFs, and text files
+90 -4
View File
@@ -9,6 +9,7 @@
var API = "/api/plugins/ollama-manager";
var CHAT_STORAGE_KEY = "hermes.ollama-manager.chat.v1";
var PLACEMENT_STORAGE_KEY = "hermes.ollama-manager.placement.v1";
var PERFORMANCE_STORAGE_KEY = "hermes.ollama-manager.performance.v1";
function readSavedPlacements() {
try {
@@ -20,6 +21,16 @@
function savePlacements(value) {
try { window.localStorage.setItem(PLACEMENT_STORAGE_KEY, JSON.stringify(value || {})); } catch (_) {}
}
function readSavedPerformanceSamples() {
try {
var raw = window.localStorage.getItem(PERFORMANCE_STORAGE_KEY);
var value = raw ? JSON.parse(raw) : [];
return Array.isArray(value) ? value.filter(function (sample) { return sample && typeof sample.captured_at === "number"; }).slice(-120) : [];
} catch (_) { return []; }
}
function savePerformanceSamples(value) {
try { window.localStorage.setItem(PERFORMANCE_STORAGE_KEY, JSON.stringify((value || []).slice(-120))); } catch (_) {}
}
function readSavedChat() {
try {
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
@@ -217,6 +228,58 @@
);
}
function graphNumber(sample, key) {
if (!sample || sample[key] === null || sample[key] === undefined) return null;
var value = Number(sample[key]);
return isFinite(value) ? value : null;
}
function PerformanceGraph(props) {
var samples = props.samples || [], values = samples.map(function (sample) { return graphNumber(sample, props.valueKey); });
var max = Number(props.max || 0), observed = values.reduce(function (result, value) { return value == null ? result : Math.max(result, value); }, 0);
if (!max) max = observed > 0 ? observed * 1.15 : 1;
var width = 240, height = 84, pad = 5, points = [], latest = null;
values.forEach(function (value, index) {
if (value == null) return;
latest = value;
var x = pad + (values.length > 1 ? index * (width - pad * 2) / (values.length - 1) : (width / 2));
var y = height - pad - Math.max(0, Math.min(1, value / max)) * (height - pad * 2);
points.push(x.toFixed(1) + "," + y.toFixed(1));
});
var line = points.join(" "), area = points.length ? pad + "," + (height - pad) + " " + line + " " + (width - pad) + "," + (height - pad) : "";
var formatted = latest == null ? "No samples yet" : (props.format ? props.format(latest) : Number(latest).toFixed(1));
return h("article", { className: "ollama-performance-graph" },
h("div", { className: "ollama-performance-graph-heading" }, h("div", null, h("strong", null, props.title), h("small", null, props.subtitle)), h("b", null, formatted)),
h("svg", { className: "ollama-performance-svg", viewBox: "0 0 240 84", role: "img", "aria-label": props.title + " history" },
h("line", { x1: pad, y1: height - pad, x2: width - pad, y2: height - pad, className: "ollama-graph-grid" }),
h("line", { x1: pad, y1: height / 2, x2: width - pad, y2: height / 2, className: "ollama-graph-grid" }),
h("line", { x1: pad, y1: pad, x2: width - pad, y2: pad, className: "ollama-graph-grid" }),
area && h("polygon", { points: area, className: "ollama-graph-area" }),
line && h("polyline", { points: line, className: "ollama-graph-line" })
),
h("div", { className: "ollama-performance-graph-scale" }, h("span", null, "0"), h("span", null, props.range || "dynamic"))
);
}
function PerformanceGraphs(props) {
var samples = props.samples || [];
function pct(value) { return value == null ? "n/a" : Number(value).toFixed(1) + "%"; }
function gib(value) { return value == null ? "n/a" : Number(value).toFixed(2) + " GiB"; }
function count(value) { return value == null ? "n/a" : String(Math.round(value)); }
return h("section", { className: "ollama-performance-graphs" },
h("div", { className: "ollama-performance-graphs-heading" }, h("div", null, h("h3", null, "Performance history"), h("p", null, "CPU, GPU, memory, storage, swap, and Ollama residency over the last ", samples.length, " samples.")), h("span", null, samples.length ? "Live · 1 second" : "Waiting for telemetry")),
h("div", { className: "ollama-performance-grid" },
h(PerformanceGraph, { title: "CPU usage", subtitle: "Total processor utilization", valueKey: "cpu_usage_percent", max: 100, range: "100%", format: pct, samples: samples }),
h(PerformanceGraph, { title: "CPU load", subtitle: "1-minute load average", valueKey: "cpu_load_1m", range: "dynamic", format: function (value) { return Number(value).toFixed(2); }, samples: samples }),
h(PerformanceGraph, { title: "System memory", subtitle: "Used RAM", valueKey: "memory_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
h(PerformanceGraph, { title: "GPU usage", subtitle: "Aggregate GPU utilization", valueKey: "gpu_usage_percent", max: 100, range: "100%", format: pct, samples: samples }),
h(PerformanceGraph, { title: "GPU VRAM", subtitle: "Used video memory", valueKey: "gpu_vram_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
h(PerformanceGraph, { title: "Disk usage", subtitle: "Root filesystem", valueKey: "disk_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
h(PerformanceGraph, { title: "Swap usage", subtitle: "Used swap memory", valueKey: "swap_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
h(PerformanceGraph, { title: "Ollama model weights", subtitle: "Resident model bytes", valueKey: "ollama_model_gib", range: "dynamic", format: gib, samples: samples }),
h(PerformanceGraph, { title: "Resident models", subtitle: "Loaded Ollama model count", valueKey: "resident_model_count", range: "dynamic", format: count, samples: samples })
)
);
}
function RuntimePanel(props) {
var runtime = props.runtime || {}, total = Number(runtime.memory_total_bytes || 0), used = Number(runtime.memory_used_bytes || 0), pct = total ? Math.min(100, used * 100 / total) : 0;
var gpu = runtime.gpu || {}, cpu = runtime.cpu || {}, disk = runtime.disk || {}, models = runtime.model_memory || [], loading = runtime.model_loading || [], cores = cpu.cores || [], gpus = gpu.gpus || [];
@@ -323,7 +386,7 @@
var metricsState = React.useState(null), metrics = metricsState[0], setMetrics = metricsState[1];
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[1];
var samplesState = React.useState([]), samples = samplesState[0], setSamples = samplesState[1];
var samplesState = React.useState(readSavedPerformanceSamples()), samples = samplesState[0], setSamples = samplesState[1];
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var validationState = React.useState(null), validationReports = validationState[0], setValidationReports = validationState[1];
@@ -411,8 +474,26 @@
refreshConversations();
setNotice({ ok: "Conversation deleted from shared storage." });
}
function pollRuntime() { fetchJSON(API + "/runtime").then(function (value) { setRuntime(value); setSamples(function (old) { return old.concat([{ used: Number(value.memory_used_bytes || 0), total: Number(value.memory_total_bytes || 0) }]).slice(-60); }); }).catch(function () {}); }
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 5000); return function () { clearInterval(timer); }; }, []);
function pollRuntime() {
fetchJSON(API + "/runtime").then(function (value) {
var total = Number(value.memory_total_bytes || 0), used = Number(value.memory_used_bytes || 0), gpu = value.gpu || {}, gpus = gpu.gpus || [], gpuTotal = gpus.reduce(function (sum, item) { return sum + Number(item.total_bytes || 0); }, 0), gpuUsed = gpus.reduce(function (sum, item) { return sum + Number(item.used_bytes || 0); }, 0), swapTotal = Number(value.swap_total_bytes || 0), swapUsed = Number(value.swap_used_bytes || 0);
setRuntime(value);
setSamples(function (old) { return old.concat([{
captured_at: Number(value.captured_at || Date.now() / 1000),
cpu_usage_percent: value.cpu && value.cpu.usage_percent,
cpu_load_1m: value.cpu && value.cpu.load_average && value.cpu.load_average[0],
memory_used_percent: total ? used * 100 / total : null,
gpu_usage_percent: gpu.utilization_percent,
gpu_vram_used_percent: gpuTotal ? gpuUsed * 100 / gpuTotal : null,
disk_used_percent: value.disk && value.disk.used_percent,
swap_used_percent: swapTotal ? swapUsed * 100 / swapTotal : null,
ollama_model_gib: Number(value.ollama_model_bytes || 0) / (1024 * 1024 * 1024),
resident_model_count: Array.isArray(value.model_memory) ? value.model_memory.length : 0
}]).slice(-120); });
}).catch(function () {});
}
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
React.useEffect(function () { savePerformanceSamples(samples); }, [samples]);
React.useEffect(function () { savePlacements(placements); }, [placements]);
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
@@ -549,8 +630,13 @@
h("div", { className: "ollama-controls-heading" }, h("h3", null, "Chat controls"), h("small", null, "Model, quality, and runtime")),
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, primaryModel: selectedModels[0] || "", validatorModels: selectedModels.slice(1), poolSelection: poolSelection, placements: placements, busy: busy, onTogglePool: togglePoolModel, onPlacementChange: setPlacement, onToggleChat: toggleChatModel, onPrimaryChange: setPrimaryModel, onLoad: loadModel, onUnload: unloadModels }),
h("details", { className: "ollama-advanced-control" }, h("summary", null, "Chat storage"), h(StoragePanel, { storage: storage, onConfigure: configureStorage, onInstall: installPostgres, onRefresh: refreshStorage })),
h("details", { className: "ollama-advanced-control" }, h("summary", null, "Runtime telemetry"), h(RuntimePanel, { runtime: runtime, samples: samples }))
h("div", { className: "ollama-advanced-note" }, "Runtime cards and historical performance graphs are shown in Operations / Performance below.")
)
),
h("section", { className: "ollama-operations-section" },
h("div", { className: "ollama-operations-heading" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "OPERATIONS / PERFORMANCE"), h("h3", null, "Runtime telemetry and historical graphs"), h("p", null, "The complete operational view is preserved below the chat so it remains available without competing with the conversation.")), h("span", null, "CPU · GPU · RAM · disk · swap")),
h(RuntimePanel, { runtime: runtime, samples: samples }),
h(PerformanceGraphs, { samples: samples })
)
);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu",
"version": "1.7.6",
"version": "1.7.7",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager
version: 1.7.6
version: 1.7.7
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry.
auto_install_dependencies: true
python_dependencies:
+10
View File
@@ -1,5 +1,6 @@
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
from dashboard import plugin_api as api
@@ -219,6 +220,15 @@ class ValidationHarnessTests(unittest.TestCase):
self.assertEqual(len(created), 1)
self.assertEqual(created[0].history, [])
def test_dashboard_restores_complete_bottom_performance_graph_set(self):
bundle = Path(__file__).parents[1].joinpath("dashboard", "dist", "index.js").read_text(encoding="utf-8")
for label in ("CPU usage", "CPU load", "System memory", "GPU usage", "GPU VRAM", "Disk usage", "Swap usage", "Ollama model weights", "Resident models"):
self.assertIn('title: "' + label + '"', bundle)
self.assertIn("function PerformanceGraphs", bundle)
self.assertIn("ollama-operations-section", bundle)
self.assertIn("historical performance graphs are shown in Operations / Performance below", bundle)
self.assertIn("setInterval(pollRuntime, 1000)", bundle)
if __name__ == "__main__":
unittest.main()