// /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=. // 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; } }