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:
parent
14c3bc7215
commit
9af4d915ed
89
server.js
89
server.js
@ -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,14 +389,30 @@ async function pushToHA(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stateBody(friendlyName, icon, usage, fetchedAt) {
|
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 {
|
return {
|
||||||
state: usage.percent.toString(),
|
state: usage.percent.toString(),
|
||||||
|
attributes: attrs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelsBody(friendlyName, models, fetchedAt) {
|
||||||
|
return {
|
||||||
|
state: models.length.toString(),
|
||||||
attributes: {
|
attributes: {
|
||||||
unit_of_measurement: "%",
|
|
||||||
state_class: "measurement",
|
|
||||||
friendly_name: friendlyName,
|
friendly_name: friendlyName,
|
||||||
icon,
|
icon: "mdi:robot-outline",
|
||||||
resets_in: usage.resetsIn || null,
|
models: models.map((m) => ({ model: m.model, requests: m.requests })),
|
||||||
last_fetched: fetchedAt,
|
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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user