diff --git a/dashboard/backend/src/modules/vm/repository.ts b/dashboard/backend/src/modules/vm/repository.ts index 21f6729..dbabcc0 100644 --- a/dashboard/backend/src/modules/vm/repository.ts +++ b/dashboard/backend/src/modules/vm/repository.ts @@ -352,3 +352,116 @@ export async function unloadOllamaModel(name: string): Promise<{ success: boolea return { success: false, message: String(error.message ?? error) }; } } + +// --------------------------------------------------------------------------- +// Full container list with CPU/RAM stats +// --------------------------------------------------------------------------- + +export interface ContainerInfo { + name: string; + image: string; + stack: string; + state: string; + health: string; + uptimeSecs: number; + cpuPercent: number; + memMiB: number; + memLimitMiB: number; // 0 = no limit configured + restartCount: number; +} + +export async function getAllContainers(): Promise { + try { + // 1. All container IDs (including stopped) + const { stdout: idsOut } = await execAsync('docker ps -aq 2>/dev/null', { timeout: 5_000 }); + const ids = idsOut.trim().split('\n').filter(Boolean); + if (!ids.length) return []; + + // 2. Batch inspect (state, health, labels, memory limit, restart count) + const { stdout: inspectOut } = await execAsync( + `docker inspect ${ids.slice(0, 120).join(' ')} 2>/dev/null`, + { timeout: 20_000 }, + ); + const inspected: any[] = JSON.parse(inspectOut); + + // 3. CPU + RAM stats for running containers only + const { stdout: statsOut } = await execAsync( + "docker stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' 2>/dev/null", + { timeout: 25_000 }, + ); + const statsMap = new Map(); + for (const line of statsOut.trim().split('\n').filter(Boolean)) { + const parts = line.split('\t'); + if (parts.length < 3) continue; + const [cname, cpuStr, memStr] = parts; + const cpu = parseFloat(cpuStr) || 0; + // Parse "95.36MiB / 15.62GiB" — take used portion before '/' + const usedStr = (memStr ?? '').split('/')[0].trim(); + const memMatch = usedStr.match(/([\d.]+)\s*(GiB|MiB|KiB|B)/i); + let memMiB = 0; + if (memMatch) { + const val = parseFloat(memMatch[1]); + const unit = memMatch[2].toLowerCase(); + memMiB = unit === 'gib' ? val * 1024 : unit === 'kib' ? val / 1024 : unit === 'b' ? val / 1_048_576 : val; + } + statsMap.set(cname.trim(), { cpu: Math.round(cpu * 10) / 10, memMiB: Math.round(memMiB) }); + } + + // 4. Merge + const result: ContainerInfo[] = inspected.map((c: any) => { + const name = (c.Name ?? '').replace(/^\//, ''); + const state = c.State?.Status ?? 'unknown'; + const health = c.State?.Health?.Status ?? 'none'; + + let uptimeSecs = 0; + if (state === 'running' && c.State?.StartedAt) { + uptimeSecs = Math.max(0, Math.round((Date.now() - new Date(c.State.StartedAt).getTime()) / 1000)); + } + + const labels = c.Config?.Labels ?? {}; + const stack = labels['com.docker.compose.project'] ?? (() => { + const parts = name.split('-'); + return parts.length > 1 ? parts.slice(0, -1).join('-') : name; + })(); + + const memLimitBytes = c.HostConfig?.Memory ?? 0; + const memLimitMiB = memLimitBytes > 0 ? Math.round(memLimitBytes / 1_048_576) : 0; + + const stats = statsMap.get(name); + return { + name, + image: c.Config?.Image ?? '', + stack, + state, + health, + uptimeSecs, + cpuPercent: stats?.cpu ?? 0, + memMiB: stats?.memMiB ?? 0, + memLimitMiB, + restartCount: c.RestartCount ?? 0, + } satisfies ContainerInfo; + }); + + return result.sort((a, b) => { + if (a.state === 'running' && b.state !== 'running') return -1; + if (a.state !== 'running' && b.state === 'running') return 1; + return a.name.localeCompare(b.name); + }); + } catch (err) { + console.error('getAllContainers failed:', err); + return []; + } +} + +export async function getContainerLogs(name: string, lines = 50): Promise { + if (!/^[\w-]+$/.test(name)) throw new Error('Invalid container name'); + try { + const { stdout } = await execAsync( + `docker logs --tail ${lines} --timestamps "${name}" 2>&1`, + { timeout: 10_000 }, + ); + return stdout.trim(); + } catch (error: any) { + return ((error.stdout ?? '') + (error.stderr ?? '')).trim() || String(error.message ?? 'Failed to get logs'); + } +} diff --git a/dashboard/backend/src/modules/vm/routes.ts b/dashboard/backend/src/modules/vm/routes.ts index 5972d68..6375231 100644 --- a/dashboard/backend/src/modules/vm/routes.ts +++ b/dashboard/backend/src/modules/vm/routes.ts @@ -9,6 +9,8 @@ import { restartContainer, getOllamaModels, unloadOllamaModel, + getAllContainers, + getContainerLogs, } from './repository.js'; import { getDiskTrend, @@ -79,6 +81,34 @@ export async function vmRoutes(fastify: FastifyInstance) { } }); + // ── All containers (full list with CPU/RAM) ─────────────────────────────── + + // GET /api/vm/containers + fastify.get('/vm/containers', { + preHandler: async (req) => requireAdmin(req), + }, async (_req, reply) => { + try { + return reply.send(await getAllContainers()); + } catch (error) { + fastify.log.error(error, 'Failed to get containers'); + return reply.code(500).send({ error: 'Failed to get containers' }); + } + }); + + // GET /api/vm/containers/:name/logs?lines=50 + fastify.get('/vm/containers/:name/logs', { + preHandler: async (req) => requireAdmin(req), + }, async (req, reply) => { + try { + const { name } = VmContainerRestartParamsSchema.parse(req.params); + const lines = Math.min(Number((req.query as any).lines) || 50, 200); + return reply.send({ logs: await getContainerLogs(name, lines) }); + } catch (error: any) { + fastify.log.error(error, 'Failed to get container logs'); + return reply.code(500).send({ error: error.message || 'Failed to get container logs' }); + } + }); + // ── Unhealthy containers ────────────────────────────────────────────────── // GET /api/vm/containers/unhealthy diff --git a/dashboard/web/src/app/vm/page.tsx b/dashboard/web/src/app/vm/page.tsx index c5d5e90..3d7f790 100644 --- a/dashboard/web/src/app/vm/page.tsx +++ b/dashboard/web/src/app/vm/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, Fragment } from 'react'; import { SidebarNav } from '@/components/sidebar-nav'; import { vmApi, @@ -10,6 +10,7 @@ import { type UnhealthyContainer, type OllamaModelsResponse, type TrendSeries, + type ContainerInfo, } from '@/lib/api'; import { CheckCircle, @@ -766,6 +767,307 @@ function TrendsPanel({ ); } +// ── Containers panel ─────────────────────────────────────────────────────── + +function ContainersPanel({ + restarting, + onRestart, +}: { + restarting: Set; + onRestart: (name: string) => Promise; +}) { + const [open, setOpen] = useState(false); + const [containers, setContainers] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [filter, setFilter] = useState<'all' | 'running' | 'unhealthy' | 'nolimit'>('all'); + const [stackFilter, setStackFilter] = useState(''); + const [expandedLog, setExpandedLog] = useState(null); + const [logCache, setLogCache] = useState>({}); + const [logsLoading, setLogsLoading] = useState(null); + const [bulkConfirm, setBulkConfirm] = useState(false); + + const loadContainers = async () => { + if (loading) return; + setLoading(true); + try { + const data = await vmApi.getAllContainers(); + setContainers(data); + setLoaded(true); + } catch (err) { + console.error('Failed to load containers:', err); + } finally { + setLoading(false); + } + }; + + const handleToggle = () => { + const next = !open; + setOpen(next); + if (next && !loaded) loadContainers(); + }; + + const handleViewLogs = async (name: string) => { + if (expandedLog === name) { setExpandedLog(null); return; } + setExpandedLog(name); + if (!logCache[name]) { + setLogsLoading(name); + try { + const { logs } = await vmApi.getContainerLogs(name, 50); + setLogCache(prev => ({ ...prev, [name]: logs })); + } catch { + setLogCache(prev => ({ ...prev, [name]: '(failed to load logs)' })); + } finally { + setLogsLoading(null); + } + } + }; + + const unhealthy = containers.filter(c => c.health === 'unhealthy'); + const stacks = [...new Set(containers.map(c => c.stack))].sort(); + + const filtered = containers.filter(c => { + const main = + filter === 'running' ? c.state === 'running' : + filter === 'unhealthy' ? c.health === 'unhealthy' : + filter === 'nolimit' ? (c.state === 'running' && c.memLimitMiB === 0) : + true; + return main && (!stackFilter || c.stack === stackFilter); + }); + + const chips: Array<{ key: typeof filter; label: string; accent?: boolean }> = [ + { key: 'all', label: `All (${containers.length})` }, + { key: 'running', label: `Running (${containers.filter(c => c.state === 'running').length})` }, + { key: 'unhealthy', label: `Unhealthy (${unhealthy.length})`, accent: unhealthy.length > 0 }, + { key: 'nolimit', label: `No Limit (${containers.filter(c => c.state === 'running' && c.memLimitMiB === 0).length})` }, + ]; + + const healthClass = (h: string) => + h === 'healthy' ? 'text-green-600' : h === 'unhealthy' ? 'text-red-600' : h === 'starting' ? 'text-yellow-600' : 'text-gray-400'; + + const fmtUptime = (s: number) => + s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`; + + const fmtMem = (mib: number) => + mib >= 1024 ? `${(mib / 1024).toFixed(1)}G` : `${mib}M`; + + return ( +
+ {/* Header toggle */} + + + {open && ( +
+ {/* Toolbar */} +
+
+ {chips.map(chip => ( + + ))} + +
+
+ {unhealthy.length > 0 && ( + + )} + +
+
+ + {/* Table */} + {loading && !loaded ? ( +
+ Collecting container stats… +
+ ) : ( +
+ + + + + + + + + + + + + + {filtered.map(c => ( + + + + + + + + + + + {expandedLog === c.name && ( + + + + )} + + ))} + +
ContainerHealthUptimeCPURAM
+

{c.name}

+

{c.stack}

+
+ + ● {c.health === 'none' ? '—' : c.health} + + {c.state !== 'running' && ( +

{c.state}

+ )} +
+ {c.state === 'running' ? fmtUptime(c.uptimeSecs) : '—'} + + {c.state === 'running' ? `${c.cpuPercent}%` : '—'} + + {c.state === 'running' ? ( + <> + {fmtMem(c.memMiB)} + {c.memLimitMiB === 0 && ( + + )} + + ) : '—'} + + {c.restartCount > 0 ? ( + 5 ? 'text-red-600' : 'text-yellow-700'}`}> + {c.restartCount} + + ) : ( + 0 + )} + +
+ + {c.state === 'running' && ( + + )} +
+
+ {logsLoading === c.name ? ( +

Loading logs…

+ ) : ( +
+                                {logCache[c.name] ?? '(no logs)'}
+                              
+ )} +
+ {filtered.length === 0 && ( +

No containers match filter

+ )} +
+ )} +
+ )} + + {/* Bulk restart modal */} + {bulkConfirm && ( +
+
+

+ Restart {unhealthy.length} unhealthy container{unhealthy.length !== 1 ? 's' : ''}? +

+
    + {unhealthy.map(c => ( +
  • {c.name}
  • + ))} +
+
+ + +
+
+
+ )} +
+ ); +} + // ── Check card meta ──────────────────────────────────────────────────────── const CHECK_META: Record = { @@ -1074,6 +1376,12 @@ export default function VmHealthPage() { onOpen={() => { if (!trendsLoaded && !trendsLoading) loadTrends(); }} /> + {/* ── All containers ── */} + + {/* ── Cleanup section ── */}
diff --git a/dashboard/web/src/lib/api.ts b/dashboard/web/src/lib/api.ts index 8d2200e..1371dec 100644 --- a/dashboard/web/src/lib/api.ts +++ b/dashboard/web/src/lib/api.ts @@ -489,6 +489,19 @@ export interface OllamaModelsResponse { running: OllamaRunning[]; } +export interface ContainerInfo { + name: string; + image: string; + stack: string; + state: string; + health: string; + uptimeSecs: number; + cpuPercent: number; + memMiB: number; + memLimitMiB: number; + restartCount: number; +} + export const vmApi = { getHealth: () => apiRequest('/api/vm/health'), getCleanupLog: (lines = 40) => @@ -518,6 +531,9 @@ export const vmApi = { apiRequest<{ available: TrendSeries; swap: TrendSeries }>( `/api/vm/metrics/trend?metric=memory&range=${range}`, ), + getAllContainers: () => apiRequest('/api/vm/containers'), + getContainerLogs: (name: string, lines = 50) => + apiRequest<{ logs: string }>(`/api/vm/containers/${encodeURIComponent(name)}/logs?lines=${lines}`), }; export interface TrendPoint { t: number; v: number }