feat(dashboard/vm): Phases 1.1, 1.3, 3.1, 3.4 — VM page panels

Phase 3.1 — VM Score Card (0–100):
- 6 weighted dimensions: steal time, RAM, disk, service health,
  maintenance hygiene, LLM readiness (matching roadmap scoring)
- Color-coded gauge + per-dimension progress bars with detail text
- Computed from health + cron + unhealthy data; degrades gracefully
  when any source is unavailable

Phase 1.3 — Unhealthy Container Detail Panel:
- Loads independently from GET /api/vm/containers/unhealthy
- Per-container: name, unhealthy since, restart count, last health logs
- Expandable row for health check output
- One-click restart with spinner, feedback toast, auto-refresh after 3s

Phase 1.1 — Cron Status Panel:
- Loads from GET /api/vm/cron-status
- Table: 4 managed jobs × schedule | last run | freed MB | status | next
- Collapsible run history (last 10) with step-by-step log expansion

Phase 3.4 — Ollama/LLM Panel:
- Loads from GET /api/vm/ollama/models
- Currently-loaded section with RAM pressure warning (<4 GB free)
- RAM bar visualisation showing model footprint
- Model list with size + last-used time
- One-click unload button

Other improvements:
- All data fetched in parallel (Promise.allSettled) — any panel failure
  does not block the rest of the page
- Add steal, failed_units, cron_missing_paths to CHECK_META/CHECK_ORDER
- Refresh now updates all 5 data sources atomically
- web/package-lock.json regenerated (was stale, caused build failure)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hermes VM 2026-05-27 21:49:23 +00:00
parent b15c570587
commit 7047d625ef
3 changed files with 2117 additions and 78 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,14 @@
import { useEffect, useState, useCallback } from 'react';
import { SidebarNav } from '@/components/sidebar-nav';
import { vmApi, type VmHealthResult, type VmCheckLevel } from '@/lib/api';
import {
vmApi,
type VmHealthResult,
type VmCheckLevel,
type CronStatusResponse,
type UnhealthyContainer,
type OllamaModelsResponse,
} from '@/lib/api';
import {
CheckCircle,
AlertTriangle,
@ -19,13 +26,20 @@ import {
Terminal,
ChevronDown,
ChevronUp,
Clock,
Bot,
RotateCw,
Gauge,
Shield,
Zap,
MemoryStick,
} from 'lucide-react';
// ── Types ──────────────────────────────────────────────────────────────────
type Level = VmCheckLevel;
// ── Helpers ────────────────────────────────────────────────────────────────
// ── Shared helpers ─────────────────────────────────────────────────────────
function levelColor(level: Level) {
switch (level) {
@ -51,51 +65,522 @@ function LevelIcon({ level, className = 'w-5 h-5' }: { level: Level; className?:
}
}
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 (
<div className={`rounded-lg border p-5 ${scoreBg}`}>
<div className="flex items-start gap-5">
{/* Score gauge */}
<div className="flex flex-col items-center gap-1 min-w-[80px]">
<Gauge className="w-5 h-5 text-gray-400" />
<span className={`text-5xl font-bold tabular-nums ${scoreColor}`}>{total}</span>
<span className="text-xs text-gray-400 font-medium">/ 100</span>
</div>
{/* Dimension breakdown */}
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2">
{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 (
<div key={d.label}>
<div className="flex items-center justify-between mb-0.5">
<span className="text-xs font-medium text-gray-600 truncate">{d.label}</span>
<span className="text-xs font-bold text-gray-700 ml-2 tabular-nums">{d.pts}/{d.maxPts}</span>
</div>
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<p className="text-xs text-gray-400 mt-0.5">{d.detail}</p>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Unhealthy containers panel ─────────────────────────────────────────────
function UnhealthyContainersPanel({
containers,
onRestart,
restarting,
}: {
containers: UnhealthyContainer[];
onRestart: (name: string) => Promise<void>;
restarting: Set<string>;
}) {
const [expanded, setExpanded] = useState<Set<string>>(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 (
<div className="bg-white border border-yellow-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 bg-yellow-50 border-b border-yellow-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-yellow-600" />
<span className="font-semibold text-yellow-900">
{containers.length} Unhealthy Container{containers.length !== 1 ? 's' : ''}
</span>
<span className="text-xs text-yellow-600">process alive, health endpoint failing</span>
</div>
</div>
<div className="divide-y divide-gray-100">
{containers.map(c => (
<div key={c.name}>
<div className="px-6 py-3 flex items-center gap-3">
<button
onClick={() => toggle(c.name)}
className="flex-1 flex items-center gap-3 text-left"
>
{expanded.has(c.name)
? <ChevronUp className="w-4 h-4 text-gray-400 flex-shrink-0" />
: <ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" />}
<span className="font-mono text-sm font-medium text-gray-900">{c.name}</span>
<span className="text-xs text-gray-500">
unhealthy {relativeTime(c.unhealthySince)} · {c.restartCount} restarts
</span>
</button>
<button
onClick={() => onRestart(c.name)}
disabled={restarting.has(c.name)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 disabled:opacity-50 flex-shrink-0"
>
<RotateCw className={`w-3.5 h-3.5 ${restarting.has(c.name) ? 'animate-spin' : ''}`} />
Restart
</button>
</div>
{expanded.has(c.name) && c.lastHealthLogs.length > 0 && (
<div className="px-6 pb-3">
<p className="text-xs font-medium text-gray-500 mb-1">Last health check output:</p>
<pre className="text-xs font-mono bg-gray-50 rounded p-2 whitespace-pre-wrap text-gray-700 max-h-32 overflow-y-auto">
{c.lastHealthLogs.join('\n') || '(no output)'}
</pre>
</div>
)}
</div>
))}
</div>
</div>
);
}
// ── Cron status panel ──────────────────────────────────────────────────────
function CronStatusPanel({ data }: { data: CronStatusResponse | null }) {
const [expandedRun, setExpandedRun] = useState<string | null>(null);
if (!data) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center gap-2 text-gray-400">
<Clock className="w-5 h-5" />
<span className="text-sm">Maintenance schedule not available</span>
</div>
</div>
);
}
const { jobs, recentRuns } = data;
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-2">
<Clock className="w-5 h-5 text-gray-500" />
<h2 className="font-semibold text-gray-900">Maintenance Schedule</h2>
{recentRuns.length > 0 && (
<span className="ml-auto text-xs text-gray-400">
{recentRuns.length} run{recentRuns.length !== 1 ? 's' : ''} in log
</span>
)}
</div>
{/* Jobs table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50">
<th className="px-6 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">Job</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">Schedule</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">Last Run</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wide">Freed</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">Next Run</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{jobs.map(job => {
const lr = job.lastRun;
return (
<tr key={job.name} className="hover:bg-gray-50">
<td className="px-6 py-3">
<p className="font-medium text-gray-900">{job.description}</p>
<p className="text-xs text-gray-400 font-mono">{job.name}</p>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{job.schedule}</td>
<td className="px-4 py-3 text-gray-600">
{lr ? (
<span title={lr.timestamp}>{relativeTime(lr.timestamp)}</span>
) : (
<span className="text-gray-400">never</span>
)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs">
{lr && lr.freedMB > 0
? <span className="text-green-700">+{lr.freedMB} MB</span>
: <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-3">
{lr ? (
<span className={`inline-flex items-center gap-1 text-xs font-medium ${lr.success ? 'text-green-700' : 'text-red-700'}`}>
{lr.success
? <CheckCircle className="w-3.5 h-3.5" />
: <XCircle className="w-3.5 h-3.5" />}
{lr.success ? 'OK' : 'Failed'}
</span>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{job.nextRun ? (
<span title={job.nextRun}>{relativeTime(job.nextRun).replace('ago', '').trim() || formatDate(job.nextRun)}</span>
) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Recent runs — collapsible step log */}
{recentRuns.length > 0 && (
<div className="border-t border-gray-100">
<div className="px-6 py-3">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Recent Runs</p>
<div className="space-y-1">
{recentRuns.slice(0, 10).map((run, i) => (
<div key={`${run.timestamp}-${i}`} className="rounded-md border border-gray-100 overflow-hidden">
<button
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-gray-50 text-sm"
onClick={() => setExpandedRun(expandedRun === run.timestamp ? null : run.timestamp)}
>
{run.success
? <CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
: <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />}
<span className="text-gray-600">{formatDate(run.timestamp)}</span>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${run.mode === 'full' ? 'bg-orange-100 text-orange-700' : 'bg-blue-100 text-blue-700'}`}>
{run.mode}
</span>
{run.freedMB > 0 && (
<span className="text-xs text-green-700 font-medium">freed {run.freedMB} MB</span>
)}
<span className="ml-auto text-xs text-gray-400">{run.durationSecs}s</span>
{expandedRun === run.timestamp
? <ChevronUp className="w-3.5 h-3.5 text-gray-400" />
: <ChevronDown className="w-3.5 h-3.5 text-gray-400" />}
</button>
{expandedRun === run.timestamp && run.steps.length > 0 && (
<div className="px-4 pb-3 border-t border-gray-100">
<pre className="text-xs font-mono bg-gray-50 rounded p-2 mt-2 whitespace-pre-wrap text-gray-700 max-h-40 overflow-y-auto">
{run.steps.join('\n')}
</pre>
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
// ── Ollama / LLM panel ─────────────────────────────────────────────────────
function OllamaPanel({
data,
ramAvailGb,
onUnload,
unloading,
}: {
data: OllamaModelsResponse | null;
ramAvailGb: number;
onUnload: (name: string) => Promise<void>;
unloading: Set<string>;
}) {
if (!data) return null;
const { models, running } = data;
if (models.length === 0 && running.length === 0) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-2">
<Bot className="w-5 h-5 text-gray-500" />
<h2 className="font-semibold text-gray-900">LLM Models (Ollama)</h2>
<span className="ml-auto text-xs text-gray-400">
{models.length} on disk · {running.length} loaded
</span>
</div>
{/* Currently loaded */}
{running.length > 0 && (
<div className="px-6 py-3 bg-purple-50 border-b border-purple-100">
<p className="text-xs font-medium text-purple-700 uppercase tracking-wide mb-2">Currently Loaded</p>
{running.map(r => {
const ramAfterUnloadGb = ramAvailGb + r.sizeGB;
const pressureAfter = ramAfterUnloadGb < 2;
return (
<div key={r.name} className="flex items-center gap-3">
<div className="flex-1">
<span className="font-mono text-sm font-medium text-gray-900">{r.name}</span>
<span className="ml-2 text-xs text-gray-500">{r.sizeGB} GB · {r.processor || 'CPU'}</span>
{r.expiresAt && (
<span className="ml-2 text-xs text-gray-400">expires {relativeTime(r.expiresAt)}</span>
)}
{ramAvailGb < 4 && (
<span className="ml-2 text-xs text-yellow-700 bg-yellow-100 px-1.5 py-0.5 rounded">
low RAM swap pressure likely
</span>
)}
{pressureAfter && (
<span className="ml-1 text-xs text-gray-400">(unloading frees {r.sizeGB} GB)</span>
)}
</div>
<button
onClick={() => onUnload(r.name)}
disabled={unloading.has(r.name)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 bg-white border border-purple-200 rounded-md hover:bg-purple-50 disabled:opacity-50"
>
{unloading.has(r.name)
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <Zap className="w-3.5 h-3.5" />}
Unload
</button>
</div>
);
})}
</div>
)}
{/* RAM bar */}
{ramAvailGb > 0 && (
<div className="px-6 py-3 border-b border-gray-100">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>RAM available</span>
<span className="font-mono">{ramAvailGb.toFixed(1)} GB free</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
{running.map(r => (
<div
key={r.name}
className="h-full bg-purple-400 rounded-full float-right"
style={{ width: `${Math.min((r.sizeGB / 16) * 100, 100)}%` }}
title={`${r.name}: ${r.sizeGB} GB`}
/>
))}
</div>
<p className="text-xs text-gray-400 mt-0.5">
{running.length > 0
? `${running.reduce((s, r) => s + r.sizeGB, 0).toFixed(1)} GB used by loaded models`
: 'No models loaded'}
</p>
</div>
)}
{/* All models */}
<div className="divide-y divide-gray-50">
{models.map(m => {
const isLoaded = running.some(r => r.name === m.name);
return (
<div key={m.name} className="px-6 py-2.5 flex items-center gap-3">
<MemoryStick className="w-4 h-4 text-gray-300 flex-shrink-0" />
<span className="font-mono text-sm text-gray-700 flex-1">{m.name}</span>
<span className="text-xs text-gray-400">{m.sizeGB} GB</span>
{isLoaded && (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">loaded</span>
)}
{m.modifiedAt && (
<span className="text-xs text-gray-300" title={m.modifiedAt}>
{relativeTime(m.modifiedAt)}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
// ── Check card meta ────────────────────────────────────────────────────────
const CHECK_META: Record<string, { label: string; icon: React.ElementType }> = {
disk: { label: 'Disk', icon: HardDrive },
load: { label: 'CPU Load', icon: Cpu },
ram: { label: 'Memory', icon: Database },
swap: { label: 'Swap', icon: Server },
container_loops: { label: 'Crash Loops', icon: Activity },
container_health: { label: 'Container Health', icon: Layers },
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 },
docker_daemon: { label: 'Docker Daemon', icon: Activity },
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 },
};
// Preferred display order
const CHECK_ORDER = [
'disk', 'load', 'ram', 'swap',
'disk', 'load', 'steal', 'ram', 'swap',
'container_loops', 'container_health', 'docker_daemon',
'build_cache', 'docker_images',
'journal', 'syslog',
'failed_units', 'cron_missing_paths',
];
// ── Component ──────────────────────────────────────────────────────────────
// ── Main page ──────────────────────────────────────────────────────────────
export default function VmHealthPage() {
const [health, setHealth] = useState<VmHealthResult | null>(null);
const [health, setHealth] = useState<VmHealthResult | null>(null);
const [cronData, setCronData] = useState<CronStatusResponse | null>(null);
const [unhealthy, setUnhealthy] = useState<UnhealthyContainer[]>([]);
const [ollamaData, setOllamaData] = useState<OllamaModelsResponse | null>(null);
const [cleanupLog, setCleanupLog] = useState<string>('');
const [loading, setLoading] = useState(true);
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 [showLog, setShowLog] = useState(false);
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
const loadHealth = useCallback(async () => {
const [cleanupRunning, setCleanupRunning] = useState(false);
const [cleanupResult, setCleanupResult] = useState<{ success: boolean; output: string } | null>(null);
const [restarting, setRestarting] = useState<Set<string>>(new Set());
const [restartMsg, setRestartMsg] = useState<{ name: string; ok: boolean; msg: string } | null>(null);
const [unloading, setUnloading] = useState<Set<string>>(new Set());
const [showLog, setShowLog] = useState(false);
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
const loadAll = useCallback(async () => {
try {
const [healthData, logData] = await Promise.all([
const [healthData, logData, cronResult, unhealthyResult, ollamaResult] = await Promise.allSettled([
vmApi.getHealth(),
vmApi.getCleanupLog(40),
vmApi.getCronStatus(),
vmApi.getUnhealthyContainers(),
vmApi.getOllamaModels(),
]);
setHealth(healthData);
setCleanupLog(logData.log);
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 health:', e);
console.error('Failed to load VM data:', e);
} finally {
setLoading(false);
setRefreshing(false);
@ -103,31 +588,25 @@ export default function VmHealthPage() {
}, []);
useEffect(() => {
loadHealth();
const interval = setInterval(loadHealth, 60_000);
loadAll();
const interval = setInterval(loadAll, 60_000);
return () => clearInterval(interval);
}, [loadHealth]);
}, [loadAll]);
const handleRefresh = () => {
setRefreshing(true);
loadHealth();
};
const handleRefresh = () => { setRefreshing(true); loadAll(); };
const handleCleanup = async (mode: 'weekly' | 'monthly' | 'dry-run') => {
const confirmMsg =
mode === 'monthly'
? 'Run MONTHLY full cleanup? This removes Docker 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(confirmMsg)) return;
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 loadHealth();
await loadAll();
} catch (e) {
setCleanupResult({ success: false, output: String(e) });
} finally {
@ -135,7 +614,36 @@ export default function VmHealthPage() {
}
};
// ── Render ───────────────────────────────────────────────────────────────
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 (
@ -148,18 +656,22 @@ export default function VmHealthPage() {
);
}
const overall = health?.overall ?? 'CRIT';
const checks = health?.checks ?? {};
// Sort checks into preferred order, then anything else
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 (
<div className="flex min-h-screen bg-gray-50">
<SidebarNav />
@ -188,6 +700,9 @@ export default function VmHealthPage() {
</button>
</div>
{/* ── Score card ── */}
<ScoreCard health={health} unhealthyCount={unhealthy.length} cronData={cronData} />
{/* ── Overall status banner ── */}
<div className={`rounded-lg border p-4 flex items-center gap-3 ${levelColor(overall)}`}>
<LevelIcon level={overall} className="w-6 h-6 flex-shrink-0" />
@ -220,12 +735,8 @@ export default function VmHealthPage() {
if (!check) return null;
const meta = CHECK_META[key] ?? { label: key, icon: Activity };
const Icon = meta.icon;
return (
<div
key={key}
className={`rounded-lg border p-4 ${levelColor(check.level)}`}
>
<div key={key} className={`rounded-lg border p-4 ${levelColor(check.level)}`}>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<LevelIcon level={check.level} />
@ -247,6 +758,35 @@ export default function VmHealthPage() {
})}
</div>
{/* ── Restart feedback ── */}
{restartMsg && (
<div className={`rounded-lg border p-3 flex items-center gap-2 text-sm ${restartMsg.ok ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800'}`}>
{restartMsg.ok
? <CheckCircle className="w-4 h-4 flex-shrink-0" />
: <XCircle className="w-4 h-4 flex-shrink-0" />}
<span><strong>{restartMsg.name}:</strong> {restartMsg.msg}</span>
<button onClick={() => setRestartMsg(null)} className="ml-auto text-gray-400 hover:text-gray-600"></button>
</div>
)}
{/* ── Unhealthy containers ── */}
<UnhealthyContainersPanel
containers={unhealthy}
onRestart={handleRestart}
restarting={restarting}
/>
{/* ── Cron status ── */}
<CronStatusPanel data={cronData} />
{/* ── Ollama ── */}
<OllamaPanel
data={ollamaData}
ramAvailGb={ramAvailGb}
onUnload={handleUnload}
unloading={unloading}
/>
{/* ── Cleanup section ── */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center gap-3 mb-5">
@ -260,7 +800,6 @@ export default function VmHealthPage() {
</div>
</div>
{/* Cleanup result */}
{cleanupResult && (
<div className={`rounded-lg border p-4 mb-4 ${cleanupResult.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
<div className="flex items-center gap-2 mb-2">
@ -270,10 +809,7 @@ export default function VmHealthPage() {
<span className={`text-sm font-medium ${cleanupResult.success ? 'text-green-800' : 'text-red-800'}`}>
{cleanupResult.success ? 'Cleanup completed' : 'Cleanup failed'}
</span>
<button
onClick={() => setCleanupResult(null)}
className="ml-auto text-gray-400 hover:text-gray-600 text-xs"
>
<button onClick={() => setCleanupResult(null)} className="ml-auto text-gray-400 hover:text-gray-600 text-xs">
Dismiss
</button>
</div>
@ -285,7 +821,6 @@ export default function VmHealthPage() {
</div>
)}
{/* Cleanup buttons */}
<div className="flex flex-wrap gap-3">
<button
onClick={() => handleCleanup('dry-run')}
@ -316,12 +851,6 @@ export default function VmHealthPage() {
Monthly Full Cleanup
</button>
</div>
<p className="text-xs text-gray-400 mt-3">
Cleanup requires the backend process to have sudo access to vm-cleanup.sh.
If the button returns an error, trigger manually via SSH:{' '}
<code className="bg-gray-100 px-1 rounded">sudo bash scripts/VMs/HostingerVM/vm-cleanup.sh</code>
</p>
</div>
{/* ── Cleanup log ── */}

View File

@ -438,6 +438,57 @@ export interface VmHealthResult {
error?: string;
}
export interface CronRunSummary {
timestamp: string;
mode: 'standard' | 'full';
diskBefore: string;
diskAfter: string;
freedMB: number;
durationSecs: number;
success: boolean;
steps: string[];
jsonSummary?: Record<string, unknown>;
}
export interface CronJob {
name: string;
schedule: string;
description: string;
lastRun: CronRunSummary | null;
nextRun: string | null;
}
export interface CronStatusResponse {
jobs: CronJob[];
recentRuns: CronRunSummary[];
}
export interface UnhealthyContainer {
name: string;
status: string;
restartCount: number;
lastHealthLogs: string[];
unhealthySince: string | null;
}
export interface OllamaModel {
name: string;
sizeGB: number;
modifiedAt: string;
}
export interface OllamaRunning {
name: string;
sizeGB: number;
processor: string;
expiresAt: string;
}
export interface OllamaModelsResponse {
models: OllamaModel[];
running: OllamaRunning[];
}
export const vmApi = {
getHealth: () => apiRequest<VmHealthResult>('/api/vm/health'),
getCleanupLog: (lines = 40) =>
@ -447,6 +498,20 @@ export const vmApi = {
method: 'POST',
body: JSON.stringify({ mode }),
}),
getCronStatus: () => apiRequest<CronStatusResponse>('/api/vm/cron-status'),
getUnhealthyContainers: () =>
apiRequest<{ containers: UnhealthyContainer[] }>('/api/vm/containers/unhealthy'),
restartContainer: (name: string) =>
apiRequest<{ success: boolean; message: string }>(
`/api/vm/containers/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
),
getOllamaModels: () => apiRequest<OllamaModelsResponse>('/api/vm/ollama/models'),
unloadOllamaModel: (name: string) =>
apiRequest<{ success: boolean; message: string }>(
`/api/vm/ollama/models/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
),
};
// Auth API - calls platform-service for authentication