feat: add scalable CPU and GPU telemetry
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Vendored
+8
-3
@@ -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."))
|
||||
);
|
||||
|
||||
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.5.16",
|
||||
"version": "1.5.17",
|
||||
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
|
||||
+69
-7
@@ -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:
|
||||
total, used, free = (int(float(value)) * 1024 * 1024 for value in parts[1:])
|
||||
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[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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user