Add Members/Model-usage tabs, 30-day/all-time range toggle, sortable columns
This commit is contained in:
1 parent
25568680b4
commit
2b42812c22
1 file changed
+133
-23
+133
-23
@@ -240,6 +240,15 @@ PAGE_HTML = """<!DOCTYPE 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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -259,20 +268,37 @@ PAGE_HTML = """<!DOCTYPE html>
|
||||
|
||||
<div class="flash" id="flash"></div>
|
||||
|
||||
<table>
|
||||
<div class="tabs" style="margin-top:28px;">
|
||||
<h2 id="view-title">Members</h2>
|
||||
<button class="tab-btn active" id="tab-members" onclick="switchView('members')">Members</button>
|
||||
<button class="tab-btn" id="tab-models" onclick="switchView('models')">Model usage</button>
|
||||
<button class="range-btn" id="range-30" onclick="setRange(30)">Last 30 days</button>
|
||||
<button class="range-btn active" id="range-all" onclick="setRange(0)">All time</button>
|
||||
</div>
|
||||
|
||||
<table id="table-members">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th><th>Status</th><th>Activated</th><th>Balance</th><th>Spend</th><th>Remaining</th><th>Reset</th><th>Adjust</th>
|
||||
<th class="sortable" onclick="sortMembers('email')">Member<span class="arrow" id="arrow-members-email"></span></th>
|
||||
<th class="sortable" onclick="sortMembers('active')">Status<span class="arrow" id="arrow-members-active"></span></th>
|
||||
<th class="sortable" onclick="sortMembers('activated')">Activated<span class="arrow" id="arrow-members-activated"></span></th>
|
||||
<th class="sortable" onclick="sortMembers('balance')">Balance<span class="arrow" id="arrow-members-balance"></span></th>
|
||||
<th class="sortable" onclick="sortMembers('spend')">Spend<span class="arrow" id="arrow-members-spend"></span></th>
|
||||
<th class="sortable" onclick="sortMembers('remaining')">Remaining<span class="arrow" id="arrow-members-remaining"></span></th>
|
||||
<th class="sortable" onclick="sortMembers('reset_at')">Reset<span class="arrow" id="arrow-members-reset_at"></span></th>
|
||||
<th>Adjust</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"><tr><td colspan="8" style="color:var(--muted)">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
<h2 style="margin:28px 0 12px;font-size:17px;font-weight:700;">Model usage</h2>
|
||||
<table>
|
||||
<table id="table-models" style="display:none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th><th>Spend</th><th>Tokens</th><th>Requests</th>
|
||||
<th class="sortable" onclick="sortModels('model')">Model<span class="arrow" id="arrow-models-model"></span></th>
|
||||
<th class="sortable" onclick="sortModels('spend')">Spend<span class="arrow" id="arrow-models-spend"></span></th>
|
||||
<th class="sortable" onclick="sortModels('tokens')">Tokens<span class="arrow" id="arrow-models-tokens"></span></th>
|
||||
<th class="sortable" onclick="sortModels('requests')">Requests<span class="arrow" id="arrow-models-requests"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="model-rows"><tr><td colspan="4" style="color:var(--muted)">Loading…</td></tr></tbody>
|
||||
@@ -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 = '<tr><td colspan="4" style="color:var(--muted)">' + esc(d.error) + '</td></tr>'; return; }
|
||||
const models = d.models || [];
|
||||
if (!models.length) {
|
||||
$('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">No usage recorded.</td></tr>';
|
||||
return;
|
||||
}
|
||||
$('model-rows').innerHTML = models.map(m =>
|
||||
'<tr>' +
|
||||
'<td>' + esc(m.model) + '</td>' +
|
||||
'<td class="num">' + fmt(m.spend) + '</td>' +
|
||||
'<td class="num">' + (m.tokens || 0).toLocaleString() + '</td>' +
|
||||
'<td class="num">' + (m.requests || 0).toLocaleString() + '</td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
if (d.error) { modelsData = []; $('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">' + esc(d.error) + '</td></tr>'; return; }
|
||||
modelsData = d.models || [];
|
||||
if (currentView === 'models') renderModels();
|
||||
} catch (e) {
|
||||
modelsData = [];
|
||||
$('model-rows').innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Could not load model usage.</td></tr>';
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
'</div></td>' +
|
||||
'</tr>';
|
||||
}).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 = '<tr><td colspan="4" style="color:var(--muted)">No usage recorded.</td></tr>';
|
||||
return;
|
||||
}
|
||||
$('model-rows').innerHTML = rows.map(m =>
|
||||
'<tr>' +
|
||||
'<td>' + esc(m.model) + '</td>' +
|
||||
'<td class="num">' + fmt(m.spend) + '</td>' +
|
||||
'<td class="num">' + (m.tokens || 0).toLocaleString() + '</td>' +
|
||||
'<td class="num">' + (m.requests || 0).toLocaleString() + '</td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
clearArrows('models', modelSort);
|
||||
}
|
||||
|
||||
function i(email) { return email.replace(/[^a-z0-9]/g, '_'); }
|
||||
|
||||
Reference in new issue
Block a user