diff --git a/dashboard/web/src/components/hermes-ops-panel.tsx b/dashboard/web/src/components/hermes-ops-panel.tsx index 7b3a0e2..fb38046 100644 --- a/dashboard/web/src/components/hermes-ops-panel.tsx +++ b/dashboard/web/src/components/hermes-ops-panel.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; -import { AlertTriangle, CheckCircle2, Cloud, Copy, DatabaseBackup, ExternalLink, Gauge, HardDrive, RefreshCw, ShieldCheck, Timer, Wifi, Activity, CalendarClock, Link2 } from 'lucide-react'; +import { AlertTriangle, BookOpen, CheckCircle2, Cloud, Copy, DatabaseBackup, ExternalLink, Gauge, HardDrive, ListChecks, RefreshCw, ShieldCheck, Terminal, Timer, Wifi, Activity, CalendarClock, Link2 } from 'lucide-react'; import { Badge, Button } from '@/components/ui/Primitives'; import { SectionCard } from '@/components/hermes-shell'; import { api, type HermesOpsInstance, type HermesOpsSnapshot } from '@/lib/api'; @@ -36,7 +36,16 @@ function StatusRow({ label, value, ok }: { label: string; value: string; ok: boo ); } -function InstanceCard({ instance }: { instance: HermesOpsInstance }) { +// SSH command per instance. Tailscale's `tailscale ssh` keeps it inside the +// private mesh — never use raw `ssh` over public IP. The user side is fixed +// per instance: root for Vijay, uma for Bheem (matches the runuser pattern +// in the backend). +function sshCommandFor(instance: HermesOpsInstance, tailscaleHost: string): string { + const user = instance.id === 'bheem' ? 'uma' : 'root'; + return `tailscale ssh ${user}@${tailscaleHost}`; +} + +function InstanceCard({ instance, tailscaleIp }: { instance: HermesOpsInstance; tailscaleIp: string | null }) { const score = [ instance.gateway.active, instance.gateway.enabled, @@ -98,11 +107,112 @@ function InstanceCard({ instance }: { instance: HermesOpsInstance }) { Copy URL + {tailscaleIp ? ( + + ) : null} + + ); } +// Severity-tagged warnings (Phase 6 polish). The backend sends warnings as +// plain strings today (not a contract change worth making just for this UI +// affordance); we infer severity from leading tokens. Falls back to 'warn' +// when no token is recognised — most ops messages are warnings. +type Severity = 'info' | 'warn' | 'critical'; +const SEVERITY_ORDER: Severity[] = ['critical', 'warn', 'info']; + +function classifyWarning(message: string): Severity { + const upper = message.toUpperCase(); + if (upper.startsWith('CRITICAL') || upper.startsWith('ERROR') || upper.startsWith('FATAL')) return 'critical'; + if (upper.startsWith('INFO') || upper.startsWith('OK')) return 'info'; + return 'warn'; +} + +function severityVariant(s: Severity): 'error' | 'warning' | 'info' { + if (s === 'critical') return 'error'; + if (s === 'warn') return 'warning'; + return 'info'; +} + +function RecentAlerts({ alerts }: { alerts: string[] }) { + const [filter, setFilter] = useState('all'); + const tagged = useMemo( + () => alerts.map((message, idx) => ({ id: `${idx}-${message}`, message, severity: classifyWarning(message) })), + [alerts], + ); + const counts = useMemo(() => { + const acc: Record = { info: 0, warn: 0, critical: 0 }; + for (const a of tagged) acc[a.severity]++; + return acc; + }, [tagged]); + const visible = filter === 'all' ? tagged : tagged.filter((a) => a.severity === filter); + + return ( +
+
+
+ + Recent sanitized alerts +
+
+ {(['all', ...SEVERITY_ORDER] as const).map((opt) => { + const active = filter === opt; + const label = opt === 'all' + ? `All (${tagged.length})` + : `${opt} (${counts[opt]})`; + return ( + + ); + })} +
+
+
+ {visible.length ? visible.map((a) => ( +
+ {a.severity} + {a.message} +
+ )) : ( +
No alerts at this severity.
+ )} +
+
+ ); +} + export function HermesOpsPanel() { const [snapshot, setSnapshot] = useState(null); const [previousSnapshot, setPreviousSnapshot] = useState(null); @@ -345,19 +455,7 @@ export function HermesOpsPanel() { )}
-
-
- - Recent sanitized alerts -
-
- {snapshot.recentAlerts.length ? snapshot.recentAlerts.map((warning) => ( -
{warning}
- )) : ( -
No recent alerts.
- )} -
-
+
@@ -383,7 +481,7 @@ export function HermesOpsPanel() {
{snapshot.instances.map((instance) => ( - + ))}
diff --git a/dashboard/web/src/lib/hermes-instance-context.tsx b/dashboard/web/src/lib/hermes-instance-context.tsx index abfde18..d340c8b 100644 --- a/dashboard/web/src/lib/hermes-instance-context.tsx +++ b/dashboard/web/src/lib/hermes-instance-context.tsx @@ -1,6 +1,7 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useSearchParams } from 'next/navigation'; import type { HermesInstanceFilter } from './hermes'; // Persisted across navigation so the switcher choice sticks. Bumping the key @@ -36,9 +37,28 @@ export function HermesInstanceProvider({ children }: { children: ReactNode }) { // agree (avoids hydration mismatch). After mount we read the persisted value. const [selectedInstance, setSelectedInstanceState] = useState(DEFAULT_FILTER); + // Deep-link hydration: `?instance=vijay` (or `bheem` / `all`) wins over + // localStorage on first mount so links from the ops panel can open a pane + // pre-filtered to the relevant instance. We don't auto-strip the param — + // the URL stays meaningful for back/forward and copy-paste. + const searchParams = useSearchParams(); + useEffect(() => { + const fromUrl = searchParams?.get('instance'); + if (fromUrl && (VALID_FILTERS as string[]).includes(fromUrl)) { + const next = fromUrl as HermesInstanceFilter; + setSelectedInstanceState(next); + try { + if (typeof window !== 'undefined') window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // ignore + } + return; + } setSelectedInstanceState(readPersisted()); - }, []); + // We deliberately depend on the search params object so navigations that + // change `?instance=` re-apply. + }, [searchParams]); const setSelectedInstance = useCallback((next: HermesInstanceFilter) => { setSelectedInstanceState(next); diff --git a/docs/hermes_dashboard_v2_roadmap.md b/docs/hermes_dashboard_v2_roadmap.md index 21748cb..05c71e4 100644 --- a/docs/hermes_dashboard_v2_roadmap.md +++ b/docs/hermes_dashboard_v2_roadmap.md @@ -132,12 +132,12 @@ This is the biggest operational asymmetry and the reason half the ops-panel warn ## Phase 6 — Mission Control UX polish (G6) -- [ ] Severity-tag warnings (info/warn/critical) and add a severity filter to the ops panel. -- [ ] Trend cards: alert volume and backup-freshness across recent refreshes (per instance). -- [ ] Deep links from the ops panel → Task Ledger filtered to the relevant instance/most-recent work. -- [ ] Per-instance action rows beyond copy-link/open-dashboard: open-runbook, copy SSH/tunnel command, "how to restart this gateway". -- [ ] Optional dark/light theme toggle if the shell supports it. -- [ ] Unified alerts feed across both instances on the overview. +- [x] Severity-tag warnings (info/warn/critical) and add a severity filter to the ops panel. *(`RecentAlerts` component classifies each warning by leading token (CRITICAL/ERROR/FATAL → critical; INFO/OK → info; default → warn) and renders a colour-coded badge; a per-severity radiogroup filter sits in the panel header with live counts. UI-only — no backend contract change.)* +- [ ] Trend cards: alert volume and backup-freshness across recent refreshes (per instance). *(Deferred — needs client-side history persistence across refreshes; not enough value yet to justify the localStorage state machine.)* +- [x] Deep links from the ops panel → Task Ledger filtered to the relevant instance/most-recent work. *(Per-instance "View tasks" button on each ops-panel `InstanceCard` links to `/hermes/tasks?instance=`. `HermesInstanceProvider` now hydrates from the `?instance=` URL param on mount (winning over the persisted localStorage selection) and keeps the param meaningful for back/forward + copy-paste.)* +- [x] Per-instance action rows beyond copy-link/open-dashboard: open-runbook, copy SSH/tunnel command, "how to restart this gateway". *(InstanceCard now exposes "Copy SSH command" (Tailscale-scoped: `tailscale ssh root@` for Vijay, `tailscale ssh uma@` for Bheem — never raw `ssh`), "View tasks" deep link, and "Open runbook" pointing at `docs/hermes-operations.md`. "How to restart this gateway" is intentionally a runbook link rather than a button — restarting is a privileged action that should go through the runbook, not the dashboard.)* +- [ ] Optional dark/light theme toggle if the shell supports it. *(Deferred — design system uses CSS custom properties throughout (`var(--bl-*)`) so a toggle is feasible, but the shell doesn't expose a switch primitive yet.)* +- [ ] Unified alerts feed across both instances on the overview. *(Partially achieved by `recentAlerts` + the new severity filter on the ops panel; full per-instance roll-up of telemetry watchdog alerts is queued behind a UI consumer for the new `/api/hermes/telemetry/:instance` endpoint.)* ## Phase 7 — Security & access (G8) @@ -192,8 +192,8 @@ Update only with evidence (source review, tests, build output, or browser/VM ver - [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 -- [ ] Phase 7 — Security & access +- [x] Phase 6 — UX polish (severity tags + deep links + per-instance actions; trend cards + theme toggle deferred) +- [x] Phase 7 — Security & access (auth on hermes routes + privacy stance documented; redact_secrets/redact_pii decision deferred) - [ ] Phase 8 — Notifications & Telegram ## Decisions (resolved 2026-05-30)