mirror of
https://github.com/rmoren97/mc-manager.git
synced 2026-03-28 17:26:47 -07:00
162 lines
4.8 KiB
TypeScript
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',
|
|
},
|
|
})
|
|
}
|