Add Users dashboard tab with per-user balances and credit grants

New cookie-gated "Users" tab on the operator dashboard: a sortable view
of every credit-ledger row (typed cloud/license/install) with computed
remaining/total balances, key filter, and a per-row "grant free credits"
action.

Endpoints (routes/admin.js):
- GET /admin/credits — snapshotAll() enriched with a type derived from
  the credit-key prefix and a computed balance (computeRemaining against
  live tier quotas), since the ledger stores consumed counters only.
- POST /admin/credits/grant {credit_key, amount} — adds free top-up via
  addPurchasedCredits. Grants land in the never-expires purchased bucket
  (spent after the tier allowance). Guards: positive integer, <=1,000,000,
  and the row must already exist (a typo can't spawn a ghost row).

Admin-only; no /relay/* client contract change. Tests added in
server/test/admin-credits.test.js (mount the real router over HTTP).
Version bumped 0.2.124 -> 0.2.125.
This commit is contained in:
Keysat
2026-06-15 16:25:14 -05:00
parent 00da92a872
commit 798a698132
6 changed files with 373 additions and 6 deletions
+179 -1
View File
@@ -1044,10 +1044,15 @@
activeTab: (() => {
try {
const saved = localStorage.getItem("recap-relay-active-tab");
if (saved === "jobs" || saved === "settings" || saved === "overview") return saved;
if (saved === "jobs" || saved === "settings" || saved === "overview" || saved === "users") return saved;
} catch {}
return "overview";
})(),
// Users-tab state — enriched credit-ledger rows from /admin/credits.
creditsData: null,
creditsLoading: false,
creditsSort: { col: "last_active_at", dir: "desc" },
creditsQuery: "",
// Jobs-tab state.
jobsData: null,
jobsLoading: false,
@@ -1176,6 +1181,9 @@
if (state.authed && state.activeTab === "meetings" && !state.meetingsList) {
loadMeetingsList();
}
if (state.authed && state.activeTab === "users" && !state.creditsData) {
loadCredits();
}
// Start the Overview auto-refresh poll on boot regardless of
// current tab — cheap (one fetch every 10s) and ensures the
// tab is current the moment the operator switches to it.
@@ -1460,6 +1468,8 @@
renderSettingsTab();
} else if (state.activeTab === "meetings") {
renderMeetingsTab();
} else if (state.activeTab === "users") {
renderUsersTab();
} else {
renderDashboard();
}
@@ -1471,6 +1481,7 @@
return '<div class="tabs">' +
t("overview", "Overview") +
t("jobs", "Jobs") +
t("users", "Users") +
t("meetings", "Internal Meetings") +
t("settings", "Settings") +
'</div>';
@@ -1561,6 +1572,9 @@
if (tab === "meetings") {
loadMeetingsList();
}
if (tab === "users") {
loadCredits();
}
// Overview tab: re-fetch the dashboard data on EVERY entry (so
// the summaries / errors / perf tables show current state, not
// whatever was last cached) AND start the 10-second auto-refresh
@@ -6662,6 +6676,170 @@
'</div>';
}
// ── Users tab ───────────────────────────────────────────────────
// A flat view of the credit ledger: every user/install + their
// current balance, with a per-row "grant free credits" action.
// Balances are COMPUTED server-side (/admin/credits) since the
// ledger stores consumed counters, not a remaining number.
async function loadCredits() {
state.creditsLoading = true;
if (state.activeTab === "users") render();
try {
const r = await fetch("/admin/credits", { cache: "no-store" });
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
state.creditsData = Array.isArray(data.rows) ? data.rows : [];
} catch (err) {
state.creditsData = [];
console.warn("loadCredits failed:", err);
}
state.creditsLoading = false;
if (state.activeTab === "users") render();
}
// Credits consumed in the current cycle: Core spends a lifetime
// budget, paid tiers spend a monthly one.
function usedCount(r) {
return (r.tier_snapshot === "pro" || r.tier_snapshot === "max")
? (r.monthly_consumed || 0)
: (r.lifetime_consumed || 0);
}
// null = unlimited tier (no cap). Render it as a word, not a number.
function fmtCredits(v) {
return v == null ? '<span class="dim">Unlimited</span>' : fmtInt(v);
}
const USERS_COLS = [
{ key: "credit_key", label: "Key", val: (r) => r.credit_key },
{ key: "type", label: "Type", val: (r) => r.type },
{ key: "tier_snapshot", label: "Tier", val: (r) => r.tier_snapshot || "core" },
{ key: "remaining", label: "Remaining", num: true, val: (r) => r.remaining == null ? Infinity : r.remaining },
{ key: "purchased", label: "Purchased", num: true, val: (r) => r.purchased || 0 },
{ key: "total", label: "Total", num: true, val: (r) => r.total == null ? Infinity : r.total },
{ key: "used", label: "Used (cycle)", num: true, val: (r) => usedCount(r) },
{ key: "last_active_at", label: "Last active", val: (r) => new Date(r.last_active_at || 0).getTime() },
];
function sortUsers(col) {
const s = state.creditsSort;
if (s.col === col) { s.dir = s.dir === "asc" ? "desc" : "asc"; }
else { s.col = col; s.dir = (col === "credit_key" || col === "type") ? "asc" : "desc"; }
refreshUsersTable();
}
function filterUsers(v) {
state.creditsQuery = v;
refreshUsersTable();
}
// Re-render ONLY the table (sort/filter) so the search box outside
// the wrap keeps focus + caret between keystrokes.
function refreshUsersTable() {
const wrap = document.getElementById("users-table-wrap");
if (wrap) wrap.innerHTML = usersTableHtml();
}
function usersTableHtml() {
const rows = (state.creditsData || []).slice();
const q = (state.creditsQuery || "").trim().toLowerCase();
const filtered = q
? rows.filter((r) => (r.credit_key || "").toLowerCase().includes(q))
: rows;
if (filtered.length === 0) {
return '<div class="empty">' + (q ? "No users match “" + esc(q) + "”." : "No users yet.") + '</div>';
}
const { col, dir } = state.creditsSort;
const colDef = USERS_COLS.find((c) => c.key === col) || USERS_COLS[0];
const mul = dir === "asc" ? 1 : -1;
filtered.sort((a, b) => {
const av = colDef.val(a), bv = colDef.val(b);
if (typeof av === "number" && typeof bv === "number") return (av - bv) * mul;
return String(av).localeCompare(String(bv)) * mul;
});
const thead = USERS_COLS.map((c) => {
const ind = c.key === col ? (dir === "asc" ? " ▲" : " ▼") : "";
return '<th class="' + (c.num ? "num " : "") + '" style="cursor:pointer;" onclick="sortUsers(\'' + c.key + '\')">'
+ esc(c.label) + '<span style="opacity:0.7;font-size:9px;">' + ind + '</span></th>';
}).join("") + '<th>Grant</th>';
const tbody = filtered.map((r) => {
const typeBadge = '<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;'
+ 'background:rgba(165,180,252,0.12);border:1px solid var(--line-2);color:var(--fg-dim);">'
+ esc(r.type) + '</span>';
const grantCell = '<div style="display:flex;gap:5px;align-items:center;">'
+ '<input type="number" min="1" step="1" placeholder="N" class="grant-amt" '
+ 'style="width:62px;padding:4px 6px;font-size:12px;background:var(--bg);border:1px solid var(--line-2);border-radius:5px;color:var(--fg);" />'
+ '<button class="grant-btn" data-key="' + esc(r.credit_key) + '" onclick="grantCredits(this)" '
+ 'style="padding:4px 10px;font-size:11px;background:transparent;border:1px solid var(--line-2);border-radius:5px;color:var(--accent);cursor:pointer;">Grant</button>'
+ '</div>';
return '<tr>'
+ '<td><code title="' + esc(r.credit_key) + '">' + esc(shortId(r.credit_key)) + '</code></td>'
+ '<td>' + typeBadge + '</td>'
+ '<td>' + tierPill(r.tier_snapshot) + '</td>'
+ '<td class="num">' + fmtCredits(r.remaining) + '</td>'
+ '<td class="num">' + fmtInt(r.purchased || 0) + '</td>'
+ '<td class="num">' + fmtCredits(r.total) + '</td>'
+ '<td class="num" title="' + (r.capped === "monthly" ? "monthly allowance" : "lifetime allowance") + '">' + fmtInt(usedCount(r)) + '</td>'
+ '<td><span class="dim">' + esc(fmtTs(r.last_active_at)) + '</span></td>'
+ '<td>' + grantCell + '</td>'
+ '</tr>';
}).join("");
return '<table><thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table>';
}
async function grantCredits(btn) {
const key = btn.dataset.key;
const input = btn.parentElement.querySelector(".grant-amt");
const amount = parseInt(input && input.value, 10);
if (!Number.isInteger(amount) || amount <= 0) {
alert("Enter a positive whole number of credits.");
return;
}
if (!confirm("Grant " + amount + " free credit(s) to " + key + "?\n\nThese never expire and are spent AFTER the user's tier allowance.")) {
return;
}
btn.disabled = true;
try {
const r = await fetch("/admin/credits/grant", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credit_key: key, amount }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || ("HTTP " + r.status));
// Refetch enriched rows so Remaining/Total/Purchased reflect the grant.
await loadCredits();
} catch (err) {
alert("Grant failed: " + (err?.message || err));
btn.disabled = false;
}
}
function renderUsersTab() {
const data = state.creditsData;
let body;
if (data == null) {
body = '<div class="loading">' + (state.creditsLoading ? "Loading users…" : "No data.") + '</div>';
} else {
const counts = data.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
const summary = '<div style="font-size:11px;color:var(--fg-dim);margin-bottom:12px;">'
+ fmtInt(data.length) + ' user(s) — '
+ (counts.cloud || 0) + ' cloud · ' + (counts.license || 0) + ' license · ' + (counts.install || 0) + ' install. '
+ 'Grants land in the never-expires top-up bucket (spent after the tier allowance).'
+ '</div>';
const search = '<input type="text" placeholder="Filter by key…" value="' + esc(state.creditsQuery || "") + '" '
+ 'oninput="filterUsers(this.value)" '
+ 'style="margin-bottom:12px;width:260px;padding:6px 10px;font-size:12px;background:var(--bg);border:1px solid var(--line-2);border-radius:5px;color:var(--fg);" />';
body = summary + search + '<div id="users-table-wrap">' + usersTableHtml() + '</div>';
}
root.innerHTML =
'<h1>Recap Relay — Operator Dashboard</h1>' +
tabsHtml() +
'<div style="max-width:1100px; padding:12px 0;">' +
body +
'</div>';
}
function table(headers, rows) {
if (rows.length === 0) return '<div class="empty">No data in this range.</div>';
const thead = headers.map(h => '<th>' + esc(h) + '</th>').join("");