Harden Hermes operations dashboard status

This commit is contained in:
root 2026-05-27 17:45:41 +00:00
parent 0e6528b366
commit 9ee060e839
3 changed files with 48 additions and 12 deletions

View File

@ -98,13 +98,15 @@ async function isUmaGatewayEnabled(): Promise<boolean> {
} }
async function getRepo(path: string): Promise<HermesOpsRepo> { async function getRepo(path: string): Promise<HermesOpsRepo> {
const [branch, status, head, lastCommitAt, size] = await Promise.all([ const [branch, status, head, lastCommitAt, gitSize, backupSize] = await Promise.all([
run('git', ['branch', '--show-current'], path), run('git', ['branch', '--show-current'], path),
run('git', ['status', '--porcelain'], path), run('git', ['status', '--porcelain'], path),
run('git', ['rev-parse', '--short', 'HEAD'], path), run('git', ['rev-parse', '--short', 'HEAD'], path),
run('git', ['log', '-1', '--format=%cI'], path), run('git', ['log', '-1', '--format=%cI'], path),
run('du', ['-sh', '.git', 'hermes_persistent_backup'], path), run('du', ['-sh', '.git'], path),
run('du', ['-sh', 'hermes_persistent_backup'], path),
]); ]);
const size = [gitSize, backupSize].filter(Boolean).join(' / ');
return { return {
path, path,
@ -169,7 +171,10 @@ export async function getHermesOpsSnapshot(): Promise<HermesOpsSnapshot> {
const dashboardUrl = tailscaleIp ? `http://${tailscaleIp}:${item.dashboardPort}/` : `:${item.dashboardPort}`; const dashboardUrl = tailscaleIp ? `http://${tailscaleIp}:${item.dashboardPort}/` : `:${item.dashboardPort}`;
if (!gatewayActive) warnings.push(`${item.label} gateway is not active`); if (!gatewayActive) warnings.push(`${item.label} gateway is not active`);
if (!gatewayEnabled) warnings.push(`${item.label} gateway auto-start is not enabled`);
if (!dashboardActive) warnings.push(`${item.label} private dashboard is not active`);
if (!backupTimer.active) warnings.push(`${item.label} backup timer is not active`); if (!backupTimer.active) warnings.push(`${item.label} backup timer is not active`);
if (!repo.head) warnings.push(`${item.label} backup repo HEAD could not be read`);
if (!repo.clean) warnings.push(`${item.label} backup repo has uncommitted changes`); if (!repo.clean) warnings.push(`${item.label} backup repo has uncommitted changes`);
if (!googleToken) warnings.push(`${item.label} Google Workspace token is missing`); if (!googleToken) warnings.push(`${item.label} Google Workspace token is missing`);

View File

@ -17,12 +17,14 @@ function boolText(value: boolean) {
function formatDate(value: string | null) { function formatDate(value: string | null) {
if (!value) return 'unknown'; if (!value) return 'unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en', { return new Intl.DateTimeFormat('en', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
}).format(new Date(value)); }).format(date);
} }
function StatusRow({ label, value, ok }: { label: string; value: string; ok: boolean }) { function StatusRow({ label, value, ok }: { label: string; value: string; ok: boolean }) {
@ -71,8 +73,8 @@ function InstanceCard({ instance }: { instance: HermesOpsInstance }) {
<DatabaseBackup className="h-4 w-4" /> <DatabaseBackup className="h-4 w-4" />
Backup repo Backup repo
</div> </div>
<p className="mt-2">HEAD {instance.backup.repo.head ?? 'unknown'}</p> <p className="mt-2 break-words">HEAD {instance.backup.repo.head ?? 'unknown'}</p>
<p>Last commit {formatDate(instance.backup.repo.lastCommitAt)}</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> <p>{instance.backup.repo.clean ? 'Clean working tree' : 'Uncommitted changes present'}</p>
</div> </div>
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3"> <div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-3">
@ -82,7 +84,7 @@ function InstanceCard({ instance }: { instance: HermesOpsInstance }) {
</div> </div>
<p className="mt-2">{instance.backup.restoredFileCount ?? 'unknown'} tracked files</p> <p className="mt-2">{instance.backup.restoredFileCount ?? 'unknown'} tracked files</p>
<p>{instance.backup.restoredCronJobs ?? 'unknown'} cron job definitions</p> <p>{instance.backup.restoredCronJobs ?? 'unknown'} cron job definitions</p>
<p>{instance.backup.repo.size ?? 'size unknown'}</p> <p className="break-words">{instance.backup.repo.size ?? 'size unknown'}</p>
</div> </div>
</div> </div>
@ -124,7 +126,7 @@ export function HermesOpsPanel() {
return ( return (
<SectionCard <SectionCard
title="Live Recovery And Dashboard Status" 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." subtitle="Real VM status for Vijay/root and Bheem/Uma: gateways, private dashboards, backups, Google auth, and restore payload health."
actions={( actions={(
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -150,28 +152,28 @@ export function HermesOpsPanel() {
<Wifi className="h-4 w-4" /> <Wifi className="h-4 w-4" />
Tailscale IP Tailscale IP
</div> </div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{snapshot.tailscaleIp ?? 'unknown'}</p> <p className="mt-2 break-words text-lg font-semibold text-[var(--bl-text-primary)]">{snapshot.tailscaleIp ?? 'unknown'}</p>
</div> </div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-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)]"> <div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
<Cloud className="h-4 w-4" /> <Cloud className="h-4 w-4" />
Emergency Drive Emergency Drive
</div> </div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.active ? 'active' : 'inactive'}</p> <p className="mt-2 break-words text-lg font-semibold text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.active ? 'active' : 'inactive'}</p>
</div> </div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-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)]"> <div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
<Timer className="h-4 w-4" /> <Timer className="h-4 w-4" />
Next Drive bundle Next Drive bundle
</div> </div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.nextRun ?? 'unknown'}</p> <p className="mt-2 break-words text-base font-semibold leading-6 text-[var(--bl-text-primary)]">{snapshot.emergencyDriveUpload.nextRun ?? 'unknown'}</p>
</div> </div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-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)]"> <div className="flex items-center gap-2 text-sm text-[var(--bl-text-secondary)]">
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
Generated Generated
</div> </div>
<p className="mt-2 text-xl font-semibold text-[var(--bl-text-primary)]">{formatDate(snapshot.generatedAt)}</p> <p className="mt-2 break-words text-lg font-semibold text-[var(--bl-text-primary)]">{formatDate(snapshot.generatedAt)}</p>
</div> </div>
</div> </div>
@ -203,7 +205,7 @@ export function HermesOpsPanel() {
<div className="text-sm text-[var(--bl-text-secondary)]"> <div className="text-sm text-[var(--bl-text-secondary)]">
Disaster recovery details live in{' '} Disaster recovery details live in{' '}
<Link href="/hermes/settings" className="text-[var(--bl-accent)] hover:underline">Hermes settings</Link> <Link href="/hermes/settings" className="text-[var(--bl-accent)] hover:underline">Hermes settings</Link>
{' '}and the tracked runbook in `docs/hermes-disaster-recovery.md`. {' '}and the tracked runbook in <span className="font-mono text-xs">docs/hermes-disaster-recovery.md</span>.
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -82,6 +82,35 @@ describe('API Client', () => {
}); });
}); });
describe('getHermesOps', () => {
it('fetches the live Hermes operations snapshot', async () => {
const snapshot = {
generatedAt: '2026-05-27T13:03:14.848Z',
tailscaleIp: '100.87.53.10',
emergencyDriveUpload: {
name: 'hermes-emergency-drive-upload.timer',
active: true,
nextRun: 'Thu 2026-05-28 03:26:15 UTC',
lastRun: null,
},
instances: [],
warnings: [],
};
vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(snapshot));
await expect(api.getHermesOps()).resolves.toEqual(snapshot);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:4004/api/hermes/ops',
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}),
);
});
});
describe('state-changing requests', () => { describe('state-changing requests', () => {
it('triggers a deployment without CSRF when no user token exists', async () => { it('triggers a deployment without CSRF when no user token exists', async () => {
const mockResponse = { const mockResponse = {