feat(dashboard): aggregate /api/dashboard/overview + Hermes-native cards (#242)

* feat(dashboard): aggregate /api/dashboard/overview + Hermes-native cards

Workspace dashboard now mirrors what the native Hermes Agent dashboard at
:9120 surfaces, on top of the existing sessions analytics, in a single
server-aggregated round trip.

Adds new server endpoint `GET /api/dashboard/overview` that fans out to:
- /api/status         (gateway state, active sessions, platforms)
- /api/cron/jobs      (cron summary)
- /api/plugins/hermes-achievements/recent-unlocks (recent ribbon)
- /api/plugins/hermes-achievements/achievements   (totals)
- /api/model/info     (provider, model, context, capabilities)
- /api/analytics/usage (token totals, top models, optional cost)

Per-section graceful fallbacks: each slice independently resolves to
null on auth failure / missing endpoint / unreachable dashboard, and
the corresponding card hides itself. Vanilla installs without the
achievements plugin or analytics auth still get a usable dashboard.

Adds 5 new dashboard cards:

- SystemStatusStrip: one-line gateway + active-agents pill at top,
  warning chip when restart_requested.
- PlatformsCard: connected platforms with per-platform state pills
  (api_server, telegram, discord, etc.).
- CronSummaryCard: scheduled / paused / running counts + next-run
  countdown, click-through to /jobs.
- AchievementsCard: 3 most recent unlocks with tier badges, plus a
  modal that fetches a wider window (?achievements=12) for the full
  ribbon view.
- AnalyticsSummaryCard: top-3 models by tokens with proportional bars,
  total tokens over the window, real cost from the dashboard (replaces
  the old hardcoded ~$5/M estimate).

Other tweaks:

- Replace the hardcoded cost subline on the Tokens MetricTile with the
  real estimated_cost_usd value from /api/analytics/usage when present.
- New section row between the chart row and Recent Sessions for
  Platforms / Analytics / Achievements.

Tests: +7 for the aggregator covering the empty / mixed / full payload
shapes plus the field-rename quirks (gateway_platforms vs platforms,
active_sessions vs active_agents). All 31 swarm/dashboard tests green.

* fix(dashboard): use existing hugeicons names (Award01Icon, CancelIcon)

* feat(dashboard): consolidate ops strip + native model card polish

Polish pass on PR #242 (Workspace dashboard parity phase 1) before
merge. Tightens layout per the dashboard spec's '10-second status
read' goal.

Layout changes:
- Drop the centered logo hero. New header is a single row with title,
  Hermes Workspace label, and inline QuickActions.
- Collapse the three stacked status rows (SystemStatusStrip,
  CronSummaryCard, PlatformsCard) into one OpsStrip that surfaces
  gateway state, version, active agents, restart-pending, config
  drift, platform pills, and cron pulse in a single horizontal bar.
- Re-flow main content as 8/4 split: Activity + Analytics on the left,
  Model + Skills + Achievements as a side rail.

Data parity:
- Aggregator now exposes status.version, releaseDate, configVersion,
  latestConfigVersion, hermesHome from /api/status. OpsStrip uses these
  for the version chip and config drift warning.
- New ModelInfoCard reads overview.modelInfo (i.e. /api/model/info, the
  active model the gateway is using) instead of /api/claude-config
  defaults. Surfaces context length and tools/vision/reasoning chips.

UX:
- AnalyticsSummaryCard now renders a stable 'No usage in last Nd'
  empty state instead of disappearing, so layout doesn't reflow on
  fresh installs.
- Cron stale next-run (>7 days overdue) downgrades to muted 'stale'
  label so March overdue jobs don't look alarming.

Cleanup:
- Remove orphaned SystemStatusStrip, CronSummaryCard, PlatformsCard
  components. Drop legacy ModelCard + dead SystemGlance helper from
  dashboard-screen.tsx (-179 lines net).

Tests/build:
- pnpm exec vitest run src/server/dashboard-aggregator.test.ts (7/7)
- pnpm build (passes)

---------

Co-authored-by: Aurora release bot <release@outsourc-e.com>
This commit is contained in:
Eric
2026-05-03 09:45:01 -04:00
committed by GitHub
parent 371ffb4b32
commit 6a6d9d958b
9 changed files with 1526 additions and 164 deletions

View File

@@ -121,6 +121,7 @@ import { Route as ApiKnowledgeReadRouteImport } from './routes/api/knowledge/rea
import { Route as ApiKnowledgeListRouteImport } from './routes/api/knowledge/list'
import { Route as ApiKnowledgeGraphRouteImport } from './routes/api/knowledge/graph'
import { Route as ApiKnowledgeConfigRouteImport } from './routes/api/knowledge/config'
import { Route as ApiDashboardOverviewRouteImport } from './routes/api/dashboard/overview'
import { Route as ApiClaudeTasksTaskIdRouteImport } from './routes/api/claude-tasks.$taskId'
import { Route as ApiClaudeProxySplatRouteImport } from './routes/api/claude-proxy/$'
import { Route as ApiClaudeJobsJobIdRouteImport } from './routes/api/claude-jobs.$jobId'
@@ -689,6 +690,11 @@ const ApiKnowledgeConfigRoute = ApiKnowledgeConfigRouteImport.update({
path: '/api/knowledge/config',
getParentRoute: () => rootRouteImport,
} as any)
const ApiDashboardOverviewRoute = ApiDashboardOverviewRouteImport.update({
id: '/api/dashboard/overview',
path: '/api/dashboard/overview',
getParentRoute: () => rootRouteImport,
} as any)
const ApiClaudeTasksTaskIdRoute = ApiClaudeTasksTaskIdRouteImport.update({
id: '/$taskId',
path: '/$taskId',
@@ -808,6 +814,7 @@ export interface FileRoutesByFullPath {
'/api/claude-jobs/$jobId': typeof ApiClaudeJobsJobIdRoute
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
@@ -927,6 +934,7 @@ export interface FileRoutesByTo {
'/api/claude-jobs/$jobId': typeof ApiClaudeJobsJobIdRoute
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
@@ -1048,6 +1056,7 @@ export interface FileRoutesById {
'/api/claude-jobs/$jobId': typeof ApiClaudeJobsJobIdRoute
'/api/claude-proxy/$': typeof ApiClaudeProxySplatRoute
'/api/claude-tasks/$taskId': typeof ApiClaudeTasksTaskIdRoute
'/api/dashboard/overview': typeof ApiDashboardOverviewRoute
'/api/knowledge/config': typeof ApiKnowledgeConfigRoute
'/api/knowledge/graph': typeof ApiKnowledgeGraphRoute
'/api/knowledge/list': typeof ApiKnowledgeListRoute
@@ -1170,6 +1179,7 @@ export interface FileRouteTypes {
| '/api/claude-jobs/$jobId'
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
| '/api/dashboard/overview'
| '/api/knowledge/config'
| '/api/knowledge/graph'
| '/api/knowledge/list'
@@ -1289,6 +1299,7 @@ export interface FileRouteTypes {
| '/api/claude-jobs/$jobId'
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
| '/api/dashboard/overview'
| '/api/knowledge/config'
| '/api/knowledge/graph'
| '/api/knowledge/list'
@@ -1409,6 +1420,7 @@ export interface FileRouteTypes {
| '/api/claude-jobs/$jobId'
| '/api/claude-proxy/$'
| '/api/claude-tasks/$taskId'
| '/api/dashboard/overview'
| '/api/knowledge/config'
| '/api/knowledge/graph'
| '/api/knowledge/list'
@@ -1524,6 +1536,7 @@ export interface RootRouteChildren {
ChatSessionKeyRoute: typeof ChatSessionKeyRoute
ChatIndexRoute: typeof ChatIndexRoute
ApiClaudeProxySplatRoute: typeof ApiClaudeProxySplatRoute
ApiDashboardOverviewRoute: typeof ApiDashboardOverviewRoute
ApiKnowledgeConfigRoute: typeof ApiKnowledgeConfigRoute
ApiKnowledgeGraphRoute: typeof ApiKnowledgeGraphRoute
ApiKnowledgeListRoute: typeof ApiKnowledgeListRoute
@@ -2333,6 +2346,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiKnowledgeConfigRouteImport
parentRoute: typeof rootRouteImport
}
'/api/dashboard/overview': {
id: '/api/dashboard/overview'
path: '/api/dashboard/overview'
fullPath: '/api/dashboard/overview'
preLoaderRoute: typeof ApiDashboardOverviewRouteImport
parentRoute: typeof rootRouteImport
}
'/api/claude-tasks/$taskId': {
id: '/api/claude-tasks/$taskId'
path: '/$taskId'
@@ -2574,6 +2594,7 @@ const rootRouteChildren: RootRouteChildren = {
ChatSessionKeyRoute: ChatSessionKeyRoute,
ChatIndexRoute: ChatIndexRoute,
ApiClaudeProxySplatRoute: ApiClaudeProxySplatRoute,
ApiDashboardOverviewRoute: ApiDashboardOverviewRoute,
ApiKnowledgeConfigRoute: ApiKnowledgeConfigRoute,
ApiKnowledgeGraphRoute: ApiKnowledgeGraphRoute,
ApiKnowledgeListRoute: ApiKnowledgeListRoute,

View File

@@ -0,0 +1,67 @@
/**
* GET /api/dashboard/overview
*
* Aggregates the data the Workspace dashboard renders:
* - gateway status (running, active_agents, restart_requested)
* - connected platforms (api_server, telegram, discord, etc.)
* - cron summary (total / paused / running / next_run_at)
* - achievements (recent unlocks + total unlocked count)
* - current model info (provider, model, context length, capabilities)
* - analytics rollup (last N days, top models, optional cost)
*
* Each section is independent: a single missing endpoint or auth
* failure leaves that section at `null` and the dashboard hides the
* card. The aggregation runs server-side so the client makes one
* request instead of six, and we get a single auth surface.
*/
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { dashboardFetch } from '../../../server/gateway-capabilities'
import {
buildDashboardOverview,
type DashboardFetcher,
} from '../../../server/dashboard-aggregator'
const overviewFetcher: DashboardFetcher = (path) => dashboardFetch(path)
export const Route = createFileRoute('/api/dashboard/overview')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const url = new URL(request.url)
const days = Number(url.searchParams.get('days') ?? '7')
const limit = Number(url.searchParams.get('achievements') ?? '3')
const overview = await buildDashboardOverview({
fetcher: overviewFetcher,
analyticsWindowDays: Number.isFinite(days) && days > 0 ? days : 7,
achievementsLimit:
Number.isFinite(limit) && limit > 0 ? Math.min(limit, 12) : 3,
})
return json(overview, {
headers: {
// The aggregate is cheap to recompute (parallel fans-out
// upstream), but cache for a few seconds so a noisy client
// doesn't hammer the dashboard. Stale-while-revalidate keeps
// the UI snappy while fresh data lands.
'Cache-Control':
'private, max-age=5, stale-while-revalidate=20',
},
})
} catch (err) {
return json(
{
error:
err instanceof Error ? err.message : 'overview build failed',
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -0,0 +1,246 @@
import { useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { CancelIcon, Award01Icon } from '@hugeicons/core-free-icons'
import type {
DashboardAchievementUnlock,
DashboardOverview,
} from '@/server/dashboard-aggregator'
const TIER_COLORS: Record<string, string> = {
Copper: '#b45309',
Silver: '#9ca3af',
Gold: '#facc15',
Diamond: '#22d3ee',
Olympian: '#f472b6',
}
function tierColor(tier: string | null): string {
if (!tier) return 'var(--theme-muted)'
return TIER_COLORS[tier] ?? 'var(--theme-muted)'
}
function relativeTime(unlockedAtSeconds: number | null): string {
if (!unlockedAtSeconds) return ''
const diff = Date.now() / 1000 - unlockedAtSeconds
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86_400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86_400)}d ago`
}
function AchievementRow({
unlock,
compact = false,
}: {
unlock: DashboardAchievementUnlock
compact?: boolean
}) {
return (
<div
className="flex items-center gap-2 rounded border px-2 py-1.5"
style={{ borderColor: 'var(--theme-border)' }}
>
<span
aria-hidden
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-base"
style={{
background:
'color-mix(in srgb, var(--theme-accent) 12%, transparent)',
color: tierColor(unlock.tier),
}}
>
🏆
</span>
<div className="min-w-0 flex-1">
<div
className="truncate text-[11px] font-semibold"
style={{ color: 'var(--theme-text)' }}
>
{unlock.name}
</div>
{!compact ? (
<div
className="truncate text-[10px]"
style={{ color: 'var(--theme-muted)' }}
>
{unlock.description || unlock.category}
</div>
) : null}
</div>
<div className="text-right">
{unlock.tier ? (
<span
className="block text-[9px] font-mono uppercase tracking-[0.1em]"
style={{ color: tierColor(unlock.tier) }}
>
{unlock.tier}
</span>
) : null}
<span
className="block text-[9px] font-mono"
style={{ color: 'var(--theme-muted)' }}
>
{relativeTime(unlock.unlockedAt)}
</span>
</div>
</div>
)
}
/**
* Compact achievements panel: shows the 3 most recent unlocks plus a
* "View all" button that opens a modal with the full ribbon. Hides
* itself when the achievements plugin isn't installed (section comes
* back null from the aggregator).
*/
export function AchievementsCard({
achievements,
}: {
achievements: DashboardOverview['achievements']
}) {
const [showAll, setShowAll] = useState(false)
const [allUnlocks, setAllUnlocks] = useState<
Array<DashboardAchievementUnlock> | null
>(null)
const [loadingAll, setLoadingAll] = useState(false)
const [allError, setAllError] = useState<string | null>(null)
if (!achievements) return null
const openModal = async () => {
setShowAll(true)
if (allUnlocks !== null) return
setLoadingAll(true)
setAllError(null)
try {
const res = await fetch('/api/dashboard/overview?achievements=12')
if (!res.ok) throw new Error(`overview ${res.status}`)
const data = (await res.json()) as DashboardOverview
setAllUnlocks(data.achievements?.recentUnlocks ?? [])
} catch (err) {
setAllError(err instanceof Error ? err.message : 'failed to load')
} finally {
setLoadingAll(false)
}
}
return (
<>
<div
className="rounded-md border bg-[var(--theme-card)]/40 p-3"
style={{ borderColor: 'var(--theme-border)' }}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={Award01Icon}
size={14}
strokeWidth={1.5}
style={{ color: 'var(--theme-muted)' }}
/>
<h3
className="text-[10px] font-semibold uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
Achievements
</h3>
</div>
<span
className="text-[10px] font-mono uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
{achievements.totalUnlocked} unlocked
</span>
</div>
<div className="space-y-1">
{achievements.recentUnlocks.length === 0 ? (
<div
className="py-3 text-center text-[11px]"
style={{ color: 'var(--theme-muted)' }}
>
No unlocks yet keep working.
</div>
) : (
achievements.recentUnlocks.map((unlock) => (
<AchievementRow key={unlock.id} unlock={unlock} compact />
))
)}
</div>
<button
type="button"
onClick={openModal}
className="mt-2 w-full rounded border py-1 text-[10px] font-mono uppercase tracking-[0.15em] transition-colors hover:bg-[var(--theme-card)]/80"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
}}
>
View all
</button>
</div>
{showAll ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8"
role="dialog"
aria-modal="true"
onClick={() => setShowAll(false)}
>
<div
className="max-h-[80vh] w-full max-w-2xl overflow-hidden rounded-lg border bg-[var(--theme-card)]"
style={{ borderColor: 'var(--theme-border)' }}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between border-b px-4 py-3"
style={{ borderColor: 'var(--theme-border)' }}
>
<h2
className="text-sm font-semibold uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-text)' }}
>
Achievement Ribbon
</h2>
<button
type="button"
onClick={() => setShowAll(false)}
aria-label="Close"
className="rounded p-1 hover:bg-[var(--theme-card)]/80"
>
<HugeiconsIcon
icon={CancelIcon}
size={16}
strokeWidth={1.5}
style={{ color: 'var(--theme-muted)' }}
/>
</button>
</div>
<div className="max-h-[64vh] overflow-y-auto p-4">
{loadingAll ? (
<div
className="py-8 text-center text-[11px]"
style={{ color: 'var(--theme-muted)' }}
>
Loading
</div>
) : allError ? (
<div
className="py-8 text-center text-[11px]"
style={{ color: 'var(--theme-danger)' }}
>
{allError}
</div>
) : (
<div className="space-y-1.5">
{(allUnlocks ?? achievements.recentUnlocks).map((unlock) => (
<AchievementRow key={unlock.id} unlock={unlock} />
))}
</div>
)}
</div>
</div>
</div>
) : null}
</>
)
}

View File

@@ -0,0 +1,121 @@
import { HugeiconsIcon } from '@hugeicons/react'
import { ChartLineData01Icon } from '@hugeicons/core-free-icons'
import { formatModelName } from '@/screens/dashboard/lib/formatters'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function formatCost(usd: number | null): string {
if (usd === null) return '—'
if (usd < 0.01) return '<$0.01'
if (usd < 1) return `$${usd.toFixed(2)}`
if (usd < 100) return `$${usd.toFixed(1)}`
return `$${Math.round(usd)}`
}
/**
* Replaces the old hardcoded `~$X` cost estimate with real
* dashboard-sourced analytics: total tokens over the window, top 3
* models with relative bars, and a cost figure when the dashboard
* provides one. Hides itself when the analytics surface is unavailable
* (vanilla install with auth disabled, or zero traffic).
*/
export function AnalyticsSummaryCard({
analytics,
}: {
analytics: DashboardOverview['analytics']
}) {
if (!analytics) return null
const hasData = analytics.topModels.length > 0
const top = hasData ? analytics.topModels[0] : null
const max = top?.tokens || 1
return (
<div
className="rounded-md border bg-[var(--theme-card)]/40 p-3"
style={{ borderColor: 'var(--theme-border)' }}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={ChartLineData01Icon}
size={14}
strokeWidth={1.5}
style={{ color: 'var(--theme-muted)' }}
/>
<h3
className="text-[10px] font-semibold uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
Top Models · {analytics.windowDays}d
</h3>
</div>
<div className="text-right">
<div
className="text-[11px] font-mono"
style={{ color: 'var(--theme-text)' }}
>
{formatTokens(analytics.totalTokens)} tok
</div>
{analytics.estimatedCostUsd !== null ? (
<div
className="text-[9px] font-mono uppercase tracking-[0.1em]"
style={{ color: 'var(--theme-muted)' }}
>
{formatCost(analytics.estimatedCostUsd)}
</div>
) : null}
</div>
</div>
{!hasData ? (
<div
className="flex items-center justify-center py-3 text-[11px]"
style={{ color: 'var(--theme-muted)' }}
>
No usage in the last {analytics.windowDays}d.
</div>
) : null}
<div className="space-y-1.5">
{analytics.topModels.map((m) => {
const widthPct = Math.max(2, Math.round((m.tokens / max) * 100))
return (
<div key={m.id} className="text-[11px]">
<div className="mb-0.5 flex items-center justify-between">
<span
className="truncate font-mono"
style={{ color: 'var(--theme-text)' }}
>
{formatModelName(m.id)}
</span>
<span
className="font-mono text-[10px]"
style={{ color: 'var(--theme-muted)' }}
>
{formatTokens(m.tokens)} · {m.calls.toLocaleString()} calls
</span>
</div>
<div
className="h-1 w-full overflow-hidden rounded-full"
style={{
background:
'color-mix(in srgb, var(--theme-border) 50%, transparent)',
}}
>
<div
className="h-full"
style={{
width: `${widthPct}%`,
background: 'var(--theme-accent)',
}}
/>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,172 @@
import { formatModelName } from '@/screens/dashboard/lib/formatters'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
function formatContext(n: number): string {
if (!n || n <= 0) return '—'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${Math.round(n / 1_000)}K`
return String(n)
}
function readBoolCap(
caps: Record<string, unknown> | null,
key: string,
): boolean {
if (!caps) return false
const v = caps[key]
return v === true
}
/**
* Drop-in replacement for the legacy ModelCard that read
* `/api/claude-config`. The overview aggregator already pulls
* `/api/model/info`, which is the correct source for the *active*
* model the gateway is using right now (not just config defaults).
*
* Surfaces the bits the spec calls out as "missing native parity":
* provider, real context length, and capability chips. Stays compact
* so it fits the new tighter row.
*/
export function ModelInfoCard({
modelInfo,
palette,
}: {
modelInfo: DashboardOverview['modelInfo']
palette: {
accent: string
success: string
danger: string
border: string
card: string
text: string
muted: string
}
}) {
const connected = !!modelInfo
const display = modelInfo
? formatModelName(modelInfo.model)
: '—'
const provider = modelInfo?.provider ?? '—'
const contextLength = modelInfo?.effectiveContextLength ?? 0
const caps = modelInfo?.capabilities ?? null
const supportsTools = readBoolCap(caps, 'supports_tools')
const supportsVision = readBoolCap(caps, 'supports_vision')
const supportsReasoning = readBoolCap(caps, 'supports_reasoning')
const family =
caps && typeof caps['model_family'] === 'string'
? (caps['model_family'] as string)
: null
return (
<div
className="relative flex h-full flex-col overflow-hidden rounded-xl border"
style={{
background: 'var(--theme-card)',
borderColor: 'var(--theme-border)',
}}
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[2px]"
style={{
background: connected
? `linear-gradient(90deg, ${palette.success}, ${palette.success}50, transparent)`
: `linear-gradient(90deg, ${palette.danger}, ${palette.danger}50, transparent)`,
}}
/>
<div className="flex items-center justify-between px-5 pt-4">
<h3
className="text-[10px] font-semibold uppercase tracking-[0.15em]"
style={{ color: palette.muted }}
>
Active Model
</h3>
<span
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold"
style={{
background: connected
? 'color-mix(in srgb, var(--theme-success) 12%, transparent)'
: 'color-mix(in srgb, var(--theme-danger) 12%, transparent)',
color: connected ? palette.success : palette.danger,
}}
>
<span
className="size-1.5 rounded-full"
style={{
background: connected ? palette.success : palette.danger,
}}
/>
{connected ? 'Online' : 'Offline'}
</span>
</div>
<div className="flex flex-1 flex-col gap-2.5 px-5 pb-4 pt-3">
<div>
<div
className="font-mono text-[15px] font-bold"
style={{ color: palette.text }}
>
{display}
</div>
<div
className="mt-0.5 truncate font-mono text-[10px]"
style={{ color: palette.muted }}
>
{provider}
{modelInfo ? ` · ${modelInfo.model}` : ''}
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<CapabilityChip
label="ctx"
value={formatContext(contextLength)}
tone={palette.accent}
/>
{family ? (
<CapabilityChip label="family" value={family} tone={palette.muted} />
) : null}
{supportsTools ? (
<CapabilityChip label="tools" value="✓" tone={palette.success} />
) : null}
{supportsVision ? (
<CapabilityChip
label="vision"
value="✓"
tone={palette.success}
/>
) : null}
{supportsReasoning ? (
<CapabilityChip
label="reason"
value="✓"
tone={palette.success}
/>
) : null}
</div>
</div>
</div>
)
}
function CapabilityChip({
label,
value,
tone,
}: {
label: string
value: string
tone: string
}) {
return (
<span
className="inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em]"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
}}
>
<span>{label}</span>
<span style={{ color: tone }}>{value}</span>
</span>
)
}

View File

@@ -0,0 +1,229 @@
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
const PLATFORM_GLYPH: Record<string, string> = {
api_server: '🌐',
telegram: '✈️',
discord: '🎮',
whatsapp: '🟢',
slack: '💼',
signal: '🔵',
matrix: '#',
nostr: '⚡',
imessage: '💬',
bluebubbles: '🫧',
mattermost: '🔷',
feishu: '🪶',
line: '💚',
zalo: '⭐',
twitch: '🎬',
qqbot: '🐧',
msteams: '🟦',
irc: '#',
}
const STATE_TONE: Record<string, string> = {
connected: 'var(--theme-success)',
running: 'var(--theme-success)',
ok: 'var(--theme-success)',
connecting: 'var(--theme-warning)',
starting: 'var(--theme-warning)',
error: 'var(--theme-danger)',
disconnected: 'var(--theme-danger)',
failed: 'var(--theme-danger)',
}
function platformTone(state: string): string {
return STATE_TONE[state.toLowerCase()] ?? 'var(--theme-muted)'
}
function formatNextRun(iso: string | null): {
text: string
tone: string
} {
if (!iso) return { text: 'no schedule', tone: 'var(--theme-muted)' }
const ms = Date.parse(iso)
if (!Number.isFinite(ms)) return { text: 'no schedule', tone: 'var(--theme-muted)' }
const diff = ms - Date.now()
if (diff < -7 * 86_400_000) {
return { text: 'stale', tone: 'var(--theme-muted)' }
}
if (diff < 0) return { text: 'overdue', tone: 'var(--theme-warning)' }
if (diff < 60_000) return { text: '<1m', tone: 'var(--theme-text)' }
if (diff < 3_600_000)
return { text: `${Math.round(diff / 60_000)}m`, tone: 'var(--theme-text)' }
if (diff < 86_400_000)
return { text: `${Math.round(diff / 3_600_000)}h`, tone: 'var(--theme-text)' }
return { text: `${Math.round(diff / 86_400_000)}d`, tone: 'var(--theme-text)' }
}
/**
* Consolidated operations strip — the "10-second status read" the
* dashboard spec calls for. Replaces three separate stacked rows
* (system status, cron summary, platforms grid) with one tight
* horizontal bar that surfaces gateway state, version drift, cron
* pulse, and platform pills in a single line.
*
* Renders nothing if there is no status (overview hasn't loaded /
* gateway is unreachable) so the dashboard does not flash an empty
* frame on first paint.
*/
export function OpsStrip({
status,
cron,
platforms,
}: {
status: DashboardOverview['status']
cron: DashboardOverview['cron']
platforms: DashboardOverview['platforms']
}) {
const navigate = useNavigate()
if (!status) return null
const ok =
status.gatewayState === 'running' ||
status.gatewayState === 'connected' ||
status.gatewayState === 'ok'
const drift =
status.configVersion !== null &&
status.latestConfigVersion !== null &&
status.latestConfigVersion > status.configVersion
? status.latestConfigVersion - status.configVersion
: 0
const next = cron ? formatNextRun(cron.nextRunAt) : null
return (
<div
className="flex flex-col gap-2 rounded-md border bg-[var(--theme-card)]/50 px-3 py-2 lg:flex-row lg:items-center lg:justify-between lg:gap-4"
style={{ borderColor: 'var(--theme-border)' }}
>
{/* Gateway block: state + version + active agents */}
<div className="flex items-center gap-3 text-[11px]">
<span className="flex items-center gap-2">
<span
className={cn(
'inline-flex h-1.5 w-1.5 rounded-full',
ok ? 'animate-pulse' : '',
)}
style={{
background: ok
? 'var(--theme-success)'
: 'var(--theme-warning)',
}}
/>
<span
className="font-mono uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
{ok ? 'gateway' : `gateway ${status.gatewayState}`}
</span>
</span>
{status.version ? (
<span
className="font-mono text-[10px] uppercase tracking-[0.1em]"
style={{ color: 'var(--theme-muted)' }}
>
v{status.version}
</span>
) : null}
<span
className="font-mono uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
· {status.activeAgents} active
</span>
{status.restartRequested ? (
<span
className="rounded px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.15em]"
style={{
background:
'color-mix(in srgb, var(--theme-warning) 15%, transparent)',
color: 'var(--theme-warning)',
border:
'1px solid color-mix(in srgb, var(--theme-warning) 35%, transparent)',
}}
>
restart pending
</span>
) : null}
{drift > 0 ? (
<button
type="button"
onClick={() => navigate({ to: '/settings', search: {} })}
className="rounded px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.15em] transition-colors hover:bg-[var(--theme-card)]/80"
style={{
background:
'color-mix(in srgb, var(--theme-warning) 12%, transparent)',
color: 'var(--theme-warning)',
border:
'1px solid color-mix(in srgb, var(--theme-warning) 30%, transparent)',
}}
title={`Local config v${status.configVersion} · latest v${status.latestConfigVersion}`}
>
config +{drift}
</button>
) : null}
</div>
{/* Platform pills + cron next-run */}
<div className="flex flex-wrap items-center gap-2 text-[11px]">
{platforms.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{platforms.map((platform) => (
<span
key={platform.name}
className="inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.1em]"
style={{
borderColor: 'var(--theme-border)',
color: platformTone(platform.state),
}}
title={
platform.errorMessage
? `${platform.name}: ${platform.errorMessage}`
: `${platform.name} · ${platform.state}`
}
>
<span aria-hidden>
{PLATFORM_GLYPH[platform.name] ?? '🔌'}
</span>
{platform.name.replace('_', ' ')}
</span>
))}
</div>
) : null}
{cron ? (
<button
type="button"
onClick={() => navigate({ to: '/jobs' })}
className="inline-flex items-center gap-2 rounded border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors hover:bg-[var(--theme-card)]/80"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
}}
title="Open cron jobs"
>
<span>cron</span>
<span style={{ color: 'var(--theme-text)' }}>{cron.total}</span>
{cron.paused > 0 ? (
<span style={{ color: 'var(--theme-warning)' }}>
· {cron.paused}p
</span>
) : null}
{cron.running > 0 ? (
<span style={{ color: 'var(--theme-success)' }}>
· {cron.running}r
</span>
) : null}
{next ? (
<span style={{ color: next.tone }}>· {next.text}</span>
) : null}
</button>
) : null}
</div>
</div>
)
}

View File

@@ -1,6 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useEffect, useMemo, useState } from 'react'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
import { OpsStrip } from './components/ops-strip'
import { ModelInfoCard } from './components/model-info-card'
import { AchievementsCard } from './components/achievements-card'
import { AnalyticsSummaryCard } from './components/analytics-summary-card'
import {
Area,
AreaChart,
@@ -12,7 +17,6 @@ import {
} from 'recharts'
import type { ReactNode } from 'react'
import type { ClaudeSession } from '@/server/claude-api'
import { chatQueryKeys } from '@/screens/chat/chat-queries'
import { getUnavailableReason } from '@/lib/feature-gates'
import { useFeatureAvailable } from '@/hooks/use-feature-available'
import { cn } from '@/lib/utils'
@@ -170,48 +174,6 @@ function UnavailableWidget({
)
}
// ── System Glance (status bar) ───────────────────
function SystemGlance({
sessions,
connected,
model,
provider,
tokens,
cost,
}: {
sessions: number
connected: boolean
model: string
provider: string
tokens: string
cost: string
}) {
return (
<div className="flex items-center gap-3 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-5 py-2.5 backdrop-blur-sm">
<span
className={cn(
'size-2 shrink-0 rounded-full',
connected ? 'bg-emerald-500 animate-pulse' : 'bg-red-500',
)}
/>
<div className="flex flex-1 items-center gap-x-4 overflow-x-auto">
<span className="text-xs font-medium text-ink">{model}</span>
<span className="text-muted">·</span>
<span className="text-xs text-neutral-500">{provider}</span>
<span className="text-muted">·</span>
<span className="text-xs text-neutral-500">{sessions} sessions</span>
<span className="text-muted">·</span>
<span className="text-xs font-bold tabular-nums text-ink">
{tokens} tokens
</span>
<span className="text-muted">·</span>
<span className="text-xs text-neutral-400">{cost}</span>
</div>
</div>
)
}
// ── Metric Tile ──────────────────────────────────────────────────
function MetricTile({
@@ -383,106 +345,6 @@ function ActivityChart({
)
}
// ── Model Card ───────────────────────────────────────────────────
function ModelCard({ palette }: { palette: ReturnType<typeof readDashboardPalette> }) {
const sessionsAvailable = useFeatureAvailable('sessions')
const configAvailable = useFeatureAvailable('config')
const configQuery = useQuery({
queryKey: ['claude-config'],
queryFn: async () => {
const res = await fetch('/api/claude-config')
if (!res.ok) return null
return res.json() as Promise<Record<string, unknown>>
},
staleTime: 30_000,
enabled: configAvailable,
})
const config = configQuery.data as Record<string, unknown> | undefined
const modelName = (config?.activeModel ?? '—') as string
const provider = (config?.activeProvider ?? '—') as string
const configBlock = config?.config as Record<string, unknown> | undefined
const modelBlock = configBlock?.model as Record<string, unknown> | undefined
const baseUrl = (modelBlock?.base_url ??
configBlock?.base_url ??
'') as string
const connected = sessionsAvailable
const fallbackBlock = config?.fallback_model as
| Record<string, unknown>
| undefined
const fallbackModel = fallbackBlock?.model as string | undefined
if (!configAvailable) {
return (
<UnavailableWidget
title="Model"
description={getUnavailableReason('config')}
/>
)
}
return (
<GlassCard
title="Model"
titleRight={
<span
className={cn(
'inline-flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full',
connected
? 'text-emerald-400 bg-emerald-500/10'
: 'text-red-400 bg-red-500/10',
)}
>
<span
className={cn(
'size-1.5 rounded-full',
connected ? 'bg-emerald-500' : 'bg-red-500',
)}
/>
{connected ? 'Online' : 'Offline'}
</span>
}
accentColor={connected ? palette.success : palette.danger}
className="h-full"
>
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-lg p-2.5 bg-[var(--theme-card2)] border border-[var(--theme-border)]">
<div
className="flex size-7 items-center justify-center rounded-md text-sm"
style={{ background: alpha(palette.accent, 0.1), color: palette.accent }}
>
🤖
</div>
<div className="min-w-0 flex-1">
<div className="font-mono text-[13px] font-bold text-ink truncate">
{typeof modelName === 'string' ? modelName : '—'}
</div>
<div className="text-[10px] text-muted font-mono truncate">
{provider}
{baseUrl ? ` · ${baseUrl}` : ''}
</div>
</div>
</div>
{fallbackModel && (
<div className="flex items-center gap-3 rounded-lg p-2.5 bg-[var(--theme-card2)] border border-[var(--theme-border)]">
<div className="flex size-7 items-center justify-center rounded-md bg-amber-500/10 text-sm">
🔄
</div>
<div className="min-w-0 flex-1">
<div className="font-mono text-[13px] text-ink truncate">
{fallbackModel}
</div>
<div className="text-[10px] text-muted font-mono truncate">
{(fallbackBlock?.provider as string) ?? ''}
</div>
</div>
</div>
)}
</div>
</GlassCard>
)
}
// ── Skills Widget ────────────────────────────────────────────────
function SkillsWidget({ palette }: { palette: ReturnType<typeof readDashboardPalette> }) {
@@ -736,7 +598,22 @@ export function DashboardScreen() {
return max
}, [recentSessions])
const costEstimate = `~$${((stats.totalTokens / 1_000_000) * 5).toFixed(2)}`
// Aggregate dashboard overview — surfaces the data the native
// Hermes dashboard exposes (status, platforms, cron, achievements,
// model info, analytics) in a single round trip with per-section
// graceful fallbacks. Each card renders only when its slice resolves.
const overviewQuery = useQuery<DashboardOverview>({
queryKey: ['dashboard', 'overview'],
queryFn: async () => {
const res = await fetch('/api/dashboard/overview')
if (!res.ok) throw new Error(`overview ${res.status}`)
return (await res.json()) as DashboardOverview
},
staleTime: 5_000,
refetchInterval: 30_000,
})
const overview = overviewQuery.data ?? null
const palette = useDashboardPalette()
const updateSettings = useSettingsStore((state) => state.updateSettings)
@@ -789,18 +666,31 @@ export function DashboardScreen() {
</button>
</div>
<div className="px-4 pt-14 md:pt-4 py-4 md:px-8 md:py-6 lg:px-10 space-y-5 pb-28">
{/* ── Header: Hermes Logo + Quick Actions ── */}
<div className="flex flex-col items-center gap-3 py-3">
<img
src="/claude-avatar.webp"
alt="Hermes Agent"
className="size-12 md:size-14 rounded-md border border-[var(--theme-border)]"
style={{ padding: '3px', background: 'var(--theme-card)' }}
/>
<p className="micro-label" style={{ color: 'var(--theme-muted)' }}>
Hermes Workspace
</p>
<div className="mt-1 grid w-full max-w-2xl grid-cols-2 gap-2 sm:grid-cols-4">
{/* ── Header: Title + inline Quick Actions (no centered hero) ── */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<img
src="/claude-avatar.webp"
alt="Hermes Agent"
className="size-9 rounded-md border border-[var(--theme-border)]"
style={{ padding: '2px', background: 'var(--theme-card)' }}
/>
<div className="flex flex-col">
<h1
className="text-base font-semibold tracking-tight"
style={{ color: 'var(--theme-text)' }}
>
Dashboard
</h1>
<span
className="font-mono text-[10px] uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
Hermes Workspace
</span>
</div>
</div>
<div className="grid w-full grid-cols-2 gap-2 sm:grid-cols-4 lg:max-w-xl">
<QuickAction
label="New Chat"
icon="💬"
@@ -835,6 +725,13 @@ export function DashboardScreen() {
</div>
</div>
{/* ── Ops strip (gateway + version drift + platforms + cron pulse) ── */}
<OpsStrip
status={overview?.status ?? null}
cron={overview?.cron ?? null}
platforms={overview?.platforms ?? []}
/>
{/* ── Metrics Row ── */}
{sessionsAvailable ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
@@ -859,7 +756,11 @@ export function DashboardScreen() {
<MetricTile
label="Tokens"
value={formatNumber(stats.totalTokens)}
sub={costEstimate}
sub={
overview?.analytics?.estimatedCostUsd != null
? `$${overview.analytics.estimatedCostUsd.toFixed(2)} · ${overview.analytics.windowDays}d`
: undefined
}
icon="⚡"
accentColor={palette.accentSecondary}
/>
@@ -871,9 +772,10 @@ export function DashboardScreen() {
/>
)}
{/* ── Charts + Model + Skills ── */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3">
<div className="lg:col-span-5">
{/* ── Primary content: Recent Sessions wide, side rail with Model/Skills/Achievements ── */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
{/* Activity area */}
<div className="flex flex-col gap-3 lg:col-span-8">
{sessionsAvailable ? (
<ActivityChart sessions={sessions} palette={palette} />
) : (
@@ -882,12 +784,16 @@ export function DashboardScreen() {
description={getUnavailableReason('sessions')}
/>
)}
<AnalyticsSummaryCard analytics={overview?.analytics ?? null} />
</div>
<div className="lg:col-span-4">
<ModelCard palette={palette} />
</div>
<div className="lg:col-span-3">
{/* Side rail */}
<div className="flex flex-col gap-3 lg:col-span-4">
<ModelInfoCard
modelInfo={overview?.modelInfo ?? null}
palette={palette}
/>
<SkillsWidget palette={palette} />
<AchievementsCard achievements={overview?.achievements ?? null} />
</div>
</div>

View File

@@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest'
import {
buildDashboardOverview,
type DashboardFetcher,
} from './dashboard-aggregator'
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function makeFetcher(routes: Record<string, unknown>): DashboardFetcher {
return async (path: string) => {
const key = Object.keys(routes).find((p) => path.startsWith(p))
if (key === undefined) {
return new Response('not found', { status: 404 })
}
const value = routes[key]
if (value instanceof Response) return value
return jsonResponse(value)
}
}
describe('buildDashboardOverview', () => {
it('returns null sections when every upstream call fails', async () => {
const fetcher: DashboardFetcher = async () =>
new Response('boom', { status: 500 })
const overview = await buildDashboardOverview({ fetcher })
expect(overview.status).toBeNull()
expect(overview.platforms).toEqual([])
expect(overview.cron).toBeNull()
expect(overview.achievements).toBeNull()
expect(overview.modelInfo).toBeNull()
expect(overview.analytics).toBeNull()
})
it('parses /api/status into status + platforms', async () => {
const fetcher = makeFetcher({
'/api/status': {
gateway_state: 'running',
active_agents: 2,
restart_requested: false,
updated_at: '2026-05-02T19:00:00Z',
version: '0.12.0',
release_date: '2026.4.30',
config_version: 17,
latest_config_version: 23,
hermes_home: '/Users/aurora/.hermes',
platforms: {
api_server: {
state: 'connected',
updated_at: '2026-05-02T18:55:00Z',
},
telegram: {
state: 'error',
updated_at: '2026-05-02T18:00:00Z',
error_message: 'rate limited',
},
},
},
})
const overview = await buildDashboardOverview({ fetcher })
expect(overview.status?.gatewayState).toBe('running')
expect(overview.status?.activeAgents).toBe(2)
expect(overview.status?.version).toBe('0.12.0')
expect(overview.status?.releaseDate).toBe('2026.4.30')
expect(overview.status?.configVersion).toBe(17)
expect(overview.status?.latestConfigVersion).toBe(23)
expect(overview.status?.hermesHome).toBe('/Users/aurora/.hermes')
expect(overview.platforms).toEqual([
{
name: 'api_server',
state: 'connected',
updatedAt: '2026-05-02T18:55:00Z',
errorMessage: null,
},
{
name: 'telegram',
state: 'error',
updatedAt: '2026-05-02T18:00:00Z',
errorMessage: 'rate limited',
},
])
})
it('summarises cron jobs and finds the earliest next-run', async () => {
const fetcher = makeFetcher({
'/api/cron/jobs': {
jobs: [
{ id: 'a', status: 'scheduled', next_run_at: '2026-05-03T01:00:00Z' },
{ id: 'b', status: 'paused' },
{ id: 'c', status: 'running', next_run_at: '2026-05-03T00:30:00Z' },
],
},
})
const overview = await buildDashboardOverview({ fetcher })
expect(overview.cron).toEqual({
total: 3,
paused: 1,
running: 1,
nextRunAt: '2026-05-03T00:30:00.000Z',
})
})
it('limits and shapes recent achievement unlocks', async () => {
const fetcher = makeFetcher({
'/api/plugins/hermes-achievements/recent-unlocks': [
{
id: 'let_him_cook',
name: 'Let Him Cook',
description: 'autonomous run',
category: 'Agent Autonomy',
icon: 'flame',
tier: 'Silver',
unlocked_at: 1777741371,
},
{
id: 'image_whisperer',
name: 'Image Whisperer',
description: '',
category: '',
icon: '',
tier: 'Copper',
unlocked_at: 1777741200,
},
{
id: 'extra1',
name: 'Extra 1',
description: '',
category: '',
icon: '',
unlocked_at: 1777741100,
},
{
id: 'extra2',
name: 'Extra 2',
description: '',
category: '',
icon: '',
unlocked_at: 1777741000,
},
],
'/api/plugins/hermes-achievements/achievements': {
achievements: [
{ id: 'a', state: 'unlocked' },
{ id: 'b', state: 'unlocked' },
{ id: 'c', state: 'locked' },
{ id: 'd', state: 'unlocked' },
],
},
})
const overview = await buildDashboardOverview({
fetcher,
achievementsLimit: 2,
})
expect(overview.achievements?.recentUnlocks).toHaveLength(2)
expect(overview.achievements?.recentUnlocks[0]).toMatchObject({
id: 'let_him_cook',
tier: 'Silver',
})
expect(overview.achievements?.totalUnlocked).toBe(3)
})
it('parses model info', async () => {
const fetcher = makeFetcher({
'/api/model/info': {
model: 'gpt-5.4',
provider: 'openai-codex',
effective_context_length: 272000,
capabilities: { supports_tools: true, model_family: 'gpt' },
},
})
const overview = await buildDashboardOverview({ fetcher })
expect(overview.modelInfo).toEqual({
provider: 'openai-codex',
model: 'gpt-5.4',
effectiveContextLength: 272000,
capabilities: { supports_tools: true, model_family: 'gpt' },
})
})
it('sorts top models by tokens and limits to 3', async () => {
const fetcher = makeFetcher({
'/api/analytics/usage': {
total_tokens: 5_000_000,
estimated_cost_usd: 12.34,
top_models: [
{ id: 'gpt-5.4', tokens: 1_000_000, calls: 200 },
{ id: 'opus-4-7', tokens: 3_500_000, calls: 80 },
{ id: 'sonnet-4-6', tokens: 250_000, calls: 50 },
{ id: 'gpt-5.5', tokens: 250_000, calls: 30 },
],
},
})
const overview = await buildDashboardOverview({ fetcher })
expect(overview.analytics?.totalTokens).toBe(5_000_000)
expect(overview.analytics?.estimatedCostUsd).toBe(12.34)
expect(overview.analytics?.topModels.map((m) => m.id)).toEqual([
'opus-4-7',
'gpt-5.4',
'sonnet-4-6',
])
})
it('survives mixed-status inputs (some succeed, some fail)', async () => {
const fetcher: DashboardFetcher = async (path) => {
if (path.startsWith('/api/status')) {
return jsonResponse({
gateway_state: 'running',
active_agents: 1,
platforms: {},
})
}
if (path.startsWith('/api/cron/jobs')) {
return jsonResponse({ jobs: [{ id: 'a', status: 'scheduled' }] })
}
// Everything else fails
return new Response('nope', { status: 401 })
}
const overview = await buildDashboardOverview({ fetcher })
expect(overview.status?.gatewayState).toBe('running')
expect(overview.status?.version).toBeNull()
expect(overview.status?.configVersion).toBeNull()
expect(overview.cron?.total).toBe(1)
expect(overview.achievements).toBeNull()
expect(overview.modelInfo).toBeNull()
expect(overview.analytics).toBeNull()
})
})

View File

@@ -0,0 +1,369 @@
/**
* Aggregator for the Workspace dashboard overview.
*
* The Workspace `/dashboard` route used to fetch a couple of pieces in
* parallel and stitch them together client-side. As the dashboard grew
* to include cron, achievements, platforms, and analytics, the client
* was making 5-6 round trips on every load. Worse, each surface had to
* implement its own capability gate.
*
* `buildDashboardOverview` is the server-side aggregator that fans out
* the fetches in parallel, applies per-section graceful fallbacks, and
* returns a single normalised payload the client can render in one shot.
*
* Each section is independent: a failure in one (auth missing, plugin
* not installed, dashboard down) leaves the corresponding field at
* `null` so the UI can hide just that card.
*/
export type DashboardOverview = {
status: DashboardStatusSection | null
platforms: Array<DashboardPlatformEntry>
cron: DashboardCronSection | null
achievements: DashboardAchievementsSection | null
modelInfo: DashboardModelInfoSection | null
analytics: DashboardAnalyticsSection | null
}
export type DashboardStatusSection = {
gatewayState: string
activeAgents: number
restartRequested: boolean
updatedAt: string | null
/** Gateway/dashboard semver. `null` when missing. */
version: string | null
/** Release date string from `/api/status`, raw value preserved. */
releaseDate: string | null
/** Current config schema version applied locally. */
configVersion: number | null
/** Latest config schema the dashboard knows about. */
latestConfigVersion: number | null
/** Resolved `HERMES_HOME` directory the dashboard reports. */
hermesHome: string | null
}
export type DashboardPlatformEntry = {
name: string
state: string
updatedAt: string | null
errorMessage: string | null
}
export type DashboardCronSection = {
total: number
paused: number
running: number
nextRunAt: string | null
}
export type DashboardAchievementUnlock = {
id: string
name: string
description: string
category: string
icon: string
tier: string | null
unlockedAt: number | null
}
export type DashboardAchievementsSection = {
totalUnlocked: number
recentUnlocks: Array<DashboardAchievementUnlock>
}
export type DashboardModelInfoSection = {
provider: string
model: string
effectiveContextLength: number
capabilities: Record<string, unknown> | null
}
export type DashboardAnalyticsSection = {
windowDays: number
totalTokens: number
topModels: Array<{ id: string; tokens: number; calls: number }>
estimatedCostUsd: number | null
}
export type DashboardFetcher = (path: string) => Promise<Response>
export type BuildOverviewOptions = {
/**
* Pluggable HTTP client. Tests pass a stub; the live route hands in a
* function that wraps `dashboardFetch` and `claudeFetch` so auth and
* base-URL handling stay in one place.
*/
fetcher: DashboardFetcher
/** How many days of analytics to roll up. Default 7. */
analyticsWindowDays?: number
/** How many recent achievement unlocks to surface. Default 3. */
achievementsLimit?: number
}
const DEFAULT_OPTIONS = {
analyticsWindowDays: 7,
achievementsLimit: 3,
}
async function safeJson<T>(
fetcher: DashboardFetcher,
path: string,
): Promise<T | null> {
try {
const res = await fetcher(path)
if (!res.ok) return null
return (await res.json()) as T
} catch {
return null
}
}
function readString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function readNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function readBoolean(value: unknown): boolean {
return typeof value === 'boolean' ? value : false
}
function readOptionalNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function readOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function normalizeStatus(raw: unknown): DashboardStatusSection | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const state = readString(r.gateway_state) || readString(r.state)
if (!state) return null
return {
gatewayState: state,
// The dashboard exposes `active_sessions`; older builds used `active_agents`.
activeAgents: readNumber(r.active_sessions ?? r.active_agents),
restartRequested: readBoolean(r.restart_requested),
updatedAt:
typeof r.gateway_updated_at === 'string'
? r.gateway_updated_at
: typeof r.updated_at === 'string'
? r.updated_at
: null,
version: readOptionalString(r.version),
releaseDate: readOptionalString(r.release_date),
configVersion: readOptionalNumber(r.config_version),
latestConfigVersion: readOptionalNumber(r.latest_config_version),
hermesHome: readOptionalString(r.hermes_home),
}
}
function normalizePlatforms(raw: unknown): Array<DashboardPlatformEntry> {
if (!raw || typeof raw !== 'object') return []
const r = raw as Record<string, unknown>
// Dashboard responds with `gateway_platforms`; older /api/status
// payloads carried `platforms`. Accept either.
const candidate = r.gateway_platforms ?? r.platforms
const platformsRaw =
candidate && typeof candidate === 'object' && !Array.isArray(candidate)
? (candidate as Record<string, unknown>)
: null
if (!platformsRaw) return []
return Object.entries(platformsRaw)
.map(([name, value]) => {
if (!value || typeof value !== 'object') return null
const v = value as Record<string, unknown>
return {
name,
state: readString(v.state) || 'unknown',
updatedAt: typeof v.updated_at === 'string' ? v.updated_at : null,
errorMessage:
typeof v.error_message === 'string' ? v.error_message : null,
}
})
.filter((entry): entry is DashboardPlatformEntry => entry !== null)
}
function normalizeCron(raw: unknown): DashboardCronSection | null {
if (!raw) return null
let jobs: Array<Record<string, unknown>> = []
if (Array.isArray(raw)) {
jobs = raw as Array<Record<string, unknown>>
} else if (raw && typeof raw === 'object') {
const r = raw as Record<string, unknown>
if (Array.isArray(r.jobs)) jobs = r.jobs as Array<Record<string, unknown>>
}
if (!Array.isArray(jobs)) return null
let paused = 0
let running = 0
let nextRunMs: number | null = null
for (const job of jobs) {
if (!job || typeof job !== 'object') continue
const status = readString(job.status).toLowerCase()
if (status === 'paused') paused += 1
else if (status === 'running') running += 1
const candidates = [
typeof job.next_run_at === 'string' ? Date.parse(job.next_run_at) : NaN,
typeof job.next_run === 'string' ? Date.parse(job.next_run) : NaN,
typeof job.next_run_at === 'number'
? (job.next_run_at as number) * 1000
: NaN,
].filter((v) => Number.isFinite(v)) as Array<number>
for (const ts of candidates) {
if (nextRunMs === null || ts < nextRunMs) nextRunMs = ts
}
}
return {
total: jobs.length,
paused,
running,
nextRunAt: nextRunMs ? new Date(nextRunMs).toISOString() : null,
}
}
function normalizeAchievementUnlock(
raw: unknown,
): DashboardAchievementUnlock | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const id = readString(r.id)
const name = readString(r.name)
if (!id || !name) return null
return {
id,
name,
description: readString(r.description),
category: readString(r.category) || 'General',
icon: readString(r.icon) || 'Star',
tier: typeof r.tier === 'string' ? r.tier : null,
unlockedAt:
typeof r.unlocked_at === 'number' ? (r.unlocked_at as number) : null,
}
}
function normalizeAchievements(
recent: unknown,
all: unknown,
limit: number,
): DashboardAchievementsSection | null {
const recentArr = Array.isArray(recent) ? recent : []
if (recentArr.length === 0 && (!all || typeof all !== 'object')) return null
const recentUnlocks = recentArr
.map(normalizeAchievementUnlock)
.filter(
(entry): entry is DashboardAchievementUnlock => entry !== null,
)
.slice(0, limit)
let totalUnlocked = 0
if (all && typeof all === 'object') {
const ach = (all as Record<string, unknown>).achievements
if (Array.isArray(ach)) {
for (const item of ach) {
if (!item || typeof item !== 'object') continue
const state = readString((item as Record<string, unknown>).state)
if (state === 'unlocked') totalUnlocked += 1
}
}
}
return { totalUnlocked, recentUnlocks }
}
function normalizeModelInfo(raw: unknown): DashboardModelInfoSection | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const model = readString(r.model)
if (!model) return null
return {
provider: readString(r.provider) || 'unknown',
model,
effectiveContextLength: readNumber(r.effective_context_length),
capabilities:
r.capabilities && typeof r.capabilities === 'object'
? (r.capabilities as Record<string, unknown>)
: null,
}
}
function normalizeAnalytics(
raw: unknown,
windowDays: number,
): DashboardAnalyticsSection | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const totalTokens = readNumber(r.total_tokens)
const modelsRaw =
Array.isArray(r.top_models) ? r.top_models : Array.isArray(r.models) ? r.models : []
const topModels = modelsRaw
.map((entry) => {
if (!entry || typeof entry !== 'object') return null
const e = entry as Record<string, unknown>
const id = readString(e.id) || readString(e.model)
if (!id) return null
return {
id,
tokens: readNumber(e.tokens),
calls: readNumber(e.calls ?? e.requests),
}
})
.filter((entry): entry is { id: string; tokens: number; calls: number } => entry !== null)
.sort((a, b) => b.tokens - a.tokens)
.slice(0, 3)
const estimatedCostUsd =
typeof r.estimated_cost_usd === 'number'
? (r.estimated_cost_usd as number)
: typeof r.cost_usd === 'number'
? (r.cost_usd as number)
: null
return {
windowDays,
totalTokens,
topModels,
estimatedCostUsd,
}
}
export async function buildDashboardOverview(
options: BuildOverviewOptions,
): Promise<DashboardOverview> {
const opts = { ...DEFAULT_OPTIONS, ...options }
const { fetcher, analyticsWindowDays, achievementsLimit } = opts
const [
statusRaw,
cronRaw,
achRecentRaw,
achAllRaw,
modelInfoRaw,
analyticsRaw,
] = await Promise.all([
safeJson<unknown>(fetcher, '/api/status'),
safeJson<unknown>(fetcher, '/api/cron/jobs'),
safeJson<unknown>(
fetcher,
`/api/plugins/hermes-achievements/recent-unlocks?limit=${achievementsLimit}`,
),
safeJson<unknown>(fetcher, '/api/plugins/hermes-achievements/achievements'),
safeJson<unknown>(fetcher, '/api/model/info'),
safeJson<unknown>(fetcher, `/api/analytics/usage?days=${analyticsWindowDays}`),
])
return {
status: normalizeStatus(statusRaw),
platforms: normalizePlatforms(statusRaw),
cron: normalizeCron(cronRaw),
achievements: normalizeAchievements(
achRecentRaw,
achAllRaw,
achievementsLimit,
),
modelInfo: normalizeModelInfo(modelInfoRaw),
analytics: normalizeAnalytics(analyticsRaw, analyticsWindowDays),
}
}