219 lines
10 KiB
TypeScript
219 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { AlertTriangle, CheckCircle2, Cloud, DatabaseBackup, ExternalLink, Gauge, HardDrive, RefreshCw, ShieldCheck, Timer, Wifi } from 'lucide-react';
|
|
import { Badge, Button } from '@/components/ui/Primitives';
|
|
import { SectionCard } from '@/components/hermes-shell';
|
|
import { api, type HermesOpsInstance, type HermesOpsSnapshot } from '@/lib/api';
|
|
|
|
function boolTone(value: boolean): 'success' | 'error' {
|
|
return value ? 'success' : 'error';
|
|
}
|
|
|
|
function boolText(value: boolean) {
|
|
return value ? 'OK' : 'Needs attention';
|
|
}
|
|
|
|
function formatDate(value: string | null) {
|
|
if (!value) return 'unknown';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return new Intl.DateTimeFormat('en', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
}
|
|
|
|
function StatusRow({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-2">
|
|
<span className="text-sm text-[var(--bl-text-secondary)]">{label}</span>
|
|
<Badge variant={boolTone(ok)}>{value}</Badge>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InstanceCard({ instance }: { instance: HermesOpsInstance }) {
|
|
const score = [
|
|
instance.gateway.active,
|
|
instance.gateway.enabled,
|
|
instance.dashboard.active,
|
|
instance.backup.timer.active,
|
|
instance.backup.repo.clean,
|
|
instance.google.workspaceToken,
|
|
].filter(Boolean).length;
|
|
|
|
return (
|
|
<article className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4 shadow-[var(--bl-shadow-sm)]">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Gauge className="h-4 w-4 text-[var(--bl-accent)]" />
|
|
<h3 className="font-semibold text-[var(--bl-text-primary)]">{instance.label}</h3>
|
|
</div>
|
|
<p className="mt-1 text-xs text-[var(--bl-text-secondary)]">{instance.hermesHome}</p>
|
|
</div>
|
|
<Badge variant={score === 6 ? 'success' : score >= 4 ? 'warning' : 'error'}>{score}/6 healthy</Badge>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-2">
|
|
<StatusRow label={instance.gateway.service} value={boolText(instance.gateway.active)} ok={instance.gateway.active} />
|
|
<StatusRow label="Auto-start enabled" value={instance.gateway.enabled ? 'enabled' : 'disabled'} ok={instance.gateway.enabled} />
|
|
<StatusRow label="Private dashboard" value={boolText(instance.dashboard.active)} ok={instance.dashboard.active} />
|
|
<StatusRow label="10-minute backup timer" value={boolText(instance.backup.timer.active)} ok={instance.backup.timer.active} />
|
|
<StatusRow label="Google Workspace token" value={boolText(instance.google.workspaceToken)} ok={instance.google.workspaceToken} />
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3 text-sm text-[var(--bl-text-secondary)] md:grid-cols-2">
|
|
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
|
|
<div className="flex items-center gap-2 text-[var(--bl-text-primary)]">
|
|
<DatabaseBackup className="h-4 w-4" />
|
|
Backup repo
|
|
</div>
|
|
<p className="mt-2 break-words">HEAD {instance.backup.repo.head ?? 'unknown'}</p>
|
|
<p className="break-words">Last commit {formatDate(instance.backup.repo.lastCommitAt)}</p>
|
|
<p>{instance.backup.repo.clean ? 'Clean working tree' : 'Uncommitted changes present'}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
|
|
<div className="flex items-center gap-2 text-[var(--bl-text-primary)]">
|
|
<HardDrive className="h-4 w-4" />
|
|
Restore payload
|
|
</div>
|
|
<p className="mt-2">{instance.backup.restoredFileCount ?? 'unknown'} tracked files</p>
|
|
<p>{instance.backup.restoredCronJobs ?? 'unknown'} cron job definitions</p>
|
|
<p className="break-words">{instance.backup.repo.size ?? 'size unknown'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Button asChild variant="secondary" size="sm">
|
|
<a href={instance.dashboard.url} target="_blank" rel="noreferrer">
|
|
Open dashboard <ExternalLink className="ml-2 h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function HermesOpsPanel() {
|
|
const [snapshot, setSnapshot] = useState<HermesOpsSnapshot | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setSnapshot(await api.getHermesOps());
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unable to load Hermes operations status');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
const id = window.setInterval(() => void load(), 60_000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
|
|
const allHealthy = useMemo(() => snapshot ? snapshot.warnings.length === 0 : false, [snapshot]);
|
|
|
|
return (
|
|
<SectionCard
|
|
title="Live Recovery and Dashboard Status"
|
|
subtitle="Real VM status for Vijay/root and Bheem/Uma: gateways, private dashboards, backups, Google auth, and restore payload health."
|
|
actions={(
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{snapshot ? <Badge variant={allHealthy ? 'success' : 'warning'}>{allHealthy ? 'All green' : `${snapshot.warnings.length} warning(s)`}</Badge> : null}
|
|
<Button variant="ghost" size="sm" onClick={() => void load()} disabled={loading}>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
{error ? (
|
|
<div className="rounded-2xl border border-[var(--bl-danger)]/30 bg-[var(--bl-danger)]/10 p-4 text-sm text-[var(--bl-danger)]">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{snapshot ? (
|
|
<div className="space-y-5">
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
|
|
<Wifi className="h-4 w-4" />
|
|
Tailscale IP
|
|
</div>
|
|
<p className="mt-2 break-words text-lg font-semibold text-[var(--bl-text-primary)]">{snapshot.tailscaleIp ?? 'unknown'}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
|
|
<Cloud className="h-4 w-4" />
|
|
Emergency Drive
|
|
</div>
|
|
<p className="mt-2 break-words text-lg font-semibold text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.active ? 'active' : 'inactive'}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
|
|
<Timer className="h-4 w-4" />
|
|
Next Drive bundle
|
|
</div>
|
|
<p className="mt-2 break-words text-base font-semibold leading-6 text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.nextRun ?? 'unknown'}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
|
|
<ShieldCheck className="h-4 w-4" />
|
|
Generated
|
|
</div>
|
|
<p className="mt-2 break-words text-lg font-semibold text-[var(--bl-text-primary)]">{formatDate(snapshot.generatedAt)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{snapshot.warnings.length ? (
|
|
<div className="rounded-2xl border border-[var(--bl-warning)]/40 bg-[var(--bl-warning)]/10 p-4">
|
|
<div className="flex items-center gap-2 font-medium text-[var(--bl-text-primary)]">
|
|
<AlertTriangle className="h-4 w-4 text-[var(--bl-warning)]" />
|
|
Recovery warnings
|
|
</div>
|
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
|
{snapshot.warnings.map((warning) => (
|
|
<div key={warning} className="rounded-xl bg-[var(--bl-surface-card)] px-3 py-2 text-sm text-[var(--bl-text-secondary)]">{warning}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 rounded-2xl border border-[var(--bl-success)]/30 bg-[var(--bl-success)]/10 p-4 text-sm text-[var(--bl-text-secondary)]">
|
|
<CheckCircle2 className="h-4 w-4 text-[var(--bl-success)]" />
|
|
Vijay and Bheem recovery paths are healthy.
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{snapshot.instances.map((instance) => (
|
|
<InstanceCard key={instance.id} instance={instance} />
|
|
))}
|
|
</div>
|
|
|
|
<div className="text-sm text-[var(--bl-text-secondary)]">
|
|
Disaster recovery details live in{' '}
|
|
<Link href="/hermes/settings" className="text-[var(--bl-accent)] hover:underline">Hermes settings</Link>
|
|
{' '}and the tracked runbook in <span className="font-mono text-xs">docs/hermes-disaster-recovery.md</span>.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 text-sm text-[var(--bl-text-secondary)]">
|
|
Loading live Hermes operations status...
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
);
|
|
}
|