feat: add selectable performance history windows
This commit is contained in:
@@ -16,7 +16,7 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat.
|
||||
- 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
|
||||
- Performance history is stored in minute buckets for up to 24 hours and can be viewed as Last 1 hour, Last 6 hours, Last 9 hours, Last 12 hours, or Last 24 hours
|
||||
- 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
|
||||
|
||||
Vendored
+39
-27
@@ -10,6 +10,13 @@
|
||||
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";
|
||||
var PERFORMANCE_WINDOWS = [
|
||||
{ value: "1", label: "Last 1 hour", seconds: 60 * 60 },
|
||||
{ value: "6", label: "Last 6 hours", seconds: 6 * 60 * 60 },
|
||||
{ value: "9", label: "Last 9 hours", seconds: 9 * 60 * 60 },
|
||||
{ value: "12", label: "Last 12 hours", seconds: 12 * 60 * 60 },
|
||||
{ value: "24", label: "Last 24 hours", seconds: 24 * 60 * 60 }
|
||||
];
|
||||
|
||||
function readSavedPlacements() {
|
||||
try {
|
||||
@@ -261,21 +268,31 @@
|
||||
}
|
||||
function PerformanceGraphs(props) {
|
||||
var samples = props.samples || [];
|
||||
var rangeState = React.useState("1"), range = rangeState[0], setRange = rangeState[1];
|
||||
var selectedWindow = PERFORMANCE_WINDOWS.find(function (item) { return item.value === range; }) || PERFORMANCE_WINDOWS[0];
|
||||
var cutoff = Date.now() / 1000 - selectedWindow.seconds;
|
||||
var visibleSamples = samples.filter(function (sample) { return Number(sample.captured_at || 0) >= cutoff; });
|
||||
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-graphs-heading" },
|
||||
h("div", null, h("h3", null, "Performance history"), h("p", null, "CPU, GPU, memory, storage, swap, and Ollama residency over the selected window. ", visibleSamples.length, " minute samples available.")),
|
||||
h("div", { className: "ollama-performance-controls" },
|
||||
h("label", { className: "ollama-performance-range" }, "History window", h("select", { value: range, onChange: function (event) { setRange(event.target.value); }, "aria-label": "Performance history window" }, PERFORMANCE_WINDOWS.map(function (item) { return h("option", { key: item.value, value: item.value }, item.label); }))),
|
||||
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 })
|
||||
h(PerformanceGraph, { title: "CPU usage", subtitle: "Total processor utilization", valueKey: "cpu_usage_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
|
||||
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: visibleSamples }),
|
||||
h(PerformanceGraph, { title: "System memory", subtitle: "Used RAM", valueKey: "memory_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
|
||||
h(PerformanceGraph, { title: "GPU usage", subtitle: "Aggregate GPU utilization", valueKey: "gpu_usage_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
|
||||
h(PerformanceGraph, { title: "GPU VRAM", subtitle: "Used video memory", valueKey: "gpu_vram_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
|
||||
h(PerformanceGraph, { title: "Disk usage", subtitle: "Root filesystem", valueKey: "disk_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
|
||||
h(PerformanceGraph, { title: "Swap usage", subtitle: "Used swap memory", valueKey: "swap_used_percent", max: 100, range: "100%", format: pct, samples: visibleSamples }),
|
||||
h(PerformanceGraph, { title: "Ollama model weights", subtitle: "Resident model bytes", valueKey: "ollama_model_gib", range: "dynamic", format: gib, samples: visibleSamples }),
|
||||
h(PerformanceGraph, { title: "Resident models", subtitle: "Loaded Ollama model count", valueKey: "resident_model_count", range: "dynamic", format: count, samples: visibleSamples })
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -387,6 +404,7 @@
|
||||
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(readSavedPerformanceSamples()), samples = samplesState[0], setSamples = samplesState[1];
|
||||
var performanceHistoryAt = React.useRef(0);
|
||||
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];
|
||||
@@ -474,25 +492,19 @@
|
||||
refreshConversations();
|
||||
setNotice({ ok: "Conversation deleted from shared storage." });
|
||||
}
|
||||
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); });
|
||||
function pollPerformanceHistory() {
|
||||
performanceHistoryAt.current = Date.now();
|
||||
fetchJSON(API + "/runtime/history?hours=24").then(function (value) {
|
||||
setSamples(Array.isArray(value.samples) ? value.samples : []);
|
||||
}).catch(function () {});
|
||||
}
|
||||
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
|
||||
function pollRuntime() {
|
||||
fetchJSON(API + "/runtime").then(function (value) {
|
||||
setRuntime(value);
|
||||
if (Date.now() - performanceHistoryAt.current >= 5000) pollPerformanceHistory();
|
||||
}).catch(function () {});
|
||||
}
|
||||
React.useEffect(function () { pollRuntime(); pollPerformanceHistory(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
|
||||
React.useEffect(function () { savePerformanceSamples(samples); }, [samples]);
|
||||
|
||||
React.useEffect(function () { savePlacements(placements); }, [placements]);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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.7",
|
||||
"version": "1.7.8",
|
||||
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
|
||||
+96
-1
@@ -82,6 +82,10 @@ CHAT_HEARTBEAT_INTERVAL = 5.0
|
||||
HARNESS_MIN_VALIDATORS = 1
|
||||
HARNESS_MAX_DRAFT_CHARS = 24_000
|
||||
HARNESS_MAX_VALIDATION_CHARS = 8_000
|
||||
PERFORMANCE_HISTORY_FILE = "performance-history.jsonl"
|
||||
PERFORMANCE_HISTORY_BUCKET_SECONDS = 60
|
||||
PERFORMANCE_HISTORY_MAX_SAMPLES = 24 * 60
|
||||
PERFORMANCE_HISTORY_WINDOWS = {1, 6, 9, 12, 24}
|
||||
|
||||
_jobs: dict[str, dict[str, Any]] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
@@ -1003,6 +1007,83 @@ def _runtime_snapshot() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _performance_sample(runtime: dict[str, Any]) -> dict[str, Any]:
|
||||
total = int(runtime.get("memory_total_bytes") or 0)
|
||||
used = int(runtime.get("memory_used_bytes") or 0)
|
||||
gpu = runtime.get("gpu") or {}
|
||||
gpus = gpu.get("gpus") or []
|
||||
gpu_total = sum(int(item.get("total_bytes") or 0) for item in gpus)
|
||||
gpu_used = sum(int(item.get("used_bytes") or 0) for item in gpus)
|
||||
swap_total = int(runtime.get("swap_total_bytes") or 0)
|
||||
swap_used = int(runtime.get("swap_used_bytes") or 0)
|
||||
cpu = runtime.get("cpu") or {}
|
||||
disk = runtime.get("disk") or {}
|
||||
load_average = cpu.get("load_average") or []
|
||||
captured_at = float(runtime.get("captured_at") or time.time())
|
||||
bucket = int(captured_at // PERFORMANCE_HISTORY_BUCKET_SECONDS) * PERFORMANCE_HISTORY_BUCKET_SECONDS
|
||||
return {
|
||||
"captured_at": bucket,
|
||||
"cpu_usage_percent": cpu.get("usage_percent"),
|
||||
"cpu_load_1m": load_average[0] if load_average else None,
|
||||
"memory_used_percent": used * 100 / total if total else None,
|
||||
"gpu_usage_percent": gpu.get("utilization_percent"),
|
||||
"gpu_vram_used_percent": gpu_used * 100 / gpu_total if gpu_total else None,
|
||||
"disk_used_percent": disk.get("used_percent"),
|
||||
"swap_used_percent": swap_used * 100 / swap_total if swap_total else None,
|
||||
"ollama_model_gib": int(runtime.get("ollama_model_bytes") or 0) / (1024 * 1024 * 1024),
|
||||
"resident_model_count": len(runtime.get("model_memory") or []),
|
||||
}
|
||||
|
||||
|
||||
def _read_performance_history() -> list[dict[str, Any]]:
|
||||
path = _home() / PERFORMANCE_HISTORY_FILE
|
||||
rows: dict[int, dict[str, Any]] = {}
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
try:
|
||||
sample = json.loads(line)
|
||||
bucket = int(sample.get("captured_at"))
|
||||
if bucket > 0:
|
||||
rows[bucket] = sample
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
return [rows[key] for key in sorted(rows)][-PERFORMANCE_HISTORY_MAX_SAMPLES:]
|
||||
|
||||
|
||||
_performance_history_lock = threading.Lock()
|
||||
|
||||
|
||||
def _record_performance_sample(runtime: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
sample = _performance_sample(runtime)
|
||||
path = _home() / PERFORMANCE_HISTORY_FILE
|
||||
with _performance_history_lock:
|
||||
rows = {int(item["captured_at"]): item for item in _read_performance_history() if item.get("captured_at")}
|
||||
rows[int(sample["captured_at"])] = sample
|
||||
cutoff = int(time.time()) - 24 * 60 * 60
|
||||
history = [rows[key] for key in sorted(rows) if key >= cutoff][-PERFORMANCE_HISTORY_MAX_SAMPLES:]
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text("".join(json.dumps(item, separators=(",", ":")) + "\n" for item in history), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
return history
|
||||
|
||||
|
||||
def _performance_history(hours: int) -> list[dict[str, Any]]:
|
||||
cutoff = time.time() - hours * 60 * 60
|
||||
return [sample for sample in _read_performance_history() if float(sample.get("captured_at") or 0) >= cutoff]
|
||||
|
||||
|
||||
def _validate_performance_hours(hours: int) -> int:
|
||||
try:
|
||||
value = int(hours)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(400, "History window must be 1, 6, 9, 12, or 24 hours") from exc
|
||||
if value not in PERFORMANCE_HISTORY_WINDOWS:
|
||||
raise HTTPException(400, "History window must be 1, 6, 9, 12, or 24 hours")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_public_url(value: str) -> str:
|
||||
parsed = urlparse(value.strip())
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
@@ -2300,7 +2381,21 @@ def metrics(limit: int = 100) -> dict[str, Any]:
|
||||
|
||||
@router.get("/runtime")
|
||||
def runtime() -> dict[str, Any]:
|
||||
return _runtime_snapshot()
|
||||
snapshot = _runtime_snapshot()
|
||||
_record_performance_sample(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.get("/runtime/history")
|
||||
def runtime_history(hours: int = 24) -> dict[str, Any]:
|
||||
selected_hours = _validate_performance_hours(hours)
|
||||
snapshot = _runtime_snapshot()
|
||||
_record_performance_sample(snapshot)
|
||||
return {
|
||||
"hours": selected_hours,
|
||||
"bucket_seconds": PERFORMANCE_HISTORY_BUCKET_SECONDS,
|
||||
"samples": _performance_history(selected_hours),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/chat/load")
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: ollama-manager
|
||||
version: 1.7.7
|
||||
version: 1.7.8
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import threading
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -229,6 +231,42 @@ class ValidationHarnessTests(unittest.TestCase):
|
||||
self.assertIn("historical performance graphs are shown in Operations / Performance below", bundle)
|
||||
self.assertIn("setInterval(pollRuntime, 1000)", bundle)
|
||||
|
||||
def test_performance_sample_uses_minute_bucket_and_runtime_values(self):
|
||||
sample = api._performance_sample({
|
||||
"captured_at": 1700000061,
|
||||
"memory_total_bytes": 200,
|
||||
"memory_used_bytes": 100,
|
||||
"swap_total_bytes": 100,
|
||||
"swap_used_bytes": 25,
|
||||
"ollama_model_bytes": 1024 ** 3,
|
||||
"model_memory": [{"name": "model"}],
|
||||
"cpu": {"usage_percent": 12.5, "load_average": [1.25]},
|
||||
"gpu": {"utilization_percent": 40, "gpus": [{"total_bytes": 100, "used_bytes": 50}]},
|
||||
"disk": {"used_percent": 33.3},
|
||||
})
|
||||
self.assertEqual(sample["captured_at"], 1700000040)
|
||||
self.assertEqual(sample["memory_used_percent"], 50.0)
|
||||
self.assertEqual(sample["gpu_vram_used_percent"], 50.0)
|
||||
self.assertEqual(sample["resident_model_count"], 1)
|
||||
|
||||
def test_performance_history_accepts_only_requested_windows(self):
|
||||
for hours in (1, 6, 9, 12, 24):
|
||||
self.assertEqual(api._validate_performance_hours(hours), hours)
|
||||
with self.assertRaises(api.HTTPException) as context:
|
||||
api._validate_performance_hours(2)
|
||||
self.assertEqual(context.exception.status_code, 400)
|
||||
|
||||
def test_performance_history_deduplicates_minute_buckets(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(api, "_home", return_value=Path(directory)):
|
||||
base = int(time.time()) - 120
|
||||
runtime = {"captured_at": base + 1, "memory_total_bytes": 1, "memory_used_bytes": 1, "cpu": {}, "gpu": {}, "disk": {}, "model_memory": []}
|
||||
first = api._record_performance_sample(runtime)
|
||||
runtime["captured_at"] = base + 2
|
||||
second = api._record_performance_sample(runtime)
|
||||
self.assertEqual(len(first), 1)
|
||||
self.assertEqual(len(second), 1)
|
||||
self.assertEqual(second[0]["captured_at"], ((base + 2) // 60) * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user