Restore bottom performance history graphs
This commit is contained in:
@@ -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 is the default view when the Ollama Models plugin opens
|
||||||
- Chat uses a conversation rail, central message timeline, and dedicated model/runtime controls
|
- 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
|
- 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
|
- 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
|
- 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
|
- Paste images directly into the composer and drag/drop images, PDFs, and text files
|
||||||
|
|||||||
Vendored
+90
-4
@@ -9,6 +9,7 @@
|
|||||||
var API = "/api/plugins/ollama-manager";
|
var API = "/api/plugins/ollama-manager";
|
||||||
var CHAT_STORAGE_KEY = "hermes.ollama-manager.chat.v1";
|
var CHAT_STORAGE_KEY = "hermes.ollama-manager.chat.v1";
|
||||||
var PLACEMENT_STORAGE_KEY = "hermes.ollama-manager.placement.v1";
|
var PLACEMENT_STORAGE_KEY = "hermes.ollama-manager.placement.v1";
|
||||||
|
var PERFORMANCE_STORAGE_KEY = "hermes.ollama-manager.performance.v1";
|
||||||
|
|
||||||
function readSavedPlacements() {
|
function readSavedPlacements() {
|
||||||
try {
|
try {
|
||||||
@@ -20,6 +21,16 @@
|
|||||||
function savePlacements(value) {
|
function savePlacements(value) {
|
||||||
try { window.localStorage.setItem(PLACEMENT_STORAGE_KEY, JSON.stringify(value || {})); } catch (_) {}
|
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() {
|
function readSavedChat() {
|
||||||
try {
|
try {
|
||||||
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
|
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) {
|
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 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 || [];
|
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 metricsState = React.useState(null), metrics = metricsState[0], setMetrics = metricsState[1];
|
||||||
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
|
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
|
||||||
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[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 busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
||||||
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
|
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
|
||||||
var validationState = React.useState(null), validationReports = validationState[0], setValidationReports = validationState[1];
|
var validationState = React.useState(null), validationReports = validationState[0], setValidationReports = validationState[1];
|
||||||
@@ -411,8 +474,26 @@
|
|||||||
refreshConversations();
|
refreshConversations();
|
||||||
setNotice({ ok: "Conversation deleted from shared storage." });
|
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 () {}); }
|
function pollRuntime() {
|
||||||
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 5000); return function () { clearInterval(timer); }; }, []);
|
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]);
|
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]); }); }
|
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("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(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, "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 })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
|||||||
"label": "Ollama Models",
|
"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.",
|
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
|
||||||
"icon": "Cpu",
|
"icon": "Cpu",
|
||||||
"version": "1.7.6",
|
"version": "1.7.7",
|
||||||
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
||||||
"entry": "dist/index.js",
|
"entry": "dist/index.js",
|
||||||
"css": "dist/style.css",
|
"css": "dist/style.css",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
name: ollama-manager
|
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.
|
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
|
auto_install_dependencies: true
|
||||||
python_dependencies:
|
python_dependencies:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from dashboard import plugin_api as api
|
from dashboard import plugin_api as api
|
||||||
@@ -219,6 +220,15 @@ class ValidationHarnessTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(created), 1)
|
self.assertEqual(len(created), 1)
|
||||||
self.assertEqual(created[0].history, [])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user