bytelyst-devops-tools/dashboard/web/src/components/hermes-ops-panel.tsx

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>
);
}