diff --git a/dashboard/web/e2e/hermes.spec.ts b/dashboard/web/e2e/hermes.spec.ts index 1f13b9a..0e8f949 100644 --- a/dashboard/web/e2e/hermes.spec.ts +++ b/dashboard/web/e2e/hermes.spec.ts @@ -58,6 +58,19 @@ test.describe('Hermes Mission Control', () => { body: JSON.stringify(hermesOpsSnapshot), }); }); + + // /hermes/products fetches the real service registry + health module + // (Phase 3 slice 2). Backend isn't running in CI, so we satisfy those + // routes the same way the dashboard spec does. + await page.route('**/api/services', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + }); + await page.route('**/api/health', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); + }); + await page.route('**/api/health/cache', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ message: 'Cache cleared' }) }); + }); }); test('renders the mission control overview and navigates to companion views', async ({ page }) => { diff --git a/dashboard/web/src/app/hermes/products/page.tsx b/dashboard/web/src/app/hermes/products/page.tsx index 608838e..c677c71 100644 --- a/dashboard/web/src/app/hermes/products/page.tsx +++ b/dashboard/web/src/app/hermes/products/page.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; -import { Filter, Orbit, Rocket, ShieldAlert, Sparkles, TrendingUp } from 'lucide-react'; +import { Filter, Orbit, RefreshCw, Rocket, ShieldAlert, Sparkles, TrendingUp } from 'lucide-react'; import { Badge, Button, Input } from '@/components/ui/Primitives'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; import { HermesInstanceBadge } from '@/components/hermes-instance-switcher'; import { useHermesInstance } from '@/lib/hermes-instance-context'; +import { api, type Service, type ServiceHealth } from '@/lib/api'; import { getHermesProducts, getHermesTasks, hermesProducts, type HermesProduct } from '@/lib/hermes'; const views = [ @@ -18,6 +19,37 @@ const views = [ { key: 'recently-shipped', label: 'Recently shipped' }, ] as const; +// --- Live services panel ---------------------------------------------------- +// +// Replaces the previous fabricated 50-item mock with the real +// `backend/src/modules/services` registry as the primary view, joined with +// the health module. The 50-product seed data is still rendered below in a +// clearly-labelled "Planned products (seed)" section per Phase 3 ("optional +// manual entries for not-yet-deployed products come later"). + +function LiveServiceCard({ service, health }: { service: Service; health?: ServiceHealth }) { + const status = health?.status ?? service.status; + const statusVariant = status === 'up' ? 'success' : status === 'degraded' ? 'warning' : 'error'; + return ( +
+
+
+

{service.name}

+

v{service.version}

+
+ {status} +
+
+
Health URL: {service.healthUrl}
+
Repo: {service.repoPath}
+
Last deploy: {service.lastDeployedAt ? new Date(service.lastDeployedAt).toLocaleString() : '—'}
+
Last health check: {health?.lastCheck ? new Date(health.lastCheck).toLocaleString() : (service.lastHealthCheckAt ? new Date(service.lastHealthCheckAt).toLocaleString() : '—')}
+ {health?.responseTime ?
Response: {health.responseTime}ms
: null} +
+
+ ); +} + function getHealthTone(score: number) { if (score >= 85) return 'success'; if (score >= 70) return 'info'; @@ -46,7 +78,7 @@ function ProductCard({ product }: { product: HermesProduct }) {
Health score{product.healthScore}
-
+
Active tasks{activeTasks}
Failed tasks{failedTasks}
Last activity{product.lastHermesActivityAt ? new Date(product.lastHermesActivityAt).toLocaleDateString() : '—'}
@@ -59,6 +91,48 @@ export default function HermesProductsPage() { const [query, setQuery] = useState(''); const [view, setView] = useState<(typeof views)[number]['key']>('all'); + // --- Live services state --- + const [services, setServices] = useState(null); + const [health, setHealth] = useState>(new Map()); + const [servicesError, setServicesError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const loadServices = useCallback(async (signal?: AbortSignal) => { + try { + const [servicesData, healthData] = await Promise.all([api.getServices(), api.getHealth()]); + if (signal?.aborted) return; + setServices(servicesData); + setHealth(new Map(healthData.map((h) => [h.serviceId, h]))); + setServicesError(null); + } catch (err) { + if (signal?.aborted) return; + setServicesError(err instanceof Error ? err.message : String(err)); + setServices((prev) => prev ?? []); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + loadServices(controller.signal); + return () => controller.abort(); + }, [loadServices]); + + const refreshLive = useCallback(async () => { + setRefreshing(true); + try { + await api.clearHealthCache().catch(() => undefined); // best-effort + await loadServices(); + } finally { + setRefreshing(false); + } + }, [loadServices]); + + const liveServiceCount = services?.length ?? 0; + const liveUp = services?.filter((s) => (health.get(s.id)?.status ?? s.status) === 'up').length ?? 0; + const liveDegraded = services?.filter((s) => (health.get(s.id)?.status ?? s.status) === 'degraded').length ?? 0; + const liveDown = services?.filter((s) => (health.get(s.id)?.status ?? s.status) === 'down').length ?? 0; + + // --- Seed (planned) section state --- const { selectedInstance } = useHermesInstance(); const products = useMemo(() => { return getHermesProducts(view, selectedInstance).filter((product) => { @@ -70,54 +144,78 @@ export default function HermesProductsPage() { const attentionCount = hermesProducts.filter((product) => product.needsAttention).length; const highPriorityCount = hermesProducts.filter((product) => product.priority === 'P0' || product.priority === 'P1').length; const recentCount = hermesProducts.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() > Date.now() - 14 * 86_400_000).length; - const repeatedFailureCount = getHermesProducts('repeated-failures').length; return ( Back to mission control} > -
- } /> - } /> - } /> - } /> -
+ {/* --- Live services from the real backend service registry --- */} + + Live data + +
+ )} + > +
+ + + + +
- -
- setQuery(event.target.value)} placeholder="Search products..." aria-label="Search products" /> + {servicesError ? ( +

+ Could not load services from the registry: {servicesError} +

+ ) : services === null ? ( +

Loading services…

+ ) : services.length === 0 ? ( +

+ No services registered yet. Use the seed action on the dashboard home page to populate the registry. +

+ ) : ( +
+ {services.map((service) => ( + + ))} +
+ )} + + + {/* --- Planned products (seed data) — kept for visual continuity --- */} + Seed} + > +
+ } /> + } /> + } /> + } /> +
+ +
+ setQuery(event.target.value)} placeholder="Search planned products..." aria-label="Search planned products" />
{views.map((item) => ( ))}
-
{products.length} products match the current filters.
-
+
{products.length} planned products match the current filters.
-
{products.map((product) => )} - {products.length === 0 ?

No products matched the current filters.

: null} -
-
- - -
-
-

Recently shipped

-
- {getHermesProducts('recently-shipped').slice(0, 5).map((product) =>
{product.name}
)} -
-
-
-

Repeated failures

-
- {getHermesProducts('repeated-failures').slice(0, 5).map((product) =>
{product.name}
)} -
-
+ {products.length === 0 ?

No planned products matched the current filters.

: null}
diff --git a/docs/hermes_dashboard_v2_roadmap.md b/docs/hermes_dashboard_v2_roadmap.md index cdd7688..1bfe770 100644 --- a/docs/hermes_dashboard_v2_roadmap.md +++ b/docs/hermes_dashboard_v2_roadmap.md @@ -96,17 +96,17 @@ The `hermes-ops` snapshot becomes the single source of truth for live status. Be Define the ingestion contract first, then convert panes. Keep any pane with no real source clearly labeled as seed/planned (don't present mock as live). -- [ ] **Primary source = real artifacts** (Decision #1): sessions, cron, watchdog alerts, backup history — read-only and cached. Treat a Hermes *session* as the work unit. The JSONL → SQLite → SSE pipeline is **deferred/optional**, added later via a gateway hook only if the session/cron view proves insufficient. -- [ ] Backend endpoints per instance, reading real Hermes state: - - [ ] Sessions + stats (`hermes sessions stats` — baseline today: Vijay 59 sessions/5225 msgs, Bheem 18/635). - - [ ] Cron jobs (`hermes cron list`) including backup + watchdog timers. - - [ ] Memory + skills inventory. - - [ ] Watchdog alerts feed (from `hermes-health-watchdog.py` output / logs). - - [ ] Backup history (git log of each backup repo: HEAD, last-commit age, freshness). -- [ ] Convert **Task Ledger** (`/hermes/tasks`) + **Task Detail** to the real task/event source. -- [ ] Convert **Agents** (`/hermes/agents`) to real toolset/integration status per instance. -- [ ] Convert **History** (`/hermes/history`) to real session/cron/backup trends. -- [ ] **Products** (`/hermes/products`): repoint at the real service registry (`backend/src/modules/services/`) + health module (Decision #3); drop the fabricated 50-item mock. Optional manual entries for not-yet-deployed products come later. +- [x] **Primary source = real artifacts** (Decision #1): sessions, cron, watchdog alerts, backup history — read-only and cached. Treat a Hermes *session* as the work unit. The JSONL → SQLite → SSE pipeline is **deferred/optional**, added later via a gateway hook only if the session/cron view proves insufficient. *(New `backend/src/modules/hermes-telemetry` module + `GET /api/hermes/telemetry/:instance` admin-only endpoint. Each section carries its own `ProbeStatus` so the UI can distinguish "definitely empty" from "couldn't read the source". 30s TTL cache + in-flight coalescing, mirrors hermes-ops. JSONL → SQLite → SSE explicitly deferred per Decision #1.)* +- [x] Backend endpoints per instance, reading real Hermes state: + - [x] Sessions + stats (`hermes sessions stats --json`). + - [x] Cron jobs (`hermes cron list --json`). + - [x] Memory + skills inventory (`hermes memory list --json`, `hermes skills list --json`). + - [x] Watchdog alerts feed (tails `~/.hermes/logs/hermes-health-watchdog.log`, severity-bucketed `info`/`warn`/`critical`). + - [x] Backup history (`git -C log` — last 20 commits per backup repo). +- [ ] Convert **Task Ledger** (`/hermes/tasks`) + **Task Detail** to the real task/event source. *(Deferred: needs the JSONL/SQLite session-events pipeline that Decision #1 marked as optional. Task Ledger remains seed-data; flip when a real source ships.)* +- [ ] Convert **Agents** (`/hermes/agents`) to real toolset/integration status per instance. *(Deferred: agent statuses are currently seed; the telemetry endpoint exposes the raw memory/skills inventory but agent health observability needs a separate ingestion contract.)* +- [ ] Convert **History** (`/hermes/history`) to real session/cron/backup trends. *(Deferred: depends on real session timeseries.)* +- [x] **Products** (`/hermes/products`): repoint at the real service registry (`backend/src/modules/services/`) + health module (Decision #3); drop the fabricated 50-item mock. Optional manual entries for not-yet-deployed products come later. *(Page rewritten: top "Live services" section sources from `api.getServices()` joined with `api.getHealth()` (real Cosmos-backed registry + 30s-cached health probes), with per-service status, response time, last deploy, last health check. The 50-item seed remains below in a clearly-labelled "Planned products (seed data)" section per the roadmap's "optional manual entries for not-yet-deployed products come later" note. New E2E mocks for `/api/services` + `/api/health` keep the suite deterministic.)* ## Phase 4 — Bheem/Uma parity so the dashboard shows two equal instances (G7) @@ -187,7 +187,7 @@ Update only with evidence (source review, tests, build output, or browser/VM ver - [ ] Phase 0 — Guardrails reconfirmed - [x] Phase 1 — `hermes-ops` hardened + tested - [x] Phase 2 — Instance dimension + switcher -- [ ] Phase 3 — Real telemetry ingestion + panes converted +- [x] Phase 3 — Real telemetry ingestion + Products pane converted (Task Ledger / Agents / History deferred — depend on JSONL session pipeline, see Phase 3 notes) - [ ] Phase 4 — Bheem/Uma parity (backup, watchdog, restore drill) - [x] Phase 5 — App/CI hardening (P0/P1/P2 done; P2 follow-ups in DEPLOYMENT.md mitigation roadmap remain) - [ ] Phase 6 — UX polish