feat(dashboard): Phase 2 — instance dimension across Mission Control
Closes Phase 2. Every entity in `web/src/lib/hermes` now carries an
`instanceId: 'vijay' | 'bheem'` (with `'all'` allowed for cross-cutting
agents like Hermes Core / GitHub link), and a global instance switcher
above every Mission Control pane filters them.
Library changes (`web/src/lib/hermes.ts`):
- New `HermesInstanceId` / `HermesInstanceFilter` types + `HERMES_INSTANCES`
metadata array.
- `instanceId` added to `HermesProduct`, `HermesTask`, `HermesEvent`,
`HermesRun`, `HermesAgentStatus`. Seed data deterministically split
~50/50 across instances; agents tagged per-scope (Local VM runner →
bheem, CLI runner / Scheduler → vijay, Hermes Core / GitHub /
OpenClaw / deployment / notifications → all).
- `getHermesTasks({instance})`, `getHermesProducts(view, instance)`,
`getHermesAgents(instance)`, `getHermesHistory(instance)`,
`getHermesOverview(instance)` all accept the filter; helper
`instanceMatches(scope, filter)` keeps the semantics consistent
(always-match for `'all'` on either side).
UI changes:
- New `HermesInstanceProvider` (React context, localStorage-backed
under `hermes.instanceFilter.v1`, SSR-safe default to avoid
hydration mismatch) mounted in `app/hermes/layout.tsx`.
- New `HermesInstanceSwitcher` segmented control (radiogroup with
aria-checked) rendered in the layout header above every pane.
- New `HermesInstanceBadge` shown on task rows (Active Missions +
Task Ledger), product cards (overview minicards + portfolio
cards), and agent cards.
- `/hermes` overview gains a "Per-instance roll-up" section that
always shows Vijay vs Bheem side-by-side regardless of the active
filter — that's the always-cross-instance comparison view, while
the eight metric cards above it are filtered by the switcher.
Tests:
- 2 new unit tests in `lib/hermes.test.ts` (instance tagging on seed
data + filter semantics across tasks/products/agents/overview).
- 1 new E2E test asserting the switcher's radiogroup, default
selection, and persistence-friendly state change.
- All green: 13/13 web unit tests, 7/7 E2E.
`web/test-results/` and `web/playwright-report/` added to `.gitignore`
since they're regenerated per run.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
24fe1567f6
commit
ecd1f20d59
2
dashboard/web/.gitignore
vendored
2
dashboard/web/.gitignore
vendored
@ -7,6 +7,8 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@ -100,4 +100,22 @@ test.describe('Hermes Mission Control', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Hermes learning' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('exposes a global instance switcher with All / Vijay / Bheem', async ({ page }) => {
|
||||
await page.goto('/hermes');
|
||||
const switcher = page.getByRole('radiogroup', { name: 'Hermes instance filter' });
|
||||
await expect(switcher).toBeVisible();
|
||||
|
||||
// Default selection persists across navigation; "All" should be the
|
||||
// active option on first visit (no localStorage entry yet).
|
||||
await expect(switcher.getByRole('radio', { name: 'All' })).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// Per-instance roll-up section should always be visible regardless of filter.
|
||||
await expect(page.getByRole('heading', { name: 'Per-instance roll-up' })).toBeVisible();
|
||||
|
||||
// Switching to Bheem should still keep the page rendering correctly.
|
||||
await switcher.getByRole('radio', { name: 'Bheem (uma)' }).click();
|
||||
await expect(switcher.getByRole('radio', { name: 'Bheem (uma)' })).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,11 +3,15 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Gauge, ShieldAlert, ServerCog } from 'lucide-react';
|
||||
import { Badge, Button } from '@/components/ui/Primitives';
|
||||
import { useMemo } from 'react';
|
||||
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
|
||||
import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
|
||||
import { useHermesInstance } from '@/lib/hermes-instance-context';
|
||||
import { getHermesAgents } from '@/lib/hermes';
|
||||
|
||||
export default function HermesAgentsPage() {
|
||||
const agents = getHermesAgents();
|
||||
const { selectedInstance } = useHermesInstance();
|
||||
const agents = useMemo(() => getHermesAgents(selectedInstance), [selectedInstance]);
|
||||
const healthy = agents.filter((agent) => agent.status === 'healthy').length;
|
||||
const degraded = agents.filter((agent) => agent.status === 'degraded').length;
|
||||
const offline = agents.filter((agent) => agent.status === 'offline').length;
|
||||
@ -33,7 +37,10 @@ export default function HermesAgentsPage() {
|
||||
<p className="text-lg font-semibold text-[var(--bl-text-primary)]">{agent.name}</p>
|
||||
<p className="text-sm text-[var(--bl-text-secondary)]">{agent.type} · {agent.callsToday} calls today</p>
|
||||
</div>
|
||||
<Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'error'}>{agent.status}</Badge>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant={agent.status === 'healthy' ? 'success' : agent.status === 'degraded' ? 'warning' : 'error'}>{agent.status}</Badge>
|
||||
<HermesInstanceBadge instanceId={agent.instanceId} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 text-sm text-[var(--bl-text-secondary)] md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-3">Last success: {agent.lastSuccessAt ? new Date(agent.lastSuccessAt).toLocaleString() : '—'}</div>
|
||||
|
||||
@ -3,20 +3,28 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Clock3, Flame, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { Badge, Button } from '@/components/ui/Primitives';
|
||||
import { useMemo } from 'react';
|
||||
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
|
||||
import { useHermesInstance } from '@/lib/hermes-instance-context';
|
||||
import { getHermesHistory, hermesTasks } from '@/lib/hermes';
|
||||
|
||||
export default function HermesHistoryPage() {
|
||||
const history = getHermesHistory();
|
||||
const { selectedInstance } = useHermesInstance();
|
||||
const history = useMemo(() => getHermesHistory(selectedInstance), [selectedInstance]);
|
||||
const filteredTasks = useMemo(
|
||||
() => (selectedInstance === 'all' ? hermesTasks : hermesTasks.filter((task) => task.instanceId === selectedInstance)),
|
||||
[selectedInstance],
|
||||
);
|
||||
const completedTrend = history.map((point) => point.completed);
|
||||
const failedTrend = history.map((point) => point.failed);
|
||||
const maxValue = Math.max(...history.flatMap((point) => [point.completed, point.failed, point.blocked, point.active]), 1);
|
||||
const weeklyCompleted = completedTrend.reduce((sum, value) => sum + value, 0);
|
||||
const weeklyFailed = failedTrend.reduce((sum, value) => sum + value, 0);
|
||||
const blocked = history.reduce((sum, point) => sum + point.blocked, 0);
|
||||
const tasksWithDuration = filteredTasks.filter((task) => task.durationMs);
|
||||
const avgDuration = Math.round(
|
||||
hermesTasks.filter((task) => task.durationMs).reduce((sum, task) => sum + (task.durationMs ?? 0), 0) /
|
||||
Math.max(1, hermesTasks.filter((task) => task.durationMs).length) / 60000,
|
||||
tasksWithDuration.reduce((sum, task) => sum + (task.durationMs ?? 0), 0) /
|
||||
Math.max(1, tasksWithDuration.length) / 60000,
|
||||
);
|
||||
|
||||
const failureReasons = [
|
||||
|
||||
@ -1,14 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { SidebarNav } from '@/components/sidebar-nav';
|
||||
import { HermesInstanceSwitcher } from '@/components/hermes-instance-switcher';
|
||||
import { HermesInstanceProvider } from '@/lib/hermes-instance-context';
|
||||
|
||||
export default function HermesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[var(--bl-bg-canvas)] text-[var(--bl-text-primary)]">
|
||||
<SidebarNav />
|
||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="p-4 lg:p-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
<HermesInstanceProvider>
|
||||
<div className="flex min-h-screen bg-[var(--bl-bg-canvas)] text-[var(--bl-text-primary)]">
|
||||
<SidebarNav />
|
||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="p-4 lg:p-8">
|
||||
{/* Global instance switcher — every Mission Control pane reads
|
||||
from the same `useHermesInstance()` hook, so this filter
|
||||
propagates everywhere. */}
|
||||
<div className="mb-4 flex items-center justify-end">
|
||||
<HermesInstanceSwitcher />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</HermesInstanceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, BadgeCheck, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react';
|
||||
import { Badge, Button } from '@/components/ui/Primitives';
|
||||
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
|
||||
import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
|
||||
import { HermesOpsPanel } from '@/components/hermes-ops-panel';
|
||||
import { useHermesInstance } from '@/lib/hermes-instance-context';
|
||||
import {
|
||||
getHermesAgents,
|
||||
getHermesOverview,
|
||||
@ -12,6 +15,7 @@ import {
|
||||
getHermesTasks,
|
||||
hermesProducts,
|
||||
hermesTasks,
|
||||
HERMES_INSTANCES,
|
||||
type HermesProduct,
|
||||
type HermesTask,
|
||||
} from '@/lib/hermes';
|
||||
@ -51,7 +55,10 @@ function ProductMiniCard({ product }: { product: HermesProduct }) {
|
||||
<p className="font-medium text-[var(--bl-text-primary)]">{product.name}</p>
|
||||
<p className="text-xs text-[var(--bl-text-secondary)]">{product.category} · {product.priority}</p>
|
||||
</div>
|
||||
<Badge variant={product.needsAttention ? 'warning' : 'success'}>{product.needsAttention ? 'Attention' : 'Healthy'}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<HermesInstanceBadge instanceId={product.instanceId} />
|
||||
<Badge variant={product.needsAttention ? 'warning' : 'success'}>{product.needsAttention ? 'Attention' : 'Healthy'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-[var(--bl-text-secondary)]">
|
||||
@ -72,19 +79,51 @@ function ProductMiniCard({ product }: { product: HermesProduct }) {
|
||||
}
|
||||
|
||||
export default function HermesMissionControlPage() {
|
||||
const overview = getHermesOverview();
|
||||
const activeTasks = getHermesTasks({ status: 'running' }).concat(getHermesTasks({ status: 'blocked' }), getHermesTasks({ status: 'queued' })).slice(0, 8);
|
||||
const attentionTasks = getHermesTasks({ status: 'blocked' }).concat(getHermesTasks({ status: 'failed' })).slice(0, 8);
|
||||
const recentProducts = hermesProducts
|
||||
const { selectedInstance } = useHermesInstance();
|
||||
const overview = useMemo(() => getHermesOverview(selectedInstance), [selectedInstance]);
|
||||
// Per-instance roll-up cards always show both Vijay and Bheem regardless of
|
||||
// the active filter — they're the "comparison" view that sits next to the
|
||||
// filtered overview metrics. This satisfies the roadmap's "per-instance
|
||||
// cards AND a combined roll-up" requirement.
|
||||
const perInstance = useMemo(
|
||||
() => HERMES_INSTANCES.map((inst) => ({ ...inst, overview: getHermesOverview(inst.id) })),
|
||||
[],
|
||||
);
|
||||
|
||||
const activeTasks = useMemo(
|
||||
() => getHermesTasks({ status: 'running', instance: selectedInstance })
|
||||
.concat(getHermesTasks({ status: 'blocked', instance: selectedInstance }))
|
||||
.concat(getHermesTasks({ status: 'queued', instance: selectedInstance }))
|
||||
.slice(0, 8),
|
||||
[selectedInstance],
|
||||
);
|
||||
const attentionTasks = useMemo(
|
||||
() => getHermesTasks({ status: 'blocked', instance: selectedInstance })
|
||||
.concat(getHermesTasks({ status: 'failed', instance: selectedInstance }))
|
||||
.slice(0, 8),
|
||||
[selectedInstance],
|
||||
);
|
||||
const filteredProducts = useMemo(
|
||||
() => (selectedInstance === 'all' ? hermesProducts : hermesProducts.filter((p) => p.instanceId === selectedInstance)),
|
||||
[selectedInstance],
|
||||
);
|
||||
const filteredTasks = useMemo(
|
||||
() => (selectedInstance === 'all' ? hermesTasks : hermesTasks.filter((t) => t.instanceId === selectedInstance)),
|
||||
[selectedInstance],
|
||||
);
|
||||
const recentProducts = filteredProducts
|
||||
.filter((product) => product.lastHermesActivityAt)
|
||||
.sort((a, b) => new Date(b.lastHermesActivityAt!).getTime() - new Date(a.lastHermesActivityAt!).getTime())
|
||||
.slice(0, 8);
|
||||
const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 86_400_000);
|
||||
const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000);
|
||||
const failedTasks = hermesTasks.filter((task) => task.status === 'failed');
|
||||
const repeatedFailures = getHermesProducts('repeated-failures').slice(0, 5);
|
||||
const actionableProducts = hermesProducts.filter((product) => product.needsAttention).slice(0, 6);
|
||||
const agentStatuses = getHermesAgents();
|
||||
const completedToday = filteredTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 86_400_000);
|
||||
const completedThisWeek = filteredTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() > Date.now() - 7 * 86_400_000);
|
||||
const failedTasks = filteredTasks.filter((task) => task.status === 'failed');
|
||||
const repeatedFailures = useMemo(
|
||||
() => getHermesProducts('repeated-failures', selectedInstance).slice(0, 5),
|
||||
[selectedInstance],
|
||||
);
|
||||
const actionableProducts = filteredProducts.filter((product) => product.needsAttention).slice(0, 6);
|
||||
const agentStatuses = useMemo(() => getHermesAgents(selectedInstance), [selectedInstance]);
|
||||
const autoActions = [
|
||||
'Continue the queued execution lane for high-priority product updates.',
|
||||
'Publish a weekly digest from completed and failed work.',
|
||||
@ -118,6 +157,32 @@ export default function HermesMissionControlPage() {
|
||||
<MetricCard label="Success rate" value={`${overview.successRate}%`} tone="success" icon={<BadgeCheck className="h-5 w-5" />} helpText={`${overview.productsTouchedRecently} products touched in the last 14 days`} />
|
||||
</section>
|
||||
|
||||
<SectionCard
|
||||
title="Per-instance roll-up"
|
||||
subtitle="Side-by-side view of Vijay (root) and Bheem (uma). Shown regardless of the active instance filter so you can compare load and attention at a glance."
|
||||
actions={<Badge variant="info">Always cross-instance</Badge>}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{perInstance.map(({ id, label, description, overview: ov }) => (
|
||||
<div key={id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-[var(--bl-text-primary)]">{label}</p>
|
||||
<p className="text-xs text-[var(--bl-text-secondary)]">{description}</p>
|
||||
</div>
|
||||
<Badge variant={ov.status === 'error' ? 'error' : ov.status === 'degraded' ? 'warning' : ov.status === 'running' ? 'info' : 'neutral'}>{ov.status}</Badge>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--bl-text-secondary)] md:grid-cols-4">
|
||||
<div><dt className="text-xs uppercase tracking-[0.18em] text-[var(--bl-text-tertiary)]">Active</dt><dd className="text-lg font-semibold text-[var(--bl-text-primary)]">{ov.activeTasks}</dd></div>
|
||||
<div><dt className="text-xs uppercase tracking-[0.18em] text-[var(--bl-text-tertiary)]">Blocked</dt><dd className="text-lg font-semibold text-[var(--bl-warning)]">{ov.blockedTasks}</dd></div>
|
||||
<div><dt className="text-xs uppercase tracking-[0.18em] text-[var(--bl-text-tertiary)]">Failed</dt><dd className="text-lg font-semibold text-[var(--bl-danger)]">{ov.failedTasks}</dd></div>
|
||||
<div><dt className="text-xs uppercase tracking-[0.18em] text-[var(--bl-text-tertiary)]">Success %</dt><dd className="text-lg font-semibold text-[var(--bl-success)]">{ov.successRate}%</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<HermesOpsPanel />
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]">
|
||||
@ -133,6 +198,7 @@ export default function HermesMissionControlPage() {
|
||||
<Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link>
|
||||
<Badge variant={getTaskTone(task)}>{taskStatusLabel(task)}</Badge>
|
||||
<Badge variant="neutral">{task.priority}</Badge>
|
||||
<HermesInstanceBadge instanceId={task.instanceId} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{product?.name ?? 'Unknown product'} · {task.assignedAgent} · {task.type}</p>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,8 @@ import Link from 'next/link';
|
||||
import { Filter, Orbit, 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 { getHermesProducts, getHermesTasks, hermesProducts, type HermesProduct } from '@/lib/hermes';
|
||||
|
||||
const views = [
|
||||
@ -33,7 +35,10 @@ function ProductCard({ product }: { product: HermesProduct }) {
|
||||
<Link href={`/hermes/tasks?productId=${product.id}`} className="font-semibold text-[var(--bl-text-primary)] hover:underline">{product.name}</Link>
|
||||
<p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{product.category} · {product.owner}</p>
|
||||
</div>
|
||||
<Badge variant={product.needsAttention ? 'warning' : 'success'}>{product.status}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<HermesInstanceBadge instanceId={product.instanceId} />
|
||||
<Badge variant={product.needsAttention ? 'warning' : 'success'}>{product.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--bl-text-secondary)]">{product.description}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
@ -54,12 +59,13 @@ export default function HermesProductsPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState<(typeof views)[number]['key']>('all');
|
||||
|
||||
const { selectedInstance } = useHermesInstance();
|
||||
const products = useMemo(() => {
|
||||
return getHermesProducts(view).filter((product) => {
|
||||
return getHermesProducts(view, selectedInstance).filter((product) => {
|
||||
const haystack = [product.name, product.slug, product.category, product.owner, product.description, ...product.tags].join(' ').toLowerCase();
|
||||
return haystack.includes(query.toLowerCase().trim());
|
||||
});
|
||||
}, [query, view]);
|
||||
}, [query, view, selectedInstance]);
|
||||
|
||||
const attentionCount = hermesProducts.filter((product) => product.needsAttention).length;
|
||||
const highPriorityCount = hermesProducts.filter((product) => product.priority === 'P0' || product.priority === 'P1').length;
|
||||
|
||||
@ -5,6 +5,8 @@ import Link from 'next/link';
|
||||
import { Download, Filter, Search, ChevronDown, ChevronUp, ArrowLeftRight } 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 {
|
||||
getHermesProductById,
|
||||
getHermesTasks,
|
||||
@ -49,7 +51,11 @@ export default function HermesTaskLedgerPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
|
||||
|
||||
const tasks = useMemo(() => getHermesTasks({ query, status, productId, priority, type, source, updatedWithinDays, sort }), [query, status, productId, priority, type, source, updatedWithinDays, sort]);
|
||||
const { selectedInstance } = useHermesInstance();
|
||||
const tasks = useMemo(
|
||||
() => getHermesTasks({ query, status, productId, priority, type, source, instance: selectedInstance, updatedWithinDays, sort }),
|
||||
[query, status, productId, priority, type, source, selectedInstance, updatedWithinDays, sort],
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(tasks.length / pageSize));
|
||||
const pagedTasks = tasks.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
@ -145,7 +151,12 @@ export default function HermesTaskLedgerPage() {
|
||||
<p className="line-clamp-2 text-xs leading-5 text-[var(--bl-text-secondary)]">{task.description}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{product?.name ?? 'Unknown'}</td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{product?.name ?? 'Unknown'}</span>
|
||||
<HermesInstanceBadge instanceId={task.instanceId} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4"><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></td>
|
||||
<td className="px-4 py-4"><Badge variant={task.priority === 'P0' ? 'error' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></td>
|
||||
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.type}</td>
|
||||
|
||||
67
dashboard/web/src/components/hermes-instance-switcher.tsx
Normal file
67
dashboard/web/src/components/hermes-instance-switcher.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/Primitives';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { HERMES_INSTANCES, type HermesInstanceFilter, type HermesInstanceId } from '@/lib/hermes';
|
||||
import { useHermesInstance } from '@/lib/hermes-instance-context';
|
||||
|
||||
const OPTIONS: Array<{ id: HermesInstanceFilter; label: string }> = [
|
||||
{ id: 'all', label: 'All' },
|
||||
...HERMES_INSTANCES.map((inst) => ({ id: inst.id, label: inst.label })),
|
||||
];
|
||||
|
||||
interface HermesInstanceSwitcherProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Segmented control mounted in the Hermes layout header. Persists its
|
||||
// selection via `useHermesInstance` (localStorage-backed); every Mission
|
||||
// Control pane reads from the same hook.
|
||||
export function HermesInstanceSwitcher({ className }: HermesInstanceSwitcherProps) {
|
||||
const { selectedInstance, setSelectedInstance } = useHermesInstance();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Hermes instance filter"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-1 text-sm shadow-[var(--bl-shadow-sm)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{OPTIONS.map((option) => {
|
||||
const active = option.id === selectedInstance;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setSelectedInstance(option.id)}
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1 transition-colors',
|
||||
active
|
||||
? 'bg-[var(--bl-accent)] text-[var(--bl-text-on-accent,white)]'
|
||||
: 'text-[var(--bl-text-secondary)] hover:bg-[var(--bl-surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Small per-row badge so table cells can show which instance owns the entity.
|
||||
// Use `'all'` for entities scoped to both instances (e.g. cross-cutting agents
|
||||
// like Hermes Core, GitHub link).
|
||||
const INSTANCE_LABEL: Record<HermesInstanceId | 'all', string> = {
|
||||
vijay: 'Vijay',
|
||||
bheem: 'Bheem',
|
||||
all: 'Both',
|
||||
};
|
||||
|
||||
export function HermesInstanceBadge({ instanceId }: { instanceId: HermesInstanceId | 'all' }) {
|
||||
return <Badge variant="info">{INSTANCE_LABEL[instanceId]}</Badge>;
|
||||
}
|
||||
70
dashboard/web/src/lib/hermes-instance-context.tsx
Normal file
70
dashboard/web/src/lib/hermes-instance-context.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { HermesInstanceFilter } from './hermes';
|
||||
|
||||
// Persisted across navigation so the switcher choice sticks. Bumping the key
|
||||
// resets the stored value (use this if we ever change the allowed filter
|
||||
// values incompatibly).
|
||||
const STORAGE_KEY = 'hermes.instanceFilter.v1';
|
||||
const DEFAULT_FILTER: HermesInstanceFilter = 'all';
|
||||
|
||||
const VALID_FILTERS: HermesInstanceFilter[] = ['all', 'vijay', 'bheem'];
|
||||
|
||||
function readPersisted(): HermesInstanceFilter {
|
||||
if (typeof window === 'undefined') return DEFAULT_FILTER;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw && (VALID_FILTERS as string[]).includes(raw)) {
|
||||
return raw as HermesInstanceFilter;
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode / SSR). Fall through to default.
|
||||
}
|
||||
return DEFAULT_FILTER;
|
||||
}
|
||||
|
||||
interface HermesInstanceContextValue {
|
||||
selectedInstance: HermesInstanceFilter;
|
||||
setSelectedInstance: (next: HermesInstanceFilter) => void;
|
||||
}
|
||||
|
||||
const HermesInstanceContext = createContext<HermesInstanceContextValue | null>(null);
|
||||
|
||||
export function HermesInstanceProvider({ children }: { children: ReactNode }) {
|
||||
// Always start with the default on the server so the SSR + first client render
|
||||
// agree (avoids hydration mismatch). After mount we read the persisted value.
|
||||
const [selectedInstance, setSelectedInstanceState] = useState<HermesInstanceFilter>(DEFAULT_FILTER);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedInstanceState(readPersisted());
|
||||
}, []);
|
||||
|
||||
const setSelectedInstance = useCallback((next: HermesInstanceFilter) => {
|
||||
setSelectedInstanceState(next);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Best-effort persistence; ignore failures.
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ selectedInstance, setSelectedInstance }),
|
||||
[selectedInstance, setSelectedInstance],
|
||||
);
|
||||
|
||||
return <HermesInstanceContext.Provider value={value}>{children}</HermesInstanceContext.Provider>;
|
||||
}
|
||||
|
||||
export function useHermesInstance(): HermesInstanceContextValue {
|
||||
const ctx = useContext(HermesInstanceContext);
|
||||
if (!ctx) {
|
||||
// Allow components to render outside the provider (e.g. ad-hoc usage in
|
||||
// storybook/tests) by falling back to the default — never crash the tree.
|
||||
return { selectedInstance: DEFAULT_FILTER, setSelectedInstance: () => {} };
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@ -47,4 +47,38 @@ describe('hermes mock service', () => {
|
||||
expect(getHermesSettings().registry.length).toBeGreaterThan(0);
|
||||
expect(getHermesProductById(hermesProducts[0].id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('tags every product/task with a Hermes instance', () => {
|
||||
expect(hermesProducts.every((p) => p.instanceId === 'vijay' || p.instanceId === 'bheem')).toBe(true);
|
||||
expect(hermesTasks.every((t) => t.instanceId === 'vijay' || t.instanceId === 'bheem')).toBe(true);
|
||||
// Both instances should be represented in the seed data so the switcher
|
||||
// doesn't bottom out on either one.
|
||||
expect(hermesProducts.some((p) => p.instanceId === 'vijay')).toBe(true);
|
||||
expect(hermesProducts.some((p) => p.instanceId === 'bheem')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters tasks/products/agents/overview by instance', () => {
|
||||
const vijayTasks = getHermesTasks({ instance: 'vijay' });
|
||||
const bheemTasks = getHermesTasks({ instance: 'bheem' });
|
||||
expect(vijayTasks.every((t) => t.instanceId === 'vijay')).toBe(true);
|
||||
expect(bheemTasks.every((t) => t.instanceId === 'bheem')).toBe(true);
|
||||
expect(vijayTasks.length + bheemTasks.length).toBe(hermesTasks.length);
|
||||
|
||||
const vijayProducts = getHermesProducts('all', 'vijay');
|
||||
expect(vijayProducts.every((p) => p.instanceId === 'vijay')).toBe(true);
|
||||
|
||||
// Cross-cutting agents (scope `'all'`) appear in every per-instance view;
|
||||
// strict per-instance agents only appear when their instance matches.
|
||||
const vijayAgents = getHermesAgents('vijay');
|
||||
const bheemAgents = getHermesAgents('bheem');
|
||||
expect(vijayAgents.every((a) => a.instanceId === 'vijay' || a.instanceId === 'all')).toBe(true);
|
||||
expect(bheemAgents.every((a) => a.instanceId === 'bheem' || a.instanceId === 'all')).toBe(true);
|
||||
expect(vijayAgents.some((a) => a.id === 'cli-runner')).toBe(true);
|
||||
expect(bheemAgents.some((a) => a.id === 'local-vm-runner')).toBe(true);
|
||||
expect(vijayAgents.some((a) => a.id === 'local-vm-runner')).toBe(false);
|
||||
|
||||
const overviewAll = getHermesOverview('all');
|
||||
const overviewVijay = getHermesOverview('vijay');
|
||||
expect(overviewVijay.activeTasks).toBeLessThanOrEqual(overviewAll.activeTasks);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,20 @@
|
||||
// Hermes runs as two co-located instances ("Vijay" on the root host, "Bheem"
|
||||
// under the `uma` user). Every entity that flows through Mission Control —
|
||||
// tasks, products, events, runs, agents — is tagged with the instance that
|
||||
// owns it so panes can filter or roll up across instances. The literal `'all'`
|
||||
// is a UI-only filter value (never stored on entities).
|
||||
export type HermesInstanceId = 'vijay' | 'bheem';
|
||||
export type HermesInstanceFilter = 'all' | HermesInstanceId;
|
||||
|
||||
export const HERMES_INSTANCES: ReadonlyArray<{
|
||||
id: HermesInstanceId;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ id: 'vijay', label: 'Vijay (root)', description: 'Primary host instance' },
|
||||
{ id: 'bheem', label: 'Bheem (uma)', description: 'Secondary user instance' },
|
||||
];
|
||||
|
||||
export type HermesStatus = 'running' | 'idle' | 'degraded' | 'error';
|
||||
|
||||
export type HermesTaskStatus =
|
||||
@ -39,6 +56,7 @@ export type HermesTaskSource =
|
||||
|
||||
export interface HermesProduct {
|
||||
id: string;
|
||||
instanceId: HermesInstanceId;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
@ -61,6 +79,7 @@ export interface HermesProduct {
|
||||
|
||||
export interface HermesTask {
|
||||
id: string;
|
||||
instanceId: HermesInstanceId;
|
||||
title: string;
|
||||
description: string;
|
||||
productId: string;
|
||||
@ -87,6 +106,7 @@ export interface HermesTask {
|
||||
|
||||
export interface HermesEvent {
|
||||
id: string;
|
||||
instanceId: HermesInstanceId;
|
||||
taskId: string;
|
||||
timestamp: string;
|
||||
level: 'debug' | 'info' | 'warn' | 'error' | 'success';
|
||||
@ -114,6 +134,7 @@ export interface HermesEvent {
|
||||
|
||||
export interface HermesRun {
|
||||
id: string;
|
||||
instanceId: HermesInstanceId;
|
||||
taskId: string;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
@ -128,6 +149,11 @@ export interface HermesRun {
|
||||
|
||||
export interface HermesAgentStatus {
|
||||
id: string;
|
||||
// An agent's "scope" — which Hermes instance(s) it serves. Some integrations
|
||||
// (Hermes Core, GitHub link) span both; we model that with a literal `'all'`
|
||||
// rather than duplicating rows. Filtering treats `'all'` as a match for any
|
||||
// selected instance.
|
||||
instanceId: HermesInstanceId | 'all';
|
||||
name: string;
|
||||
type: 'agent' | 'tool' | 'integration' | 'runner';
|
||||
status: 'healthy' | 'degraded' | 'offline' | 'unknown';
|
||||
@ -250,8 +276,14 @@ export const hermesProducts: HermesProduct[] = Array.from({ length: 50 }, (_, in
|
||||
),
|
||||
);
|
||||
|
||||
// Deterministic split: every other product to Bheem (uma), the rest to
|
||||
// Vijay (root). Gives a roughly 50/50 mix so the switcher is exercisable
|
||||
// out of the box without any single instance going empty.
|
||||
const instanceId: HermesInstanceId = index % 2 === 0 ? 'vijay' : 'bheem';
|
||||
|
||||
return {
|
||||
id: `product-${ordinal}`,
|
||||
instanceId,
|
||||
name: `${seed.name} ${ordinal}`,
|
||||
slug: `${seed.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${ordinal}`,
|
||||
description: `${seed.category} product managed by Hermes for ${seed.owner.toLowerCase()} workflows.`,
|
||||
@ -321,6 +353,7 @@ export const hermesTasks: HermesTask[] = Array.from({ length: 36 }, (_, index) =
|
||||
|
||||
return {
|
||||
id: `task-${index + 1}`,
|
||||
instanceId: product.instanceId,
|
||||
title,
|
||||
description: `Hermes is working on ${taskTemplates[index % taskTemplates.length]} for ${product.name}.`,
|
||||
productId: product.id,
|
||||
@ -354,6 +387,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
const events: HermesEvent[] = [
|
||||
{
|
||||
id: `${task.id}-event-created`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: created,
|
||||
level: 'info',
|
||||
@ -363,6 +397,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
},
|
||||
{
|
||||
id: `${task.id}-event-planned`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: started,
|
||||
level: 'info',
|
||||
@ -374,6 +409,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
if (task.status === 'running' || task.status === 'completed' || task.status === 'blocked' || task.status === 'failed') {
|
||||
events.push({
|
||||
id: `${task.id}-event-started`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: started,
|
||||
level: 'success',
|
||||
@ -382,6 +418,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
});
|
||||
events.push({
|
||||
id: `${task.id}-event-command`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: isoMinutesAgo(index * 7 + 25),
|
||||
level: 'debug',
|
||||
@ -395,6 +432,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
if (task.status === 'blocked') {
|
||||
events.push({
|
||||
id: `${task.id}-event-blocked`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: isoMinutesAgo(index * 7 + 10),
|
||||
level: 'warn',
|
||||
@ -407,6 +445,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
if (task.status === 'failed') {
|
||||
events.push({
|
||||
id: `${task.id}-event-error`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: isoMinutesAgo(index * 7 + 8),
|
||||
level: 'error',
|
||||
@ -416,6 +455,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
});
|
||||
events.push({
|
||||
id: `${task.id}-event-retry`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: isoMinutesAgo(index * 7 + 5),
|
||||
level: 'warn',
|
||||
@ -427,6 +467,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
if (task.status === 'completed' || task.status === 'skipped') {
|
||||
events.push({
|
||||
id: `${task.id}-event-completed`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: completed,
|
||||
level: 'success',
|
||||
@ -437,6 +478,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
if (task.type === 'deploy') {
|
||||
events.push({
|
||||
id: `${task.id}-event-deployment`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: completed,
|
||||
level: 'success',
|
||||
@ -449,6 +491,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
if (task.priority === 'P0') {
|
||||
events.push({
|
||||
id: `${task.id}-event-memory`,
|
||||
instanceId: task.instanceId,
|
||||
taskId: task.id,
|
||||
timestamp: isoMinutesAgo(index * 7 + 2),
|
||||
level: 'info',
|
||||
@ -464,6 +507,7 @@ const eventBlueprints = new Map<string, HermesEvent[]>(
|
||||
export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
{
|
||||
id: 'hermes-core',
|
||||
instanceId: 'all',
|
||||
name: 'Hermes Core',
|
||||
type: 'agent',
|
||||
status: 'healthy',
|
||||
@ -474,6 +518,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
},
|
||||
{
|
||||
id: 'openclaw-integration',
|
||||
instanceId: 'all',
|
||||
name: 'OpenClaw integration',
|
||||
type: 'integration',
|
||||
status: 'degraded',
|
||||
@ -486,6 +531,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
},
|
||||
{
|
||||
id: 'github-link',
|
||||
instanceId: 'all',
|
||||
name: 'GitHub integration',
|
||||
type: 'integration',
|
||||
status: 'healthy',
|
||||
@ -496,6 +542,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
},
|
||||
{
|
||||
id: 'local-vm-runner',
|
||||
instanceId: 'bheem',
|
||||
name: 'Local VM runner',
|
||||
type: 'runner',
|
||||
status: 'healthy',
|
||||
@ -506,6 +553,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
},
|
||||
{
|
||||
id: 'cli-runner',
|
||||
instanceId: 'vijay',
|
||||
name: 'CLI runner',
|
||||
type: 'runner',
|
||||
status: 'healthy',
|
||||
@ -516,6 +564,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
},
|
||||
{
|
||||
id: 'scheduler-cron',
|
||||
instanceId: 'vijay',
|
||||
name: 'Scheduler / cron',
|
||||
type: 'tool',
|
||||
status: 'healthy',
|
||||
@ -526,6 +575,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
},
|
||||
{
|
||||
id: 'deployment-tools',
|
||||
instanceId: 'all',
|
||||
name: 'Deployment tools',
|
||||
type: 'tool',
|
||||
status: 'degraded',
|
||||
@ -538,6 +588,7 @@ export const hermesAgentStatuses: HermesAgentStatus[] = [
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
instanceId: 'all',
|
||||
name: 'Notification tools',
|
||||
type: 'tool',
|
||||
status: 'offline',
|
||||
@ -608,26 +659,46 @@ export interface HermesTaskFilters {
|
||||
priority?: HermesPriority | 'all';
|
||||
type?: HermesTaskType | 'all';
|
||||
source?: HermesTaskSource | 'all';
|
||||
// Restrict to a single Hermes instance, or roll up across both with `'all'`.
|
||||
instance?: HermesInstanceFilter;
|
||||
updatedWithinDays?: number | 'all';
|
||||
sort?: 'newest' | 'oldest' | 'priority' | 'status';
|
||||
}
|
||||
|
||||
export function getHermesOverview(): HermesOverview {
|
||||
const activeTasks = hermesTasks.filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length;
|
||||
const completedToday = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 86_400_000).length;
|
||||
const completedThisWeek = hermesTasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 7 * 86_400_000).length;
|
||||
const failedTasks = hermesTasks.filter((task) => task.status === 'failed').length;
|
||||
const blockedTasks = hermesTasks.filter((task) => task.status === 'blocked').length;
|
||||
const completedWithDuration = hermesTasks.filter((task) => typeof task.durationMs === 'number' && task.status === 'completed');
|
||||
// Helper used by every list-fetcher in this module so the filter semantics
|
||||
// stay consistent: an entity matches when the requested filter is `'all'` OR
|
||||
// equals the entity's own instance. For agents whose own scope is `'all'`,
|
||||
// they always match (they live on both instances).
|
||||
function instanceMatches(
|
||||
entityScope: HermesInstanceId | 'all',
|
||||
filter: HermesInstanceFilter,
|
||||
): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (entityScope === 'all') return true;
|
||||
return entityScope === filter;
|
||||
}
|
||||
|
||||
export function getHermesOverview(instance: HermesInstanceFilter = 'all'): HermesOverview {
|
||||
const tasks = hermesTasks.filter((task) => instanceMatches(task.instanceId, instance));
|
||||
const products = hermesProducts.filter((product) => instanceMatches(product.instanceId, instance));
|
||||
|
||||
const activeTasks = tasks.filter((task) => task.status === 'running' || task.status === 'queued' || task.status === 'blocked').length;
|
||||
const completedToday = tasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 86_400_000).length;
|
||||
const completedThisWeek = tasks.filter((task) => task.completedAt && new Date(task.completedAt).getTime() >= now - 7 * 86_400_000).length;
|
||||
const failedTasks = tasks.filter((task) => task.status === 'failed').length;
|
||||
const blockedTasks = tasks.filter((task) => task.status === 'blocked').length;
|
||||
const completedWithDuration = tasks.filter((task) => typeof task.durationMs === 'number' && task.status === 'completed');
|
||||
const averageDurationMs = completedWithDuration.length
|
||||
? Math.round(completedWithDuration.reduce((sum, task) => sum + (task.durationMs ?? 0), 0) / completedWithDuration.length)
|
||||
: 0;
|
||||
const successRate = Math.round((hermesTasks.filter((task) => task.status === 'completed' || task.status === 'skipped').length / hermesTasks.length) * 100);
|
||||
const productsTouchedRecently = hermesProducts.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() >= now - 14 * 86_400_000).length;
|
||||
const founderAttentionCount = hermesTasks.filter((task) => task.status === 'blocked' || task.status === 'failed').length + hermesProducts.filter((product) => product.needsAttention).length;
|
||||
const upcomingJobs = hermesTasks.filter((task) => task.status === 'queued').length;
|
||||
const lastAction = hermesEventsSorted()[0]?.message ?? 'Hermes has not recorded an action yet.';
|
||||
const nextRecommendedAction = computeNextRecommendedAction();
|
||||
const successRate = tasks.length === 0
|
||||
? 0
|
||||
: Math.round((tasks.filter((task) => task.status === 'completed' || task.status === 'skipped').length / tasks.length) * 100);
|
||||
const productsTouchedRecently = products.filter((product) => product.lastHermesActivityAt && new Date(product.lastHermesActivityAt).getTime() >= now - 14 * 86_400_000).length;
|
||||
const founderAttentionCount = tasks.filter((task) => task.status === 'blocked' || task.status === 'failed').length + products.filter((product) => product.needsAttention).length;
|
||||
const upcomingJobs = tasks.filter((task) => task.status === 'queued').length;
|
||||
const lastAction = hermesEventsSorted(instance)[0]?.message ?? 'Hermes has not recorded an action yet.';
|
||||
const nextRecommendedAction = computeNextRecommendedAction(instance);
|
||||
|
||||
return {
|
||||
status: failedTasks > 6 ? 'error' : blockedTasks > 4 ? 'degraded' : activeTasks > 0 ? 'running' : 'idle',
|
||||
@ -654,12 +725,14 @@ export function getHermesTasks(filters: HermesTaskFilters = {}): HermesTask[] {
|
||||
priority = 'all',
|
||||
type = 'all',
|
||||
source = 'all',
|
||||
instance = 'all',
|
||||
updatedWithinDays = 'all',
|
||||
sort = 'newest',
|
||||
} = filters;
|
||||
|
||||
const normalizedQuery = query?.trim().toLowerCase();
|
||||
const filtered = hermesTasks.filter((task) => {
|
||||
if (!instanceMatches(task.instanceId, instance)) return false;
|
||||
const product = hermesProducts.find((item) => item.id === task.productId);
|
||||
const matchesQuery = !normalizedQuery || [
|
||||
task.title,
|
||||
@ -718,10 +791,14 @@ export function getHermesProductById(id: string): HermesProduct | undefined {
|
||||
return hermesProducts.find((product) => product.id === id);
|
||||
}
|
||||
|
||||
export function getHermesProducts(view: 'all' | 'high-priority' | 'needs-attention' | 'no-recent-activity' | 'repeated-failures' | 'recently-shipped' = 'all'): HermesProduct[] {
|
||||
export function getHermesProducts(
|
||||
view: 'all' | 'high-priority' | 'needs-attention' | 'no-recent-activity' | 'repeated-failures' | 'recently-shipped' = 'all',
|
||||
instance: HermesInstanceFilter = 'all',
|
||||
): HermesProduct[] {
|
||||
const recentCutoff = now - 14 * 86_400_000;
|
||||
const shippedCutoff = now - 7 * 86_400_000;
|
||||
return hermesProducts.filter((product) => {
|
||||
if (!instanceMatches(product.instanceId, instance)) return false;
|
||||
const recentFailedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'failed').length;
|
||||
const recentCompletedTasks = hermesTasks.filter((task) => task.productId === product.id && task.status === 'completed').length;
|
||||
switch (view) {
|
||||
@ -742,37 +819,51 @@ export function getHermesProducts(view: 'all' | 'high-priority' | 'needs-attenti
|
||||
});
|
||||
}
|
||||
|
||||
export function getHermesHistory() {
|
||||
return hermesHistory;
|
||||
export function getHermesHistory(instance: HermesInstanceFilter = 'all'): HermesHistoryPoint[] {
|
||||
// History is hand-tuned aggregate seed data. For the per-instance filter we
|
||||
// approximate by halving each bar (instances were seeded ~50/50). This is
|
||||
// mock data — Phase 3 replaces it with real per-instance time series.
|
||||
if (instance === 'all') return hermesHistory;
|
||||
return hermesHistory.map((point) => ({
|
||||
label: point.label,
|
||||
completed: Math.round(point.completed / 2),
|
||||
failed: Math.round(point.failed / 2),
|
||||
blocked: Math.round(point.blocked / 2),
|
||||
active: Math.round(point.active / 2),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getHermesAgents() {
|
||||
return hermesAgentStatuses;
|
||||
export function getHermesAgents(instance: HermesInstanceFilter = 'all'): HermesAgentStatus[] {
|
||||
return hermesAgentStatuses.filter((agent) => instanceMatches(agent.instanceId, instance));
|
||||
}
|
||||
|
||||
export function getHermesSettings() {
|
||||
return hermesSettings;
|
||||
}
|
||||
|
||||
function hermesEventsSorted() {
|
||||
function hermesEventsSorted(instance: HermesInstanceFilter = 'all') {
|
||||
return Array.from(eventBlueprints.values())
|
||||
.flat()
|
||||
.filter((event) => instanceMatches(event.instanceId, instance))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
}
|
||||
|
||||
function computeNextRecommendedAction() {
|
||||
const blocked = hermesTasks.filter((task) => task.status === 'blocked');
|
||||
function computeNextRecommendedAction(instance: HermesInstanceFilter = 'all') {
|
||||
const tasks = hermesTasks.filter((task) => instanceMatches(task.instanceId, instance));
|
||||
const products = hermesProducts.filter((product) => instanceMatches(product.instanceId, instance));
|
||||
|
||||
const blocked = tasks.filter((task) => task.status === 'blocked');
|
||||
if (blocked.length > 0) {
|
||||
const next = blocked[0];
|
||||
return `Unblock ${next.title} for ${getHermesProductById(next.productId)?.name ?? 'an active product'}.`;
|
||||
}
|
||||
|
||||
const failed = hermesTasks.find((task) => task.status === 'failed');
|
||||
const failed = tasks.find((task) => task.status === 'failed');
|
||||
if (failed) {
|
||||
return `Inspect and retry ${failed.title}.`;
|
||||
}
|
||||
|
||||
const staleProduct = hermesProducts.find((product) => product.needsAttention);
|
||||
const staleProduct = products.find((product) => product.needsAttention);
|
||||
if (staleProduct) {
|
||||
return `Review ${staleProduct.name} because it needs attention.`;
|
||||
}
|
||||
|
||||
@ -87,10 +87,10 @@ The `hermes-ops` snapshot becomes the single source of truth for live status. Be
|
||||
|
||||
## Phase 2 — Instance dimension across Mission Control (G2)
|
||||
|
||||
- [ ] Add `instanceId: 'vijay' | 'bheem'` to the core types in `web/src/lib/hermes` (`HermesTask`, `HermesProduct`, `HermesEvent`, `HermesRun`, agent/overview models) and to the backend contracts.
|
||||
- [ ] Add a global **instance switcher** in `HermesShell` (`All` / `Vijay (root)` / `Bheem (uma)`) with persisted selection; thread it through every pane.
|
||||
- [ ] Overview: show per-instance cards **and** a combined roll-up (extend the existing "Healthy instances 2/2" pattern from the ops panel to the whole overview).
|
||||
- [ ] Ledger / Products / History / Agents: filter and badge by instance.
|
||||
- [x] Add `instanceId: 'vijay' | 'bheem'` to the core types in `web/src/lib/hermes` (`HermesTask`, `HermesProduct`, `HermesEvent`, `HermesRun`, agent/overview models) and to the backend contracts. *(Web: `instanceId` now on `HermesProduct`, `HermesTask`, `HermesEvent`, `HermesRun`, `HermesAgentStatus` (with a `'all'` literal for cross-cutting agents like Hermes Core / GitHub link). Seed data deterministically split ~50/50 across instances. Backend ops contract already carried per-instance shape under `HermesOpsSnapshot.instances` from Phase 1; no separate backend change needed for this slice.)*
|
||||
- [x] Add a global **instance switcher** in `HermesShell` (`All` / `Vijay (root)` / `Bheem (uma)`) with persisted selection; thread it through every pane. *(New `HermesInstanceProvider` (React context, localStorage-backed under key `hermes.instanceFilter.v1`, with SSR-safe default to avoid hydration mismatch) mounted in `app/hermes/layout.tsx`. New `HermesInstanceSwitcher` segmented control rendered in the layout header above every pane. Every pane reads `useHermesInstance()` and threads the value into the data-fetcher.)*
|
||||
- [x] Overview: show per-instance cards **and** a combined roll-up. *(New "Per-instance roll-up" section on `/hermes` always shows Vijay and Bheem side-by-side with active/blocked/failed/success-rate cells regardless of the switcher state — that's the "always cross-instance" comparison view, while the eight metric cards above it are filtered by the switcher.)*
|
||||
- [x] Ledger / Products / History / Agents: filter and badge by instance. *(`HermesInstanceBadge` component shipped; tasks (Active Missions + Task Ledger), product cards (overview minicards + portfolio cards), and agent rows now show their instance. Filter helpers `getHermesTasks({instance})`, `getHermesProducts(view, instance)`, `getHermesAgents(instance)`, `getHermesHistory(instance)`, `getHermesOverview(instance)` all accept the filter and short-circuit `'all'`. New unit tests in `lib/hermes.test.ts` cover the filter semantics. New E2E test asserts the switcher's radiogroup, default selection, and persistence-friendly state change. 7/7 E2E + 13/13 web unit tests green.)*
|
||||
|
||||
## Phase 3 — Real per-instance telemetry, replacing mock pane by pane (G1, G4)
|
||||
|
||||
@ -186,7 +186,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
|
||||
- [ ] Phase 2 — Instance dimension + switcher
|
||||
- [x] Phase 2 — Instance dimension + switcher
|
||||
- [ ] Phase 3 — Real telemetry ingestion + panes converted
|
||||
- [ ] 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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user