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>
887 lines
38 KiB
TypeScript
887 lines
38 KiB
TypeScript
'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 <CheckCircle className={`${className} text-green-600`} />;
|
|
case 'WARN': return <AlertTriangle className={`${className} text-yellow-600`} />;
|
|
case 'CRIT': return <XCircle className={`${className} text-red-600`} />;
|
|
}
|
|
}
|
|
|
|
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 },
|
|
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<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 [refreshing, setRefreshing] = useState(false);
|
|
|
|
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, 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 (
|
|
<div className="flex min-h-screen bg-gray-50">
|
|
<SidebarNav />
|
|
<main className="flex-1 flex items-center justify-center">
|
|
<div className="text-gray-500">Loading VM health…</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 />
|
|
|
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
<div className="p-8 max-md:p-4 space-y-6">
|
|
|
|
{/* ── Header ── */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">VM Health</h1>
|
|
<p className="text-sm text-gray-500">
|
|
{health?.hostname ?? 'srv1491630'} ·{' '}
|
|
{lastRefreshed
|
|
? `last checked ${lastRefreshed.toLocaleTimeString()}`
|
|
: 'checking…'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</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" />
|
|
<div className="flex-1">
|
|
<p className="font-semibold">
|
|
{overall === 'OK'
|
|
? 'All checks passing'
|
|
: overall === 'WARN'
|
|
? `${warnings.length} warning${warnings.length !== 1 ? 's' : ''}`
|
|
: `${crits.length} critical issue${crits.length !== 1 ? 's' : ''}`}
|
|
</p>
|
|
{health?.error && (
|
|
<p className="text-sm mt-1 opacity-80">{health.error}</p>
|
|
)}
|
|
{(crits.length > 0 || warnings.length > 0) && !health?.error && (
|
|
<p className="text-sm mt-1 opacity-80">
|
|
{[...crits, ...warnings].map(k => checks[k]?.message).join(' · ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span className={`px-3 py-1 rounded-full text-sm font-bold ${levelBadge(overall)}`}>
|
|
{overall}
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── Check cards grid ── */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{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 (
|
|
<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} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Icon className="w-4 h-4 opacity-60 flex-shrink-0" />
|
|
<span className="text-sm font-semibold">{meta.label}</span>
|
|
<span className={`ml-auto px-2 py-0.5 rounded text-xs font-bold ${levelBadge(check.level)}`}>
|
|
{check.level}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm leading-snug">{check.message}</p>
|
|
<p className="text-xs opacity-60 mt-1 font-mono truncate">{check.value}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</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">
|
|
<Trash2 className="w-5 h-5 text-gray-500" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">VM Cleanup</h2>
|
|
<p className="text-sm text-gray-500">
|
|
Cron runs automatically: daily build-cache prune, weekly cleanup, monthly full cleanup.
|
|
Use buttons below to trigger manually.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{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">
|
|
{cleanupResult.success
|
|
? <CheckCircle className="w-4 h-4 text-green-600" />
|
|
: <XCircle className="w-4 h-4 text-red-600" />}
|
|
<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">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
{cleanupResult.output && (
|
|
<pre className="text-xs font-mono whitespace-pre-wrap text-gray-700 bg-white/60 rounded p-2 max-h-48 overflow-y-auto">
|
|
{cleanupResult.output}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={() => handleCleanup('dry-run')}
|
|
disabled={cleanupRunning}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-300 rounded-md hover:bg-gray-100 disabled:opacity-50"
|
|
>
|
|
<Terminal className="w-4 h-4" />
|
|
Dry Run
|
|
</button>
|
|
<button
|
|
onClick={() => handleCleanup('weekly')}
|
|
disabled={cleanupRunning}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 border border-blue-300 rounded-md hover:bg-blue-100 disabled:opacity-50"
|
|
>
|
|
{cleanupRunning
|
|
? <RefreshCw className="w-4 h-4 animate-spin" />
|
|
: <Trash2 className="w-4 h-4" />}
|
|
Weekly Cleanup
|
|
</button>
|
|
<button
|
|
onClick={() => handleCleanup('monthly')}
|
|
disabled={cleanupRunning}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-orange-700 bg-orange-50 border border-orange-300 rounded-md hover:bg-orange-100 disabled:opacity-50"
|
|
>
|
|
{cleanupRunning
|
|
? <RefreshCw className="w-4 h-4 animate-spin" />
|
|
: <Trash2 className="w-4 h-4" />}
|
|
Monthly Full Cleanup
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Cleanup log ── */}
|
|
{cleanupLog && (
|
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
<button
|
|
className="w-full flex items-center justify-between px-6 py-4 text-left"
|
|
onClick={() => setShowLog(v => !v)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ScrollText className="w-5 h-5 text-gray-500" />
|
|
<span className="font-semibold text-gray-900">Cleanup Log</span>
|
|
<span className="text-xs text-gray-400">(last 40 lines of /var/log/vm-cleanup.log)</span>
|
|
</div>
|
|
{showLog
|
|
? <ChevronUp className="w-4 h-4 text-gray-400" />
|
|
: <ChevronDown className="w-4 h-4 text-gray-400" />}
|
|
</button>
|
|
{showLog && (
|
|
<div className="border-t border-gray-100 px-6 py-4">
|
|
<pre className="text-xs font-mono whitespace-pre-wrap text-gray-700 bg-gray-50 rounded p-3 max-h-80 overflow-y-auto">
|
|
{cleanupLog}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|