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> {
|
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`);
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user