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 { useEffect, useState, useCallback } from 'react';
|
||||||
import { SidebarNav } from '@/components/sidebar-nav';
|
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 {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -19,13 +26,20 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Bot,
|
||||||
|
RotateCw,
|
||||||
|
Gauge,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
MemoryStick,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Level = VmCheckLevel;
|
type Level = VmCheckLevel;
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Shared helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function levelColor(level: Level) {
|
function levelColor(level: Level) {
|
||||||
switch (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 }> = {
|
const CHECK_META: Record<string, { label: string; icon: React.ElementType }> = {
|
||||||
disk: { label: 'Disk', icon: HardDrive },
|
disk: { label: 'Disk', icon: HardDrive },
|
||||||
load: { label: 'CPU Load', icon: Cpu },
|
load: { label: 'CPU Load', icon: Cpu },
|
||||||
ram: { label: 'Memory', icon: Database },
|
steal: { label: 'CPU Steal', icon: Shield },
|
||||||
swap: { label: 'Swap', icon: Server },
|
ram: { label: 'Memory', icon: Database },
|
||||||
container_loops: { label: 'Crash Loops', icon: Activity },
|
swap: { label: 'Swap', icon: Server },
|
||||||
container_health: { label: 'Container Health', icon: Layers },
|
container_loops: { label: 'Crash Loops', icon: Activity },
|
||||||
build_cache: { label: 'Build Cache', icon: Layers },
|
container_health: { label: 'Container Health', icon: Layers },
|
||||||
docker_images: { label: 'Docker Images', icon: Layers },
|
docker_daemon: { label: 'Docker Daemon', icon: Activity },
|
||||||
journal: { label: 'Journal Logs', icon: ScrollText },
|
build_cache: { label: 'Build Cache', icon: Layers },
|
||||||
syslog: { label: 'Syslog', icon: ScrollText },
|
docker_images: { label: 'Docker Images', icon: Layers },
|
||||||
docker_daemon: { label: 'Docker Daemon', icon: Activity },
|
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 = [
|
const CHECK_ORDER = [
|
||||||
'disk', 'load', 'ram', 'swap',
|
'disk', 'load', 'steal', 'ram', 'swap',
|
||||||
'container_loops', 'container_health', 'docker_daemon',
|
'container_loops', 'container_health', 'docker_daemon',
|
||||||
'build_cache', 'docker_images',
|
'build_cache', 'docker_images',
|
||||||
'journal', 'syslog',
|
'journal', 'syslog',
|
||||||
|
'failed_units', 'cron_missing_paths',
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Main page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function VmHealthPage() {
|
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 [cleanupLog, setCleanupLog] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
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 {
|
try {
|
||||||
const [healthData, logData] = await Promise.all([
|
const [healthData, logData, cronResult, unhealthyResult, ollamaResult] = await Promise.allSettled([
|
||||||
vmApi.getHealth(),
|
vmApi.getHealth(),
|
||||||
vmApi.getCleanupLog(40),
|
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());
|
setLastRefreshed(new Date());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load VM health:', e);
|
console.error('Failed to load VM data:', e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@ -103,31 +588,25 @@ export default function VmHealthPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHealth();
|
loadAll();
|
||||||
const interval = setInterval(loadHealth, 60_000);
|
const interval = setInterval(loadAll, 60_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadHealth]);
|
}, [loadAll]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => { setRefreshing(true); loadAll(); };
|
||||||
setRefreshing(true);
|
|
||||||
loadHealth();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCleanup = async (mode: 'weekly' | 'monthly' | 'dry-run') => {
|
const handleCleanup = async (mode: 'weekly' | 'monthly' | 'dry-run') => {
|
||||||
const confirmMsg =
|
const msg =
|
||||||
mode === 'monthly'
|
mode === 'monthly' ? 'Run MONTHLY full cleanup? This removes build cache, pnpm store, old logs, and HOLD node_modules.' :
|
||||||
? '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.' :
|
||||||
: mode === 'dry-run'
|
'Run weekly cleanup? This prunes Docker build cache, journal, apt, and .next/cache.';
|
||||||
? 'Run cleanup in DRY-RUN mode? Nothing will be deleted.'
|
if (!confirm(msg)) return;
|
||||||
: 'Run weekly cleanup? This prunes Docker build cache, journal, apt, and .next/cache.';
|
|
||||||
if (!confirm(confirmMsg)) return;
|
|
||||||
|
|
||||||
setCleanupRunning(true);
|
setCleanupRunning(true);
|
||||||
setCleanupResult(null);
|
setCleanupResult(null);
|
||||||
try {
|
try {
|
||||||
const result = await vmApi.runCleanup(mode);
|
const result = await vmApi.runCleanup(mode);
|
||||||
setCleanupResult(result);
|
setCleanupResult(result);
|
||||||
await loadHealth();
|
await loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCleanupResult({ success: false, output: String(e) });
|
setCleanupResult({ success: false, output: String(e) });
|
||||||
} finally {
|
} 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -148,18 +656,22 @@ export default function VmHealthPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const overall = health?.overall ?? 'CRIT';
|
const overall = health?.overall ?? 'CRIT';
|
||||||
const checks = health?.checks ?? {};
|
const checks = health?.checks ?? {};
|
||||||
|
|
||||||
// Sort checks into preferred order, then anything else
|
|
||||||
const sortedKeys = [
|
const sortedKeys = [
|
||||||
...CHECK_ORDER.filter(k => k in checks),
|
...CHECK_ORDER.filter(k => k in checks),
|
||||||
...Object.keys(checks).filter(k => !CHECK_ORDER.includes(k)),
|
...Object.keys(checks).filter(k => !CHECK_ORDER.includes(k)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const warnings = sortedKeys.filter(k => checks[k]?.level === 'WARN');
|
const warnings = sortedKeys.filter(k => checks[k]?.level === 'WARN');
|
||||||
const crits = sortedKeys.filter(k => checks[k]?.level === 'CRIT');
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen bg-gray-50">
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
@ -188,6 +700,9 @@ export default function VmHealthPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Score card ── */}
|
||||||
|
<ScoreCard health={health} unhealthyCount={unhealthy.length} cronData={cronData} />
|
||||||
|
|
||||||
{/* ── Overall status banner ── */}
|
{/* ── Overall status banner ── */}
|
||||||
<div className={`rounded-lg border p-4 flex items-center gap-3 ${levelColor(overall)}`}>
|
<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" />
|
<LevelIcon level={overall} className="w-6 h-6 flex-shrink-0" />
|
||||||
@ -220,12 +735,8 @@ export default function VmHealthPage() {
|
|||||||
if (!check) return null;
|
if (!check) return null;
|
||||||
const meta = CHECK_META[key] ?? { label: key, icon: Activity };
|
const meta = CHECK_META[key] ?? { label: key, icon: Activity };
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={key} className={`rounded-lg border p-4 ${levelColor(check.level)}`}>
|
||||||
key={key}
|
|
||||||
className={`rounded-lg border p-4 ${levelColor(check.level)}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
<LevelIcon level={check.level} />
|
<LevelIcon level={check.level} />
|
||||||
@ -247,6 +758,35 @@ export default function VmHealthPage() {
|
|||||||
})}
|
})}
|
||||||
</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 ── */}
|
{/* ── Cleanup section ── */}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
<div className="flex items-center gap-3 mb-5">
|
<div className="flex items-center gap-3 mb-5">
|
||||||
@ -260,7 +800,6 @@ export default function VmHealthPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cleanup result */}
|
|
||||||
{cleanupResult && (
|
{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={`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">
|
<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'}`}>
|
<span className={`text-sm font-medium ${cleanupResult.success ? 'text-green-800' : 'text-red-800'}`}>
|
||||||
{cleanupResult.success ? 'Cleanup completed' : 'Cleanup failed'}
|
{cleanupResult.success ? 'Cleanup completed' : 'Cleanup failed'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button onClick={() => setCleanupResult(null)} className="ml-auto text-gray-400 hover:text-gray-600 text-xs">
|
||||||
onClick={() => setCleanupResult(null)}
|
|
||||||
className="ml-auto text-gray-400 hover:text-gray-600 text-xs"
|
|
||||||
>
|
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -285,7 +821,6 @@ export default function VmHealthPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cleanup buttons */}
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCleanup('dry-run')}
|
onClick={() => handleCleanup('dry-run')}
|
||||||
@ -316,12 +851,6 @@ export default function VmHealthPage() {
|
|||||||
Monthly Full Cleanup
|
Monthly Full Cleanup
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── Cleanup log ── */}
|
{/* ── Cleanup log ── */}
|
||||||
|
|||||||
@ -438,6 +438,57 @@ export interface VmHealthResult {
|
|||||||
error?: string;
|
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 = {
|
export const vmApi = {
|
||||||
getHealth: () => apiRequest<VmHealthResult>('/api/vm/health'),
|
getHealth: () => apiRequest<VmHealthResult>('/api/vm/health'),
|
||||||
getCleanupLog: (lines = 40) =>
|
getCleanupLog: (lines = 40) =>
|
||||||
@ -447,6 +498,20 @@ export const vmApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mode }),
|
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
|
// Auth API - calls platform-service for authentication
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user