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:
@@ -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,
|
||||
|
||||
67
src/routes/api/dashboard/overview.ts
Normal file
67
src/routes/api/dashboard/overview.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
246
src/screens/dashboard/components/achievements-card.tsx
Normal file
246
src/screens/dashboard/components/achievements-card.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
121
src/screens/dashboard/components/analytics-summary-card.tsx
Normal file
121
src/screens/dashboard/components/analytics-summary-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
src/screens/dashboard/components/model-info-card.tsx
Normal file
172
src/screens/dashboard/components/model-info-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
src/screens/dashboard/components/ops-strip.tsx
Normal file
229
src/screens/dashboard/components/ops-strip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
231
src/server/dashboard-aggregator.test.ts
Normal file
231
src/server/dashboard-aggregator.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
369
src/server/dashboard-aggregator.ts
Normal file
369
src/server/dashboard-aggregator.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user