/** * Admin operations routes -- runtime log level, Prometheus metrics, * diagnostic support bundle, usage dashboard, and backup status tracking. * * GET /api/v1/admin/log-level -- read current pino log level * POST /api/v1/admin/log-level -- change level at runtime * GET /api/v1/metrics -- Prometheus scrape endpoint * GET /api/v1/admin/support-bundle -- download redacted diagnostic zip * GET /api/v1/admin/usage -- local usage dashboard data * POST /api/v1/admin/backup-status -- record backup completion * GET /api/v1/admin/backup-status -- read last backup info + staleness */ import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db, schema } from "../db/index.js"; import { formatZodErrors } from "../lib/errors.js"; import { metricsText } from "../lib/metrics.js"; import { buildSupportBundle } from "../lib/support-bundle.js"; import { requirePermission } from "../permissions.js"; const logLevelSchema = z.object({ level: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), }); export async function adminOpsRoutes(app: FastifyInstance): Promise { // GET /api/v1/admin/log-level app.get("/api/v1/admin/log-level", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("settings:write")(request, reply); if (!admin) return; return { level: app.log.level }; }); // POST /api/v1/admin/log-level app.post("/api/v1/admin/log-level", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("settings:write")(request, reply); if (!admin) return; const parsed = logLevelSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: "Invalid log level", code: "VALIDATION_ERROR", details: formatZodErrors(parsed.error.issues), }); } app.log.level = parsed.data.level; return { level: app.log.level }; }); // GET /api/v1/metrics -- Prometheus scrape endpoint app.get("/api/v1/metrics", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("system:health")(request, reply); if (!admin) return; const text = await metricsText(); return reply.type("text/plain; version=0.0.4").send(text); }); // GET /api/v1/admin/support-bundle -- download diagnostic zip app.get("/api/v1/admin/support-bundle", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("system:health")(request, reply); if (!admin) return; const date = new Date().toISOString().slice(0, 10); const stream = buildSupportBundle(); return reply .type("application/zip") .header("Content-Disposition", `attachment; filename=snapotter-support-${date}.zip`) .send(stream); }); // GET /api/v1/admin/usage -- local usage dashboard data const usageQuerySchema = z.object({ days: z .string() .optional() .transform((v) => { const n = v ? Number(v) : 30; if (Number.isNaN(n)) return 30; return Math.max(1, Math.min(365, Math.round(n))); }), }); app.get("/api/v1/admin/usage", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("audit:read")(request, reply); if (!admin) return; const parsed = usageQuerySchema.safeParse(request.query); if (!parsed.success) { return reply.status(400).send({ error: "Invalid query parameters", code: "VALIDATION_ERROR", details: formatZodErrors(parsed.error.issues), }); } const days = parsed.data.days; // Jobs per day const jobsPerDayResult = await db.execute( sql`SELECT date_trunc('day', created_at)::date::text AS day, count(*)::int AS total, count(*) FILTER (WHERE status = 'completed')::int AS completed, count(*) FILTER (WHERE status = 'failed')::int AS failed FROM jobs WHERE created_at > now() - make_interval(days => ${days}) GROUP BY 1 ORDER BY 1`, ); // Top tools const topToolsResult = await db.execute( sql`SELECT tool_id, count(*)::int AS runs FROM jobs WHERE tool_id IS NOT NULL AND created_at > now() - make_interval(days => ${days}) GROUP BY 1 ORDER BY 2 DESC LIMIT 15`, ); // Per user const perUserResult = await db.execute( sql`SELECT u.username, count(*)::int AS runs, coalesce(sum(j.bytes_in), 0)::text AS bytes_in FROM jobs j LEFT JOIN users u ON u.id = j.user_id WHERE j.created_at > now() - make_interval(days => ${days}) GROUP BY 1 ORDER BY 2 DESC LIMIT 15`, ); // Duration percentiles const durationsResult = await db.execute( sql`SELECT pool, percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95 FROM jobs WHERE duration_ms IS NOT NULL AND created_at > now() - make_interval(days => ${days}) GROUP BY pool`, ); // Storage totals const storageResult = await db.execute( sql`SELECT coalesce(sum(size), 0)::text AS bytes, count(*)::int AS files FROM user_files`, ); // Per-team storage breakdown from pre-computed user counters const teamStorageRows = await db .select({ teamName: schema.users.team, totalBytes: sql`coalesce(sum(${schema.users.storageUsed}), 0)::text`, userCount: sql`count(*)::int`, }) .from(schema.users) .groupBy(schema.users.team); const jobsPerDay = (jobsPerDayResult.rows as Array>).map((r) => ({ day: String(r.day), total: Number(r.total), completed: Number(r.completed), failed: Number(r.failed), })); const topTools = (topToolsResult.rows as Array>).map((r) => ({ toolId: String(r.tool_id), runs: Number(r.runs), })); const perUser = (perUserResult.rows as Array>).map((r) => ({ username: r.username != null ? String(r.username) : null, runs: Number(r.runs), bytesIn: String(r.bytes_in), })); const durations = (durationsResult.rows as Array>).map((r) => ({ pool: String(r.pool), p50Ms: r.p50 != null ? Math.round(Number(r.p50)) : null, p95Ms: r.p95 != null ? Math.round(Number(r.p95)) : null, })); const storageRow = (storageResult.rows as Array>)[0] || { bytes: "0", files: 0, }; const storage = { libraryBytes: String(storageRow.bytes), libraryFiles: Number(storageRow.files), }; const teamStorage = teamStorageRows.map((r) => ({ teamName: r.teamName, totalBytes: r.totalBytes, userCount: r.userCount, })); return { days, jobsPerDay, topTools, perUser, durations, storage, teamStorage, }; }); // --------------------------------------------------------------------------- // Backup status tracking // --------------------------------------------------------------------------- const backupStatusSchema = z.object({ type: z.string().min(1), sizeBytes: z.number().optional(), notes: z.string().optional(), }); const BACKUP_KEY = "backup_last_completed"; // POST /api/v1/admin/backup-status -- record backup completion app.post("/api/v1/admin/backup-status", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("system:health")(request, reply); if (!admin) return; const parsed = backupStatusSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: "Invalid backup status", code: "VALIDATION_ERROR", details: formatZodErrors(parsed.error.issues), }); } const timestamp = new Date().toISOString(); const payload = JSON.stringify({ timestamp, type: parsed.data.type, sizeBytes: parsed.data.sizeBytes ?? null, notes: parsed.data.notes ?? "", }); const [existing] = await db .select() .from(schema.settings) .where(eq(schema.settings.key, BACKUP_KEY)); if (existing) { await db .update(schema.settings) .set({ value: payload, updatedAt: new Date() }) .where(eq(schema.settings.key, BACKUP_KEY)); } else { await db.insert(schema.settings).values({ key: BACKUP_KEY, value: payload }); } return { success: true, timestamp }; }); // GET /api/v1/admin/backup-status -- read last backup info + staleness app.get("/api/v1/admin/backup-status", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("system:health")(request, reply); if (!admin) return; const result = await db .select({ value: schema.settings.value }) .from(schema.settings) .where(eq(schema.settings.key, BACKUP_KEY)) .limit(1); if (result.length === 0) { return { lastBackup: null, status: "critical", ageHours: null }; } let lastBackup: Record; try { lastBackup = JSON.parse(result[0].value); } catch { return { lastBackup: null, status: "critical", ageHours: null }; } const ts = new Date(lastBackup.timestamp as string); const ageMs = Date.now() - ts.getTime(); const ageHours = Math.round((ageMs / 3_600_000) * 10) / 10; let status: "ok" | "warning" | "critical"; if (ageHours < 24) { status = "ok"; } else if (ageHours < 48) { status = "warning"; } else { status = "critical"; } return { lastBackup, status, ageHours }; }); }