From 2e47e0eae805f6ebbebe999da5a1b8f46946fd11 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 26 Aug 2026 00:11:11 +1000 Subject: [PATCH] feat: add scalable CPU and GPU telemetry --- README.md | 5 ++- dashboard/dist/index.js | 11 ++++-- dashboard/dist/style.css | 2 +- dashboard/manifest.json | 2 +- dashboard/plugin_api.py | 76 ++++++++++++++++++++++++++++++++++++---- plugin.yaml | 2 +- 6 files changed, 84 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1f463ee..74a7a48 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index fbc4b90..afa528b 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -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.")) ); diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css index 0345508..ac0ed9a 100644 --- a/dashboard/dist/style.css +++ b/dashboard/dist/style.css @@ -2,7 +2,7 @@ @media(max-width:760px){.ollama-page{padding:20px 16px 40px}.ollama-hero,.ollama-toolbar{display:block}.ollama-health{justify-content:flex-start;margin-top:15px}.ollama-search{margin-top:12px;max-width:none}.ollama-grid{grid-template-columns:1fr}.ollama-card-top{display:block}.ollama-card-actions{justify-content:flex-start;margin-top:12px}.ollama-model-summary{grid-template-columns:repeat(2,1fr)}} .ollama-popular-note{margin:14px 0;color:#a5bfba;font-size:12px;line-height:1.5} .ollama-model-pool{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-pool-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-pool-heading h3{margin:0 0 3px}.ollama-pool-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-pool-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:8px;margin-top:12px}.ollama-pool-item,.ollama-chat-model-selection label{display:flex;gap:8px;align-items:flex-start;padding:9px;border:1px solid rgba(155,205,194,.16);border-radius:7px;background:rgba(71,117,108,.1);color:#c5ddd7;font-size:11px;cursor:pointer}.ollama-pool-item.loaded{border-color:rgba(117,210,183,.75);background:linear-gradient(135deg,rgba(48,113,96,.48),rgba(35,82,73,.42));box-shadow:0 0 0 1px rgba(117,210,183,.18),0 0 18px rgba(117,210,183,.12)}.ollama-pool-item.loaded strong{color:#effff9}.ollama-pool-item.loaded small{color:#a8ead8}.ollama-pool-item.selected{outline:1px solid rgba(117,210,183,.45)}.ollama-pool-item input,.ollama-chat-model-selection input{margin-top:2px;accent-color:#75d2b7}.ollama-pool-item span{display:flex;flex-direction:column;gap:3px}.ollama-pool-item small,.ollama-chat-model-selection small{color:#8fa9a4;font-size:10px}.ollama-placement-control{display:flex!important;flex-direction:row!important;align-items:center;gap:6px;margin-top:3px}.ollama-placement-control select{max-width:190px;border:1px solid rgba(155,205,194,.28);border-radius:5px;background:#102d29;color:#e8f2ef;padding:5px;font:inherit;font-size:10px}.ollama-chat-model-selection label.loaded{border-color:rgba(117,210,183,.48);background:rgba(48,113,96,.28);color:#effff9}.ollama-pool-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.ollama-chat-model-selection{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:14px;padding-top:12px;border-top:1px solid rgba(164,211,199,.14);color:#d5e8e2}.ollama-chat-model-selection>strong{margin-right:4px}.ollama-chat-model-selection>small{margin-right:8px}.ollama-chat-model-selection label{align-items:center;padding:6px 8px}.ollama-loaded-capabilities{flex-basis:100%;color:#b8ead9;font-size:10px}.ollama-permanent-label{color:#9af1c7;font-size:10px} -.ollama-chat{margin-top:18px}.ollama-chat-header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;padding:18px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-chat-header h2{margin:6px 0;color:#effcf8}.ollama-chat-header p{margin:0;color:#a5bfba;font-size:12px;max-width:720px}.ollama-chat-model{display:flex;align-items:flex-end;gap:10px;min-width:260px}.ollama-chat-model label{display:flex;flex-direction:column;gap:6px;color:#a5bfba;font-size:11px}.ollama-chat-model select,.ollama-url-input{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:9px;font:inherit;font-size:12px}.ollama-runtime-panel{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-runtime-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-runtime-heading h3{margin:0 0 3px}.ollama-runtime-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-badge.muted{color:#a7b7b4;border-color:rgba(167,183,180,.25)}.ollama-runtime-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}.ollama-runtime-stat{display:flex;flex-direction:column;gap:5px;min-height:70px;padding:11px;border-radius:8px;background:rgba(71,117,108,.11);color:#a5bfba}.ollama-runtime-stat strong{color:#effcf8;font-size:15px}.ollama-runtime-stat small{font-size:10px}.ollama-meter{height:5px;border-radius:99px;background:#173c36;overflow:hidden}.ollama-meter span{display:block;height:100%;border-radius:inherit;background:#75d2b7}.ollama-memory-chart{height:70px;display:flex;align-items:flex-end;gap:2px;margin-top:14px;padding:5px 0;border-bottom:1px solid rgba(164,211,199,.18)}.ollama-memory-chart span{flex:1;min-width:2px;background:#55a995;border-radius:2px 2px 0 0;opacity:.8}.ollama-memory-chart.loading{height:70px;align-items:center;padding:0 16px;border:1px solid rgba(117,210,183,.55);border-radius:8px;background:linear-gradient(90deg,rgba(38,104,88,.35),rgba(25,70,62,.7),rgba(38,104,88,.35));background-size:200% 100%;animation:ollama-loading-sweep 1.8s linear infinite}.ollama-loading-progress{display:grid;gap:4px;width:100%;color:#bff7e2}.ollama-loading-progress strong{font-size:12px}.ollama-loading-progress span{font-size:11px;color:#e8fff7;overflow-wrap:anywhere}.ollama-loading-progress small{font-size:10px;color:#9dd7c5}.ollama-loading-track{height:5px;overflow:hidden;border-radius:99px;background:rgba(117,210,183,.18)}.ollama-loading-track span{display:block;width:35%;height:100%;border-radius:inherit;background:#75d2b7;animation:ollama-loading-indicator 1.2s ease-in-out infinite}.ollama-weight-stat .ollama-meter span{background:#86b9ff}@keyframes ollama-loading-sweep{0%{background-position:0 0}100%{background-position:200% 0}}@keyframes ollama-loading-indicator{0%{margin-left:-35%}100%{margin-left:100%}}.ollama-loaded-memory h4{margin:15px 0 8px}.ollama-loaded-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:9px 0;border-top:1px solid rgba(164,211,199,.1);font-size:11px;color:#a5bfba}.ollama-loaded-row strong{color:#effcf8;min-width:180px}.ollama-chat-layout{display:flex;flex-direction:column;gap:14px;margin-top:14px}.ollama-conversation{min-height:360px;max-height:620px;overflow:auto;order:1}.ollama-composer{order:2}.ollama-conversation{min-height:320px;max-height:620px;overflow:auto;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,25,23,.62)}.ollama-message{margin:0 0 14px;padding:11px 13px;border-radius:9px;white-space:pre-wrap;line-height:1.5;font-size:13px}.ollama-message small{display:block;margin-bottom:5px;color:#8fc7ba;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.ollama-message.user{margin-left:12%;background:rgba(54,105,94,.3)}.ollama-message.assistant{margin-right:8%;background:rgba(34,63,62,.6)}.ollama-composer{display:flex;flex-direction:column;gap:10px;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-composer textarea{min-height:160px;resize:vertical;border:1px solid rgba(155,205,194,.25);border-radius:8px;background:#0d2522;color:#e8f2ef;padding:11px;font:inherit;font-size:13px}.ollama-attachment-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.ollama-file-button{display:inline-flex;align-items:center;border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font-size:11px}.ollama-file-button input{display:none}.ollama-url-input{flex:1;min-width:160px}.ollama-attachments{display:flex;gap:6px;flex-wrap:wrap}.ollama-attachment{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border-radius:999px;background:rgba(80,125,115,.16);color:#c5dfd8;font-size:10px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.ollama-attachment button{border:0;background:none;color:#ffb3b3;cursor:pointer}.ollama-chat-footnote{margin:0;color:#819b96;font-size:10px;line-height:1.45} +.ollama-chat{margin-top:18px}.ollama-chat-header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;padding:18px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-chat-header h2{margin:6px 0;color:#effcf8}.ollama-chat-header p{margin:0;color:#a5bfba;font-size:12px;max-width:720px}.ollama-chat-model{display:flex;align-items:flex-end;gap:10px;min-width:260px}.ollama-chat-model label{display:flex;flex-direction:column;gap:6px;color:#a5bfba;font-size:11px}.ollama-chat-model select,.ollama-url-input{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:9px;font:inherit;font-size:12px}.ollama-runtime-panel{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-runtime-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-runtime-heading h3{margin:0 0 3px}.ollama-runtime-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-badge.muted{color:#a7b7b4;border-color:rgba(167,183,180,.25)}.ollama-runtime-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px;margin-top:14px}.ollama-runtime-stat{display:flex;flex-direction:column;gap:5px;min-height:70px;padding:11px;border-radius:8px;background:rgba(71,117,108,.11);color:#a5bfba}.ollama-runtime-stat strong{color:#effcf8;font-size:15px}.ollama-runtime-stat small{font-size:10px}.ollama-meter{height:5px;border-radius:99px;background:#173c36;overflow:hidden}.ollama-meter span{display:block;height:100%;border-radius:inherit;background:#75d2b7}.ollama-device-section{margin-top:14px;padding-top:12px;border-top:1px solid rgba(164,211,199,.14)}.ollama-device-heading{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:8px}.ollama-device-heading h4{margin:0;color:#d8ebe6;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.ollama-device-heading small{color:#8fa9a4;font-size:10px}.ollama-device-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;max-height:260px;overflow:auto;padding-right:2px}.ollama-device-card{display:grid;gap:5px;min-width:0;padding:9px;border-radius:8px;background:rgba(71,117,108,.11);color:#a5bfba;font-size:10px}.ollama-device-card strong{color:#effcf8;font-size:11px;overflow-wrap:anywhere}.ollama-device-card>span{color:#b8ead9;font-size:13px}.ollama-gpu-device-card small{font-size:9px;line-height:1.35}.ollama-memory-chart{height:70px;display:flex;align-items:flex-end;gap:2px;margin-top:14px;padding:5px 0;border-bottom:1px solid rgba(164,211,199,.18)}.ollama-memory-chart span{flex:1;min-width:2px;background:#55a995;border-radius:2px 2px 0 0;opacity:.8}.ollama-memory-chart.loading{height:70px;align-items:center;padding:0 16px;border:1px solid rgba(117,210,183,.55);border-radius:8px;background:linear-gradient(90deg,rgba(38,104,88,.35),rgba(25,70,62,.7),rgba(38,104,88,.35));background-size:200% 100%;animation:ollama-loading-sweep 1.8s linear infinite}.ollama-loading-progress{display:grid;gap:4px;width:100%;color:#bff7e2}.ollama-loading-progress strong{font-size:12px}.ollama-loading-progress span{font-size:11px;color:#e8fff7;overflow-wrap:anywhere}.ollama-loading-progress small{font-size:10px;color:#9dd7c5}.ollama-loading-track{height:5px;overflow:hidden;border-radius:99px;background:rgba(117,210,183,.18)}.ollama-loading-track span{display:block;width:35%;height:100%;border-radius:inherit;background:#75d2b7;animation:ollama-loading-indicator 1.2s ease-in-out infinite}.ollama-weight-stat .ollama-meter span{background:#86b9ff}@keyframes ollama-loading-sweep{0%{background-position:0 0}100%{background-position:200% 0}}@keyframes ollama-loading-indicator{0%{margin-left:-35%}100%{margin-left:100%}}.ollama-loaded-memory h4{margin:15px 0 8px}.ollama-loaded-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:9px 0;border-top:1px solid rgba(164,211,199,.1);font-size:11px;color:#a5bfba}.ollama-loaded-row strong{color:#effcf8;min-width:180px}.ollama-chat-layout{display:flex;flex-direction:column;gap:14px;margin-top:14px}.ollama-conversation{min-height:360px;max-height:620px;overflow:auto;order:1}.ollama-composer{order:2}.ollama-conversation{min-height:320px;max-height:620px;overflow:auto;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,25,23,.62)}.ollama-message{margin:0 0 14px;padding:11px 13px;border-radius:9px;white-space:pre-wrap;line-height:1.5;font-size:13px}.ollama-message small{display:block;margin-bottom:5px;color:#8fc7ba;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.ollama-message.user{margin-left:12%;background:rgba(54,105,94,.3)}.ollama-message.assistant{margin-right:8%;background:rgba(34,63,62,.6)}.ollama-composer{display:flex;flex-direction:column;gap:10px;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-composer textarea{min-height:160px;resize:vertical;border:1px solid rgba(155,205,194,.25);border-radius:8px;background:#0d2522;color:#e8f2ef;padding:11px;font:inherit;font-size:13px}.ollama-attachment-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.ollama-file-button{display:inline-flex;align-items:center;border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font-size:11px}.ollama-file-button input{display:none}.ollama-url-input{flex:1;min-width:160px}.ollama-attachments{display:flex;gap:6px;flex-wrap:wrap}.ollama-attachment{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border-radius:999px;background:rgba(80,125,115,.16);color:#c5dfd8;font-size:10px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.ollama-attachment button{border:0;background:none;color:#ffb3b3;cursor:pointer}.ollama-chat-footnote{margin:0;color:#819b96;font-size:10px;line-height:1.45} @media(max-width:900px){.ollama-chat-header,.ollama-chat-layout{display:block}.ollama-chat-model{margin-top:15px;align-items:center}.ollama-composer{margin-top:14px}.ollama-runtime-grid{grid-template-columns:1fr 1fr}} .ollama-persistence-panel{display:grid;gap:12px;margin:16px 0;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-persistence-heading{display:flex;justify-content:space-between;gap:16px;align-items:center}.ollama-persistence-heading h3{margin:0}.ollama-persistence-heading p{margin:4px 0 0;color:#8fa9a4;font-size:11px}.ollama-conversation-list{display:flex;flex-wrap:wrap;gap:8px}.ollama-conversation-list .ollama-button{font-size:11px}.ollama-conversation-list .selected{background:#3e8073;border-color:#8dd2c1}.ollama-metrics-summary,.ollama-metrics-detail{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#a5bfba}.ollama-metrics-summary strong{color:#effcf8}.ollama-metrics-detail{padding-top:8px;border-top:1px solid rgba(164,211,199,.14)} @media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:340px;max-width:620px;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:flex;gap:6px;align-items:center;margin-top:9px}.ollama-connection-role,.ollama-connection-input{border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{min-width:190px;flex:1}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-connection-row-main{display:flex;align-items:center;gap:7px;flex:1;min-width:0;border:0;padding:0;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}.ollama-connection-remove{flex:0 0 auto;padding:5px 7px;font-size:9px}.ollama-connection-row-main>span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row-main strong{font-size:10px}.ollama-connection-row-main small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere} diff --git a/dashboard/manifest.json b/dashboard/manifest.json index 812fd4b..8c14f32 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -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", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 134e2c8..ca73bc6 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -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, } diff --git a/plugin.yaml b/plugin.yaml index ab401fe..fd0e7c5 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -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: