Complete Hermes ops dashboard and roadmap
This commit is contained in:
parent
e3d1dddf51
commit
90f6db2014
@ -2,7 +2,7 @@ import { execFile } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { readFile, stat } from 'fs/promises';
|
import { readFile, stat } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import type { HermesOpsInstance, HermesOpsRepo, HermesOpsSnapshot, HermesOpsTimer } from './types.js';
|
import type { HermesOpsCronJob, HermesOpsInstance, HermesOpsRepo, HermesOpsSnapshot, HermesOpsTimer } from './types.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@ -148,10 +148,20 @@ async function getTailscaleIp(): Promise<string | null> {
|
|||||||
return output?.split('\n')[0] || null;
|
return output?.split('\n')[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getActiveHermesSessionCount(): Promise<number> {
|
||||||
|
const output = await run('ps', ['-ef']);
|
||||||
|
if (!output) return 0;
|
||||||
|
return output
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.includes('hermes_cli.main') && !line.includes('gateway') && !line.includes('grep'))
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getHermesOpsSnapshot(): Promise<HermesOpsSnapshot> {
|
export async function getHermesOpsSnapshot(): Promise<HermesOpsSnapshot> {
|
||||||
const tailscaleIp = await getTailscaleIp();
|
const tailscaleIp = await getTailscaleIp();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const emergencyDriveUpload = await getTimer('hermes-emergency-drive-upload.timer');
|
const emergencyDriveUpload = await getTimer('hermes-emergency-drive-upload.timer');
|
||||||
|
const activeSessions = await getActiveHermesSessionCount();
|
||||||
|
|
||||||
const results: HermesOpsInstance[] = [];
|
const results: HermesOpsInstance[] = [];
|
||||||
for (const item of instances) {
|
for (const item of instances) {
|
||||||
@ -210,10 +220,50 @@ export async function getHermesOpsSnapshot(): Promise<HermesOpsSnapshot> {
|
|||||||
warnings.push('Emergency Drive OAuth token is missing');
|
warnings.push('Emergency Drive OAuth token is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cronJobs: HermesOpsCronJob[] = [
|
||||||
|
{
|
||||||
|
name: emergencyDriveUpload.name,
|
||||||
|
label: 'Emergency Drive upload',
|
||||||
|
active: emergencyDriveUpload.active,
|
||||||
|
nextRun: emergencyDriveUpload.nextRun,
|
||||||
|
lastRun: emergencyDriveUpload.lastRun,
|
||||||
|
},
|
||||||
|
...results.map((instance) => ({
|
||||||
|
name: instance.backup.timer.name,
|
||||||
|
label: `${instance.label} backup`,
|
||||||
|
active: instance.backup.timer.active,
|
||||||
|
nextRun: instance.backup.timer.nextRun,
|
||||||
|
lastRun: instance.backup.timer.lastRun,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
tailscaleIp,
|
tailscaleIp,
|
||||||
emergencyDriveUpload,
|
emergencyDriveUpload,
|
||||||
|
activeSessions: {
|
||||||
|
active: activeSessions,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
cronJobs,
|
||||||
|
recentAlerts: warnings.slice(0, 6),
|
||||||
|
quickLinks: [
|
||||||
|
{
|
||||||
|
label: 'Hermes operations',
|
||||||
|
href: 'https://github.com/saravanakumardb/learning_ai_devops_tools/blob/main/docs/hermes-operations.md',
|
||||||
|
description: 'Runbook for gateways, backups, fallbacks, and recovery.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Disaster recovery',
|
||||||
|
href: 'https://github.com/saravanakumardb/learning_ai_devops_tools/blob/main/docs/hermes-disaster-recovery.md',
|
||||||
|
description: 'Restore and rebuild steps for a fresh VM.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Setup roadmap',
|
||||||
|
href: 'https://github.com/saravanakumardb/learning_ai_devops_tools/blob/main/docs/hermes-setup-upgrade-roadmap.md',
|
||||||
|
description: 'Tracked rollout, security, and workflow checklist.',
|
||||||
|
},
|
||||||
|
],
|
||||||
instances: results,
|
instances: results,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -42,10 +42,33 @@ export interface HermesOpsInstance {
|
|||||||
google: HermesOpsGoogle;
|
google: HermesOpsGoogle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HermesOpsSessionSummary {
|
||||||
|
active: number;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesOpsCronJob {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
nextRun: string | null;
|
||||||
|
lastRun: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesOpsLink {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HermesOpsSnapshot {
|
export interface HermesOpsSnapshot {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
tailscaleIp: string | null;
|
tailscaleIp: string | null;
|
||||||
emergencyDriveUpload: HermesOpsTimer;
|
emergencyDriveUpload: HermesOpsTimer;
|
||||||
|
activeSessions: HermesOpsSessionSummary;
|
||||||
|
cronJobs: HermesOpsCronJob[];
|
||||||
|
recentAlerts: string[];
|
||||||
|
quickLinks: HermesOpsLink[];
|
||||||
instances: HermesOpsInstance[];
|
instances: HermesOpsInstance[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,15 @@ test.describe('DevOps Dashboard', () => {
|
|||||||
await expect(refreshButton).toBeEnabled();
|
await expect(refreshButton).toBeEnabled();
|
||||||
await expect(page.getByRole('heading', { name: 'Investment Trading' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Investment Trading' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders the dashboard at mobile width', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /create service/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Investment Trading' })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login page renders the platform credential form without baked-in credentials', async ({ page }) => {
|
test('login page renders the platform credential form without baked-in credentials', async ({ page }) => {
|
||||||
|
|||||||
@ -52,4 +52,17 @@ test.describe('Hermes Mission Control', () => {
|
|||||||
await page.goto('/hermes/settings');
|
await page.goto('/hermes/settings');
|
||||||
await expect(page.getByRole('heading', { name: 'Settings & Configuration' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Settings & Configuration' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders the mission control overview at mobile width', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
|
||||||
|
await page.goto('/hermes');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Task Ledger' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Product Portfolio' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/hermes/tasks/task-1');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Hermes learning' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AlertTriangle, CheckCircle2, Cloud, DatabaseBackup, ExternalLink, Gauge, HardDrive, RefreshCw, ShieldCheck, Timer, Wifi } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Cloud, DatabaseBackup, ExternalLink, Gauge, HardDrive, RefreshCw, ShieldCheck, Timer, Wifi, Activity, CalendarClock, Link2 } from 'lucide-react';
|
||||||
import { Badge, Button } from '@/components/ui/Primitives';
|
import { Badge, Button } from '@/components/ui/Primitives';
|
||||||
import { SectionCard } from '@/components/hermes-shell';
|
import { SectionCard } from '@/components/hermes-shell';
|
||||||
import { api, type HermesOpsInstance, type HermesOpsSnapshot } from '@/lib/api';
|
import { api, type HermesOpsInstance, type HermesOpsSnapshot } from '@/lib/api';
|
||||||
@ -177,6 +177,48 @@ export function HermesOpsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<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 font-medium text-[var(--bl-text-primary)]">
|
||||||
|
<Activity className="h-4 w-4 text-[var(--bl-accent)]" />
|
||||||
|
Active Hermes sessions
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Running now</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-[var(--bl-text-primary)]">{snapshot.activeSessions.active}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Last updated</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-[var(--bl-text-primary)]">{snapshot.activeSessions.updatedAt ? formatDate(snapshot.activeSessions.updatedAt) : 'unknown'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Interpretation</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--bl-text-secondary)]">Counted from Hermes CLI processes outside the gateway daemons.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 font-medium text-[var(--bl-text-primary)]">
|
||||||
|
<CalendarClock className="h-4 w-4 text-[var(--bl-accent)]" />
|
||||||
|
Cron job state
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{snapshot.cronJobs.map((job) => (
|
||||||
|
<div key={job.name} className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-medium text-[var(--bl-text-primary)]">{job.label}</p>
|
||||||
|
<Badge variant={job.active ? 'success' : 'error'}>{job.active ? 'active' : 'inactive'}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[var(--bl-text-secondary)]">Next: {job.nextRun ?? 'unknown'}</p>
|
||||||
|
<p className="text-xs text-[var(--bl-text-secondary)]">Last: {job.lastRun ?? 'unknown'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{snapshot.warnings.length ? (
|
{snapshot.warnings.length ? (
|
||||||
<div className="rounded-2xl border border-[var(--bl-warning)]/40 bg-[var(--bl-warning)]/10 p-4">
|
<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)]">
|
<div className="flex items-center gap-2 font-medium text-[var(--bl-text-primary)]">
|
||||||
@ -196,6 +238,43 @@ export function HermesOpsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1fr_1fr]">
|
||||||
|
<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 font-medium text-[var(--bl-text-primary)]">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-[var(--bl-warning)]" />
|
||||||
|
Recent sanitized alerts
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{snapshot.recentAlerts.length ? snapshot.recentAlerts.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 className="rounded-xl bg-[var(--bl-surface-card)] px-3 py-2 text-sm text-[var(--bl-text-secondary)]">No recent alerts.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 font-medium text-[var(--bl-text-primary)]">
|
||||||
|
<Link2 className="h-4 w-4 text-[var(--bl-accent)]" />
|
||||||
|
Quick links
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{snapshot.quickLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3 hover:border-[var(--bl-accent)]"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-[var(--bl-text-primary)]">{link.label}</p>
|
||||||
|
<p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{link.description}</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
{snapshot.instances.map((instance) => (
|
{snapshot.instances.map((instance) => (
|
||||||
<InstanceCard key={instance.id} instance={instance} />
|
<InstanceCard key={instance.id} instance={instance} />
|
||||||
|
|||||||
@ -98,10 +98,33 @@ export interface HermesOpsInstance {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HermesOpsSessionSummary {
|
||||||
|
active: number;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesOpsCronJob {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
nextRun: string | null;
|
||||||
|
lastRun: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesOpsLink {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HermesOpsSnapshot {
|
export interface HermesOpsSnapshot {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
tailscaleIp: string | null;
|
tailscaleIp: string | null;
|
||||||
emergencyDriveUpload: HermesOpsTimer;
|
emergencyDriveUpload: HermesOpsTimer;
|
||||||
|
activeSessions: HermesOpsSessionSummary;
|
||||||
|
cronJobs: HermesOpsCronJob[];
|
||||||
|
recentAlerts: string[];
|
||||||
|
quickLinks: HermesOpsLink[];
|
||||||
instances: HermesOpsInstance[];
|
instances: HermesOpsInstance[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ Observed on 2026-05-27:
|
|||||||
- Private dashboards:
|
- Private dashboards:
|
||||||
- Root: `http://100.87.53.10:9119/`, `hermes-root-dashboard.service`
|
- Root: `http://100.87.53.10:9119/`, `hermes-root-dashboard.service`
|
||||||
- Uma: `http://100.87.53.10:9120/`, `uma-hermes-dashboard.service`
|
- Uma: `http://100.87.53.10:9120/`, `uma-hermes-dashboard.service`
|
||||||
|
- Live ops panel shows gateway state, active sessions, cron state, backup freshness, sanitized alerts, and runbook links for both instances.
|
||||||
|
|
||||||
## Safety guardrail: no public Hermes dashboard/API
|
## Safety guardrail: no public Hermes dashboard/API
|
||||||
|
|
||||||
|
|||||||
@ -333,15 +333,15 @@ A healthy ByteLyst Hermes setup should be:
|
|||||||
- [x] If a dashboard is useful, make it private-only and operationally scoped.
|
- [x] If a dashboard is useful, make it private-only and operationally scoped.
|
||||||
- vijay: root dashboard is running as `hermes-root-dashboard.service` at `http://100.87.53.10:9119/`, bound only to the Tailscale IP.
|
- vijay: root dashboard is running as `hermes-root-dashboard.service` at `http://100.87.53.10:9119/`, bound only to the Tailscale IP.
|
||||||
- bheem: Uma dashboard is running as `uma-hermes-dashboard.service` at `http://100.87.53.10:9120/`, bound only to the Tailscale IP.
|
- bheem: Uma dashboard is running as `uma-hermes-dashboard.service` at `http://100.87.53.10:9120/`, bound only to the Tailscale IP.
|
||||||
- [ ] Dashboard should show:
|
- [x] Dashboard should show:
|
||||||
- [ ] gateway status
|
- [x] gateway status
|
||||||
- [ ] active sessions
|
- [x] active sessions
|
||||||
- [ ] cron job state
|
- [x] cron job state
|
||||||
- [ ] backup freshness
|
- [x] backup freshness
|
||||||
- [ ] recent sanitized alerts
|
- [x] recent sanitized alerts
|
||||||
- [ ] quick links to docs/runbooks
|
- [x] quick links to docs/runbooks
|
||||||
- vijay: root dashboard HTTP endpoint returns `200` over Tailscale; feature-by-feature UI validation remains pending.
|
- vijay: root live ops panel now shows gateway state, active sessions, cron state, backup freshness, sanitized alerts, and runbook links over Tailscale.
|
||||||
- bheem: Uma dashboard HTTP endpoint returns `200` over Tailscale; feature-by-feature UI validation remains pending.
|
- bheem: Uma live ops panel now shows the same operational fields over Tailscale.
|
||||||
- [x] Any dashboard actions must require authentication and ideally remain reachable only over private network/tunnel.
|
- [x] Any dashboard actions must require authentication and ideally remain reachable only over private network/tunnel.
|
||||||
- vijay: root dashboard is private-network-only via Tailscale IP binding; no public listener or Caddy route was added.
|
- vijay: root dashboard is private-network-only via Tailscale IP binding; no public listener or Caddy route was added.
|
||||||
- bheem: Uma dashboard is private-network-only via Tailscale IP binding; no public listener or Caddy route was added.
|
- bheem: Uma dashboard is private-network-only via Tailscale IP binding; no public listener or Caddy route was added.
|
||||||
|
|||||||
@ -654,7 +654,7 @@ Update this checklist only after each item has evidence from source review, test
|
|||||||
- [x] Unit/component tests pass.
|
- [x] Unit/component tests pass.
|
||||||
- [x] Production build passes.
|
- [x] Production build passes.
|
||||||
- [x] E2E or browser smoke verification covers all new routes with no console errors.
|
- [x] E2E or browser smoke verification covers all new routes with no console errors.
|
||||||
- [ ] Responsive layout checked at desktop and mobile widths.
|
- [x] Responsive layout checked at desktop and mobile widths.
|
||||||
|
|
||||||
Known roadmap assumptions to handle safely during implementation:
|
Known roadmap assumptions to handle safely during implementation:
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user