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:
+72
-2
@@ -8,8 +8,8 @@
|
||||
// action but reachable from the dashboard)
|
||||
|
||||
import express from "express";
|
||||
import { getConfigSnapshot, getTierPrices } from "../config.js";
|
||||
import { snapshotAll } from "../credits.js";
|
||||
import { getConfigSnapshot, getTierPrices, getTierQuotas } from "../config.js";
|
||||
import { snapshotAll, computeRemaining, addPurchasedCredits } from "../credits.js";
|
||||
import { snapshotCache } from "../keysat-client.js";
|
||||
// snapshotJobs is exported by BOTH ../jobs.js (the in-memory job
|
||||
// tracker) and ../job-credits.js (the credit-ledger). They return
|
||||
@@ -47,6 +47,16 @@ import { getHardwareQueueStatus } from "../hardware-queue.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
// Human-facing row category derived from the ledger credit-key prefix.
|
||||
// `user:` → cloud user (recaps.cc), `lic:` → shared license pool,
|
||||
// `inst:` and legacy bare-installId rows → a single install.
|
||||
function creditKeyType(key) {
|
||||
if (typeof key !== "string") return "install";
|
||||
if (key.startsWith("user:")) return "cloud";
|
||||
if (key.startsWith("lic:")) return "license";
|
||||
return "install";
|
||||
}
|
||||
|
||||
export function adminRouter({ dataDir }) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -58,6 +68,66 @@ export function adminRouter({ dataDir }) {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Users / credit-balance view ──────────────────────────────────
|
||||
// Like /usage but enriched for the dashboard's Users tab: each row
|
||||
// carries a human-facing `type` (derived from the credit-key prefix)
|
||||
// and a COMPUTED balance (remaining tier credits + purchased top-up),
|
||||
// since the ledger stores consumed counters, not a remaining number.
|
||||
router.get("/credits", async (_req, res) => {
|
||||
const quotas = await getTierQuotas();
|
||||
const rows = snapshotAll().map((r) => {
|
||||
const balance = computeRemaining(r, quotas);
|
||||
return {
|
||||
...r,
|
||||
type: creditKeyType(r.credit_key),
|
||||
remaining: balance.remaining, // tier portion; null = unlimited
|
||||
purchased: balance.purchased,
|
||||
total: balance.total, // remaining + purchased; null = unlimited
|
||||
capped: balance.capped, // "monthly" | "lifetime"
|
||||
gemini_remaining: balance.gemini_remaining,
|
||||
};
|
||||
});
|
||||
res.json({ count: rows.length, rows });
|
||||
});
|
||||
|
||||
// Grant free credits to one user. Lands in the never-expires
|
||||
// `purchased_balance` bucket (spent AFTER the tier allotment), so this
|
||||
// is a pure top-up — it doesn't touch the user's monthly/lifetime
|
||||
// allowance. Only grants to an EXISTING ledger row: a typo'd key must
|
||||
// not spawn a ghost row, so we check snapshotAll() first.
|
||||
router.post("/credits/grant", express.json(), async (req, res) => {
|
||||
const creditKey =
|
||||
typeof req.body?.credit_key === "string" ? req.body.credit_key.trim() : "";
|
||||
const amount = Number(req.body?.amount);
|
||||
if (!creditKey) {
|
||||
return res.status(400).json({ error: "credit_key required" });
|
||||
}
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
return res.status(400).json({ error: "amount must be a positive integer" });
|
||||
}
|
||||
// Fat-finger guard — a manual top-up in the millions is almost
|
||||
// certainly a typo, not intent. Operator can repeat the grant if
|
||||
// they genuinely mean to add more.
|
||||
if (amount > 1_000_000) {
|
||||
return res.status(400).json({ error: "amount too large (max 1,000,000 per grant)" });
|
||||
}
|
||||
const exists = snapshotAll().some((r) => r.credit_key === creditKey);
|
||||
if (!exists) {
|
||||
return res.status(404).json({ error: "unknown credit_key" });
|
||||
}
|
||||
const newBalance = await addPurchasedCredits({ creditKey, amount });
|
||||
console.log(
|
||||
`[admin/credits] manual grant: +${amount} free credit(s) to ${creditKey} ` +
|
||||
`(new purchased_balance: ${newBalance})`
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
credit_key: creditKey,
|
||||
granted: amount,
|
||||
purchased_balance: newBalance,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/config", async (_req, res) => {
|
||||
const cfg = await getConfigSnapshot();
|
||||
const hw = await (await import("../hardware-config.js")).resolveHardwareConfig(cfg);
|
||||
|
||||
Reference in New Issue
Block a user