diff --git a/dashboard/backend/src/modules/hermes-ops/repository.ts b/dashboard/backend/src/modules/hermes-ops/repository.ts index fe252aa..9b95255 100644 --- a/dashboard/backend/src/modules/hermes-ops/repository.ts +++ b/dashboard/backend/src/modules/hermes-ops/repository.ts @@ -98,13 +98,15 @@ async function isUmaGatewayEnabled(): Promise { } async function getRepo(path: string): Promise { - 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', ['status', '--porcelain'], path), run('git', ['rev-parse', '--short', 'HEAD'], 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 { path, @@ -169,7 +171,10 @@ export async function getHermesOpsSnapshot(): Promise { const dashboardUrl = tailscaleIp ? `http://${tailscaleIp}:${item.dashboardPort}/` : `:${item.dashboardPort}`; 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 (!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 (!googleToken) warnings.push(`${item.label} Google Workspace token is missing`); diff --git a/dashboard/web/src/components/hermes-ops-panel.tsx b/dashboard/web/src/components/hermes-ops-panel.tsx index d7b0725..417064a 100644 --- a/dashboard/web/src/components/hermes-ops-panel.tsx +++ b/dashboard/web/src/components/hermes-ops-panel.tsx @@ -17,12 +17,14 @@ function boolText(value: boolean) { 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(new Date(value)); + }).format(date); } function StatusRow({ label, value, ok }: { label: string; value: string; ok: boolean }) { @@ -71,8 +73,8 @@ function InstanceCard({ instance }: { instance: HermesOpsInstance }) { Backup repo -

HEAD {instance.backup.repo.head ?? 'unknown'}

-

Last commit {formatDate(instance.backup.repo.lastCommitAt)}

+

HEAD {instance.backup.repo.head ?? 'unknown'}

+

Last commit {formatDate(instance.backup.repo.lastCommitAt)}

{instance.backup.repo.clean ? 'Clean working tree' : 'Uncommitted changes present'}

@@ -82,7 +84,7 @@ function InstanceCard({ instance }: { instance: HermesOpsInstance }) {

{instance.backup.restoredFileCount ?? 'unknown'} tracked files

{instance.backup.restoredCronJobs ?? 'unknown'} cron job definitions

-

{instance.backup.repo.size ?? 'size unknown'}

+

{instance.backup.repo.size ?? 'size unknown'}

@@ -124,7 +126,7 @@ export function HermesOpsPanel() { return ( @@ -150,28 +152,28 @@ export function HermesOpsPanel() { Tailscale IP -

{snapshot.tailscaleIp ?? 'unknown'}

+

{snapshot.tailscaleIp ?? 'unknown'}

Emergency Drive
-

{snapshot.emergencyDriveUpload.active ? 'active' : 'inactive'}

+

{snapshot.emergencyDriveUpload.active ? 'active' : 'inactive'}

Next Drive bundle
-

{snapshot.emergencyDriveUpload.nextRun ?? 'unknown'}

+

{snapshot.emergencyDriveUpload.nextRun ?? 'unknown'}

Generated
-

{formatDate(snapshot.generatedAt)}

+

{formatDate(snapshot.generatedAt)}

@@ -203,7 +205,7 @@ export function HermesOpsPanel() {
Disaster recovery details live in{' '} Hermes settings - {' '}and the tracked runbook in `docs/hermes-disaster-recovery.md`. + {' '}and the tracked runbook in docs/hermes-disaster-recovery.md.
) : ( diff --git a/dashboard/web/src/lib/api.test.ts b/dashboard/web/src/lib/api.test.ts index d8eac5d..fc7f12e 100644 --- a/dashboard/web/src/lib/api.test.ts +++ b/dashboard/web/src/lib/api.test.ts @@ -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', () => { it('triggers a deployment without CSRF when no user token exists', async () => { const mockResponse = {