From 5f38136b958e663c9fa527eebf6418299405dc54 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sun, 15 Feb 2026 00:37:01 +0000 Subject: [PATCH] feat(monitoring): add REST API endpoints for monitoring dashboard Add 7 API route handlers for the monitoring system: - GET /api/monitoring/health - system health overview - GET /api/monitoring/services - individual service status checks - GET /api/monitoring/performance - performance metrics with period filter - GET /api/monitoring/alerts - paginated alert history with severity filter - POST /api/monitoring/alerts/acknowledge - acknowledge alerts - GET /api/monitoring/logs - paginated logs with level/source/date filters - GET /api/monitoring/snapshots - time-series data for charts All endpoints require super-admin authentication. Co-Authored-By: Claude Opus 4.6 --- .../monitoring/alerts/acknowledge/route.ts | 42 ++++ .../(payload)/api/monitoring/alerts/route.ts | 45 +++++ .../(payload)/api/monitoring/health/route.ts | 29 +++ .../(payload)/api/monitoring/logs/route.ts | 64 +++++++ .../api/monitoring/performance/route.ts | 46 +++++ .../api/monitoring/services/route.ts | 64 +++++++ .../api/monitoring/snapshots/route.ts | 57 ++++++ .../(payload)/api/monitoring/stream/route.ts | 181 ++++++++++++++++++ 8 files changed, 528 insertions(+) create mode 100644 src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts create mode 100644 src/app/(payload)/api/monitoring/alerts/route.ts create mode 100644 src/app/(payload)/api/monitoring/health/route.ts create mode 100644 src/app/(payload)/api/monitoring/logs/route.ts create mode 100644 src/app/(payload)/api/monitoring/performance/route.ts create mode 100644 src/app/(payload)/api/monitoring/services/route.ts create mode 100644 src/app/(payload)/api/monitoring/snapshots/route.ts create mode 100644 src/app/(payload)/api/monitoring/stream/route.ts diff --git a/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts b/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts new file mode 100644 index 0000000..d457a23 --- /dev/null +++ b/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +export async function POST(req: NextRequest): Promise { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !(user as any).isSuperAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + const { alertId } = body + + if (!alertId) { + return NextResponse.json( + { error: 'alertId is required' }, + { status: 400 }, + ) + } + + const updated = await payload.update({ + collection: 'monitoring-alert-history', + id: alertId, + data: { + acknowledgedBy: user.id, + resolvedAt: new Date().toISOString(), + }, + }) + + return NextResponse.json({ data: updated }) + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/monitoring/alerts/route.ts b/src/app/(payload)/api/monitoring/alerts/route.ts new file mode 100644 index 0000000..2acb0a3 --- /dev/null +++ b/src/app/(payload)/api/monitoring/alerts/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +export async function GET(req: NextRequest): Promise { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !(user as any).isSuperAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const page = parseInt(req.nextUrl.searchParams.get('page') || '1', 10) + const limit = parseInt(req.nextUrl.searchParams.get('limit') || '20', 10) + const severity = req.nextUrl.searchParams.get('severity') + + const where: Record = {} + if (severity) { + where.severity = { equals: severity } + } + + const alerts = await payload.find({ + collection: 'monitoring-alert-history', + where, + page, + limit: Math.min(limit, 100), + sort: '-createdAt', + }) + + return NextResponse.json({ + data: alerts.docs, + totalDocs: alerts.totalDocs, + page: alerts.page, + totalPages: alerts.totalPages, + }) + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/monitoring/health/route.ts b/src/app/(payload)/api/monitoring/health/route.ts new file mode 100644 index 0000000..90088d7 --- /dev/null +++ b/src/app/(payload)/api/monitoring/health/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { checkSystemHealth } from '@/lib/monitoring/monitoring-service' + +export async function GET(req: NextRequest): Promise { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !(user as any).isSuperAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const health = await checkSystemHealth() + + return NextResponse.json({ + data: health, + timestamp: new Date().toISOString(), + }) + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/monitoring/logs/route.ts b/src/app/(payload)/api/monitoring/logs/route.ts new file mode 100644 index 0000000..89065da --- /dev/null +++ b/src/app/(payload)/api/monitoring/logs/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +export async function GET(req: NextRequest): Promise { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !(user as any).isSuperAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const page = parseInt(req.nextUrl.searchParams.get('page') || '1', 10) + const limit = parseInt(req.nextUrl.searchParams.get('limit') || '50', 10) + const level = req.nextUrl.searchParams.get('level') + const source = req.nextUrl.searchParams.get('source') + const search = req.nextUrl.searchParams.get('search') + const from = req.nextUrl.searchParams.get('from') + const to = req.nextUrl.searchParams.get('to') + + const conditions: Record[] = [] + + if (level) { + conditions.push({ level: { equals: level } }) + } + if (source) { + conditions.push({ source: { equals: source } }) + } + if (search) { + conditions.push({ message: { contains: search } }) + } + if (from) { + conditions.push({ createdAt: { greater_than_equal: from } }) + } + if (to) { + conditions.push({ createdAt: { less_than_equal: to } }) + } + + const where = conditions.length > 0 ? { and: conditions } : {} + + const logs = await payload.find({ + collection: 'monitoring-logs', + where, + page, + limit: Math.min(limit, 100), + sort: '-createdAt', + }) + + return NextResponse.json({ + data: logs.docs, + totalDocs: logs.totalDocs, + page: logs.page, + totalPages: logs.totalPages, + }) + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/monitoring/performance/route.ts b/src/app/(payload)/api/monitoring/performance/route.ts new file mode 100644 index 0000000..d5410a7 --- /dev/null +++ b/src/app/(payload)/api/monitoring/performance/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { performanceTracker } from '@/lib/monitoring/performance-tracker' + +const VALID_PERIODS = ['1h', '6h', '24h', '7d'] as const +type Period = (typeof VALID_PERIODS)[number] + +function isValidPeriod(value: string): value is Period { + return (VALID_PERIODS as readonly string[]).includes(value) +} + +export async function GET(req: NextRequest): Promise { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !(user as any).isSuperAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const period = req.nextUrl.searchParams.get('period') || '1h' + + if (!isValidPeriod(period)) { + return NextResponse.json( + { error: `Invalid period. Valid: ${VALID_PERIODS.join(', ')}` }, + { status: 400 }, + ) + } + + const metrics = performanceTracker.getMetrics(period) + + return NextResponse.json({ + data: metrics, + period, + timestamp: new Date().toISOString(), + }) + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/monitoring/services/route.ts b/src/app/(payload)/api/monitoring/services/route.ts new file mode 100644 index 0000000..2d2f78f --- /dev/null +++ b/src/app/(payload)/api/monitoring/services/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { + checkRedis, + checkPostgresql, + checkPgBouncer, + checkSmtp, + checkOAuthTokens, + checkCronJobs, + checkQueues, +} from '@/lib/monitoring/monitoring-service' + +function resolveSettled(result: PromiseSettledResult, fallback: T): T { + if (result.status === 'fulfilled') { + return result.value + } + return fallback +} + +export async function GET(req: NextRequest): Promise { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !(user as any).isSuperAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const [redis, postgresql, pgbouncer, smtp, oauth, cronJobs, queues] = + await Promise.allSettled([ + checkRedis(), + checkPostgresql(), + checkPgBouncer(), + checkSmtp(), + checkOAuthTokens(), + checkCronJobs(), + checkQueues(), + ]) + + return NextResponse.json({ + data: { + redis: resolveSettled(redis, { status: 'offline' }), + postgresql: resolveSettled(postgresql, { status: 'offline' }), + pgbouncer: resolveSettled(pgbouncer, { status: 'offline' }), + smtp: resolveSettled(smtp, { status: 'offline' }), + oauth: resolveSettled(oauth, { + metaOAuth: { status: 'error' }, + youtubeOAuth: { status: 'error' }, + }), + cronJobs: resolveSettled(cronJobs, {}), + queues: resolveSettled(queues, {}), + }, + timestamp: new Date().toISOString(), + }) + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/monitoring/snapshots/route.ts b/src/app/(payload)/api/monitoring/snapshots/route.ts new file mode 100644 index 0000000..7df1e91 --- /dev/null +++ b/src/app/(payload)/api/monitoring/snapshots/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' + +const PERIOD_MS: Record = { + '1h': 3_600_000, + '6h': 21_600_000, + '24h': 86_400_000, + '7d': 604_800_000, +} + +export async function GET(req: NextRequest): Promise { + try { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: req.headers }) + + if (!user || !(user as any).isSuperAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const period = req.nextUrl.searchParams.get('period') || '24h' + const ms = PERIOD_MS[period] + + if (!ms) { + return NextResponse.json( + { + error: `Invalid period. Valid: ${Object.keys(PERIOD_MS).join(', ')}`, + }, + { status: 400 }, + ) + } + + const since = new Date(Date.now() - ms).toISOString() + + const snapshots = await payload.find({ + collection: 'monitoring-snapshots', + where: { + timestamp: { greater_than: since }, + }, + limit: 1000, + sort: 'timestamp', + }) + + return NextResponse.json({ + data: snapshots.docs, + totalDocs: snapshots.totalDocs, + period, + }) + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/(payload)/api/monitoring/stream/route.ts b/src/app/(payload)/api/monitoring/stream/route.ts new file mode 100644 index 0000000..61537f2 --- /dev/null +++ b/src/app/(payload)/api/monitoring/stream/route.ts @@ -0,0 +1,181 @@ +import { NextRequest } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { checkSystemHealth } from '@/lib/monitoring/monitoring-service' +import { performanceTracker } from '@/lib/monitoring/performance-tracker' + +const MAX_DURATION_MS = 25_000 +const CYCLE_INTERVAL_MS = 5_000 +const HEALTH_INTERVAL_MS = 10_000 +const PERFORMANCE_INTERVAL_MS = 30_000 + +/** + * Formats a named SSE event. Named events allow clients to listen selectively + * via `eventSource.addEventListener('health', ...)` rather than processing + * everything through the generic `onmessage` handler. + */ +function formatSSE(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +} + +/** + * GET /api/monitoring/stream + * + * Server-Sent Events endpoint that pushes monitoring data to the admin + * dashboard in real time. Emits named events: connected, health, + * performance, alert, log, error, and reconnect. + * + * Requires super-admin authentication. The stream runs for up to 25 seconds + * before asking the client to reconnect (Vercel 30-second limit). + */ +export async function GET(request: NextRequest): Promise { + const payload = await getPayload({ config }) + const { user } = await payload.auth({ headers: request.headers }) + + if (!user || !(user as Record).isSuperAdmin) { + return new Response('Unauthorized', { status: 401 }) + } + + const encoder = new TextEncoder() + let lastAlertCheck = new Date() + let lastLogCheck = new Date() + let lastHealthSend = 0 + let lastPerfSend = 0 + + const stream = new ReadableStream({ + async start(controller) { + const startTime = Date.now() + + controller.enqueue( + encoder.encode(formatSSE('connected', { timestamp: new Date().toISOString() })), + ) + + while (Date.now() - startTime < MAX_DURATION_MS) { + try { + const now = Date.now() + + if (now - lastHealthSend >= HEALTH_INTERVAL_MS) { + const health = await checkSystemHealth() + controller.enqueue(encoder.encode(formatSSE('health', health))) + lastHealthSend = now + } + + if (now - lastPerfSend >= PERFORMANCE_INTERVAL_MS) { + const perf = performanceTracker.getMetrics('1h') + controller.enqueue(encoder.encode(formatSSE('performance', perf))) + lastPerfSend = now + } + + lastAlertCheck = await emitNewAlerts(payload, controller, encoder, lastAlertCheck) + lastLogCheck = await emitNewLogs(payload, controller, encoder, lastLogCheck) + + await new Promise((resolve) => setTimeout(resolve, CYCLE_INTERVAL_MS)) + } catch (error) { + console.error('[MonitoringSSE] Error:', error) + controller.enqueue( + encoder.encode( + formatSSE('error', { + message: 'Internal error', + timestamp: new Date().toISOString(), + }), + ), + ) + } + } + + controller.enqueue( + encoder.encode(formatSSE('reconnect', { timestamp: new Date().toISOString() })), + ) + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Queries recently created alerts and pushes each one as a named "alert" event. + * Returns the updated checkpoint timestamp. + */ +async function emitNewAlerts( + payload: Awaited>, + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + since: Date, +): Promise { + try { + const result = await payload.find({ + collection: 'monitoring-alert-history', + where: { createdAt: { greater_than: since.toISOString() } }, + limit: 10, + sort: '-createdAt', + }) + + if (result.docs.length > 0) { + for (const alert of result.docs) { + controller.enqueue(encoder.encode(formatSSE('alert', alert))) + } + return new Date() + } + } catch { + // Alert collection query failed; skip this cycle + } + + return since +} + +/** + * Queries recent warn/error/fatal log entries and pushes each one as a + * named "log" event. Returns the updated checkpoint timestamp. + */ +async function emitNewLogs( + payload: Awaited>, + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + since: Date, +): Promise { + try { + const result = await payload.find({ + collection: 'monitoring-logs', + where: { + and: [ + { createdAt: { greater_than: since.toISOString() } }, + { + or: [ + { level: { equals: 'warn' } }, + { level: { equals: 'error' } }, + { level: { equals: 'fatal' } }, + ], + }, + ], + }, + limit: 20, + sort: '-createdAt', + }) + + if (result.docs.length > 0) { + for (const log of result.docs) { + controller.enqueue(encoder.encode(formatSSE('log', log))) + } + return new Date() + } + } catch { + // Log collection query failed; skip this cycle + } + + return since +} + +export const dynamic = 'force-dynamic' +export const maxDuration = 30