Files
UsageKiosk/static/app.js
T
nessi 58eab24258 chore: initial project setup with AI usage dashboard for Codex and Devin
Add FastAPI-based monitoring dashboard with support for Codex CLI and Devin API v3 Enterprise consumption tracking. Includes Docker deployment, systemd service configuration, kiosk mode launcher, comprehensive documentation, and demo mode for testing without credentials.
2026-09-18 11:34:33 +02:00

91 lines
6.9 KiB
JavaScript

(() => {
const state = { last: { codex: null, devin: null }, refresh: 10, countdowns: new Map() };
const byId = (id) => document.getElementById(id);
const text = (node, value) => { node.textContent = value; return node; };
const fmt = (value) => Number.isInteger(value) ? String(value) : Number(value).toFixed(1).replace(/\.0$/, '');
const clockValue = (date) => date.toLocaleTimeString([], { hour12: false });
const remainingClass = (value) => value < 20 ? 'red' : value <= 50 ? 'amber' : '';
const amount = (limit) => {
if (limit.used != null && limit.limit != null) return `${fmt(limit.used)} / ${fmt(limit.limit)} ${limit.unit || ''}`.trim();
if (limit.remaining != null) return `${fmt(limit.remaining)} ${limit.unit || ''}`.trim();
return '';
};
const countdown = (date) => {
const delta = new Date(date).getTime() - Date.now();
if (!Number.isFinite(delta) || delta <= 0) return 'Reset due';
let seconds = Math.floor(delta / 1000);
const days = Math.floor(seconds / 86400); seconds %= 86400;
const hours = Math.floor(seconds / 3600); seconds %= 3600;
const minutes = Math.floor(seconds / 60); seconds %= 60;
if (days) return `Reset in ${days}d ${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m`;
return `Reset in ${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
};
const updateClock = () => text(byId('clock'), clockValue(new Date()));
const updateCountdowns = () => state.countdowns.forEach((date, node) => text(node, countdown(date)));
const renderLimit = (limit, prior) => {
const item = document.createElement('section'); item.className = 'limit';
const top = document.createElement('div'); top.className = 'limit-top';
const name = document.createElement('span'); name.className = 'limit-name'; text(name, limit.name || 'Usage limit'); top.append(name);
if (limit.remaining_percent != null) {
const remaining = document.createElement('strong'); remaining.className = `remaining ${remainingClass(limit.remaining_percent)}`; text(remaining, `${fmt(limit.remaining_percent)}%`); top.append(remaining);
}
item.append(top);
if (limit.remaining_percent != null) {
const target = Math.max(0, Math.min(100, limit.remaining_percent));
const bar = document.createElement('div'); bar.className = 'bar'; bar.setAttribute('role', 'progressbar'); bar.setAttribute('aria-valuemin', '0'); bar.setAttribute('aria-valuemax', '100'); bar.setAttribute('aria-valuenow', String(limit.remaining_percent));
const fill = document.createElement('div'); fill.className = `bar-fill ${remainingClass(limit.remaining_percent)}`;
if (prior && Number.isFinite(prior.remaining_percent) && prior.remaining_percent !== limit.remaining_percent) {
fill.style.width = `${Math.max(0, Math.min(100, prior.remaining_percent))}%`;
requestAnimationFrame(() => { fill.style.width = `${target}%`; });
} else {
fill.style.width = `${target}%`;
}
bar.append(fill); item.append(bar);
}
const meta = document.createElement('div'); meta.className = 'meta';
if (limit.used_percent != null) { const used = document.createElement('span'); text(used, `${fmt(limit.used_percent)}% used`); meta.append(used); }
const value = amount(limit); if (value) { const numeric = document.createElement('span'); numeric.className = 'amount'; text(numeric, value); meta.append(numeric); }
if (meta.childNodes.length) item.append(meta);
if (limit.reset_at) { const reset = document.createElement('div'); reset.className = 'reset'; text(reset, countdown(limit.reset_at)); state.countdowns.set(reset, limit.reset_at); item.append(reset); }
return item;
};
const clearContent = (content) => { while (content.firstChild) content.removeChild(content.firstChild); };
const renderProvider = (name, provider) => {
const content = byId(`${name}-content`); const status = byId(`${name}-status`);
status.className = `status-pill ${provider.status}`; text(status, provider.status === 'ok' ? 'LIVE' : provider.status === 'disabled' ? 'DISABLED' : 'ERROR');
if (provider.status === 'ok') {
const prior = state.last[name];
state.last[name] = provider;
state.countdowns.forEach((_, node) => { if (content.contains(node)) state.countdowns.delete(node); });
clearContent(content);
provider.limits.forEach((limit) => {
const priorLimit = prior && prior.limits.find((entry) => entry.id === limit.id);
content.append(renderLimit(limit, priorLimit));
});
return;
}
if (provider.status === 'error' && state.last[name]) {
if (!content.querySelector('.overlay')) {
const overlay = document.createElement('div'); overlay.className = 'overlay'; const strong = document.createElement('strong'); text(strong, 'Data currently unavailable'); overlay.append(strong);
const small = document.createElement('small'); const lastUpdate = provider.last_successful_update || state.last[name].last_successful_update; text(small, lastUpdate ? `Last successful update: ${clockValue(new Date(lastUpdate))}` : 'No successful update yet'); overlay.append(small); content.append(overlay);
}
return;
}
state.countdowns.forEach((_, node) => { if (content.contains(node)) state.countdowns.delete(node); });
clearContent(content);
const notice = document.createElement('div'); notice.className = 'notice'; const strong = document.createElement('strong'); text(strong, provider.status === 'disabled' ? 'Provider disabled' : 'Data currently unavailable'); notice.append(strong); const detail = document.createElement('span'); text(detail, provider.status === 'disabled' ? 'Enable this provider in configuration to view usage.' : 'Waiting for a successful update.'); notice.append(detail); content.append(notice);
};
const setConnection = (online) => { text(byId('connection-state'), online ? 'LIVE' : 'Connection error'); byId('connection-dot').classList.toggle('offline', !online); };
const fetchUsage = async () => {
try {
const response = await fetch('/api/usage', { cache: 'no-store' });
if (!response.ok) throw new Error('request failed');
const data = await response.json();
renderProvider('codex', data.codex); renderProvider('devin', data.devin); state.refresh = Math.max(1, Number(data.refresh_interval) || 10);
byId('demo-badge').hidden = !data.demo_mode; setConnection(true); text(byId('last-update'), `Last update: ${clockValue(new Date(data.server_time))}`);
window.clearInterval(state.networkTimer); state.networkTimer = window.setInterval(fetchUsage, state.refresh * 1000);
} catch (_) { setConnection(false); window.clearInterval(state.networkTimer); state.networkTimer = window.setInterval(fetchUsage, state.refresh * 1000); }
};
state.networkTimer = null; updateClock(); setInterval(() => { updateClock(); updateCountdowns(); }, 1000); fetchUsage();
})();