2026-02-13 15:16:54 -08:00

162 lines
4.8 KiB
TypeScript

import { NextRequest } from 'next/server'
import { validateSession, hasServerPermission } from '@/lib/auth'
import connectToDatabase from '@/lib/mongodb'
import { Server } from '@/lib/models'
import { isValidObjectId } from '@/lib/input-validation'
import { getContainerByName } from '@/lib/docker'
interface DockerCpuStats {
cpu_stats: {
cpu_usage: { total_usage: number }
system_cpu_usage: number
online_cpus: number
}
precpu_stats: {
cpu_usage: { total_usage: number }
system_cpu_usage: number
}
}
interface DockerMemoryStats {
memory_stats: {
usage: number
limit: number
stats?: { cache?: number }
}
}
type DockerStats = DockerCpuStats & DockerMemoryStats
function calculateCpuPercent(stats: DockerStats): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage
const cpuCount = stats.cpu_stats.online_cpus || 1
if (systemDelta <= 0 || cpuDelta < 0) return 0
return Math.min(100, (cpuDelta / systemDelta) * cpuCount * 100)
}
// GET /api/servers/[id]/stats — SSE stream of container CPU & memory stats
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await validateSession(request)
if (!session) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
}
const { id } = await params
if (!isValidObjectId(id)) {
return new Response(JSON.stringify({ error: 'Invalid server ID' }), { status: 400 })
}
await connectToDatabase()
const server = await Server.findById(id).lean()
if (!server) {
return new Response(JSON.stringify({ error: 'Server not found' }), { status: 404 })
}
const adminIds = (server.admins ?? []).map((a) => a.toString())
if (!hasServerPermission(session, 'servers:view', adminIds)) {
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
}
const container = await getContainerByName(`mc-${server._id}`).catch(() => null)
if (!container) {
return new Response(JSON.stringify({ error: 'Container not found' }), { status: 404 })
}
// Check if container is running
try {
const inspect = await container.inspect()
if (!inspect.State?.Running) {
return new Response(JSON.stringify({ error: 'Container is not running' }), { status: 400 })
}
} catch {
return new Response(JSON.stringify({ error: 'Failed to inspect container' }), { status: 500 })
}
const encoder = new TextEncoder()
let cancelled = false
const stream = new ReadableStream({
async start(controller) {
try {
const statsStream = await container.stats({ stream: true }) as NodeJS.ReadableStream
let buffer = ''
const onData = (chunk: Buffer) => {
if (cancelled) return
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) continue
try {
const stats: DockerStats = JSON.parse(line)
const cpuPercent = calculateCpuPercent(stats)
const memUsage = stats.memory_stats.usage || 0
const memLimit = stats.memory_stats.limit || 0
const memCache = stats.memory_stats.stats?.cache || 0
const memActual = memUsage - memCache
const payload = {
cpu: Math.round(cpuPercent * 100) / 100,
memUsed: memActual,
memLimit: memLimit,
memPercent: memLimit > 0 ? Math.round((memActual / memLimit) * 10000) / 100 : 0,
timestamp: Date.now(),
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
} catch {
// Skip malformed JSON lines
}
}
}
const onEnd = () => {
if (!cancelled) {
controller.close()
}
}
const onError = () => {
if (!cancelled) {
controller.close()
}
}
statsStream.on('data', onData)
statsStream.on('end', onEnd)
statsStream.on('error', onError)
// Cleanup on cancel
request.signal.addEventListener('abort', () => {
cancelled = true
statsStream.removeListener('data', onData)
statsStream.removeListener('end', onEnd)
statsStream.removeListener('error', onError)
try { (statsStream as any).destroy?.() } catch {}
try { controller.close() } catch {}
})
} catch {
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}