mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
283 lines
9.7 KiB
TypeScript
283 lines
9.7 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
// 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<string>`coalesce(sum(${schema.users.storageUsed}), 0)::text`,
|
|
userCount: sql<number>`count(*)::int`,
|
|
})
|
|
.from(schema.users)
|
|
.groupBy(schema.users.team);
|
|
|
|
const jobsPerDay = (jobsPerDayResult.rows as Array<Record<string, unknown>>).map((r) => ({
|
|
day: String(r.day),
|
|
total: Number(r.total),
|
|
completed: Number(r.completed),
|
|
failed: Number(r.failed),
|
|
}));
|
|
|
|
const topTools = (topToolsResult.rows as Array<Record<string, unknown>>).map((r) => ({
|
|
toolId: String(r.tool_id),
|
|
runs: Number(r.runs),
|
|
}));
|
|
|
|
const perUser = (perUserResult.rows as Array<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>)[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<string, unknown>;
|
|
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 };
|
|
});
|
|
}
|