diff --git a/README.md b/README.md index e36743e..18ce210 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index ab86b9f..e0958d8 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -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]); diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css index d408fa3..ebbacda 100644 --- a/dashboard/dist/style.css +++ b/dashboard/dist/style.css @@ -11,5 +11,5 @@ .ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}} .ollama-catalog-controls{display:grid;grid-template-columns:repeat(3,minmax(130px,1fr));gap:8px;align-items:end;margin-top:0;padding:10px;border:1px solid rgba(164,211,199,.16);border-radius:10px;background:rgba(10,31,28,.55)}.ollama-catalog-controls label{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-catalog-checkbox{display:flex!important;flex-direction:row!important;align-items:center;gap:8px;grid-column:1 / -1;padding:8px 4px;color:#b8ead9!important;text-transform:none!important;letter-spacing:normal!important;cursor:pointer}.ollama-catalog-checkbox input{width:15px;height:15px;margin:0;accent-color:#75d2b7}.ollama-catalog-checkbox span{font-size:11px}.ollama-catalog-memory-bypass{color:#ffd89a!important;background:rgba(142,90,25,.12);border-radius:7px}.ollama-catalog-select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal} @media(max-width:1000px){.ollama-nav-row{display:grid;grid-template-columns:1fr}.ollama-toolbar-disk{justify-self:end}.ollama-browse-row{grid-template-columns:1fr}.ollama-catalog-controls{margin-top:0}} -@media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}}.ollama-harness-primary,.ollama-harness-validators{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.ollama-harness-primary{min-width:260px}.ollama-harness-primary label{display:flex;align-items:center;gap:8px;color:#a5bfba;font-size:11px}.ollama-harness-primary select{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;max-width:260px}.ollama-harness-validators{flex-basis:100%;padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}.ollama-harness-validators>strong{color:#d5e8e2;font-size:11px}.ollama-harness-ready,.ollama-harness-warning{flex-basis:100%;font-size:10px}.ollama-harness-ready{color:#9af1c7}.ollama-harness-warning{color:#ffd89a}.ollama-validation-evidence{margin-top:10px;padding:10px 12px;border:1px solid rgba(141,210,193,.22);border-radius:8px;background:rgba(10,31,28,.5);color:#a5bfba;font-size:11px}.ollama-validation-evidence summary{cursor:pointer;color:#b8ead9;font-weight:700}.ollama-validation-report{margin-top:10px;padding-top:8px;border-top:1px solid rgba(164,211,199,.12)}.ollama-validation-report strong{color:#effcf8;font-size:11px}.ollama-validation-report p{margin:4px 0 0;white-space:pre-wrap;line-height:1.45}.ollama-storage-panel{margin-top:14px;padding:14px 16px;border:1px solid rgba(141,210,193,.22);border-radius:10px;background:rgba(10,31,28,.5)}.ollama-storage-copy{display:flex;flex-direction:column;gap:4px;margin-top:10px}.ollama-storage-copy strong{color:#effcf8;font-size:12px}.ollama-storage-copy small,.ollama-storage-note{color:#a5bfba;font-size:10px}.ollama-storage-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.ollama-storage-note{display:block;margin-top:10px} +@media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}}.ollama-harness-primary,.ollama-harness-validators{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.ollama-harness-primary{min-width:260px}.ollama-harness-primary label{display:flex;align-items:center;gap:8px;color:#a5bfba;font-size:11px}.ollama-harness-primary select{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;max-width:260px}.ollama-harness-validators{flex-basis:100%;padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}.ollama-harness-validators>strong{color:#d5e8e2;font-size:11px}.ollama-harness-ready,.ollama-harness-warning{flex-basis:100%;font-size:10px}.ollama-harness-ready{color:#9af1c7}.ollama-harness-warning{color:#ffd89a}.ollama-validation-evidence{margin-top:10px;padding:10px 12px;border:1px solid rgba(141,210,193,.22);border-radius:8px;background:rgba(10,31,28,.5);color:#a5bfba;font-size:11px}.ollama-validation-evidence summary{cursor:pointer;color:#b8ead9;font-weight:700}.ollama-validation-report{margin-top:10px;padding-top:8px;border-top:1px solid rgba(164,211,199,.12)}.ollama-validation-report strong{color:#effcf8;font-size:11px}.ollama-validation-report p{margin:4px 0 0;white-space:pre-wrap;line-height:1.45}.ollama-storage-panel{margin-top:14px;padding:14px 16px;border:1px solid rgba(141,210,193,.22);border-radius:10px;background:rgba(10,31,28,.5)}.ollama-storage-copy{display:flex;flex-direction:column;gap:4px;margin-top:10px}.ollama-storage-copy strong{color:#effcf8;font-size:12px}.ollama-storage-copy small,.ollama-storage-note{color:#a5bfba;font-size:10px}.ollama-storage-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.ollama-performance-controls{display:flex;align-items:flex-end;justify-content:flex-end;gap:12px;flex-wrap:wrap}.ollama-performance-range{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-performance-range select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal}.ollama-performance-controls>span{padding-bottom:8px;color:#8fa9a4;font-size:10px}@media(max-width:600px){.ollama-performance-controls{justify-content:flex-start}.ollama-performance-range{width:100%}.ollama-performance-range select{width:100%}}.ollama-storage-note{display:block;margin-top:10px} .ollama-thinking-status{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:14px;padding:14px 16px;border:1px solid rgba(117,210,183,.42);border-radius:10px;background:linear-gradient(90deg,rgba(46,111,96,.34),rgba(24,64,57,.5));box-shadow:0 0 20px rgba(74,190,158,.08)}.ollama-thinking-copy{flex:1;min-width:200px}.ollama-thinking-details{flex-basis:100%;padding:12px;border-top:1px solid rgba(164,211,199,.17);color:#a5bfba}.ollama-thinking-detail-grid{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:8px;margin-bottom:8px}.ollama-thinking-detail-grid span{display:flex;flex-direction:column;gap:3px;padding:8px;border-radius:7px;background:rgba(71,117,108,.11);font-size:10px;color:#8fb5ac}.ollama-thinking-detail-grid strong{color:#e4f4ef;font-size:11px;overflow-wrap:anywhere}.ollama-thinking-details small{font-size:10px;color:#819b96}.ollama-thinking-status .thinking-stop{color:#ffb8b8;border-color:rgba(255,110,110,.45)}.ollama-composer.drop-active{border-color:rgba(117,210,183,.8);background:linear-gradient(135deg,rgba(33,92,79,.52),rgba(23,52,48,.62));box-shadow:0 0 24px rgba(117,210,183,.16)}.ollama-drop-hint{padding:9px;border:1px dashed rgba(117,210,183,.7);border-radius:7px;text-align:center;color:#b8ead9;font-size:11px;background:rgba(117,210,183,.08)}.ollama-thinking-spinner{display:flex;align-items:center;gap:4px;min-width:28px}.ollama-thinking-spinner span{width:7px;height:7px;border-radius:50%;background:#75d2b7;animation:ollama-thinking-pulse 1.1s ease-in-out infinite}.ollama-thinking-spinner span:nth-child(2){animation-delay:.18s}.ollama-thinking-spinner span:nth-child(3){animation-delay:.36s}.ollama-thinking-copy{display:flex;flex-direction:column;gap:3px}.ollama-thinking-copy strong{color:#effcf8;font-size:13px}.ollama-thinking-copy span{color:#b9d8d0;font-size:12px}.ollama-thinking-copy small{color:#8fb5ac;font-size:10px}@keyframes ollama-thinking-pulse{0%,80%,100%{opacity:.35;transform:scale(.8)}40%{opacity:1;transform:scale(1.2)}}.ollama-operations-section{margin-top:30px;padding-top:22px;border-top:1px solid rgba(164,211,199,.22)}.ollama-operations-heading{display:flex;justify-content:space-between;gap:18px;align-items:flex-start;margin-bottom:14px;padding:0 2px}.ollama-operations-heading h3{margin:5px 0;color:#effcf8;font-size:18px}.ollama-operations-heading p{margin:0;color:#8fa9a4;font-size:11px;max-width:720px}.ollama-operations-heading>span{padding:7px 9px;border:1px solid rgba(117,210,183,.3);border-radius:999px;color:#9af1c7;font-size:10px;white-space:nowrap}.ollama-advanced-note{padding:10px 0;color:#8fa9a4;font-size:10px;line-height:1.45}.ollama-performance-graphs{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-performance-graphs-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;margin-bottom:12px}.ollama-performance-graphs-heading h3{margin:0 0 4px;color:#effcf8}.ollama-performance-graphs-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-performance-graphs-heading>span{color:#9af1c7;font-size:10px;white-space:nowrap}.ollama-performance-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.ollama-performance-graph{min-width:0;padding:11px;border:1px solid rgba(155,205,194,.16);border-radius:8px;background:rgba(71,117,108,.11)}.ollama-performance-graph-heading{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}.ollama-performance-graph-heading div{display:flex;flex-direction:column;gap:3px;min-width:0}.ollama-performance-graph-heading strong{color:#dff5ee;font-size:11px}.ollama-performance-graph-heading small{color:#8fa9a4;font-size:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ollama-performance-graph-heading b{color:#9af1c7;font-size:12px;white-space:nowrap}.ollama-performance-svg{display:block;width:100%;height:92px;margin-top:7px;overflow:visible}.ollama-graph-grid{stroke:rgba(164,211,199,.16);stroke-width:1}.ollama-graph-area{fill:rgba(117,210,183,.16)}.ollama-graph-line{fill:none;stroke:#75d2b7;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ollama-performance-graph-scale{display:flex;justify-content:space-between;color:#708e87;font-size:9px}.ollama-performance-graph-scale span:last-child{color:#8fa9a4}@media(max-width:1000px){.ollama-performance-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:760px){.ollama-operations-heading,.ollama-performance-graphs-heading{display:block}.ollama-operations-heading>span,.ollama-performance-graphs-heading>span{display:inline-block;margin-top:10px}.ollama-performance-grid{grid-template-columns:1fr}}.ollama-chat-shell{display:grid;grid-template-columns:240px minmax(0,1fr) 330px;gap:14px;align-items:start;margin-top:14px}.ollama-conversation-rail,.ollama-chat-controls{min-width:0;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-conversation-rail{position:sticky;top:14px;max-height:calc(100vh - 110px);overflow:auto}.ollama-chat-controls{position:sticky;top:14px;max-height:calc(100vh - 110px);overflow:auto}.ollama-rail-heading,.ollama-controls-heading{display:flex;justify-content:space-between;gap:8px;align-items:flex-start;margin-bottom:10px}.ollama-rail-heading h3,.ollama-controls-heading h3{margin:0;font-size:14px}.ollama-rail-heading small,.ollama-controls-heading small{color:#8fa9a4;font-size:10px}.ollama-conversation-rail .ollama-conversation-list{display:grid;gap:6px}.ollama-conversation-rail .ollama-conversation-list .ollama-button{display:flex;justify-content:space-between;gap:8px;width:100%;text-align:left}.ollama-conversation-rail .ollama-conversation-list .ollama-button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ollama-conversation-rail .ollama-conversation-list .ollama-button small{color:#8fa9a4;white-space:nowrap}.ollama-chat-main{display:flex;flex-direction:column;gap:12px;min-width:0}.ollama-chat-main .ollama-conversation{min-height:52vh;max-height:calc(100vh - 310px);overflow:auto;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(7,22,20,.56)}.ollama-chat-main .ollama-composer{position:sticky;bottom:10px;margin-top:0;padding:12px;border:1px solid rgba(141,210,193,.3);border-radius:12px;background:rgba(16,45,41,.96);box-shadow:0 10px 32px rgba(0,0,0,.24)}.ollama-chat-controls>.ollama-model-pool{margin-top:0;padding:0;border:0;background:none}.ollama-chat-controls .ollama-runtime-panel,.ollama-chat-controls .ollama-storage-panel{margin-top:10px;padding:0;border:0;background:none}.ollama-advanced-control{margin-top:12px;padding-top:10px;border-top:1px solid rgba(164,211,199,.14)}.ollama-advanced-control summary{cursor:pointer;color:#b8ead9;font-size:12px;font-weight:700}.ollama-message-meta{display:flex;justify-content:space-between;gap:10px;margin-bottom:6px;color:#8fa9a4;font-size:10px}.ollama-rich-text{line-height:1.55;overflow-wrap:anywhere}.ollama-rich-text>div{min-height:1.2em}.ollama-inline-code{padding:2px 5px;border-radius:4px;background:rgba(0,0,0,.25);color:#c9f4e4;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em}.ollama-code-block{overflow:auto;margin:9px 0;padding:11px;border-radius:7px;background:#081714;color:#d7eee7;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.ollama-message-actions{display:flex;gap:6px;margin-top:9px;opacity:.75}.ollama-message-actions button{border:0;background:none;color:#8fb5ac;padding:2px 0;cursor:pointer;font:inherit;font-size:10px}.ollama-message-actions button:hover{color:#b8ead9}.ollama-catalog-more{display:flex;justify-content:center;padding:18px}.ollama-message.assistant{border-left-color:rgba(117,210,183,.45)}@media(max-width:1180px){.ollama-chat-shell{grid-template-columns:210px minmax(0,1fr)}.ollama-chat-controls{grid-column:1 / -1;position:static;max-height:none}.ollama-chat-controls .ollama-model-pool{padding:12px;border:1px solid rgba(164,211,199,.16);background:rgba(10,31,28,.7)}}@media(max-width:760px){.ollama-chat-shell{display:flex;flex-direction:column}.ollama-conversation-rail,.ollama-chat-controls{position:static;width:auto;max-height:none}.ollama-chat-main{order:-1}.ollama-chat-main .ollama-conversation{min-height:48vh;max-height:none}.ollama-chat-controls .ollama-model-pool{border:0;padding:0;background:none}.ollama-chat-header{display:block}.ollama-chat-header .ollama-button{margin-top:12px}} \ No newline at end of file diff --git a/dashboard/manifest.json b/dashboard/manifest.json index 25fadbf..666b3ea 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.7.7", + "version": "1.7.8", "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 33eb0f8..8cd609d 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -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") diff --git a/plugin.yaml b/plugin.yaml index 611b93c..9edee54 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -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: diff --git a/tests/test_validation_harness.py b/tests/test_validation_harness.py index 3d859a5..d9e53fa 100644 --- a/tests/test_validation_harness.py +++ b/tests/test_validation_harness.py @@ -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()