Files
recap-relay/server/routes/admin.js
T

148 lines
5.1 KiB
JavaScript

// /admin/* — operator dashboard endpoints. All require the admin
// session cookie (enforced by admin-auth middleware).
//
// v0.1 endpoints (JSON only; v0.2 will add an HTML dashboard):
// GET /admin/usage — all install rows + last-month aggregates
// GET /admin/config — current operator config (sans password hash)
// POST /admin/quotas — adjust tier quotas live (mirror of StartOS
// action but reachable from the dashboard)
import express from "express";
import { getConfigSnapshot } from "../config.js";
import { snapshotAll } from "../credits.js";
import { snapshotCache } from "../keysat-client.js";
import { snapshotJobs } from "../job-credits.js";
import { readEntries, aggregate } from "../audit-log.js";
import { GEMINI_PRICING } from "../pricing.js";
import fs from "fs/promises";
import path from "path";
export function adminRouter({ dataDir }) {
const router = express.Router();
router.get("/usage", async (_req, res) => {
const rows = snapshotAll();
res.json({
installs: rows.length,
rows,
});
});
router.get("/config", async (_req, res) => {
const cfg = await getConfigSnapshot();
// Strip secrets before exposing to the dashboard.
const safe = {
keysat_base_url: cfg.relay_keysat_base_url,
parakeet_base_url: cfg.relay_parakeet_base_url,
gemma_base_url: cfg.relay_gemma_base_url,
gemini_configured: !!cfg.relay_gemini_api_key,
admin_username: cfg.relay_admin_username,
tier_quotas: tryParse(cfg.relay_tier_quotas_json),
};
res.json(safe);
});
router.get("/license-cache", async (_req, res) => {
res.json({ entries: snapshotCache() });
});
router.get("/jobs", async (_req, res) => {
res.json({ entries: snapshotJobs() });
});
// ── Dashboard ─────────────────────────────────────────────────────────
// Time-range aggregations over the per-call audit log. Default range
// is the last 30 days; override with ?days=N or ?since=<ms-epoch>.
// Returns { range, summary, by_tier, by_model, by_pipeline,
// by_backend, by_install, by_hour_utc, cost_vs_speed, pricing }.
router.get("/dashboard", async (req, res) => {
const days =
typeof req.query.days === "string"
? parseInt(req.query.days, 10)
: null;
const explicitSince =
typeof req.query.since === "string"
? parseInt(req.query.since, 10)
: null;
const sinceMs =
explicitSince && Number.isFinite(explicitSince)
? explicitSince
: Date.now() -
(Number.isFinite(days) && days > 0 ? days : 30) *
24 *
3600 *
1000;
try {
const entries = await readEntries({ sinceMs });
const agg = aggregate(entries);
res.json({
range: {
since_ms: sinceMs,
until_ms: Date.now(),
days: Number.isFinite(days) && days > 0 ? days : null,
total_entries: entries.length,
},
...agg,
pricing: GEMINI_PRICING,
});
} catch (err) {
console.error(`[admin/dashboard] failed: ${err?.message || err}`);
res
.status(500)
.json({ error: "dashboard_failed", message: err?.message || String(err) });
}
});
// Adjust the live quotas blob. Same shape the StartOS action writes
// to relay_tier_quotas_json — kept here so the dashboard can tune
// quotas without round-tripping the StartOS UI.
router.post("/quotas", express.json(), async (req, res) => {
const incoming = req.body || {};
const normalized = {
core: {
lifetime: numOrNull(incoming?.core?.lifetime, 5),
monthly: numOrNull(incoming?.core?.monthly, null),
geminiCapMonthly: numOrNull(incoming?.core?.geminiCapMonthly, null),
},
pro: {
lifetime: numOrNull(incoming?.pro?.lifetime, null),
monthly: numOrNull(incoming?.pro?.monthly, 50),
geminiCapMonthly: numOrNull(incoming?.pro?.geminiCapMonthly, 25),
},
max: {
lifetime: numOrNull(incoming?.max?.lifetime, null),
monthly: numOrNull(incoming?.max?.monthly, null),
geminiCapMonthly: numOrNull(incoming?.max?.geminiCapMonthly, 50),
},
};
// Write directly into relay-config.json — the live-reloader picks
// it up on the next read.
const configPath = path.join(dataDir, "config", "relay-config.json");
let existing = {};
try {
existing = JSON.parse(await fs.readFile(configPath, "utf8"));
} catch {}
existing.relay_tier_quotas_json = JSON.stringify(normalized);
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(existing), { mode: 0o600 });
res.json({ ok: true, quotas: normalized });
});
return router;
}
function numOrNull(v, fallback) {
if (v === null) return null;
const n = Number(v);
if (Number.isFinite(n)) return n;
return fallback;
}
function tryParse(s) {
try {
return JSON.parse(s);
} catch {
return null;
}
}