From 9af4d915ed2ee9fcf70b9eaec46646a753f268b6 Mon Sep 17 00:00:00 2001 From: Karl Date: Sat, 23 May 2026 20:56:57 +0100 Subject: [PATCH] feat(api): expose individual model usage in session and weekly reports - Extend `findUsage()` to parse individual model segments from HTML - Extract model names, request counts, and visual styling data - Add `sensor.ollama_session_models` and `sensor.ollama_weekly_models` entities in Home Assistant - Enhance `stateBody()` to include models array in attributes - Introduce `modelsBody()` to publish model counts and details as separate entities - Update UI to render model breakdown with horizontal flex layout - Increase HTML parsing slice size from 3000 to 5000 characters for full coverage --- server.js | 89 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/server.js b/server.js index 8106059..d1500a2 100644 --- a/server.js +++ b/server.js @@ -99,13 +99,32 @@ async function fetchWithCookies(url, options = {}) { function findUsage(html, label) { const idx = html.indexOf(label); if (idx === -1) return null; - const slice = html.slice(idx, idx + 3000); + const slice = html.slice(idx, idx + 5000); const pct = slice.match(/(\d+(?:\.\d+)?)\s*%/); const reset = slice.match(/Resets?\s+in\s+([^<\n.]+?)(?:<|\n|$)/i); if (!pct) return null; + + const models = []; + const segRe = /]*?class="usage-meter__segment"[^>]*>[\s\S]*?<\/button>/g; + let segMatch; + while ((segMatch = segRe.exec(slice)) !== null) { + const tag = segMatch[0]; + const modelM = tag.match(/data-model="([^"]*)"/); + const reqM = tag.match(/data-requests="([^"]*)"/); + const widthM = tag.match(/width:\s*([\d.]+)%/); + const colorM = tag.match(/background:\s*([^";\s]+)/); + if (modelM && reqM && widthM) { + models.push({ + model: modelM[1], + requests: parseInt(reqM[1], 10), + }); + } + } + return { percent: parseFloat(pct[1]), resetsIn: reset ? reset[1].trim().replace(/\s+/g, " ") : null, + models: models.length > 0 ? models : undefined, }; } @@ -314,12 +333,24 @@ async function pushToHA(state) { entity_id: "sensor.ollama_session_usage", body: stateBody("Ollama Session Usage", "mdi:chart-arc", state.session, state.fetchedAt), }); + if (state.session.models) { + sensors.push({ + entity_id: "sensor.ollama_session_models", + body: modelsBody("Ollama Session Models", state.session.models, state.fetchedAt), + }); + } } if (state.weekly) { sensors.push({ entity_id: "sensor.ollama_weekly_usage", body: stateBody("Ollama Weekly Usage", "mdi:chart-donut", state.weekly, state.fetchedAt), }); + if (state.weekly.models) { + sensors.push({ + entity_id: "sensor.ollama_weekly_models", + body: modelsBody("Ollama Weekly Models", state.weekly.models, state.fetchedAt), + }); + } } const haHeaders = { @@ -358,14 +389,30 @@ async function pushToHA(state) { } function stateBody(friendlyName, icon, usage, fetchedAt) { + const attrs = { + unit_of_measurement: "%", + state_class: "measurement", + friendly_name: friendlyName, + icon, + resets_in: usage.resetsIn || null, + last_fetched: fetchedAt, + }; + if (usage.models && usage.models.length > 0) { + attrs.models = usage.models.map((m) => ({ model: m.model, requests: m.requests })); + } return { state: usage.percent.toString(), + attributes: attrs, + }; +} + +function modelsBody(friendlyName, models, fetchedAt) { + return { + state: models.length.toString(), attributes: { - unit_of_measurement: "%", - state_class: "measurement", friendly_name: friendlyName, - icon, - resets_in: usage.resetsIn || null, + icon: "mdi:robot-outline", + models: models.map((m) => ({ model: m.model, requests: m.requests })), last_fetched: fetchedAt, }, }; @@ -398,10 +445,14 @@ app.get("/", (_req, res) => { .status.err { display: block; background: #1f0d0d; border: 1px solid #da3633; color: #f85149; } .usage { margin-top: 8px; } .usage .label { font-size: 13px; color: #8b949e; } - .usage .bar { height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; margin-top: 4px; } + .usage .bar { height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; margin-top: 4px; display: flex; } .usage .bar .fill { height: 100%; border-radius: 4px; transition: width 0.5s; } .usage .fill.session { background: #58a6ff; } .usage .fill.weekly { background: #d2a8ff; } + .models { margin-top: 6px; } + .models .model-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 13px; } + .models .model-name { flex: 1; color: #c9d1d9; } + .models .model-requests { color: #8b949e; font-size: 12px; } .usage .meta { font-size: 12px; color: #8b949e; margin-top: 4px; } pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; max-height: 300px; overflow-y: auto; margin-top: 8px; } @@ -462,14 +513,10 @@ async function fetchUsage() { if (data.session || data.weekly) { let html = ''; if (data.session) { - html += '
Session Usage: '+data.session.percent+'%
'; - if(data.session.resetsIn) html+='
Resets in '+data.session.resetsIn+'
'; - html+='
'; + html += renderUsage('Session', data.session, 'session'); } if (data.weekly) { - html += '
Weekly Usage: '+data.weekly.percent+'%
'; - if(data.weekly.resetsIn) html+='
Resets in '+data.weekly.resetsIn+'
'; - html+='
'; + html += renderUsage('Weekly', data.weekly, 'weekly'); } el.innerHTML = html; } else if (data.error || data.needsLogin) { @@ -482,6 +529,24 @@ async function fetchUsage() { } catch(e) { status.className='status err'; status.textContent='Fetch error: '+e.message; } } +function renderUsage(label, data, cls) { + let html = '
'; + html += '
'+label+' Usage: '+data.percent+'%
'; + html += '
'; + if (data.resetsIn) html += '
Resets in '+data.resetsIn+'
'; + if (data.models && data.models.length > 0) { + html += '
'; + for (const m of data.models) { + html += '
'+escHtml(m.model)+''+m.requests+' req
'; + } + html += '
'; + } + html += '
'; + return html; +} + +function escHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; } + async function fetchHealth() { try { const res = await fetch('/api/health');