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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-15 00:37:01 +00:00
parent 0615b22188
commit 5f38136b95
8 changed files with 528 additions and 0 deletions

View file

@ -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<NextResponse> {
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'

View file

@ -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<NextResponse> {
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<string, unknown> = {}
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'

View file

@ -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<NextResponse> {
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'

View file

@ -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<NextResponse> {
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<string, unknown>[] = []
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'

View file

@ -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<NextResponse> {
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'

View file

@ -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<T>(result: PromiseSettledResult<T>, fallback: T): T {
if (result.status === 'fulfilled') {
return result.value
}
return fallback
}
export async function GET(req: NextRequest): Promise<NextResponse> {
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'

View file

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
const PERIOD_MS: Record<string, number> = {
'1h': 3_600_000,
'6h': 21_600_000,
'24h': 86_400_000,
'7d': 604_800_000,
}
export async function GET(req: NextRequest): Promise<NextResponse> {
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'

View file

@ -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<Response> {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers })
if (!user || !(user as Record<string, unknown>).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<ReturnType<typeof getPayload>>,
controller: ReadableStreamDefaultController,
encoder: TextEncoder,
since: Date,
): Promise<Date> {
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<ReturnType<typeof getPayload>>,
controller: ReadableStreamDefaultController,
encoder: TextEncoder,
since: Date,
): Promise<Date> {
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