diff --git a/app/main.py b/app/main.py
index 3319131..df6897e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -240,6 +240,15 @@ PAGE_HTML = """
.adjust input { width: 64px; padding: 4px 6px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; }
.adjust button { cursor: pointer; border: none; border-radius: 6px; background: var(--green-deep); color: #fff; font-size: 12px; font-weight: 600; padding: 4px 10px; }
.adjust button:hover { background: #4e7a4d; }
+ .tabs { display: flex; gap: 8px; margin: 28px 0 12px; align-items: center; }
+ .tabs h2 { font-size: 17px; font-weight: 700; margin-right: 8px; }
+ .tab-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 13px; font-weight: 600; padding: 6px 14px; }
+ .tab-btn.active { background: var(--green-deep); color: #fff; border-color: var(--green-deep); }
+ .range-btn { cursor: pointer; border: 1px solid var(--border); background: #fff; color: var(--muted); border-radius: 8px; font-size: 12px; font-weight: 600; padding: 5px 12px; margin-left: auto; }
+ .range-btn.active { background: var(--green-mid); color: #fff; border-color: var(--green-mid); }
+ th.sortable { cursor: pointer; user-select: none; }
+ th.sortable:hover { color: var(--green-deep); }
+ th .arrow { font-size: 10px; margin-left: 3px; opacity: 0.7; }
@@ -259,20 +268,37 @@ PAGE_HTML = """
-
+
+
Members
+
+
+
+
+
+
+
- | Member | Status | Activated | Balance | Spend | Remaining | Reset | Adjust |
+ Member |
+ Status |
+ Activated |
+ Balance |
+ Spend |
+ Remaining |
+ Reset |
+ Adjust |
| Loading… |
- Model usage
-
+
- | Model | Spend | Tokens | Requests |
+ Model |
+ Spend |
+ Tokens |
+ Requests |
| Loading… |
@@ -293,24 +319,14 @@ function fmt(n) { return '$' + (Number(n) || 0).toFixed(2); }
async function loadModels() {
try {
- const r = await fetch('/api/model-usage');
+ const r = await fetch('/api/model-usage?days=' + rangeDays);
if (r.status === 403) return;
const d = await r.json();
- if (d.error) { $('model-rows').innerHTML = '| ' + esc(d.error) + ' |
'; return; }
- const models = d.models || [];
- if (!models.length) {
- $('model-rows').innerHTML = '| No usage recorded. |
';
- return;
- }
- $('model-rows').innerHTML = models.map(m =>
- '' +
- '| ' + esc(m.model) + ' | ' +
- '' + fmt(m.spend) + ' | ' +
- '' + (m.tokens || 0).toLocaleString() + ' | ' +
- '' + (m.requests || 0).toLocaleString() + ' | ' +
- '
'
- ).join('');
+ if (d.error) { modelsData = []; $('model-rows').innerHTML = '| ' + esc(d.error) + ' |
'; return; }
+ modelsData = d.models || [];
+ if (currentView === 'models') renderModels();
} catch (e) {
+ modelsData = [];
$('model-rows').innerHTML = '| Could not load model usage. |
';
}
}
@@ -323,14 +339,84 @@ async function load() {
$('tot-members').textContent = d.totals.members;
$('tot-active').textContent = d.totals.active;
$('tot-spend').textContent = fmt(d.totals.total_spend);
- render(d.members);
+ membersData = d.members || [];
+ if (currentView === 'members') renderMembers();
} catch (e) {
flash('Could not load: ' + e.message, false);
}
}
-function render(members) {
- $('rows').innerHTML = members.map(m => {
+// ---- shared state ----
+let membersData = [];
+let modelsData = [];
+let currentView = 'members';
+let memberSort = { key: 'email', dir: 1 };
+let modelSort = { key: 'spend', dir: -1 };
+let rangeDays = 0; // 0 = all time
+
+function arrowFor(key, sortState) {
+ if (sortState.key !== key) return '';
+ return sortState.dir === 1 ? '▲' : '▼';
+}
+
+function clearArrows(table, sortState) {
+ document.querySelectorAll('#table-' + table + ' .arrow').forEach(el => { el.textContent = ''; });
+ document.querySelectorAll('#table-' + table + ' th').forEach(th => {
+ const span = th.querySelector('.arrow');
+ if (!span) return;
+ const key = th.getAttribute('onclick')?.match(/'([^']+)'/)?.[1];
+ if (key) span.textContent = arrowFor(key, sortState);
+ });
+}
+
+function switchView(view) {
+ currentView = view;
+ $('table-members').style.display = view === 'members' ? '' : 'none';
+ $('table-models').style.display = view === 'models' ? '' : 'none';
+ $('tab-members').className = 'tab-btn' + (view === 'members' ? ' active' : '');
+ $('tab-models').className = 'tab-btn' + (view === 'models' ? ' active' : '');
+ $('view-title').textContent = view === 'members' ? 'Members' : 'Model usage';
+ // Range buttons only apply to the models view (spend logs are model-level).
+ const showRange = view === 'models';
+ $('range-30').style.display = showRange ? '' : 'none';
+ $('range-all').style.display = showRange ? '' : 'none';
+ render();
+}
+
+function setRange(days) {
+ rangeDays = days;
+ $('range-30').className = 'range-btn' + (days === 30 ? ' active' : '');
+ $('range-all').className = 'range-btn' + (days === 0 ? ' active' : '');
+ loadModels();
+}
+
+function sortMembers(key) {
+ memberSort = { key, dir: memberSort.key === key ? -memberSort.dir : 1 };
+ clearArrows('members', memberSort);
+ render();
+}
+
+function sortModels(key) {
+ modelSort = { key, dir: modelSort.key === key ? -modelSort.dir : -1 };
+ clearArrows('models', modelSort);
+ render();
+}
+
+function render() {
+ if (currentView === 'members') renderMembers();
+ else renderModels();
+}
+
+function renderMembers() {
+ const rows = [...membersData].sort((a, b) => {
+ let va = a[memberSort.key], vb = b[memberSort.key];
+ if (memberSort.key === 'active' || memberSort.key === 'activated') { va = va ? 1 : 0; vb = vb ? 1 : 0; }
+ if (typeof va === 'string' || typeof vb === 'string') {
+ return String(va ?? '').localeCompare(String(vb ?? '')) * memberSort.dir;
+ }
+ return ((va ?? 0) - (vb ?? 0)) * memberSort.dir;
+ });
+ $('rows').innerHTML = rows.map(m => {
const reset = m.reset_at ? m.reset_at.slice(0,10) : '—';
const pill = m.active ? 'active' : 'inactive';
const act = m.activated
@@ -350,6 +436,30 @@ function render(members) {
'' +
'';
}).join('');
+ clearArrows('members', memberSort);
+}
+
+function renderModels() {
+ const rows = [...modelsData].sort((a, b) => {
+ const va = a[modelSort.key], vb = b[modelSort.key];
+ if (typeof va === 'string' || typeof vb === 'string') {
+ return String(va ?? '').localeCompare(String(vb ?? '')) * modelSort.dir;
+ }
+ return ((va ?? 0) - (vb ?? 0)) * modelSort.dir;
+ });
+ if (!rows.length) {
+ $('model-rows').innerHTML = '| No usage recorded. |
';
+ return;
+ }
+ $('model-rows').innerHTML = rows.map(m =>
+ '' +
+ '| ' + esc(m.model) + ' | ' +
+ '' + fmt(m.spend) + ' | ' +
+ '' + (m.tokens || 0).toLocaleString() + ' | ' +
+ '' + (m.requests || 0).toLocaleString() + ' | ' +
+ '
'
+ ).join('');
+ clearArrows('models', modelSort);
}
function i(email) { return email.replace(/[^a-z0-9]/g, '_'); }