798a698132
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.
1319 lines
58 KiB
JavaScript
1319 lines
58 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, 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
|
|
// different shapes — ../jobs.js gives {id, kind, status, progress,
|
|
// started_at, ...}, ../job-credits.js gives {key, backend, tier,
|
|
// charged_at, refunded}. The /admin/jobs route exposes the in-memory
|
|
// tracker for the dashboard's discovery poll, so the import MUST be
|
|
// from ../jobs.js. Pre-v0.2.65, this import accidentally targeted
|
|
// job-credits.js, which made discovery silently report "found: 0
|
|
// running" for every Recap submission (the response had entries but
|
|
// none with the expected status/kind fields). Symptom that surfaced
|
|
// the bug: the v0.2.61 raw-JSON-viewer showed entries with `key`,
|
|
// `backend`, `tier`, `charged_at`, `refunded` instead of the expected
|
|
// `id`, `kind`, `status`, `progress`. Don't change this back without
|
|
// also redesigning /admin/jobs.
|
|
import { snapshotJobs } from "../jobs.js";
|
|
import { readEntries, aggregate, computeRevenue, clearAllAuditEntries, deleteAuditRowsByJobIds } from "../audit-log.js";
|
|
import { DEFAULT_TRANSCRIBE_PROMPT_BODY } from "../backends/gemini.js";
|
|
import { DEFAULT_ANALYZE_PROMPT_TEMPLATE } from "../chunked-analyze.js";
|
|
import {
|
|
DEFAULT_NAME_INFERENCE_PROMPT_TEMPLATE,
|
|
DEFAULT_SUMMARY_POLISH_PROMPT_TEMPLATE,
|
|
} from "../post-cluster-polish.js";
|
|
import { DEFAULT_MEETING_EXTRAS_PROMPT_TEMPLATE } from "../meeting-extras.js";
|
|
import { aggregateJobs, summarizeJobs } from "../job-stats.js";
|
|
import {
|
|
getJobOutput,
|
|
listJobOutputIds,
|
|
bulkDeleteOutputs,
|
|
getStoredOutputsSummary,
|
|
} from "../output-store.js";
|
|
import { GEMINI_PRICING } from "../pricing.js";
|
|
import { getSparkDiscoveryStatus } from "../spark-control.js";
|
|
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();
|
|
|
|
router.get("/usage", async (_req, res) => {
|
|
const rows = snapshotAll();
|
|
res.json({
|
|
installs: rows.length,
|
|
rows,
|
|
});
|
|
});
|
|
|
|
// ── 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);
|
|
// Strip secrets before exposing to the dashboard.
|
|
const safe = {
|
|
keysat_base_url: cfg.relay_keysat_base_url,
|
|
spark_control_url: cfg.relay_spark_control_url || null,
|
|
// Effective values from Spark Control discovery (single source
|
|
// of truth as of v0.2.84 — operator-typed overrides removed).
|
|
effective_transcribe_url: hw.transcribe.url,
|
|
effective_transcribe_model: hw.transcribe.model,
|
|
effective_analyze_url: hw.analyze.url,
|
|
effective_analyze_model: hw.analyze.model,
|
|
effective_spark_base: hw.sparkBase || null,
|
|
// Operator-facing diagnostic — when Spark Control reports a
|
|
// delegate (parakeet / vllm) as not ready, the resolver leaves
|
|
// the effective_* URL null and stamps blocked_reason with the
|
|
// SC-supplied detail. Surfaced here so the dashboard's
|
|
// hardware health panel can display "parakeet currently
|
|
// offline — model swap in progress" without the operator
|
|
// having to grep relay logs. Client-facing routes never
|
|
// surface these strings — they let planBackend route around
|
|
// the unavailable path.
|
|
effective_transcribe_blocked_reason: hw.transcribe.blocked_reason || null,
|
|
effective_analyze_blocked_reason: hw.analyze.blocked_reason || null,
|
|
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() });
|
|
});
|
|
|
|
// Hardware FIFO queue status — polled by the operator dashboard
|
|
// for the top-bar queue chip. Cheap (single in-memory snapshot).
|
|
router.get("/hardware-queue", async (_req, res) => {
|
|
res.json(getHardwareQueueStatus());
|
|
});
|
|
|
|
router.get("/jobs", async (_req, res) => {
|
|
res.json({ entries: snapshotJobs() });
|
|
});
|
|
|
|
// ── Per-job history (the "Jobs" tab) ──────────────────────────────────
|
|
// Returns audit entries aggregated by X-Recap-Job-Id, with derived
|
|
// per-video stats the dashboard's Jobs tab renders as a sortable +
|
|
// filterable table. Pagination is server-side (?page=1&page_size=100);
|
|
// filters and sort are also server-side so the client doesn't need
|
|
// to ship every row to do them.
|
|
//
|
|
// Query params:
|
|
// days=N | since=<ms> time window (default 30 days)
|
|
// page, page_size pagination (default page=1, size=100)
|
|
// status "success" | "partial" | "failed"
|
|
// transcribe_backend "gemini" | "hardware"
|
|
// analyze_backend "gemini" | "hardware"
|
|
// model substring match on transcribe_model OR analyze_model
|
|
// q substring match on title or media_url
|
|
// sort column name (see SORTABLE below)
|
|
// dir "asc" | "desc" (default "desc")
|
|
router.get("/jobs-history", async (req, res) => {
|
|
const days = numQ(req.query.days);
|
|
const explicitSince = numQ(req.query.since);
|
|
const sinceMs =
|
|
explicitSince ?? Date.now() - (days ?? 30) * 24 * 3600 * 1000;
|
|
try {
|
|
const entries = await readEntries({ sinceMs });
|
|
// Read the stored-output ID set once so the aggregator can flag
|
|
// each row's has_output without per-row filesystem stats.
|
|
const outputIds = await listJobOutputIds();
|
|
const outputIdSet = new Set(outputIds);
|
|
let jobs = aggregateJobs(entries, { outputIdSet });
|
|
const summary = summarizeJobs(jobs);
|
|
|
|
// ── Filters ──
|
|
const status = oneOf(req.query.status, ["success", "partial", "failed"]);
|
|
if (status) jobs = jobs.filter((j) => j.overall_status === status);
|
|
|
|
const txBackend = oneOf(req.query.transcribe_backend, ["gemini", "hardware"]);
|
|
if (txBackend) jobs = jobs.filter((j) => j.transcribe_backend === txBackend);
|
|
|
|
const anBackend = oneOf(req.query.analyze_backend, ["gemini", "hardware"]);
|
|
if (anBackend) jobs = jobs.filter((j) => j.analyze_backend === anBackend);
|
|
|
|
const model = strQ(req.query.model);
|
|
if (model) {
|
|
const m = model.toLowerCase();
|
|
jobs = jobs.filter(
|
|
(j) =>
|
|
(j.transcribe_model && j.transcribe_model.toLowerCase().includes(m)) ||
|
|
(j.analyze_model && j.analyze_model.toLowerCase().includes(m))
|
|
);
|
|
}
|
|
|
|
const q = strQ(req.query.q);
|
|
if (q) {
|
|
const needle = q.toLowerCase();
|
|
jobs = jobs.filter(
|
|
(j) =>
|
|
(j.title && j.title.toLowerCase().includes(needle)) ||
|
|
(j.media_url && j.media_url.toLowerCase().includes(needle))
|
|
);
|
|
}
|
|
|
|
// Batch filter: substring match on batch_id. The dashboard's
|
|
// benchmark suite stamps a shared batch_id across all 6-8 test
|
|
// runs from one click; filtering by that ID surfaces just that
|
|
// suite's rows for side-by-side comparison.
|
|
const batchId = strQ(req.query.batch_id);
|
|
if (batchId) {
|
|
const b = batchId.toLowerCase();
|
|
jobs = jobs.filter((j) => j.batch_id && j.batch_id.toLowerCase().includes(b));
|
|
}
|
|
|
|
// Source filter: "admin-test" to see only test runs, or empty
|
|
// to see all (including real user traffic).
|
|
const source = oneOf(req.query.source, ["admin-test"]);
|
|
if (source) {
|
|
jobs = jobs.filter((j) => j.source === source);
|
|
}
|
|
|
|
// ── Sort ──
|
|
const SORTABLE = new Set([
|
|
"started_at",
|
|
"completed_at",
|
|
"audio_seconds",
|
|
"audio_bytes",
|
|
"wall_time_ms",
|
|
"transcribe_ms",
|
|
"transcribe_ms_per_min",
|
|
"transcribe_ms_per_mb",
|
|
"download_ms",
|
|
"download_ms_per_mb",
|
|
"analyze_ms",
|
|
"analyze_ms_per_min",
|
|
"analyze_ms_per_mb",
|
|
"analyze_windows_total",
|
|
"analyze_windows_success",
|
|
"analyze_windows_failed",
|
|
"chunk_count",
|
|
"cost_usd",
|
|
"tier",
|
|
"transcribe_backend",
|
|
"analyze_backend",
|
|
"transcribe_model",
|
|
"analyze_model",
|
|
"overall_status",
|
|
"title",
|
|
"batch_id",
|
|
"source",
|
|
]);
|
|
const sort =
|
|
SORTABLE.has(req.query.sort) ? req.query.sort : "started_at";
|
|
const dir = req.query.dir === "asc" ? 1 : -1;
|
|
jobs.sort((a, b) => {
|
|
const av = a[sort];
|
|
const bv = b[sort];
|
|
if (av == null && bv == null) return 0;
|
|
if (av == null) return 1; // nulls last regardless of direction
|
|
if (bv == null) return -1;
|
|
if (typeof av === "number" && typeof bv === "number") {
|
|
return (av - bv) * dir;
|
|
}
|
|
return String(av).localeCompare(String(bv)) * dir;
|
|
});
|
|
|
|
// ── Paginate ──
|
|
const pageSize = Math.min(Math.max(numQ(req.query.page_size) || 100, 1), 500);
|
|
const page = Math.max(numQ(req.query.page) || 1, 1);
|
|
const totalFiltered = jobs.length;
|
|
const start = (page - 1) * pageSize;
|
|
const slice = jobs.slice(start, start + pageSize);
|
|
|
|
res.json({
|
|
range: {
|
|
since_ms: sinceMs,
|
|
until_ms: Date.now(),
|
|
days: days ?? null,
|
|
total_entries: entries.length,
|
|
},
|
|
summary,
|
|
page,
|
|
page_size: pageSize,
|
|
total_filtered: totalFiltered,
|
|
total_pages: Math.max(1, Math.ceil(totalFiltered / pageSize)),
|
|
sort,
|
|
dir: dir === 1 ? "asc" : "desc",
|
|
jobs: slice,
|
|
});
|
|
} catch (err) {
|
|
console.error(`[admin/jobs-history] failed: ${err?.message || err}`);
|
|
res
|
|
.status(500)
|
|
.json({ error: "jobs_history_failed", message: err?.message || String(err) });
|
|
}
|
|
});
|
|
|
|
// ── Stored job outputs (transcript + analysis JSON) ───────────────────
|
|
// GET /admin/job-output/:id — fetch a single output payload
|
|
// GET /admin/output-store-stats — count + bytes for the dashboard panel
|
|
// GET /admin/output-store-ids — set of job_ids that have stored outputs
|
|
// (consumed by the Jobs table to set the
|
|
// has_output flag without reading each file)
|
|
// DELETE /admin/job-outputs — bulk delete; body { job_ids: [...] } or { all: true }
|
|
router.get("/job-output/:id", async (req, res) => {
|
|
const out = await getJobOutput(req.params.id);
|
|
if (!out) return res.status(404).json({ error: "output_not_found" });
|
|
res.json(out);
|
|
});
|
|
|
|
// ── Per-job audit-row drill-down ──────────────────────────────────────
|
|
// GET /admin/job/:id/details — every audit row keyed to a single job_id,
|
|
// sorted by timestamp ascending so the operator reads the pipeline in
|
|
// execution order: download (no row), transcribe (1 row), analyze
|
|
// (N rows, one per window).
|
|
//
|
|
// Powers the Jobs-tab row-expand diagnostic. The existing
|
|
// /admin/jobs-history endpoint aggregates this data into per-job
|
|
// summaries (which the table already renders), but you can't see
|
|
// WHICH window failed or WHY without these raw rows. Use case: a job
|
|
// shows up as "partial" with "5 (1 failed)" in the AN-windows column;
|
|
// expanding the row hits this endpoint to surface "window_idx=3
|
|
// status=error error='fetch timeout after 60000ms' model=gemini-2.5-flash"
|
|
// so the operator can decide whether it's a flaky-network issue, a
|
|
// model-output issue, or systematic.
|
|
router.get("/job/:id/details", async (req, res) => {
|
|
const jobId = req.params.id;
|
|
if (!jobId) return res.status(400).json({ error: "missing job_id" });
|
|
try {
|
|
// Look back 60 days by default — same horizon as jobs-history.
|
|
// Audit rows older than the on-disk retention window won't exist
|
|
// even if they're requested.
|
|
const sinceMs = numQ(req.query.since) ?? Date.now() - 60 * 24 * 3600 * 1000;
|
|
const all = await readEntries({ sinceMs });
|
|
const rows = all
|
|
.filter((r) => r.job_id === jobId)
|
|
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
if (rows.length === 0) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: "no_audit_rows", job_id: jobId, since_ms: sinceMs });
|
|
}
|
|
// Quick per-pipeline tallies so the UI doesn't have to recompute.
|
|
const tx = rows.find((r) => r.pipeline === "transcribe");
|
|
const analyzeRows = rows.filter((r) => r.pipeline === "analyze");
|
|
const analyzeFailed = analyzeRows.filter((r) => r.status !== "success");
|
|
const summary = {
|
|
transcribe_status: tx?.status || "missing",
|
|
transcribe_truncated_chunks: tx?.truncated_chunks || null,
|
|
analyze_rows: analyzeRows.length,
|
|
analyze_failed: analyzeFailed.length,
|
|
analyze_window_count_planned:
|
|
// Each analyze row carries window_count = the total planned
|
|
// windows. If any are missing entirely (process crashed
|
|
// mid-window before recordCall ran), planned > rows.length.
|
|
analyzeRows.reduce(
|
|
(max, r) => Math.max(max, r.window_count || 0),
|
|
0
|
|
),
|
|
};
|
|
res.json({ job_id: jobId, summary, rows });
|
|
} catch (err) {
|
|
res
|
|
.status(500)
|
|
.json({ error: "job_details_failed", message: err?.message || String(err) });
|
|
}
|
|
});
|
|
|
|
router.get("/output-store-stats", async (_req, res) => {
|
|
res.json(await getStoredOutputsSummary());
|
|
});
|
|
|
|
router.get("/output-store-ids", async (_req, res) => {
|
|
res.json({ ids: await listJobOutputIds() });
|
|
});
|
|
|
|
router.delete("/job-outputs", express.json(), async (req, res) => {
|
|
const body = req.body || {};
|
|
if (!body.all && !Array.isArray(body.job_ids)) {
|
|
return res.status(400).json({
|
|
error: "request body must include { all: true } or { job_ids: [...] }",
|
|
});
|
|
}
|
|
const outputResult = await bulkDeleteOutputs({ jobIds: body.job_ids, all: !!body.all });
|
|
// Optional escalation: also delete audit-log rows for the matched
|
|
// jobs. The dashboard's "Delete selected" button sets
|
|
// include_audit=true so a single click clears BOTH the stored
|
|
// output and every audit row keyed to that job_id. The "Delete
|
|
// all stored outputs" button does NOT include audit by default —
|
|
// the operator has to confirm a separate scarier "Delete
|
|
// everything" action to nuke the whole log.
|
|
let auditResult = { deleted: 0 };
|
|
if (body.include_audit) {
|
|
if (body.all) {
|
|
const cleared = await clearAllAuditEntries();
|
|
auditResult = cleared.ok ? { deleted: "all" } : { deleted: 0, error: cleared.error };
|
|
} else if (Array.isArray(body.job_ids) && body.job_ids.length > 0) {
|
|
auditResult = await deleteAuditRowsByJobIds(body.job_ids);
|
|
}
|
|
}
|
|
res.json({ ...outputResult, audit: auditResult });
|
|
});
|
|
|
|
// ── Nuclear: delete all stored outputs AND truncate the audit log ──
|
|
// Used by the dashboard's "Delete EVERYTHING" button when the
|
|
// operator wants a totally clean slate before going live (or after
|
|
// a string of test-run cycles producing bad data). Confirmation
|
|
// happens client-side; this endpoint just executes.
|
|
router.post("/wipe-all", express.json(), async (_req, res) => {
|
|
const outputResult = await bulkDeleteOutputs({ all: true });
|
|
const auditResult = await clearAllAuditEntries();
|
|
res.json({
|
|
outputs_deleted: outputResult.deleted ?? 0,
|
|
audit_cleared: auditResult.ok,
|
|
audit_error: auditResult.error || null,
|
|
});
|
|
});
|
|
|
|
// ── 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);
|
|
const prices = await getTierPrices();
|
|
const revenue = computeRevenue({
|
|
activeInstallsByTier: agg.summary.active_installs_by_tier,
|
|
prices,
|
|
geminiCostInRange: agg.summary.total_cost_usd,
|
|
});
|
|
// 24-hour success-rate window for the dashboard alert banner —
|
|
// independent of the larger range the operator is viewing, so a
|
|
// recent regression surfaces even when looking at the 30d view.
|
|
const recentEntries = entries.filter(
|
|
(e) => e.ts >= Date.now() - 24 * 3600 * 1000
|
|
);
|
|
const recentCalls = recentEntries.length;
|
|
const recentSuccess = recentEntries.filter(
|
|
(e) => e.status === "success"
|
|
).length;
|
|
const recentSuccessRate =
|
|
recentCalls > 0 ? recentSuccess / recentCalls : 1;
|
|
res.json({
|
|
range: {
|
|
since_ms: sinceMs,
|
|
until_ms: Date.now(),
|
|
days: Number.isFinite(days) && days > 0 ? days : null,
|
|
total_entries: entries.length,
|
|
},
|
|
...agg,
|
|
revenue,
|
|
tier_prices_usd: prices,
|
|
recent_24h: {
|
|
calls: recentCalls,
|
|
success: recentSuccess,
|
|
success_rate: recentSuccessRate,
|
|
},
|
|
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) });
|
|
}
|
|
});
|
|
|
|
// ── CSV export ────────────────────────────────────────────────────────
|
|
// Streams the raw audit entries in the requested range as CSV so the
|
|
// operator can pivot in a spreadsheet. Same window-selection as
|
|
// /dashboard (?days=N or ?since=<ms-epoch>). Columns mirror the
|
|
// shape recordCall() writes — easier to keep aligned with future
|
|
// audit-log field additions than a curated subset.
|
|
router.get("/dashboard.csv", 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 cols = [
|
|
"ts",
|
|
"iso_time",
|
|
"install_id",
|
|
"license_fingerprint",
|
|
"tier",
|
|
"pipeline",
|
|
"backend",
|
|
"model",
|
|
"status",
|
|
"credit_charged",
|
|
"duration_ms",
|
|
"download_ms",
|
|
"audio_bytes",
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"thinking_tokens",
|
|
"cost_usd",
|
|
"job_id",
|
|
"error",
|
|
];
|
|
const lines = [cols.join(",")];
|
|
for (const e of entries) {
|
|
const row = cols.map((c) => {
|
|
if (c === "iso_time") return csvCell(new Date(e.ts || 0).toISOString());
|
|
return csvCell(e[c]);
|
|
});
|
|
lines.push(row.join(","));
|
|
}
|
|
const ymd = new Date().toISOString().slice(0, 10);
|
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="recap-relay-${ymd}.csv"`
|
|
);
|
|
res.send(lines.join("\n") + "\n");
|
|
} catch (err) {
|
|
console.error(`[admin/dashboard.csv] failed: ${err?.message || err}`);
|
|
res
|
|
.status(500)
|
|
.json({ error: "csv_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 });
|
|
});
|
|
|
|
// ── Settings (chunking / concurrency knobs) ──
|
|
// GET returns the 11 chunking-related fields from the live config
|
|
// snapshot. PUT validates each value against its allowed range
|
|
// (mirrors the Zod schema in startos/file-models/config.json.ts)
|
|
// and writes them into /data/config/relay-config.json. The
|
|
// live-reloader picks the new values up on the next request — no
|
|
// restart, no in-flight benchmark interruption.
|
|
//
|
|
// Validation rules (kept in sync with the Zod schema):
|
|
// tx_chunk_minutes integer, 1..120
|
|
// tx_concurrency integer, 1..32
|
|
// analyze_window_minutes integer, 1..60
|
|
// analyze_overlap_minutes integer, 0..10
|
|
// analyze_concurrency integer, 1..32
|
|
// analyze_cutoff_minutes integer, 1..60
|
|
const SETTINGS_KEYS = [
|
|
"relay_gemini_tx_chunk_minutes",
|
|
"relay_gemini_tx_concurrency",
|
|
"relay_gemini_analyze_window_minutes",
|
|
"relay_gemini_analyze_overlap_minutes",
|
|
"relay_gemini_analyze_concurrency",
|
|
"relay_hardware_tx_chunk_minutes",
|
|
"relay_hardware_tx_chunk_overlap_seconds",
|
|
"relay_hardware_tx_concurrency",
|
|
"relay_hardware_voice_clustering_threshold",
|
|
"relay_hardware_anchor_min_speaking_sec",
|
|
"relay_hardware_small_cluster_max_speaking_sec",
|
|
"relay_hardware_uncertain_margin_pct",
|
|
"relay_hardware_analyze_window_minutes",
|
|
"relay_hardware_analyze_overlap_minutes",
|
|
"relay_hardware_analyze_concurrency",
|
|
"relay_analyze_cutoff_minutes",
|
|
// Output-token caps, added v0.2.62. Numeric inputs; ranges below.
|
|
"relay_gemini_tx_max_output_tokens",
|
|
"relay_gemini_an_max_output_tokens",
|
|
"relay_hardware_an_max_tokens",
|
|
// Per-video-duration TOTAL section targets, added v0.2.67.
|
|
// Replaces the old per-window-bucket target strings. Each is a
|
|
// small positive integer = target total sections for a video in
|
|
// the matching duration bucket.
|
|
"relay_analyze_total_sections_under_30",
|
|
"relay_analyze_total_sections_30_60",
|
|
"relay_analyze_total_sections_60_90",
|
|
"relay_analyze_total_sections_90_120",
|
|
"relay_analyze_total_sections_120_150",
|
|
"relay_analyze_total_sections_150_180",
|
|
"relay_analyze_total_sections_over_180",
|
|
];
|
|
const SETTINGS_RANGES = {
|
|
relay_gemini_tx_chunk_minutes: [1, 120],
|
|
relay_gemini_tx_concurrency: [1, 32],
|
|
relay_gemini_analyze_window_minutes: [1, 60],
|
|
relay_gemini_analyze_overlap_minutes: [0, 10],
|
|
relay_gemini_analyze_concurrency: [1, 32],
|
|
relay_hardware_tx_chunk_minutes: [1, 120],
|
|
relay_hardware_tx_chunk_overlap_seconds: [0, 120],
|
|
relay_hardware_tx_concurrency: [1, 32],
|
|
// Cosine-similarity threshold for cross-chunk speaker
|
|
// clustering, stored as integer percentage. Range allows from
|
|
// 50% (loose — easy to merge similar-sounding voices) to 95%
|
|
// (strict — only near-identical fingerprints get merged).
|
|
relay_hardware_voice_clustering_threshold: [50, 95],
|
|
// Diarization suppression thresholds (Phase 2 cluster cleanup).
|
|
// anchor_min_speaking_sec: a cluster needs at least this much
|
|
// total speaking time to be considered a "real" speaker
|
|
// (anchor). Below this and the cluster is a suppression
|
|
// candidate. Range 5-120s.
|
|
// small_cluster_max_speaking_sec: clusters with LESS than this
|
|
// that aren't close to any anchor get merged into
|
|
// "Speaker_Unknown". Range 1-60s.
|
|
// uncertain_margin_pct: a small cluster whose best similarity
|
|
// to any anchor is within this many percentage points of the
|
|
// main clustering threshold gets reassigned to that anchor
|
|
// with a "?" suffix marker. Range 0-30 (0 = no fuzzy
|
|
// reassignment; 30 = very lenient).
|
|
relay_hardware_anchor_min_speaking_sec: [5, 120],
|
|
relay_hardware_small_cluster_max_speaking_sec: [1, 60],
|
|
relay_hardware_uncertain_margin_pct: [0, 30],
|
|
relay_hardware_analyze_window_minutes: [1, 60],
|
|
relay_hardware_analyze_overlap_minutes: [0, 10],
|
|
relay_hardware_analyze_concurrency: [1, 32],
|
|
relay_analyze_cutoff_minutes: [1, 60],
|
|
// 1024 lower bound is reasonable for any audio/JSON output; below
|
|
// that and even a tiny chunk's output would clip. Upper bound 65536
|
|
// is Gemini's documented per-call max; vLLM/Ollama operators
|
|
// with larger context windows can still hit this cap.
|
|
relay_gemini_tx_max_output_tokens: [1024, 65536],
|
|
relay_gemini_an_max_output_tokens: [1024, 65536],
|
|
relay_hardware_an_max_tokens: [1024, 32768],
|
|
// Total-sections targets — practical bounds. 1 = single section
|
|
// for the whole video (probably too coarse to be useful); 40 is
|
|
// a generous upper bound for marathon 4+ hour content. Operators
|
|
// who want exotic values can hand-edit relay-config.json directly.
|
|
relay_analyze_total_sections_under_30: [1, 40],
|
|
relay_analyze_total_sections_30_60: [1, 40],
|
|
relay_analyze_total_sections_60_90: [1, 40],
|
|
relay_analyze_total_sections_90_120: [1, 40],
|
|
relay_analyze_total_sections_120_150: [1, 40],
|
|
relay_analyze_total_sections_150_180: [1, 40],
|
|
relay_analyze_total_sections_over_180: [1, 40],
|
|
};
|
|
// Canonical defaults — mirrors server/config.js defaultConfig().
|
|
// Used when a config field is missing entirely (e.g. older operator
|
|
// config from before v0.2.32). One source of truth.
|
|
const SETTINGS_DEFAULTS = {
|
|
relay_gemini_tx_chunk_minutes: 30,
|
|
relay_gemini_tx_concurrency: 12,
|
|
relay_gemini_analyze_window_minutes: 18,
|
|
relay_gemini_analyze_overlap_minutes: 2,
|
|
relay_gemini_analyze_concurrency: 12,
|
|
relay_hardware_tx_chunk_minutes: 5,
|
|
relay_hardware_tx_chunk_overlap_seconds: 30,
|
|
relay_hardware_tx_concurrency: 4,
|
|
relay_hardware_voice_clustering_threshold: 70,
|
|
relay_hardware_anchor_min_speaking_sec: 30,
|
|
relay_hardware_small_cluster_max_speaking_sec: 15,
|
|
relay_hardware_uncertain_margin_pct: 10,
|
|
relay_hardware_analyze_window_minutes: 18,
|
|
relay_hardware_analyze_overlap_minutes: 2,
|
|
relay_hardware_analyze_concurrency: 8,
|
|
relay_analyze_cutoff_minutes: 25,
|
|
relay_gemini_tx_max_output_tokens: 65536,
|
|
relay_gemini_an_max_output_tokens: 8192,
|
|
relay_hardware_an_max_tokens: 16000,
|
|
relay_analyze_total_sections_under_30: 6,
|
|
relay_analyze_total_sections_30_60: 8,
|
|
relay_analyze_total_sections_60_90: 9,
|
|
relay_analyze_total_sections_90_120: 10,
|
|
relay_analyze_total_sections_120_150: 11,
|
|
relay_analyze_total_sections_150_180: 12,
|
|
relay_analyze_total_sections_over_180: 12,
|
|
};
|
|
|
|
// Boolean settings (presented as toggles in the dashboard's
|
|
// Settings tab). Same get/put endpoint as the chunking knobs;
|
|
// validated separately because they're booleans not bounded ints.
|
|
const SETTINGS_BOOLS = [
|
|
"relay_save_user_outputs",
|
|
"relay_hardware_diarization_enabled",
|
|
"relay_post_cluster_polish_enabled",
|
|
"relay_meeting_extras_enabled",
|
|
];
|
|
const SETTINGS_BOOL_DEFAULTS = {
|
|
relay_save_user_outputs: false,
|
|
relay_hardware_diarization_enabled: false,
|
|
// Phase 2 polish pass — default ON. Operator can disable to
|
|
// skip the ~15-25s polish step at the end of the pipeline.
|
|
relay_post_cluster_polish_enabled: true,
|
|
// Internal-meetings extras (Path 2A Phase 2) — default ON.
|
|
// Adds ~5-15s LLM call to extract decisions / action items /
|
|
// open questions / key quotes after polish. Affects internal
|
|
// meetings only; YouTube/podcast flow ignores it.
|
|
relay_meeting_extras_enabled: true,
|
|
};
|
|
|
|
// Enum / select settings (Gemini SKU + backend routing preference)
|
|
// — historically only editable via StartOS actions. Migrated to the
|
|
// dashboard Settings tab so the operator doesn't have to leave
|
|
// their tuning workflow. The same fields stay in the StartOS
|
|
// schema; both paths converge on relay-config.json.
|
|
//
|
|
// Each entry: { default, options: { value: human-label } }
|
|
const SETTINGS_ENUMS = {
|
|
relay_gemini_transcription_model: {
|
|
default: "gemini-3-flash-preview",
|
|
options: {
|
|
"gemini-3.1-pro-preview": "Gemini 3.1 Pro — top quality (Preview)",
|
|
"gemini-3-flash-preview": "Gemini 3 Flash — Pro-class quality at Flash price (Preview)",
|
|
"gemini-3.1-flash-lite": "Gemini 3.1 Flash-Lite — cost-optimized (Stable GA)",
|
|
"gemini-2.5-pro": "Gemini 2.5 Pro — Stable",
|
|
"gemini-2.5-flash": "Gemini 2.5 Flash — Stable",
|
|
},
|
|
},
|
|
relay_gemini_analysis_model: {
|
|
default: "gemini-3.1-pro-preview",
|
|
options: {
|
|
"gemini-3.1-pro-preview": "Gemini 3.1 Pro — top quality (Preview)",
|
|
"gemini-3-flash-preview": "Gemini 3 Flash — Pro-class at Flash price (Preview)",
|
|
"gemini-3.1-flash-lite": "Gemini 3.1 Flash-Lite — cost-optimized (Stable GA)",
|
|
"gemini-2.5-pro": "Gemini 2.5 Pro — Stable",
|
|
"gemini-2.5-flash": "Gemini 2.5 Flash — Stable",
|
|
},
|
|
},
|
|
relay_transcribe_backend_preference: {
|
|
default: "gemini_first",
|
|
options: {
|
|
gemini_first: "Gemini first → operator hardware when cap exceeded",
|
|
hardware_first: "Operator hardware first → Gemini as fallback",
|
|
gemini_only: "Gemini only — fail when cap is exceeded",
|
|
hardware_only: "Hardware only — fail when no operator-hardware transcribe endpoint configured",
|
|
},
|
|
},
|
|
relay_analyze_backend_preference: {
|
|
default: "gemini_first",
|
|
options: {
|
|
gemini_first: "Gemini first → operator hardware when cap exceeded",
|
|
hardware_first: "Operator hardware first → Gemini as fallback",
|
|
gemini_only: "Gemini only — fail when cap is exceeded",
|
|
hardware_only: "Hardware only — fail when no operator-hardware analyze endpoint configured",
|
|
},
|
|
},
|
|
};
|
|
const SETTINGS_ENUM_KEYS = Object.keys(SETTINGS_ENUMS);
|
|
|
|
// Free-form string settings (textareas in the dashboard). Each
|
|
// entry carries its default value AND optional validation rules
|
|
// checked on PUT — e.g., the analyze prompt MUST contain the JSON
|
|
// output instruction and the {{transcript}} variable, so an
|
|
// accidental edit can't silently break the pipeline.
|
|
const SETTINGS_STRINGS = {
|
|
relay_transcribe_prompt: {
|
|
default: DEFAULT_TRANSCRIBE_PROMPT_BODY,
|
|
validate: () => null, // no required substrings
|
|
// The auto-prepended metadata block (title/channel/description/
|
|
// chapters) is rendered separately and is NOT part of the
|
|
// editable prompt. The operator only edits the instruction body.
|
|
help: "Edits the INSTRUCTION body sent to Gemini for transcription. The metadata block (title / channel / description / chapters) is auto-prepended by the relay at request time. The operator-hardware transcribe path is typically a pure STT model with no prompt input — this override is ignored when routed to hardware. Empty = use the latest hardcoded default (the value shown below as 'Current default').",
|
|
},
|
|
relay_analyze_prompt: {
|
|
default: DEFAULT_ANALYZE_PROMPT_TEMPLATE,
|
|
validate: (s) => {
|
|
if (!s) return null; // empty = use default, no validation
|
|
const errors = [];
|
|
if (!/\{\{\s*transcript\s*\}\}/i.test(s)) {
|
|
errors.push("must contain {{transcript}} — the relay needs this to inject the transcript text into your prompt");
|
|
}
|
|
if (!/json/i.test(s)) {
|
|
errors.push("must reference JSON output — the analyzer parses the model response as JSON and will fail without an explicit instruction");
|
|
}
|
|
return errors.length > 0 ? errors.join("; ") : null;
|
|
},
|
|
help: "Applied to BOTH the Gemini and operator-hardware analyze paths. Template variables {{transcript}}, {{windowMin}}, {{targetSections}}, {{maxIndex}} are interpolated at request time — keep {{transcript}} (the pipeline will break without it) and prefer keeping the others where the prompt references them. The transcript is rendered as numbered + timestamped lines ([N] (MM:SS) text) so the model can read indices directly off the input rather than counting bracketed lines. {{maxIndex}} = position of the last line in this window's transcript — use it in completeness constraints ('every index from 0 to {{maxIndex}}'). The prompt MUST instruct the model to output JSON (validation enforced on save).",
|
|
},
|
|
relay_polish_name_inference_prompt: {
|
|
default: DEFAULT_NAME_INFERENCE_PROMPT_TEMPLATE,
|
|
validate: (s) => {
|
|
if (!s) return null;
|
|
const errors = [];
|
|
if (!/\{\{\s*transcript\s*\}\}/i.test(s)) {
|
|
errors.push("must contain {{transcript}} — the relay needs this to inject the speaker-labeled transcript");
|
|
}
|
|
if (!/\{\{\s*speakerKeys\s*\}\}/i.test(s)) {
|
|
errors.push("must contain {{speakerKeys}} — needed in the JSON schema block so the model knows which Speaker_X keys to populate");
|
|
}
|
|
if (!/json/i.test(s)) {
|
|
errors.push("must reference JSON output — the polish parser expects a JSON {speakers: {...}} object and will fail without an explicit instruction");
|
|
}
|
|
return errors.length > 0 ? errors.join("; ") : null;
|
|
},
|
|
help: "Stage 1 of the post-cluster polish pass. ONE LLM call sees the full speaker-labeled transcript + episode metadata and infers real names. Template variables {{channel}}, {{title}}, {{description}}, {{speakerStats}}, {{transcript}}, {{speakerKeys}} are interpolated at request time — {{transcript}} and {{speakerKeys}} are required (validation enforced on save), the others are recommended for accuracy. Output must be JSON shaped { \"speakers\": { \"Speaker_A\": \"Name or null\", ... } }. Skipped automatically when <2 speakers detected OR the polish toggle is off.",
|
|
},
|
|
relay_polish_summary_rewrite_prompt: {
|
|
default: DEFAULT_SUMMARY_POLISH_PROMPT_TEMPLATE,
|
|
validate: (s) => {
|
|
if (!s) return null;
|
|
const errors = [];
|
|
if (!/\{\{\s*sections\s*\}\}/i.test(s)) {
|
|
errors.push("must contain {{sections}} — the relay needs this to inject the per-window section list to be polished");
|
|
}
|
|
if (!/\{\{\s*transcript\s*\}\}/i.test(s)) {
|
|
errors.push("must contain {{transcript}} — the speaker-labeled window transcript is needed for speaker attribution");
|
|
}
|
|
if (!/json/i.test(s)) {
|
|
errors.push("must reference JSON output — the polish parser expects { sections: [...] } and will fail without an explicit instruction");
|
|
}
|
|
return errors.length > 0 ? errors.join("; ") : null;
|
|
},
|
|
help: "Stage 2 of the post-cluster polish pass. N parallel LLM calls (one per analyze window) rewrite each section's summary to attribute statements to specific speakers. Template variables {{speakerRoster}}, {{transcript}}, {{sections}} are interpolated at request time — {{sections}} and {{transcript}} are required (validation enforced on save). Output must be JSON shaped { \"sections\": [{ \"index\": N, \"summary\": \"...\" }, ...] }. Titles and section indices are NEVER modified by polish; only summary text gets rewritten.",
|
|
},
|
|
relay_meeting_extras_prompt: {
|
|
default: DEFAULT_MEETING_EXTRAS_PROMPT_TEMPLATE,
|
|
validate: (s) => {
|
|
if (!s) return null;
|
|
const errors = [];
|
|
if (!/\{\{\s*transcript\s*\}\}/i.test(s)) {
|
|
errors.push("must contain {{transcript}} — the relay needs this to inject the speaker-labeled transcript");
|
|
}
|
|
if (!/json/i.test(s)) {
|
|
errors.push("must reference JSON output — the extras parser expects a JSON object with decisions / action_items / open_questions / key_quotes arrays and will fail without an explicit instruction");
|
|
}
|
|
return errors.length > 0 ? errors.join("; ") : null;
|
|
},
|
|
help: "Path 2A Phase 2 — internal-meetings extras extraction. ONE LLM call at the end of the pipeline (after analyze + polish) pulls out structured items: decisions, action items, open questions, key quotes. Template variables {{title}}, {{duration}}, {{speakerRoster}}, {{topics}}, {{transcript}} are interpolated at request time — {{transcript}} is required (validation enforced on save). Output must be JSON shaped { decisions: [...], action_items: [...], open_questions: [...], key_quotes: [...] } — each item has speaker IDs (Speaker_X) + integer second offsets so the dashboard can render speaker chips + clickable timestamp jumps. Skipped automatically when the extras toggle is off OR transcript_segments is empty. Affects internal meetings only.",
|
|
},
|
|
// (The old per-window-bucket section-count targets — short / medium
|
|
// / long — were removed in v0.2.67. Section-count targets are now
|
|
// operator-set as INTEGER total-sections-per-video-duration
|
|
// numbers, exposed as 7 numeric settings above (see SETTINGS_KEYS /
|
|
// SETTINGS_RANGES / SETTINGS_DEFAULTS for the new
|
|
// relay_analyze_total_sections_* fields). The relay computes the
|
|
// per-window section count at request time using the actual video
|
|
// duration + AN window body. See computePerWindowTarget() in
|
|
// server/chunked-analyze.js.)
|
|
};
|
|
const SETTINGS_STRING_KEYS = Object.keys(SETTINGS_STRINGS);
|
|
|
|
// ── Short text settings (single-line inputs in the dashboard) ──
|
|
// Endpoint URLs, model names, and the Gemini API key — previously
|
|
// only editable via StartOS Actions, now also editable inline in
|
|
// the dashboard Settings tab. Same backing store (relay-config.json)
|
|
// — the StartOS actions still work for operators who prefer that
|
|
// workflow.
|
|
//
|
|
// Each entry:
|
|
// { default, masked?, urlPattern?, help, placeholder?, group }
|
|
//
|
|
// masked: true if the value is a secret — GET never returns the
|
|
// actual value (only a `set: bool` flag); PUT treats an
|
|
// empty string as "leave unchanged" rather than "clear".
|
|
// Use the StartOS action to explicitly clear a masked
|
|
// secret if you really need to.
|
|
// urlPattern: if set, value must match this regex on PUT. URL fields
|
|
// use '^(https?://.+)?$' to permit empty (= use discovery
|
|
// or disable that path).
|
|
// group: UI section the dashboard renders this row under.
|
|
const SETTINGS_TEXT = {
|
|
relay_gemini_api_key: {
|
|
default: "",
|
|
masked: true,
|
|
help: "The relay's Google Gemini API key. Required — the relay will refuse to serve Gemini traffic until this is set. Get one at https://aistudio.google.com/apikey.",
|
|
placeholder: "AIza... (paste your key here)",
|
|
group: "credentials",
|
|
},
|
|
relay_spark_control_url: {
|
|
default: "",
|
|
urlPattern: "^(https?://.+)?$",
|
|
help: "Spark Control's /api/endpoints discovery URL on your LAN. Required for the operator-hardware path — the relay reads it (60s TTL) to find your transcribe + analyze backends, and POSTs diarize-chunk to the same host. Spark Control's StartOS Local CA cert is auto-trusted by the relay's LAN fetcher. Example: https://192.168.1.72:62419/api/endpoints",
|
|
placeholder: "https://192.168.1.72:62419/api/endpoints",
|
|
group: "endpoints",
|
|
},
|
|
relay_keysat_base_url: {
|
|
default: "https://keysat.xyz",
|
|
urlPattern: "^https?://.+$",
|
|
help: "Where the relay validates Recap user licenses. Defaults to the public Keysat endpoint. If you're running Keysat as a co-located StartOS package, override to the internal hostname (http://keysat.startos:<port>) to skip the public-internet roundtrip.",
|
|
placeholder: "https://keysat.xyz",
|
|
group: "credentials",
|
|
},
|
|
relay_cloud_operator_key: {
|
|
default: "",
|
|
masked: true,
|
|
help: "Shared secret that lets the operator's cloud Recaps server vouch for its Pro/Max users by account-id (core-decoupling), instead of each user carrying a Keysat license. Must EXACTLY match the Recaps server's \"Relay Operator Key\" action value. Generate with `openssl rand -hex 32`. Empty = cloud user-id requests are rejected (the relay still serves the license/install path).",
|
|
placeholder: "paste the same key set on the Recaps server",
|
|
group: "credentials",
|
|
},
|
|
};
|
|
const SETTINGS_TEXT_KEYS = Object.keys(SETTINGS_TEXT);
|
|
|
|
router.get("/settings", async (_req, res) => {
|
|
const cfg = await getConfigSnapshot();
|
|
const out = {};
|
|
for (const k of SETTINGS_KEYS) {
|
|
out[k] = cfg[k] ?? SETTINGS_DEFAULTS[k];
|
|
}
|
|
for (const k of SETTINGS_BOOLS) {
|
|
out[k] = cfg[k] ?? SETTINGS_BOOL_DEFAULTS[k];
|
|
}
|
|
for (const k of SETTINGS_ENUM_KEYS) {
|
|
out[k] = cfg[k] ?? SETTINGS_ENUMS[k].default;
|
|
}
|
|
for (const k of SETTINGS_STRING_KEYS) {
|
|
// String overrides are stored verbatim. Empty string means
|
|
// "use the hardcoded default" — UI displays the default in
|
|
// that case but keeps the saved value as empty so future
|
|
// default changes flow through.
|
|
out[k] = cfg[k] || "";
|
|
}
|
|
// Short text settings (endpoint URLs, model names, Gemini key).
|
|
// Masked entries (secrets) NEVER expose their value over the wire
|
|
// — the dashboard sees a `set: true|false` flag and renders a
|
|
// "(saved — leave blank to keep)" placeholder.
|
|
const textMeta = {};
|
|
for (const k of SETTINGS_TEXT_KEYS) {
|
|
const meta = SETTINGS_TEXT[k];
|
|
const stored = cfg[k] != null ? String(cfg[k]) : "";
|
|
if (meta.masked) {
|
|
out[k] = ""; // never send secret value
|
|
textMeta[k] = {
|
|
masked: true,
|
|
set: stored.length > 0,
|
|
help: meta.help,
|
|
placeholder: meta.placeholder || "",
|
|
};
|
|
} else {
|
|
out[k] = stored;
|
|
textMeta[k] = {
|
|
masked: false,
|
|
set: stored.length > 0,
|
|
help: meta.help,
|
|
placeholder: meta.placeholder || "",
|
|
urlPattern: meta.urlPattern || null,
|
|
};
|
|
}
|
|
}
|
|
// Build a defaults map covering all four types.
|
|
const allDefaults = { ...SETTINGS_DEFAULTS, ...SETTINGS_BOOL_DEFAULTS };
|
|
for (const k of SETTINGS_ENUM_KEYS) {
|
|
allDefaults[k] = SETTINGS_ENUMS[k].default;
|
|
}
|
|
for (const k of SETTINGS_STRING_KEYS) {
|
|
// Effective default = operator-promoted default (if set) →
|
|
// hardcoded code-side default. The dashboard renders this as
|
|
// the "Current default" preview block + uses it as the
|
|
// "Reset to default" target.
|
|
const operatorDefault = cfg[k + "_default"];
|
|
allDefaults[k] =
|
|
(typeof operatorDefault === "string" && operatorDefault.trim())
|
|
? operatorDefault
|
|
: SETTINGS_STRINGS[k].default;
|
|
}
|
|
res.json({
|
|
settings: out,
|
|
ranges: SETTINGS_RANGES,
|
|
defaults: allDefaults,
|
|
booleans: SETTINGS_BOOLS,
|
|
// Enum metadata for the UI: { key: { options: { value: label } } }
|
|
enums: SETTINGS_ENUM_KEYS.reduce((acc, k) => {
|
|
acc[k] = { options: SETTINGS_ENUMS[k].options };
|
|
return acc;
|
|
}, {}),
|
|
// String metadata for the UI: { key: { help: "..." } }. The
|
|
// current default value already lives in `defaults[key]`.
|
|
strings: SETTINGS_STRING_KEYS.reduce((acc, k) => {
|
|
acc[k] = { help: SETTINGS_STRINGS[k].help };
|
|
return acc;
|
|
}, {}),
|
|
// Text metadata for short single-line inputs (endpoint URLs,
|
|
// model names, masked secrets). The dashboard reads
|
|
// `text[key].masked` to decide whether to render a password-style
|
|
// placeholder, `text[key].set` to indicate whether a saved value
|
|
// exists, `text[key].urlPattern` to validate before submit, and
|
|
// `text[key].help` + `text[key].placeholder` for the UI surface.
|
|
text: SETTINGS_TEXT_KEYS.reduce((acc, k) => {
|
|
acc[k] = { ...textMeta[k], group: SETTINGS_TEXT[k].group };
|
|
return acc;
|
|
}, {}),
|
|
// Spark Control discovery health — read-only snapshot of the
|
|
// last fetch attempt. The dashboard surfaces this under the
|
|
// Service Discovery URL row so the operator can spot a silently-
|
|
// failing discovery without grepping container logs. Reflects
|
|
// current operator config (so it stays in sync if the URL was
|
|
// just changed and not yet refetched).
|
|
discoveryStatus: getSparkDiscoveryStatus(cfg.relay_spark_control_url),
|
|
});
|
|
});
|
|
|
|
router.put("/settings", express.json(), async (req, res) => {
|
|
const incoming = req.body || {};
|
|
const errors = [];
|
|
const validated = {};
|
|
for (const k of SETTINGS_KEYS) {
|
|
const v = incoming[k];
|
|
if (v === undefined) continue; // partial update — leave existing value
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
errors.push(`${k}: must be an integer (got ${JSON.stringify(v)})`);
|
|
continue;
|
|
}
|
|
const [lo, hi] = SETTINGS_RANGES[k];
|
|
if (n < lo || n > hi) {
|
|
errors.push(`${k}: out of range ${lo}..${hi} (got ${n})`);
|
|
continue;
|
|
}
|
|
validated[k] = n;
|
|
}
|
|
for (const k of SETTINGS_BOOLS) {
|
|
if (incoming[k] === undefined) continue;
|
|
validated[k] = !!incoming[k];
|
|
}
|
|
for (const k of SETTINGS_ENUM_KEYS) {
|
|
const v = incoming[k];
|
|
if (v === undefined) continue;
|
|
if (typeof v !== "string") {
|
|
errors.push(`${k}: must be a string (got ${typeof v})`);
|
|
continue;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(SETTINGS_ENUMS[k].options, v)) {
|
|
errors.push(`${k}: ${JSON.stringify(v)} is not one of the allowed values`);
|
|
continue;
|
|
}
|
|
validated[k] = v;
|
|
}
|
|
for (const k of SETTINGS_STRING_KEYS) {
|
|
const v = incoming[k];
|
|
if (v === undefined) continue;
|
|
if (typeof v !== "string") {
|
|
errors.push(`${k}: must be a string (got ${typeof v})`);
|
|
continue;
|
|
}
|
|
// Empty string is always valid — means "use the hardcoded default".
|
|
if (v.trim() === "") {
|
|
validated[k] = "";
|
|
continue;
|
|
}
|
|
// Field-specific validation (e.g. analyze prompt must contain
|
|
// JSON + {{transcript}}).
|
|
const validateErr = SETTINGS_STRINGS[k].validate(v);
|
|
if (validateErr) {
|
|
errors.push(`${k}: ${validateErr}`);
|
|
continue;
|
|
}
|
|
validated[k] = v;
|
|
}
|
|
// Short text settings (endpoint URLs / model names / Gemini key).
|
|
// Masked entries: empty string = leave unchanged (no-op), so the
|
|
// operator never has to re-type their Gemini key just to tweak
|
|
// an adjacent URL. Non-masked entries: empty string clears the
|
|
// saved value. URL-pattern fields are validated against their
|
|
// declared regex.
|
|
for (const k of SETTINGS_TEXT_KEYS) {
|
|
const v = incoming[k];
|
|
if (v === undefined) continue;
|
|
if (typeof v !== "string") {
|
|
errors.push(`${k}: must be a string (got ${typeof v})`);
|
|
continue;
|
|
}
|
|
const trimmed = v.trim();
|
|
const meta = SETTINGS_TEXT[k];
|
|
if (meta.masked && trimmed === "") {
|
|
// Skip — keep whatever's currently saved.
|
|
continue;
|
|
}
|
|
if (meta.urlPattern && trimmed !== "") {
|
|
try {
|
|
if (!new RegExp(meta.urlPattern).test(trimmed)) {
|
|
errors.push(`${k}: must match ${meta.urlPattern}`);
|
|
continue;
|
|
}
|
|
} catch {
|
|
// Pattern compile error shouldn't ever happen for our
|
|
// hardcoded regexes; if it does, skip pattern validation
|
|
// rather than fail the request.
|
|
}
|
|
}
|
|
// Soft length cap to keep noisy paste-accidents out of config.
|
|
if (trimmed.length > 512) {
|
|
errors.push(`${k}: value too long (max 512 chars)`);
|
|
continue;
|
|
}
|
|
validated[k] = trimmed;
|
|
}
|
|
if (errors.length > 0) {
|
|
return res.status(400).json({ ok: false, errors });
|
|
}
|
|
// Merge into existing relay-config.json and write back.
|
|
const configPath = path.join(dataDir, "config", "relay-config.json");
|
|
let existing = {};
|
|
try {
|
|
existing = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
} catch {}
|
|
Object.assign(existing, validated);
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
// Return the merged final state so the dashboard can refresh without
|
|
// a second GET.
|
|
const cfg = await getConfigSnapshot();
|
|
const final = {};
|
|
for (const k of SETTINGS_KEYS) {
|
|
final[k] = cfg[k] ?? SETTINGS_DEFAULTS[k];
|
|
}
|
|
for (const k of SETTINGS_BOOLS) {
|
|
final[k] = cfg[k] ?? SETTINGS_BOOL_DEFAULTS[k];
|
|
}
|
|
for (const k of SETTINGS_ENUM_KEYS) {
|
|
final[k] = cfg[k] ?? SETTINGS_ENUMS[k].default;
|
|
}
|
|
for (const k of SETTINGS_STRING_KEYS) {
|
|
final[k] = cfg[k] || "";
|
|
}
|
|
// Text settings — return saved values for non-masked, never
|
|
// return masked secret values (just emit empty string so the
|
|
// dashboard doesn't accidentally display them).
|
|
for (const k of SETTINGS_TEXT_KEYS) {
|
|
if (SETTINGS_TEXT[k].masked) {
|
|
final[k] = "";
|
|
} else {
|
|
final[k] = cfg[k] || "";
|
|
}
|
|
}
|
|
res.json({ ok: true, settings: final });
|
|
});
|
|
|
|
// POST /admin/settings/promote-prompt
|
|
// Body: { key: "relay_transcribe_prompt" | "relay_analyze_prompt" }
|
|
//
|
|
// Promotes the operator's CURRENT override into a persistent
|
|
// operator-default. Used by the dashboard's "Set as new default"
|
|
// button so the operator can evolve their prompt baselines over
|
|
// time without code redeploys. Effect:
|
|
//
|
|
// 1. Validate the override against the same rules PUT uses
|
|
// (analyze prompt must contain {{transcript}} + JSON output)
|
|
// 2. Move cfg[key] (the override) → cfg[key + "_default"]
|
|
// (the operator-promoted default)
|
|
// 3. Clear cfg[key] (no override anymore — the override and
|
|
// the default are now the same string, so the override is
|
|
// redundant; clearing it lets future "Reset to default"
|
|
// operations restore to the new operator default)
|
|
//
|
|
// Request-time resolution becomes: override (empty after promote)
|
|
// → operator-promoted default (just written) → code-side default.
|
|
// The active prompt sent to Gemini doesn't change, but future
|
|
// edits start from the new baseline.
|
|
router.post(
|
|
"/settings/promote-prompt",
|
|
express.json(),
|
|
async (req, res) => {
|
|
const key = String(req.body?.key || "").trim();
|
|
if (!SETTINGS_STRING_KEYS.includes(key)) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: "unknown_prompt_key",
|
|
message: `Expected one of: ${SETTINGS_STRING_KEYS.join(", ")}`,
|
|
});
|
|
}
|
|
const cfg = await getConfigSnapshot();
|
|
const override = (cfg[key] || "").trim();
|
|
if (!override) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: "nothing_to_promote",
|
|
message:
|
|
"No override is currently saved — promote nothing means promote nothing. Edit the textarea and Save first, then click Set as new default.",
|
|
});
|
|
}
|
|
const validateErr = SETTINGS_STRINGS[key].validate(override);
|
|
if (validateErr) {
|
|
return res.status(400).json({
|
|
ok: false,
|
|
error: "invalid_override",
|
|
message: `Can't promote — current override fails validation: ${validateErr}`,
|
|
});
|
|
}
|
|
const configPath = path.join(dataDir, "config", "relay-config.json");
|
|
let existing = {};
|
|
try {
|
|
existing = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
} catch {}
|
|
// Atomically: write the new operator default, clear the override.
|
|
existing[key + "_default"] = override;
|
|
existing[key] = "";
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify(existing, null, 2),
|
|
{ mode: 0o600 },
|
|
);
|
|
res.json({
|
|
ok: true,
|
|
promoted: { key, length: override.length },
|
|
});
|
|
},
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Query-string helpers for /jobs-history.
|
|
function numQ(v) {
|
|
if (typeof v !== "string") return null;
|
|
const n = parseInt(v, 10);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
function strQ(v) {
|
|
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
}
|
|
function oneOf(v, allowed) {
|
|
if (typeof v !== "string") return null;
|
|
return allowed.includes(v) ? v : null;
|
|
}
|
|
|
|
// RFC4180-ish CSV cell escaping: wrap in quotes if the value contains
|
|
// a comma, quote, or newline; double up any embedded quotes.
|
|
function csvCell(v) {
|
|
if (v === null || v === undefined) return "";
|
|
const s = String(v);
|
|
if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) {
|
|
return '"' + s.replace(/"/g, '""') + '"';
|
|
}
|
|
return s;
|
|
}
|