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
This commit is contained in:
Karl 2026-05-23 20:56:57 +01:00
parent 14c3bc7215
commit 9af4d915ed

View File

@ -99,13 +99,32 @@ async function fetchWithCookies(url, options = {}) {
function findUsage(html, label) { function findUsage(html, label) {
const idx = html.indexOf(label); const idx = html.indexOf(label);
if (idx === -1) return null; 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 pct = slice.match(/(\d+(?:\.\d+)?)\s*%/);
const reset = slice.match(/Resets?\s+in\s+([^<\n.]+?)(?:<|\n|$)/i); const reset = slice.match(/Resets?\s+in\s+([^<\n.]+?)(?:<|\n|$)/i);
if (!pct) return null; if (!pct) return null;
const models = [];
const segRe = /<button[^>]*?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 { return {
percent: parseFloat(pct[1]), percent: parseFloat(pct[1]),
resetsIn: reset ? reset[1].trim().replace(/\s+/g, " ") : null, 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", entity_id: "sensor.ollama_session_usage",
body: stateBody("Ollama Session Usage", "mdi:chart-arc", state.session, state.fetchedAt), 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) { if (state.weekly) {
sensors.push({ sensors.push({
entity_id: "sensor.ollama_weekly_usage", entity_id: "sensor.ollama_weekly_usage",
body: stateBody("Ollama Weekly Usage", "mdi:chart-donut", state.weekly, state.fetchedAt), 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 = { const haHeaders = {
@ -358,15 +389,31 @@ async function pushToHA(state) {
} }
function stateBody(friendlyName, icon, usage, fetchedAt) { function stateBody(friendlyName, icon, usage, fetchedAt) {
return { const attrs = {
state: usage.percent.toString(),
attributes: {
unit_of_measurement: "%", unit_of_measurement: "%",
state_class: "measurement", state_class: "measurement",
friendly_name: friendlyName, friendly_name: friendlyName,
icon, icon,
resets_in: usage.resetsIn || null, resets_in: usage.resetsIn || null,
last_fetched: fetchedAt, 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: {
friendly_name: friendlyName,
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; } .status.err { display: block; background: #1f0d0d; border: 1px solid #da3633; color: #f85149; }
.usage { margin-top: 8px; } .usage { margin-top: 8px; }
.usage .label { font-size: 13px; color: #8b949e; } .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 .bar .fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
.usage .fill.session { background: #58a6ff; } .usage .fill.session { background: #58a6ff; }
.usage .fill.weekly { background: #d2a8ff; } .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; } .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; } pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; max-height: 300px; overflow-y: auto; margin-top: 8px; }
</style> </style>
@ -462,14 +513,10 @@ async function fetchUsage() {
if (data.session || data.weekly) { if (data.session || data.weekly) {
let html = ''; let html = '';
if (data.session) { if (data.session) {
html += '<div style="margin-bottom:10px;"><div class="label">Session Usage: '+data.session.percent+'%</div><div class="bar"><div class="fill session" style="width:'+data.session.percent+'%"></div></div>'; html += renderUsage('Session', data.session, 'session');
if(data.session.resetsIn) html+='<div class="meta">Resets in '+data.session.resetsIn+'</div>';
html+='</div>';
} }
if (data.weekly) { if (data.weekly) {
html += '<div><div class="label">Weekly Usage: '+data.weekly.percent+'%</div><div class="bar"><div class="fill weekly" style="width:'+data.weekly.percent+'%"></div></div>'; html += renderUsage('Weekly', data.weekly, 'weekly');
if(data.weekly.resetsIn) html+='<div class="meta">Resets in '+data.weekly.resetsIn+'</div>';
html+='</div>';
} }
el.innerHTML = html; el.innerHTML = html;
} else if (data.error || data.needsLogin) { } 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; } } catch(e) { status.className='status err'; status.textContent='Fetch error: '+e.message; }
} }
function renderUsage(label, data, cls) {
let html = '<div style="margin-bottom:10px;">';
html += '<div class="label">'+label+' Usage: '+data.percent+'%</div>';
html += '<div class="bar"><div class="fill '+cls+'" style="width:'+data.percent+'%"></div></div>';
if (data.resetsIn) html += '<div class="meta">Resets in '+data.resetsIn+'</div>';
if (data.models && data.models.length > 0) {
html += '<div class="models">';
for (const m of data.models) {
html += '<div class="model-row"><span class="model-name">'+escHtml(m.model)+'</span><span class="model-requests">'+m.requests+' req</span></div>';
}
html += '</div>';
}
html += '</div>';
return html;
}
function escHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
async function fetchHealth() { async function fetchHealth() {
try { try {
const res = await fetch('/api/health'); const res = await fetch('/api/health');