feat: add scalable CPU and GPU telemetry

This commit is contained in:
Hermes Agent
2026-08-26 00:11:11 +10:00
parent e858f7a979
commit 2e47e0eae8
6 changed files with 84 additions and 14 deletions
+4 -1
View File
@@ -47,7 +47,10 @@ The Available downloads controls support:
Popularity and date ordering use upstream metadata only; the plugin does not invent popularity, dates, RAM requirements, or token metrics.
## Staged multi-model loading
## CPU and GPU telemetry
The Live runtime panel now shows overall CPU usage, logical CPU count, load averages, overall GPU utilization, and per-GPU VRAM usage. When multiple logical CPUs are detected, it expands into a scrollable responsive per-core grid. When multiple GPUs are detected, it expands into a responsive per-GPU grid showing utilization, VRAM used/free, temperature, and power when the driver reports them. The grids use auto-fit sizing and bounded scrolling so the panel scales to larger CPU and GPU counts without overflowing the dashboard.
A single **Load selected permanently** action now loads models in verified stages. RAM-only models are attempted first, followed by GPU + RAM models. The backend checks Ollama `/api/ps` after each runner starts and automatically performs one recovery pass for any selected model Ollama evicted. Already resident models are not reloaded. The result includes a retry count and reports when automatic eviction recovery completed, so users do not need to click the load action again manually.
+8 -3
View File
@@ -167,16 +167,21 @@
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 || {}, models = runtime.model_memory || [], loading = runtime.model_loading || [];
var gpu = runtime.gpu || {}, cpu = runtime.cpu || {}, models = runtime.model_memory || [], loading = runtime.model_loading || [], cores = cpu.cores || [], gpus = gpu.gpus || [];
var ollamaBytes = Number(runtime.ollama_model_bytes || 0), ollamaTargetBytes = Number(runtime.ollama_target_model_bytes || ollamaBytes), ollamaPct = total ? Math.min(100, ollamaTargetBytes * 100 / total) : 0;
function percent(value) { return value == null ? "n/a" : Number(value).toFixed(1) + "%"; }
function meter(value) { return value == null ? 0 : Math.max(0, Math.min(100, Number(value))); }
return h("section", { className: "ollama-runtime-panel" },
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory"), h("p", null, "Updates every second while this panel is open.")), h(Badge, { tone: loading.length ? "live" : (gpu.detected ? "live" : "muted") }, loading.length ? "MODEL LOADING" : (gpu.detected ? "GPU detected" : "CPU-only / no supported GPU telemetry"))),
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory and hardware"), h("p", null, "Updates every second while this panel is open. Multiple CPUs and GPUs expand into individual cards.")), h(Badge, { tone: loading.length ? "live" : (gpu.detected ? "live" : "muted") }, loading.length ? "MODEL LOADING" : (gpu.detected ? "GPU detected" : "CPU telemetry"))),
h("div", { className: "ollama-runtime-grid" },
h("div", { className: "ollama-runtime-stat" }, h("small", null, "System RAM used"), h("strong", null, fmtBytes(used), " / ", fmtBytes(total)), h("div", { className: "ollama-meter" }, h("span", { style: { width: pct + "%" } })), h("small", null, fmtBytes(runtime.memory_available_bytes || 0), " available")),
h("div", { className: "ollama-runtime-stat ollama-cpu-stat" }, h("small", null, "CPU usage"), h("strong", null, percent(cpu.usage_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(cpu.usage_percent) + "%" } })), h("small", null, cpu.count ? cpu.count + " logical CPUs · load " + (cpu.load_average || []).map(function (value) { return Number(value).toFixed(2); }).join(" / ") : "Unavailable")),
h("div", { className: "ollama-runtime-stat" }, h("small", null, "Swap used"), h("strong", null, fmtBytes(runtime.swap_used_bytes || 0), " / ", fmtBytes(runtime.swap_total_bytes || 0)), h("small", null, "Host-wide live statistic")),
h("div", { className: "ollama-runtime-stat" }, h("small", null, "GPU telemetry"), h("strong", null, gpu.telemetry_available ? (gpu.gpus || []).map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable"), h("small", null, gpu.detected ? "Ollama VRAM split is still shown below." : "No supported GPU was detected.")),
h("div", { className: "ollama-runtime-stat ollama-gpu-stat" }, h("small", null, "GPU usage"), h("strong", null, percent(gpu.utilization_percent), " · ", gpu.count || 0, " GPU", (gpu.count || 0) === 1 ? "" : "s"), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(gpu.utilization_percent) + "%" } })), h("small", null, gpu.telemetry_available && gpus.length ? gpus.map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable")),
h("div", { className: "ollama-runtime-stat ollama-weight-stat" }, h("small", null, "Ollama model weights"), h("strong", null, fmtBytes(ollamaBytes), " resident"), h("div", { className: "ollama-meter" }, h("span", { style: { width: ollamaPct + "%" } })), h("small", null, loading.length ? "Loading target: " + fmtBytes(ollamaTargetBytes) : "Mapped weight bytes; Linux may report them as file cache"))
),
cores.length > 1 && h("div", { className: "ollama-device-section" }, h("div", { className: "ollama-device-heading" }, h("h4", null, "CPU cores · ", cores.length), h("small", null, "Per-core usage")), h("div", { className: "ollama-device-grid" }, cores.map(function (core) { return h("div", { className: "ollama-device-card", key: core.name }, h("strong", null, core.name.toUpperCase()), h("span", null, percent(core.usage_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(core.usage_percent) + "%" } }))); }))),
gpus.length > 1 && h("div", { className: "ollama-device-section" }, h("div", { className: "ollama-device-heading" }, h("h4", null, "GPUs · ", gpus.length), h("small", null, "Per-GPU telemetry")), h("div", { className: "ollama-device-grid" }, gpus.map(function (item) { return h("div", { className: "ollama-device-card ollama-gpu-device-card", key: item.index }, h("strong", null, "GPU ", item.index, " · ", item.name), h("span", null, "Usage ", percent(item.utilization_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(item.utilization_percent) + "%" } })), h("small", null, "VRAM ", fmtBytes(item.used_bytes), " / ", fmtBytes(item.total_bytes), " · Free ", fmtBytes(item.free_bytes)), h("small", null, item.temperature_c == null ? "Temperature n/a" : "Temperature " + item.temperature_c.toFixed(0) + "°C", " · ", item.power_watts == null ? "Power n/a" : "Power " + item.power_watts.toFixed(0) + " W")); }))),
h("div", { className: "ollama-memory-chart" + (loading.length ? " loading" : ""), role: loading.length ? "status" : undefined, "aria-live": loading.length ? "polite" : undefined }, loading.length ? h("div", { className: "ollama-loading-progress" }, h("strong", null, "Loading into Ollama memory"), h("span", null, loading.map(function (item) { return item.name; }).join(", ")), h("small", null, loading.map(function (item) { return item.stage + " · " + fmtElapsed(item.elapsed); }).join(" · ")), h("div", { className: "ollama-loading-track" }, h("span", null))) : (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded models and capabilities"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload"), h("span", { className: "ollama-loaded-capabilities" }, "Capabilities: ", (model.capabilities || []).join(", ") || "Unknown", " · Input: ", (model.input_modalities || []).join(", ") || "Text", " · ", model.parameter_size || "unknown", " · ", model.quantization || "unknown", " · Context ", model.context_length || "unknown"), h("span", { className: "ollama-permanent-label" }, model.permanent ? "Permanent keep-alive" : "Runtime-loaded")); }) : loading.length ? h("p", null, "Ollama is loading the selected model. Resident memory will appear here when the runner finishes starting.") : h("p", null, "No model is currently loaded. Use the model pool below to load one or more permanently."))
);
+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.5.16",
"version": "1.5.17",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+69 -7
View File
@@ -73,6 +73,8 @@ _jobs: dict[str, dict[str, Any]] = {}
_jobs_lock = threading.Lock()
_model_loads: dict[str, dict[str, Any]] = {}
_model_loads_lock = threading.Lock()
_cpu_previous: dict[str, tuple[int, int]] = {}
_cpu_previous_lock = threading.Lock()
_chat_requests: dict[str, dict[str, Any]] = {}
_chat_requests_lock = threading.Lock()
_catalog_lock = threading.Lock()
@@ -481,9 +483,51 @@ def _host_ram_gib() -> float | None:
return round(total / (1024 ** 3), 1) if total else None
def _cpu_snapshot() -> dict[str, Any]:
"""Return total and per-core CPU usage from Linux procfs counters."""
counters: dict[str, tuple[int, int]] = {}
try:
for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines():
parts = line.split()
if not parts or not re.fullmatch(r"cpu(?:[0-9]+)?", parts[0]) or len(parts) < 5:
continue
values = [int(value) for value in parts[1:]]
total = sum(values)
idle = values[3] + (values[4] if len(values) > 4 else 0)
counters[parts[0]] = (total, idle)
except (OSError, ValueError):
return {"detected": False, "count": 0, "usage_percent": None, "cores": [], "load_average": []}
with _cpu_previous_lock:
previous = dict(_cpu_previous)
_cpu_previous.clear()
_cpu_previous.update(counters)
def usage(name: str) -> float:
current_total, current_idle = counters[name]
previous_values = previous.get(name)
if not previous_values:
return 0.0
previous_total, previous_idle = previous_values
delta_total = current_total - previous_total
delta_idle = current_idle - previous_idle
return round(max(0.0, min(100.0, (delta_total - delta_idle) * 100 / delta_total)), 1) if delta_total else 0.0
core_names = sorted((name for name in counters if name != "cpu"), key=lambda name: int(name[3:]))
cores = [{"id": int(name[3:]), "name": name, "usage_percent": usage(name)} for name in core_names]
try:
load_average = [float(value) for value in Path("/proc/loadavg").read_text(encoding="utf-8").split()[:3]]
except (OSError, ValueError):
load_average = []
return {
"detected": "cpu" in counters,
"count": len(cores),
"usage_percent": usage("cpu") if "cpu" in counters else None,
"cores": cores,
"load_average": load_average,
}
def _gpu_snapshot() -> dict[str, Any]:
"""Return NVIDIA GPU telemetry when available, without requiring CUDA."""
query = "name,memory.total,memory.used,memory.free"
"""Return per-NVIDIA-GPU telemetry when available, without requiring CUDA."""
query = "index,name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu,power.draw,power.limit"
try:
result = subprocess.run(
["nvidia-smi", f"--query-gpu={query}", "--format=csv,noheader,nounits"],
@@ -498,22 +542,38 @@ def _gpu_snapshot() -> dict[str, Any]:
gpus = []
for line in result.stdout.splitlines():
parts = [part.strip() for part in line.split(",")]
if len(parts) != 4:
if len(parts) != 9:
continue
def number(value: str) -> float | None:
try:
return None if value.upper() in {"N/A", "NA", "[N/A]"} else float(value)
except ValueError:
return None
try:
total, used, free = (int(float(value)) * 1024 * 1024 for value in parts[1:])
total, used, free = (int(float(value)) * 1024 * 1024 for value in parts[2:5])
except ValueError:
continue
gpus.append({"name": parts[0], "total_bytes": total, "used_bytes": used, "free_bytes": free})
gpus.append({
"index": int(parts[0]) if parts[0].isdigit() else len(gpus),
"name": parts[1],
"total_bytes": total,
"used_bytes": used,
"free_bytes": free,
"utilization_percent": number(parts[5]),
"temperature_c": number(parts[6]),
"power_watts": number(parts[7]),
"power_limit_watts": number(parts[8]),
})
if gpus:
return {"detected": True, "telemetry_available": True, "gpus": gpus}
utilization_values = [item["utilization_percent"] for item in gpus if item["utilization_percent"] is not None]
return {"detected": True, "telemetry_available": True, "count": len(gpus), "utilization_percent": round(sum(utilization_values) / len(utilization_values), 1) if utilization_values else None, "gpus": gpus}
nvidia_present = False
for vendor in Path("/sys/class/drm").glob("card*/device/vendor"):
try:
nvidia_present = nvidia_present or vendor.read_text().strip().lower() == "0x10de"
except OSError:
continue
return {"detected": nvidia_present, "telemetry_available": False, "gpus": []}
return {"detected": nvidia_present, "telemetry_available": False, "count": 0, "utilization_percent": None, "gpus": []}
def _runtime_snapshot() -> dict[str, Any]:
@@ -548,6 +608,7 @@ def _runtime_snapshot() -> dict[str, Any]:
"permanent": True,
})
gpu = _gpu_snapshot()
cpu = _cpu_snapshot()
ollama_model_bytes = sum(int(row.get("total_bytes") or 0) for row in model_memory)
ollama_model_vram_bytes = sum(int(row.get("gpu_bytes") or 0) for row in model_memory)
model_loading = []
@@ -572,6 +633,7 @@ def _runtime_snapshot() -> dict[str, Any]:
"ollama_model_bytes": ollama_model_bytes,
"ollama_model_vram_bytes": ollama_model_vram_bytes,
"ollama_target_model_bytes": ollama_target_model_bytes,
"cpu": cpu,
"gpu": gpu,
}
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager
version: 1.5.16
version: 1.5.17
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: