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:
+179
-1
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user