'use client';
import { useEffect, useState, useCallback } from 'react';
import { SidebarNav } from '@/components/sidebar-nav';
import {
vmApi,
type VmHealthResult,
type VmCheckLevel,
type CronStatusResponse,
type UnhealthyContainer,
type OllamaModelsResponse,
} from '@/lib/api';
import {
CheckCircle,
AlertTriangle,
XCircle,
RefreshCw,
HardDrive,
Cpu,
Database,
Server,
Activity,
Layers,
ScrollText,
Trash2,
Terminal,
ChevronDown,
ChevronUp,
Clock,
Bot,
RotateCw,
Gauge,
Shield,
Zap,
MemoryStick,
} from 'lucide-react';
// ── Types ──────────────────────────────────────────────────────────────────
type Level = VmCheckLevel;
// ── Shared helpers ─────────────────────────────────────────────────────────
function levelColor(level: Level) {
switch (level) {
case 'OK': return 'text-green-700 bg-green-50 border-green-200';
case 'WARN': return 'text-yellow-700 bg-yellow-50 border-yellow-200';
case 'CRIT': return 'text-red-700 bg-red-50 border-red-200';
}
}
function levelBadge(level: Level) {
switch (level) {
case 'OK': return 'bg-green-100 text-green-800';
case 'WARN': return 'bg-yellow-100 text-yellow-800';
case 'CRIT': return 'bg-red-100 text-red-800';
}
}
function LevelIcon({ level, className = 'w-5 h-5' }: { level: Level; className?: string }) {
switch (level) {
case 'OK': return ;
case 'WARN': return ;
case 'CRIT': return ;
}
}
function relativeTime(iso: string | null | undefined): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
if (isNaN(diff)) return '—';
const mins = Math.floor(diff / 60000);
if (mins < 2) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
// ── Score card ─────────────────────────────────────────────────────────────
interface ScoreDim { label: string; pts: number; maxPts: number; detail: string }
function computeScore(
health: VmHealthResult | null,
unhealthyCount: number,
cronData: CronStatusResponse | null,
): { total: number; dims: ScoreDim[] } {
const checks = health?.checks ?? {};
// 1. CPU efficiency — steal %
const stealPct = parseFloat((checks.steal?.value ?? '0%').match(/^([\d.]+)/)?.[1] ?? '0');
const cpuPts = stealPct < 2 ? 20 : stealPct < 5 ? 15 : stealPct < 10 ? 10 : 5;
// 2. Memory pressure — available GB (first number in value, e.g. "11G / 15G")
const ramAvailGb = parseFloat((checks.ram?.value ?? '0G').match(/^([\d.]+)/)?.[1] ?? '0');
const ramPts = ramAvailGb > 6 ? 20 : ramAvailGb > 3 ? 15 : ramAvailGb > 1 ? 5 : 0;
// 3. Disk health — used % (first number in value, e.g. "37% used, 123G free")
const diskPct = parseInt((checks.disk?.value ?? '100%').match(/^(\d+)/)?.[1] ?? '100', 10);
const diskPts = diskPct < 40 ? 15 : diskPct < 55 ? 10 : diskPct < 70 ? 5 : 0;
// 4. Service health — unhealthy container count
const svcPts = unhealthyCount === 0 ? 20 : unhealthyCount <= 2 ? 15 : unhealthyCount <= 5 ? 8 : 2;
// 5. Maintenance hygiene — last successful cleanup
let maintPts = 0;
let maintDetail = 'no history';
if (cronData?.recentRuns?.length) {
const lastRun = cronData.recentRuns.find(r => r.success);
if (lastRun) {
const daysSince = (Date.now() - new Date(lastRun.timestamp).getTime()) / 86_400_000;
maintPts = daysSince < 7 && lastRun.freedMB > 0 ? 15 : daysSince < 30 ? 8 : 0;
maintDetail = `${Math.floor(daysSince)}d ago, freed ${lastRun.freedMB > 0 ? lastRun.freedMB + ' MB' : '0 MB'}`;
}
}
// 6. LLM readiness — available RAM
const llmPts = ramAvailGb > 8 ? 10 : ramAvailGb > 4 ? 7 : ramAvailGb > 2 ? 4 : 1;
const total = cpuPts + ramPts + diskPts + svcPts + maintPts + llmPts;
return {
total,
dims: [
{ label: 'CPU Efficiency', pts: cpuPts, maxPts: 20, detail: `${stealPct.toFixed(1)}% steal` },
{ label: 'Memory Pressure', pts: ramPts, maxPts: 20, detail: `${ramAvailGb}G available` },
{ label: 'Disk Health', pts: diskPts, maxPts: 15, detail: `${diskPct}% used` },
{ label: 'Service Health', pts: svcPts, maxPts: 20, detail: `${unhealthyCount} unhealthy` },
{ label: 'Maintenance', pts: maintPts, maxPts: 15, detail: maintDetail },
{ label: 'LLM Readiness', pts: llmPts, maxPts: 10, detail: `${ramAvailGb}G free` },
],
};
}
function ScoreCard({
health,
unhealthyCount,
cronData,
}: {
health: VmHealthResult | null;
unhealthyCount: number;
cronData: CronStatusResponse | null;
}) {
const { total, dims } = computeScore(health, unhealthyCount, cronData);
const scoreColor = total >= 80 ? 'text-green-600' : total >= 60 ? 'text-yellow-600' : 'text-red-600';
const scoreBg = total >= 80 ? 'bg-green-50 border-green-200' : total >= 60 ? 'bg-yellow-50 border-yellow-200' : 'bg-red-50 border-red-200';
return (
{/* Score gauge */}
{total}
/ 100
{/* Dimension breakdown */}
{dims.map(d => {
const pct = Math.round((d.pts / d.maxPts) * 100);
const barColor = pct >= 75 ? 'bg-green-400' : pct >= 50 ? 'bg-yellow-400' : 'bg-red-400';
return (
{d.label}
{d.pts}/{d.maxPts}
{d.detail}
);
})}
);
}
// ── Unhealthy containers panel ─────────────────────────────────────────────
function UnhealthyContainersPanel({
containers,
onRestart,
restarting,
}: {
containers: UnhealthyContainer[];
onRestart: (name: string) => Promise;
restarting: Set;
}) {
const [expanded, setExpanded] = useState>(new Set());
const toggle = (name: string) =>
setExpanded(prev => {
const next = new Set(prev);
next.has(name) ? next.delete(name) : next.add(name);
return next;
});
if (containers.length === 0) return null;
return (
{containers.length} Unhealthy Container{containers.length !== 1 ? 's' : ''}
process alive, health endpoint failing
{containers.map(c => (
{expanded.has(c.name) && c.lastHealthLogs.length > 0 && (
Last health check output:
{c.lastHealthLogs.join('\n') || '(no output)'}
)}
))}
);
}
// ── Cron status panel ──────────────────────────────────────────────────────
function CronStatusPanel({ data }: { data: CronStatusResponse | null }) {
const [expandedRun, setExpandedRun] = useState(null);
if (!data) {
return (
Maintenance schedule not available
);
}
const { jobs, recentRuns } = data;
return (
Maintenance Schedule
{recentRuns.length > 0 && (
{recentRuns.length} run{recentRuns.length !== 1 ? 's' : ''} in log
)}
{/* Jobs table */}
| Job |
Schedule |
Last Run |
Freed |
Status |
Next Run |
{jobs.map(job => {
const lr = job.lastRun;
return (
|
{job.description}
{job.name}
|
{job.schedule} |
{lr ? (
{relativeTime(lr.timestamp)}
) : (
never
)}
|
{lr && lr.freedMB > 0
? +{lr.freedMB} MB
: —}
|
{lr ? (
{lr.success
?
: }
{lr.success ? 'OK' : 'Failed'}
) : (
—
)}
|
{job.nextRun ? (
{relativeTime(job.nextRun).replace('ago', '').trim() || formatDate(job.nextRun)}
) : '—'}
|
);
})}
{/* Recent runs — collapsible step log */}
{recentRuns.length > 0 && (
Recent Runs
{recentRuns.slice(0, 10).map((run, i) => (
{expandedRun === run.timestamp && run.steps.length > 0 && (
)}
))}
)}
);
}
// ── Ollama / LLM panel ─────────────────────────────────────────────────────
function OllamaPanel({
data,
ramAvailGb,
onUnload,
unloading,
}: {
data: OllamaModelsResponse | null;
ramAvailGb: number;
onUnload: (name: string) => Promise;
unloading: Set;
}) {
if (!data) return null;
const { models, running } = data;
if (models.length === 0 && running.length === 0) return null;
return (
LLM Models (Ollama)
{models.length} on disk · {running.length} loaded
{/* Currently loaded */}
{running.length > 0 && (
Currently Loaded
{running.map(r => {
const ramAfterUnloadGb = ramAvailGb + r.sizeGB;
const pressureAfter = ramAfterUnloadGb < 2;
return (
{r.name}
{r.sizeGB} GB · {r.processor || 'CPU'}
{r.expiresAt && (
expires {relativeTime(r.expiresAt)}
)}
{ramAvailGb < 4 && (
low RAM — swap pressure likely
)}
{pressureAfter && (
(unloading frees {r.sizeGB} GB)
)}
);
})}
)}
{/* RAM bar */}
{ramAvailGb > 0 && (
RAM available
{ramAvailGb.toFixed(1)} GB free
{running.length > 0
? `${running.reduce((s, r) => s + r.sizeGB, 0).toFixed(1)} GB used by loaded models`
: 'No models loaded'}
)}
{/* All models */}
{models.map(m => {
const isLoaded = running.some(r => r.name === m.name);
return (
{m.name}
{m.sizeGB} GB
{isLoaded && (
loaded
)}
{m.modifiedAt && (
{relativeTime(m.modifiedAt)}
)}
);
})}
);
}
// ── Check card meta ────────────────────────────────────────────────────────
const CHECK_META: Record = {
disk: { label: 'Disk', icon: HardDrive },
load: { label: 'CPU Load', icon: Cpu },
steal: { label: 'CPU Steal', icon: Shield },
ram: { label: 'Memory', icon: Database },
swap: { label: 'Swap', icon: Server },
container_loops: { label: 'Crash Loops', icon: Activity },
container_health: { label: 'Container Health', icon: Layers },
docker_daemon: { label: 'Docker Daemon', icon: Activity },
build_cache: { label: 'Build Cache', icon: Layers },
docker_images: { label: 'Docker Images', icon: Layers },
journal: { label: 'Journal Logs', icon: ScrollText },
syslog: { label: 'Syslog', icon: ScrollText },
failed_units: { label: 'Systemd Units', icon: Activity },
cron_missing_paths: { label: 'Cron Paths', icon: Clock },
};
const CHECK_ORDER = [
'disk', 'load', 'steal', 'ram', 'swap',
'container_loops', 'container_health', 'docker_daemon',
'build_cache', 'docker_images',
'journal', 'syslog',
'failed_units', 'cron_missing_paths',
];
// ── Main page ──────────────────────────────────────────────────────────────
export default function VmHealthPage() {
const [health, setHealth] = useState(null);
const [cronData, setCronData] = useState(null);
const [unhealthy, setUnhealthy] = useState([]);
const [ollamaData, setOllamaData] = useState(null);
const [cleanupLog, setCleanupLog] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [cleanupRunning, setCleanupRunning] = useState(false);
const [cleanupResult, setCleanupResult] = useState<{ success: boolean; output: string } | null>(null);
const [restarting, setRestarting] = useState>(new Set());
const [restartMsg, setRestartMsg] = useState<{ name: string; ok: boolean; msg: string } | null>(null);
const [unloading, setUnloading] = useState>(new Set());
const [showLog, setShowLog] = useState(false);
const [lastRefreshed, setLastRefreshed] = useState(null);
const loadAll = useCallback(async () => {
try {
const [healthData, logData, cronResult, unhealthyResult, ollamaResult] = await Promise.allSettled([
vmApi.getHealth(),
vmApi.getCleanupLog(40),
vmApi.getCronStatus(),
vmApi.getUnhealthyContainers(),
vmApi.getOllamaModels(),
]);
if (healthData.status === 'fulfilled') setHealth(healthData.value);
if (logData.status === 'fulfilled') setCleanupLog(logData.value.log);
if (cronResult.status === 'fulfilled') setCronData(cronResult.value);
if (unhealthyResult.status === 'fulfilled') setUnhealthy(unhealthyResult.value.containers);
if (ollamaResult.status === 'fulfilled') setOllamaData(ollamaResult.value);
setLastRefreshed(new Date());
} catch (e) {
console.error('Failed to load VM data:', e);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadAll();
const interval = setInterval(loadAll, 60_000);
return () => clearInterval(interval);
}, [loadAll]);
const handleRefresh = () => { setRefreshing(true); loadAll(); };
const handleCleanup = async (mode: 'weekly' | 'monthly' | 'dry-run') => {
const msg =
mode === 'monthly' ? 'Run MONTHLY full cleanup? This removes build cache, pnpm store, old logs, and HOLD node_modules.' :
mode === 'dry-run' ? 'Run cleanup in DRY-RUN mode? Nothing will be deleted.' :
'Run weekly cleanup? This prunes Docker build cache, journal, apt, and .next/cache.';
if (!confirm(msg)) return;
setCleanupRunning(true);
setCleanupResult(null);
try {
const result = await vmApi.runCleanup(mode);
setCleanupResult(result);
await loadAll();
} catch (e) {
setCleanupResult({ success: false, output: String(e) });
} finally {
setCleanupRunning(false);
}
};
const handleRestart = async (name: string) => {
setRestarting(prev => new Set(prev).add(name));
setRestartMsg(null);
try {
const result = await vmApi.restartContainer(name);
setRestartMsg({ name, ok: result.success, msg: result.message });
if (result.success) {
await new Promise(r => setTimeout(r, 3000));
await loadAll();
}
} catch (e) {
setRestartMsg({ name, ok: false, msg: String(e) });
} finally {
setRestarting(prev => { const s = new Set(prev); s.delete(name); return s; });
}
};
const handleUnload = async (name: string) => {
setUnloading(prev => new Set(prev).add(name));
try {
await vmApi.unloadOllamaModel(name);
await loadAll();
} catch (e) {
console.error('Failed to unload model:', e);
} finally {
setUnloading(prev => { const s = new Set(prev); s.delete(name); return s; });
}
};
// ── Loading ───────────────────────────────────────────────────────────────
if (loading) {
return (
);
}
const overall = health?.overall ?? 'CRIT';
const checks = health?.checks ?? {};
const sortedKeys = [
...CHECK_ORDER.filter(k => k in checks),
...Object.keys(checks).filter(k => !CHECK_ORDER.includes(k)),
];
const warnings = sortedKeys.filter(k => checks[k]?.level === 'WARN');
const crits = sortedKeys.filter(k => checks[k]?.level === 'CRIT');
// Parse available RAM for Ollama panel RAM bar
const ramAvailGb = parseFloat(
(checks.ram?.value ?? '0G').match(/^([\d.]+)/)?.[1] ?? '0'
);
// ── Render ────────────────────────────────────────────────────────────────
return (
{/* ── Header ── */}
VM Health
{health?.hostname ?? 'srv1491630'} ·{' '}
{lastRefreshed
? `last checked ${lastRefreshed.toLocaleTimeString()}`
: 'checking…'}
{/* ── Score card ── */}
{/* ── Overall status banner ── */}
{overall === 'OK'
? 'All checks passing'
: overall === 'WARN'
? `${warnings.length} warning${warnings.length !== 1 ? 's' : ''}`
: `${crits.length} critical issue${crits.length !== 1 ? 's' : ''}`}
{health?.error && (
{health.error}
)}
{(crits.length > 0 || warnings.length > 0) && !health?.error && (
{[...crits, ...warnings].map(k => checks[k]?.message).join(' · ')}
)}
{overall}
{/* ── Check cards grid ── */}
{sortedKeys.map(key => {
const check = checks[key];
if (!check) return null;
const meta = CHECK_META[key] ?? { label: key, icon: Activity };
const Icon = meta.icon;
return (
{meta.label}
{check.level}
{check.message}
{check.value}
);
})}
{/* ── Restart feedback ── */}
{restartMsg && (
{restartMsg.ok
?
: }
{restartMsg.name}: {restartMsg.msg}
)}
{/* ── Unhealthy containers ── */}
{/* ── Cron status ── */}
{/* ── Ollama ── */}
{/* ── Cleanup section ── */}
VM Cleanup
Cron runs automatically: daily build-cache prune, weekly cleanup, monthly full cleanup.
Use buttons below to trigger manually.
{cleanupResult && (
{cleanupResult.success
?
: }
{cleanupResult.success ? 'Cleanup completed' : 'Cleanup failed'}
{cleanupResult.output && (
{cleanupResult.output}
)}
)}
{/* ── Cleanup log ── */}
{cleanupLog && (
{showLog && (
)}
)}
);
}