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:
parent
b15c570587
commit
7047d625ef
1469
dashboard/web/package-lock.json
generated
1469
dashboard/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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 ── */}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user