Harden Hermes operations dashboard status
This commit is contained in:
parent
0e6528b366
commit
9ee060e839
@ -98,13 +98,15 @@ async function isUmaGatewayEnabled(): Promise<boolean> {
|
||||
}
|
||||
|
||||
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', ['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<HermesOpsSnapshot> {
|
||||
|
||||
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`);
|
||||
|
||||
|
||||
@ -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 }) {
|
||||
<DatabaseBackup className="h-4 w-4" />
|
||||
Backup repo
|
||||
</div>
|
||||
<p className="mt-2">HEAD {instance.backup.repo.head ?? 'unknown'}</p>
|
||||
<p>Last commit {formatDate(instance.backup.repo.lastCommitAt)}</p>
|
||||
<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">
|
||||
@ -82,7 +84,7 @@ function InstanceCard({ instance }: { instance: HermesOpsInstance }) {
|
||||
</div>
|
||||
<p className="mt-2">{instance.backup.restoredFileCount ?? 'unknown'} tracked files</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>
|
||||
|
||||
@ -124,7 +126,7 @@ export function HermesOpsPanel() {
|
||||
|
||||
return (
|
||||
<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."
|
||||
actions={(
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@ -150,28 +152,28 @@ export function HermesOpsPanel() {
|
||||
<Wifi className="h-4 w-4" />
|
||||
Tailscale IP
|
||||
</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 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 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 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 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 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 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>
|
||||
|
||||
@ -203,7 +205,7 @@ export function HermesOpsPanel() {
|
||||
<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 `docs/hermes-disaster-recovery.md`.
|
||||
{' '}and the tracked runbook in <span className="font-mono text-xs">docs/hermes-disaster-recovery.md</span>.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user