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.
This commit is contained in:
2026-09-18 11:34:33 +02:00
commit 58eab24258
24 changed files with 1309 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
(() => {
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();
})();
+34
View File
@@ -0,0 +1,34 @@
:root {
color-scheme: dark;
--bg: #080c14;
--panel: #111824;
--panel-light: #172131;
--line: #26354a;
--text: #edf4ff;
--muted: #8493a8;
--green: #49e39b;
--amber: #f2bf63;
--red: #ff687e;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; color: var(--text); background: radial-gradient(circle at 85% 0%, #14233b 0, var(--bg) 42%); }
.shell { max-width: 1760px; min-height: 100vh; margin: auto; padding: 42px 54px 30px; display: flex; flex-direction: column; }
.topbar { display: grid; grid-template-columns: 1fr auto auto; align-items: end; gap: 30px; padding-bottom: 34px; border-bottom: 1px solid var(--line); }
.eyebrow { color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .18em; }
h1, h2, p { margin: 0; } h1 { font-size: clamp(32px, 4vw, 58px); letter-spacing: -.06em; line-height: .94; } h2 { margin-top: 8px; font-size: 26px; letter-spacing: .08em; }
.header-status { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; white-space: nowrap; }
#connection-state { color: var(--green); font-weight: 700; letter-spacing: .12em; font-size: 11px; } #clock { color: var(--text); font-variant-numeric: tabular-nums; margin-left: 16px; } #last-update { margin-left: 10px; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 14px var(--green); } .live-dot.offline { background: var(--red); box-shadow: 0 0 14px var(--red); }
.demo-badge { padding: 9px 13px; border: 1px solid #7d6434; background: #392d14; color: #f3c86b; border-radius: 5px; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
.cards { flex: 1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; padding-top: 28px; }
.provider-card { position: relative; min-width: 0; padding: 27px 29px; background: linear-gradient(145deg, rgba(24,35,52,.97), rgba(13,19,30,.97)); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 20px 50px rgba(0,0,0,.22); }
.card-heading { display: flex; justify-content: space-between; align-items: start; padding-bottom: 20px; border-bottom: 1px solid var(--line); } .status-pill { color: var(--green); border: 1px solid currentColor; padding: 5px 8px; border-radius: 4px; font-size: 10px; font-weight: 800; letter-spacing: .12em; } .status-pill.error { color: var(--red); } .status-pill.disabled { color: var(--muted); }
.limits { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 14px; padding-top: 20px; align-content: start; } .limit { min-width: 0; padding: 17px; border: 1px solid #25344a; border-radius: 8px; background: rgba(11,17,27,.62); }
.limit-top { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; } .limit-name { color: var(--muted); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } .remaining { color: var(--green); font-size: 30px; font-weight: 750; letter-spacing: -.05em; white-space: nowrap; } .remaining.amber { color: var(--amber); } .remaining.red { color: var(--red); }
.bar { height: 7px; margin: 15px 0 11px; overflow: hidden; border-radius: 6px; background: #263447; } .bar-fill { height: 100%; width: 0; border-radius: inherit; background: var(--green); transition: width .55s ease; } .bar-fill.amber { background: var(--amber); } .bar-fill.red { background: var(--red); }
.meta { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 12px; } .amount { color: var(--text); } .reset { margin-top: 14px; color: #b5c1d1; font-size: 12px; font-variant-numeric: tabular-nums; }
.notice { padding: 22px 14px; color: var(--muted); font-size: 14px; } .notice strong { display: block; color: var(--text); margin-bottom: 6px; } .overlay { margin: 18px 0 0; padding: 13px 15px; color: #ffc4ca; border-left: 2px solid var(--red); background: rgba(130,34,52,.15); font-size: 13px; } .overlay small { display: block; margin-top: 5px; color: var(--muted); }
@media (max-width: 900px) { .shell { padding: 28px 20px; } .topbar { grid-template-columns: 1fr auto; gap: 18px; } .header-status { grid-column: 1 / -1; grid-row: 2; } .demo-badge { grid-column: 2; grid-row: 1; } .cards { grid-template-columns: 1fr; } }
@media (max-width: 520px) { .shell { padding: 20px 14px; } .provider-card { padding: 21px 17px; } .limits { grid-template-columns: 1fr; } #last-update { display: none; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } }