feat(dashboard): attention card, period switch, split analytics, action hierarchy

Iteration 002 - addresses the Hermes Agent product review.

New widgets:
- AttentionCard: prioritized 'what to look at right now' list. Pulls
  stale cron, log errors/warns, config drift, restart-pending, and
  platform error states from the existing overview payload. Shows an
  explicit 'all clear' state when nothing needs eyes. Replaces the
  scattered warning chips and gives the operator a single command line.
- AnalyticsChartCard: daily trend chart + 2-3 client-side insight
  callouts ('Usage peaked Apr 17, driven by GPT-5.4', 'Cache reads up
  X% vs prior period', 'no active runs · restart pending'). Period
  switch (7d / 14d / 30d) at top-right; selection persists to
  localStorage and feeds the same window into Hero KPIs and the rest of
  the overview.
- TopModelsCard: standalone right-rail card so the model breakdown is
  no longer cramped inside the analytics hero. Shows tokens bar plus
  '% of calls' (proxy for routing share) and sessions per model.
- ModelInfoCard now adds an operational microcopy line
  ('66% of calls · 113 sessions · 30d') and a click-through 'Inventory'
  modal that lists every model from /api/models grouped by provider,
  with active-model highlight and live filter.

UX/microcopy fixes:
- OpsStrip: '0 ACTIVE' -> '0 active runs', 'CONFIG +6' -> '6 config
  diffs', stale-cron pill now visually warns (warning border + tinted
  background) instead of muted text only.
- Action row: New Chat is now a primary gradient button, Terminal +
  Skills are secondary monochrome buttons, Settings is icon-only. Top
  visual weight cut ~30%.
- SkillsWidget: replaced the 6-row mini list with a summary tile
  ('42 installed · 39 enabled · top: Airtable'). Click-through opens
  /skills.
- Analytics card period-aware loading state: shows ' · refreshing…'
  microcopy in the header while the period switch is in flight.

Plumbing:
- /api/dashboard/overview now respects ?days=N from the client; query
  key includes period so React Query refetches when the operator
  switches windows.
- Period default 30d preserved; valid options 7 / 14 / 30 only.

Tests/build:
- pnpm exec vitest run src/server/dashboard-aggregator.test.ts (9/9)
- pnpm build (passes)
- Live: /api/dashboard/overview?days=7 returns 7-day window with
  56.5M tokens, top models gpt-5.4 + claude-opus-4-7.
This commit is contained in:
Aurora release bot
2026-05-02 16:52:07 -04:00
parent b5671a2115
commit 6deb16bc10
7 changed files with 1676 additions and 182 deletions

View File

@@ -0,0 +1,594 @@
import { useMemo, useState } from 'react'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { HugeiconsIcon } from '@hugeicons/react'
import {
ChartLineData01Icon,
CancelIcon,
} from '@hugeicons/core-free-icons'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
import { formatModelName } from '@/screens/dashboard/lib/formatters'
import { buildInsights } from '@/screens/dashboard/lib/insights'
export type AnalyticsPeriod = 7 | 14 | 30
const PERIODS: Array<AnalyticsPeriod> = [7, 14, 30]
function formatTokens(n: number): string {
if (!n || n <= 0) return '0'
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`
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): string {
if (!usd || usd <= 0) return '$0'
if (usd < 0.01) return '<$0.01'
if (usd < 1) return `$${usd.toFixed(3)}`
if (usd < 100) return `$${usd.toFixed(2)}`
return `$${Math.round(usd).toLocaleString()}`
}
function shortDay(day: string): string {
const ts = Date.parse(day)
if (!Number.isFinite(ts)) return day
return new Date(ts).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}
type ChartDatum = {
day: string
label: string
tokens: number
input: number
output: number
cache: number
reasoning: number
sessions: number
cost: number
}
/**
* Analytics trend chart card — the daily token mix area plot, plus a
* row of insight callouts the operator can scan in 2 seconds before
* looking at the curve. The period selector at top-right swaps the
* window between 7 / 14 / 30 days, persisted up to the parent so
* Hero KPIs use the same window.
*/
export function AnalyticsChartCard({
analytics,
cron,
status,
period,
onPeriodChange,
loading,
}: {
analytics: DashboardOverview['analytics']
cron: DashboardOverview['cron']
status: DashboardOverview['status']
period: AnalyticsPeriod
onPeriodChange: (next: AnalyticsPeriod) => void
loading?: boolean
}) {
const [showModal, setShowModal] = useState(false)
const data: Array<ChartDatum> = useMemo(() => {
if (!analytics) return []
return analytics.daily.map((d) => ({
day: d.day,
label: shortDay(d.day),
tokens: d.inputTokens + d.outputTokens,
input: d.inputTokens,
output: d.outputTokens,
cache: d.cacheReadTokens,
reasoning: d.reasoningTokens,
sessions: d.sessions,
cost: d.estimatedCost,
}))
}, [analytics])
const insights = useMemo(
() => buildInsights(analytics, cron, status),
[analytics, cron, status],
)
if (!analytics) return null
const hasData = analytics.source === 'analytics' && data.length > 0
return (
<>
<div
className="relative flex flex-col gap-3 overflow-hidden rounded-xl border p-4"
style={{
background:
'linear-gradient(150deg, color-mix(in srgb, var(--theme-card) 96%, transparent), color-mix(in srgb, var(--theme-card) 90%, transparent))',
borderColor: 'var(--theme-border)',
}}
>
<div
aria-hidden
className="pointer-events-none absolute -right-12 -top-16 h-48 w-48 rounded-full opacity-20 blur-3xl"
style={{ background: 'var(--theme-accent)' }}
/>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={ChartLineData01Icon}
size={16}
strokeWidth={1.5}
style={{ color: 'var(--theme-accent)' }}
/>
<div>
<h3
className="text-[11px] font-semibold uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-text)' }}
>
Usage trend · {period}d
</h3>
<p
className="font-mono text-[10px] uppercase tracking-[0.1em]"
style={{ color: 'var(--theme-muted)' }}
>
{formatTokens(analytics.totalTokens)} tokens ·{' '}
{analytics.totalApiCalls.toLocaleString()} calls ·{' '}
{formatCost(analytics.estimatedCostUsd ?? 0)}
{loading ? ' · refreshing…' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<PeriodSwitch
value={period}
onChange={onPeriodChange}
/>
{hasData ? (
<button
type="button"
onClick={() => setShowModal(true)}
className="ml-1 rounded border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.15em] transition-colors hover:bg-[var(--theme-card)]/80"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
}}
>
Expand
</button>
) : null}
</div>
</div>
{insights.length > 0 ? (
<ul className="flex flex-col gap-1 rounded-md border p-2 text-[11px]"
style={{
borderColor: 'var(--theme-border)',
background:
'color-mix(in srgb, var(--theme-card) 92%, transparent)',
}}
>
{insights.map((line, i) => {
const tone =
line.tone === 'positive'
? 'var(--theme-success)'
: line.tone === 'warn'
? 'var(--theme-warning)'
: 'var(--theme-accent)'
return (
<li
key={i}
className="flex items-center gap-2"
style={{ color: 'var(--theme-text)' }}
>
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full"
style={{ background: tone }}
/>
<span>{line.text}</span>
</li>
)
})}
</ul>
) : null}
{hasData ? (
<div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 4, right: 4, left: -22, bottom: 0 }}
>
<defs>
<linearGradient id="atok" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--theme-accent)"
stopOpacity={0.45}
/>
<stop
offset="100%"
stopColor="var(--theme-accent)"
stopOpacity={0}
/>
</linearGradient>
<linearGradient id="acache" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--theme-accent-secondary)"
stopOpacity={0.25}
/>
<stop
offset="100%"
stopColor="var(--theme-accent-secondary)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="2 4"
stroke="var(--theme-border)"
opacity={0.4}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 9, fill: 'var(--theme-muted)' }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
minTickGap={20}
/>
<YAxis
tick={{ fontSize: 9, fill: 'var(--theme-muted)' }}
axisLine={false}
tickLine={false}
width={40}
tickFormatter={(v: number) => formatTokens(v)}
/>
<Tooltip
contentStyle={{
background: 'var(--theme-card)',
border: '1px solid var(--theme-border)',
borderRadius: 8,
fontSize: 11,
}}
labelStyle={{
color: 'var(--theme-muted)',
fontSize: 10,
}}
formatter={(value: number, name: string) => [
formatTokens(value),
name,
]}
/>
<Area
type="monotone"
dataKey="cache"
name="cache"
stroke="var(--theme-accent-secondary)"
fill="url(#acache)"
strokeWidth={1}
dot={false}
/>
<Area
type="monotone"
dataKey="tokens"
name="tokens"
stroke="var(--theme-accent)"
fill="url(#atok)"
strokeWidth={1.6}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div
className="flex h-[120px] items-center justify-center rounded-md border border-dashed text-[11px]"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
}}
>
No analytics usage in the last {analytics.windowDays}d.
</div>
)}
{hasData ? (
<div className="flex items-center gap-4 text-[10px]">
<Legend tone="var(--theme-accent)" label="tokens (in+out)" />
<Legend
tone="var(--theme-accent-secondary)"
label="cache reads"
/>
</div>
) : null}
</div>
{showModal && hasData ? (
<AnalyticsModal
analytics={analytics}
data={data}
period={period}
onClose={() => setShowModal(false)}
/>
) : null}
</>
)
}
function PeriodSwitch({
value,
onChange,
}: {
value: AnalyticsPeriod
onChange: (next: AnalyticsPeriod) => void
}) {
return (
<div
className="inline-flex items-center overflow-hidden rounded border"
style={{ borderColor: 'var(--theme-border)' }}
role="tablist"
aria-label="Analytics period"
>
{PERIODS.map((p) => {
const active = p === value
return (
<button
key={p}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(p)}
className="px-2 py-1 font-mono text-[10px] uppercase tracking-[0.15em] transition-colors"
style={{
background: active
? 'color-mix(in srgb, var(--theme-accent) 18%, transparent)'
: 'transparent',
color: active ? 'var(--theme-accent)' : 'var(--theme-muted)',
borderRight:
p !== PERIODS[PERIODS.length - 1]
? '1px solid var(--theme-border)'
: 'none',
}}
>
{p}d
</button>
)
})}
</div>
)
}
function Legend({ tone, label }: { tone: string; label: string }) {
return (
<span
className="flex items-center gap-1.5"
style={{ color: 'var(--theme-muted)' }}
>
<span
className="size-2 rounded-full"
style={{ background: tone }}
aria-hidden
/>
{label}
</span>
)
}
function AnalyticsModal({
analytics,
data,
period,
onClose,
}: {
analytics: NonNullable<DashboardOverview['analytics']>
data: Array<ChartDatum>
period: AnalyticsPeriod
onClose: () => void
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 px-4 py-6"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div
className="flex max-h-[88vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border bg-[var(--theme-card)]"
style={{ borderColor: 'var(--theme-border)' }}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between border-b px-5 py-3"
style={{ borderColor: 'var(--theme-border)' }}
>
<div>
<h2
className="text-sm font-semibold uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-text)' }}
>
Usage trend · last {period}d
</h2>
<p
className="font-mono text-[10px] uppercase tracking-[0.1em]"
style={{ color: 'var(--theme-muted)' }}
>
{formatTokens(analytics.totalTokens)} tokens ·{' '}
{analytics.totalSessions.toLocaleString()} sessions ·{' '}
{analytics.totalApiCalls.toLocaleString()} calls ·{' '}
{formatCost(analytics.estimatedCostUsd ?? 0)}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded p-1 hover:bg-[var(--theme-card)]/80"
>
<HugeiconsIcon
icon={CancelIcon}
size={18}
strokeWidth={1.5}
style={{ color: 'var(--theme-muted)' }}
/>
</button>
</div>
<div className="grid flex-1 grid-cols-1 gap-4 overflow-y-auto p-5 lg:grid-cols-12">
<div className="lg:col-span-8">
<h3
className="mb-2 text-[10px] font-semibold uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
Daily token mix
</h3>
<div className="h-[260px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 8, right: 8, left: -10, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="2 4"
stroke="var(--theme-border)"
opacity={0.4}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: 'var(--theme-muted)' }}
axisLine={false}
tickLine={false}
minTickGap={20}
/>
<YAxis
tick={{ fontSize: 10, fill: 'var(--theme-muted)' }}
axisLine={false}
tickLine={false}
width={48}
tickFormatter={(v: number) => formatTokens(v)}
/>
<Tooltip
contentStyle={{
background: 'var(--theme-card)',
border: '1px solid var(--theme-border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(value: number, name: string) => [
formatTokens(value),
name,
]}
/>
<Bar
dataKey="input"
name="input"
stackId="t"
fill="var(--theme-accent)"
radius={[2, 2, 0, 0]}
/>
<Bar
dataKey="output"
name="output"
stackId="t"
fill="var(--theme-success)"
radius={[2, 2, 0, 0]}
/>
<Bar
dataKey="reasoning"
name="reasoning"
stackId="t"
fill="var(--theme-warning)"
radius={[2, 2, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex flex-wrap items-center gap-4 text-[10px]">
<Legend tone="var(--theme-accent)" label="input" />
<Legend tone="var(--theme-success)" label="output" />
<Legend tone="var(--theme-warning)" label="reasoning" />
</div>
</div>
<div className="lg:col-span-4">
<h3
className="mb-2 text-[10px] font-semibold uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
Models · ranked by tokens
</h3>
<div className="space-y-2">
{analytics.topModels.map((m, i) => (
<div
key={m.id}
className="rounded border px-3 py-2"
style={{ borderColor: 'var(--theme-border)' }}
>
<div className="flex items-center justify-between">
<span
className="font-mono text-[12px] font-semibold"
style={{ color: 'var(--theme-text)' }}
>
<span
className="mr-1.5 inline-block w-4 text-right tabular-nums"
style={{ color: 'var(--theme-muted)' }}
>
{i + 1}
</span>
{formatModelName(m.id)}
</span>
<span
className="font-mono text-[10px] tabular-nums"
style={{ color: 'var(--theme-muted)' }}
>
{formatTokens(m.tokens)}
</span>
</div>
<div
className="mt-1 truncate font-mono text-[10px]"
style={{ color: 'var(--theme-muted)' }}
title={m.id}
>
{m.id}
</div>
<div className="mt-1 flex items-center gap-3 text-[10px]">
<span style={{ color: 'var(--theme-muted)' }}>
sessions{' '}
<span style={{ color: 'var(--theme-text)' }}>
{m.sessions.toLocaleString()}
</span>
</span>
<span style={{ color: 'var(--theme-muted)' }}>
calls{' '}
<span style={{ color: 'var(--theme-text)' }}>
{m.calls.toLocaleString()}
</span>
</span>
<span style={{ color: 'var(--theme-muted)' }}>
cost{' '}
<span style={{ color: 'var(--theme-text)' }}>
{formatCost(m.cost)}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { useNavigate } from '@tanstack/react-router'
import { HugeiconsIcon } from '@hugeicons/react'
import {
AlertCircleIcon,
CheckmarkCircle02Icon,
Time04Icon,
Settings02Icon,
ConsoleIcon,
} from '@hugeicons/core-free-icons'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
type AttentionItem = {
id: string
severity: 'warn' | 'error' | 'info'
icon: typeof AlertCircleIcon
label: string
detail: string
href?: { to: string; search?: Record<string, unknown> }
}
function isCronStale(nextRunAt: string | null): boolean {
if (!nextRunAt) return false
const ms = Date.parse(nextRunAt)
if (!Number.isFinite(ms)) return false
return ms - Date.now() < -7 * 86_400_000
}
function buildItems(overview: DashboardOverview | null): Array<AttentionItem> {
if (!overview) return []
const items: Array<AttentionItem> = []
// Stale or paused cron
if (overview.cron) {
const stale = isCronStale(overview.cron.nextRunAt)
if (stale) {
items.push({
id: 'cron-stale',
severity: 'warn',
icon: Time04Icon,
label: `${overview.cron.total} cron job${overview.cron.total === 1 ? '' : 's'} stale`,
detail: 'Last scheduled run is more than 7 days overdue.',
href: { to: '/jobs' },
})
} else if (overview.cron.paused > 0) {
items.push({
id: 'cron-paused',
severity: 'warn',
icon: Time04Icon,
label: `${overview.cron.paused} paused job${overview.cron.paused === 1 ? '' : 's'}`,
detail: 'Resume from /jobs if these should be running.',
href: { to: '/jobs' },
})
}
}
// Log errors / warnings
if (overview.logs && overview.logs.errorCount > 0) {
items.push({
id: 'log-errors',
severity: 'error',
icon: ConsoleIcon,
label: `${overview.logs.errorCount} log error${overview.logs.errorCount === 1 ? '' : 's'} in tail`,
detail: 'Recent agent log shows tracebacks or fatal errors.',
})
} else if (overview.logs && overview.logs.warnCount > 0) {
items.push({
id: 'log-warns',
severity: 'warn',
icon: ConsoleIcon,
label: `${overview.logs.warnCount} log warning${overview.logs.warnCount === 1 ? '' : 's'}`,
detail: 'Recent agent log emitted warnings.',
})
}
// Config drift
if (
overview.status &&
overview.status.configVersion !== null &&
overview.status.latestConfigVersion !== null &&
overview.status.latestConfigVersion > overview.status.configVersion
) {
const diff =
overview.status.latestConfigVersion - overview.status.configVersion
items.push({
id: 'config-drift',
severity: 'warn',
icon: Settings02Icon,
label: `${diff} config diff${diff === 1 ? '' : 's'} pending`,
detail: `Local v${overview.status.configVersion} · latest v${overview.status.latestConfigVersion}`,
href: { to: '/settings', search: {} },
})
}
// Restart pending
if (overview.status?.restartRequested) {
items.push({
id: 'restart-pending',
severity: 'warn',
icon: AlertCircleIcon,
label: 'Restart pending',
detail: 'Hermes flagged restart_requested on /api/status.',
})
}
// Platform errors
for (const p of overview.platforms) {
if (
p.state.toLowerCase() === 'error' ||
p.state.toLowerCase() === 'failed' ||
p.state.toLowerCase() === 'disconnected'
) {
items.push({
id: `platform-${p.name}`,
severity: 'error',
icon: AlertCircleIcon,
label: `${p.name} ${p.state}`,
detail: p.errorMessage || 'Platform reports a non-connected state.',
})
}
}
return items
}
/**
* The "Attention" card — what the operator should look at right now.
*
* Replaces the noisy mix of separate warning chips scattered across the
* dashboard with a single prioritized list. Items are derived from the
* already-aggregated overview payload, so no new endpoints needed.
*
* Renders an "All clear" state when nothing demands attention. That
* keeps the card present in the layout (no reflow) and makes the
* positive signal explicit, which the Hermes Agent review specifically
* called out.
*/
export function AttentionCard({
overview,
}: {
overview: DashboardOverview | null
}) {
const navigate = useNavigate()
const items = buildItems(overview)
const empty = items.length === 0
return (
<div
className="relative flex flex-col gap-2 overflow-hidden rounded-xl border p-3"
style={{
background:
'linear-gradient(150deg, color-mix(in srgb, var(--theme-card) 96%, transparent), color-mix(in srgb, var(--theme-card) 90%, transparent))',
borderColor: 'var(--theme-border)',
}}
>
<div
aria-hidden
className="pointer-events-none absolute -right-10 -top-12 h-32 w-32 rounded-full opacity-15 blur-3xl"
style={{
background: empty ? 'var(--theme-success)' : 'var(--theme-warning)',
}}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={empty ? CheckmarkCircle02Icon : AlertCircleIcon}
size={14}
strokeWidth={1.5}
style={{
color: empty ? 'var(--theme-success)' : 'var(--theme-warning)',
}}
/>
<h3
className="text-[10px] font-semibold uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-text)' }}
>
Attention
</h3>
</div>
<span
className="rounded px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.15em]"
style={{
background: empty
? 'color-mix(in srgb, var(--theme-success) 14%, transparent)'
: 'color-mix(in srgb, var(--theme-warning) 14%, transparent)',
color: empty ? 'var(--theme-success)' : 'var(--theme-warning)',
}}
>
{empty ? 'all clear' : `${items.length}`}
</span>
</div>
{empty ? (
<p
className="py-1 text-[11px]"
style={{ color: 'var(--theme-muted)' }}
>
Nothing to triage. Gateway healthy, no stale jobs, logs quiet.
</p>
) : (
<ul className="flex flex-col gap-1">
{items.map((item) => {
const tone =
item.severity === 'error'
? 'var(--theme-danger)'
: item.severity === 'warn'
? 'var(--theme-warning)'
: 'var(--theme-muted)'
const content = (
<div className="flex items-start gap-2">
<HugeiconsIcon
icon={item.icon}
size={12}
strokeWidth={1.5}
style={{ color: tone, marginTop: 2 }}
/>
<div className="min-w-0 flex-1">
<div
className="truncate text-[11px] font-semibold"
style={{ color: 'var(--theme-text)' }}
>
{item.label}
</div>
<div
className="truncate text-[10px]"
style={{ color: 'var(--theme-muted)' }}
title={item.detail}
>
{item.detail}
</div>
</div>
</div>
)
if (item.href) {
return (
<li key={item.id}>
<button
type="button"
onClick={() => navigate(item.href as never)}
className="w-full rounded border px-2 py-1.5 text-left transition-colors hover:bg-[var(--theme-card)]/80"
style={{ borderColor: 'var(--theme-border)' }}
>
{content}
</button>
</li>
)
}
return (
<li
key={item.id}
className="rounded border px-2 py-1.5"
style={{ borderColor: 'var(--theme-border)' }}
>
{content}
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -1,3 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { CancelIcon } from '@hugeicons/core-free-icons'
import { formatModelName } from '@/screens/dashboard/lib/formatters'
import type { DashboardOverview } from '@/server/dashboard-aggregator'
@@ -13,39 +16,40 @@ function readBoolCap(
key: string,
): boolean {
if (!caps) return false
const v = caps[key]
return v === true
return caps[key] === true
}
type Palette = {
accent: string
success: string
danger: string
border: string
card: string
text: string
muted: string
}
/**
* 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.
* Active model card. Reads `/api/model/info` via the overview
* aggregator (so it matches whatever the gateway is actually using
* right now, not config defaults). The "operational line" surfaces a
* one-glance routing summary — share of API calls in the analytics
* window — instead of leaving the card half-empty. Click "Inventory"
* to open a modal listing every model the gateway exposes via
* `/api/models`.
*/
export function ModelInfoCard({
modelInfo,
analytics,
palette,
}: {
modelInfo: DashboardOverview['modelInfo']
palette: {
accent: string
success: string
danger: string
border: string
card: string
text: string
muted: string
}
analytics: DashboardOverview['analytics']
palette: Palette
}) {
const [showInventory, setShowInventory] = useState(false)
const connected = !!modelInfo
const display = modelInfo
? formatModelName(modelInfo.model)
: '—'
const display = modelInfo ? formatModelName(modelInfo.model) : '—'
const provider = modelInfo?.provider ?? '—'
const contextLength = modelInfo?.effectiveContextLength ?? 0
const caps = modelInfo?.capabilities ?? null
@@ -57,94 +61,149 @@ export function ModelInfoCard({
? (caps['model_family'] as string)
: null
// Operational line: share of API calls served by this model in the
// active analytics window. If no analytics, fall back to capability
// summary so the card never looks half-empty.
const opsLine = useMemo(() => {
if (modelInfo && analytics && analytics.totalApiCalls > 0) {
const match = analytics.topModels.find(
(m) => m.id === modelInfo.model,
)
if (match) {
const pct = Math.round((match.calls / analytics.totalApiCalls) * 100)
return `${pct}% of calls · ${match.sessions.toLocaleString()} sessions · ${analytics.windowDays}d`
}
}
const flags: Array<string> = []
if (supportsTools) flags.push('tools')
if (supportsReasoning) flags.push('reasoning')
if (supportsVision) flags.push('vision')
return flags.length > 0
? `default routing · ${flags.join(' + ')}`
: 'default routing target'
}, [analytics, modelInfo, supportsReasoning, supportsTools, supportsVision])
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]"
className="relative flex h-full flex-col overflow-hidden rounded-xl border"
style={{
background: connected
? `linear-gradient(90deg, ${palette.success}, ${palette.success}50, transparent)`
: `linear-gradient(90deg, ${palette.danger}, ${palette.danger}50, transparent)`,
background: 'var(--theme-card)',
borderColor: 'var(--theme-border)',
}}
/>
<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"
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[2px]"
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,
? `linear-gradient(90deg, ${palette.success}, ${palette.success}50, transparent)`
: `linear-gradient(90deg, ${palette.danger}, ${palette.danger}50, transparent)`,
}}
>
<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]"
/>
<div className="flex items-center justify-between px-4 pt-3">
<h3
className="text-[10px] font-semibold uppercase tracking-[0.18em]"
style={{ color: palette.muted }}
>
{provider}
{modelInfo ? ` · ${modelInfo.model}` : ''}
</div>
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 px-4 pb-3 pt-2">
<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 }}
title={modelInfo?.model}
>
{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 ? (
<div
className="font-mono text-[10px] uppercase tracking-[0.1em]"
style={{ color: palette.muted }}
>
{opsLine}
</div>
<div className="flex flex-wrap items-center gap-1">
<CapabilityChip
label="vision"
value="✓"
tone={palette.success}
label="ctx"
value={formatContext(contextLength)}
tone={palette.accent}
/>
) : null}
{supportsReasoning ? (
<CapabilityChip
label="reason"
value="✓"
tone={palette.success}
/>
) : null}
{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>
<button
type="button"
onClick={() => setShowInventory(true)}
className="mt-1 self-start rounded border px-2 py-0.5 font-mono text-[9px] uppercase tracking-[0.15em] transition-colors hover:bg-[var(--theme-card)]/80"
style={{
borderColor: 'var(--theme-border)',
color: palette.muted,
}}
>
Inventory
</button>
</div>
</div>
</div>
{showInventory ? (
<ModelInventoryModal
activeModel={modelInfo?.model ?? null}
onClose={() => setShowInventory(false)}
/>
) : null}
</>
)
}
@@ -170,3 +229,218 @@ function CapabilityChip({
</span>
)
}
type InventoryModel = {
id: string
name: string
provider: string
}
function ModelInventoryModal({
activeModel,
onClose,
}: {
activeModel: string | null
onClose: () => void
}) {
const [models, setModels] = useState<Array<InventoryModel>>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/models')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const list = (data?.data ?? data?.models ?? []) as Array<
Record<string, unknown>
>
if (cancelled) return
setModels(
list
.map((m) => ({
id: String(m.id ?? ''),
name: String(m.name ?? m.id ?? ''),
provider: String(m.provider ?? ''),
}))
.filter((m) => m.id),
)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'failed to load')
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const grouped = useMemo(() => {
const map = new Map<string, Array<InventoryModel>>()
for (const m of models) {
if (
filter &&
!m.id.toLowerCase().includes(filter.toLowerCase()) &&
!m.name.toLowerCase().includes(filter.toLowerCase()) &&
!m.provider.toLowerCase().includes(filter.toLowerCase())
) {
continue
}
const list = map.get(m.provider) ?? []
list.push(m)
map.set(m.provider, list)
}
return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]))
}, [filter, models])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 px-4 py-6"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div
className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border bg-[var(--theme-card)]"
style={{ borderColor: 'var(--theme-border)' }}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between gap-3 border-b px-4 py-3"
style={{ borderColor: 'var(--theme-border)' }}
>
<div>
<h2
className="text-sm font-semibold uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-text)' }}
>
Model inventory
</h2>
<p
className="font-mono text-[10px] uppercase tracking-[0.1em]"
style={{ color: 'var(--theme-muted)' }}
>
{models.length} models from {grouped.length || '—'} providers
</p>
</div>
<div className="flex items-center gap-2">
<input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="filter…"
className="rounded border bg-transparent px-2 py-1 font-mono text-[11px]"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-text)',
}}
/>
<button
type="button"
onClick={onClose}
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>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div
className="py-8 text-center text-[11px]"
style={{ color: 'var(--theme-muted)' }}
>
Loading models
</div>
) : error ? (
<div
className="py-8 text-center text-[11px]"
style={{ color: 'var(--theme-danger)' }}
>
{error}
</div>
) : grouped.length === 0 ? (
<div
className="py-8 text-center text-[11px]"
style={{ color: 'var(--theme-muted)' }}
>
No matching models.
</div>
) : (
grouped.map(([provider, list]) => (
<div key={provider} className="mb-4">
<h3
className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-muted)' }}
>
{provider} · {list.length}
</h3>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{list.map((m) => {
const active = m.id === activeModel
return (
<li
key={m.id}
className="rounded border px-2 py-1.5"
style={{
borderColor: active
? 'color-mix(in srgb, var(--theme-success) 50%, transparent)'
: 'var(--theme-border)',
background: active
? 'color-mix(in srgb, var(--theme-success) 8%, transparent)'
: 'transparent',
}}
>
<div className="flex items-center justify-between gap-2">
<span
className="truncate font-mono text-[11px] font-semibold"
style={{ color: 'var(--theme-text)' }}
title={m.id}
>
{m.name}
</span>
{active ? (
<span
className="rounded px-1 py-0.5 font-mono text-[8px] uppercase tracking-[0.15em]"
style={{
background:
'color-mix(in srgb, var(--theme-success) 18%, transparent)',
color: 'var(--theme-success)',
}}
>
active
</span>
) : null}
</div>
<div
className="mt-0.5 truncate font-mono text-[9px]"
style={{ color: 'var(--theme-muted)' }}
title={m.id}
>
{m.id}
</div>
</li>
)
})}
</ul>
</div>
))
)}
</div>
</div>
</div>
)
}

View File

@@ -133,7 +133,8 @@ export function OpsStrip({
className="font-mono uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
· {status.activeAgents} active
· {status.activeAgents} active{' '}
{status.activeAgents === 1 ? 'run' : 'runs'}
</span>
{status.restartRequested ? (
<span
@@ -163,7 +164,7 @@ export function OpsStrip({
}}
title={`Local config v${status.configVersion} · latest v${status.latestConfigVersion}`}
>
config +{drift}
{drift} config diff{drift === 1 ? '' : 's'}
</button>
) : null}
</div>
@@ -195,34 +196,47 @@ export function OpsStrip({
</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}
{cron ? (() => {
const isStale = next?.text === 'stale'
const isWarn = next?.text === 'overdue' || isStale
return (
<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: isWarn
? 'color-mix(in srgb, var(--theme-warning) 35%, transparent)'
: 'var(--theme-border)',
background: isWarn
? 'color-mix(in srgb, var(--theme-warning) 10%, transparent)'
: 'transparent',
color: 'var(--theme-muted)',
}}
title={
isStale
? 'Cron next-run is more than 7 days overdue'
: '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} paused
</span>
) : null}
{cron.running > 0 ? (
<span style={{ color: 'var(--theme-success)' }}>
· {cron.running} running
</span>
) : null}
{next ? (
<span style={{ color: next.tone }}>· {next.text}</span>
) : null}
</button>
)
})() : null}
</div>
</div>
)

View File

@@ -0,0 +1,133 @@
import { HugeiconsIcon } from '@hugeicons/react'
import { ChartBarLineIcon } 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 || n <= 0) return '0'
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`
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): string {
if (!usd || usd <= 0) return '$0'
if (usd < 0.01) return '<$0.01'
if (usd < 1) return `$${usd.toFixed(3)}`
if (usd < 100) return `$${usd.toFixed(2)}`
return `$${Math.round(usd).toLocaleString()}`
}
/**
* Standalone top-models card. Previously this was the right column
* inside the analytics hero card and felt cramped. Hoisting it out
* gives each model row enough room to show its share of API calls
* (proxy for routing share) plus tokens, and lets the chart breathe.
*/
export function TopModelsCard({
analytics,
}: {
analytics: DashboardOverview['analytics']
}) {
if (!analytics || analytics.topModels.length === 0) return null
const totalCalls = analytics.totalApiCalls || 0
const maxTokens = analytics.topModels[0]?.tokens || 1
return (
<div
className="relative flex flex-col gap-2 overflow-hidden rounded-xl border p-3"
style={{
background:
'linear-gradient(150deg, color-mix(in srgb, var(--theme-card) 96%, transparent), color-mix(in srgb, var(--theme-card) 92%, transparent))',
borderColor: 'var(--theme-border)',
}}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={ChartBarLineIcon}
size={14}
strokeWidth={1.5}
style={{ color: 'var(--theme-accent-secondary)' }}
/>
<h3
className="text-[10px] font-semibold uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-text)' }}
>
Top models · {analytics.windowDays}d
</h3>
</div>
<span
className="font-mono text-[9px] uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
{analytics.topModels.length} ranked
</span>
</div>
<ul className="flex flex-col gap-1.5">
{analytics.topModels.map((m, i) => {
const widthPct = Math.max(2, Math.round((m.tokens / maxTokens) * 100))
const sharePct =
totalCalls > 0 ? Math.round((m.calls / totalCalls) * 100) : 0
const tone =
i === 0
? 'var(--theme-accent)'
: i === 1
? 'var(--theme-accent-secondary)'
: 'var(--theme-muted)'
return (
<li key={m.id}>
<div className="flex items-center justify-between gap-2 text-[11px]">
<span
className="flex min-w-0 items-center gap-1.5 truncate font-mono"
style={{ color: 'var(--theme-text)' }}
title={m.id}
>
<span
className="inline-block w-3 text-right tabular-nums"
style={{ color: 'var(--theme-muted)' }}
>
{i + 1}
</span>
{formatModelName(m.id)}
</span>
<span
className="font-mono text-[10px] tabular-nums"
style={{ color: 'var(--theme-muted)' }}
>
{formatTokens(m.tokens)}
</span>
</div>
<div
className="mt-0.5 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: tone,
}}
/>
</div>
<div
className="mt-0.5 flex items-center justify-between gap-2 font-mono text-[9px] uppercase tracking-[0.1em]"
style={{ color: 'var(--theme-muted)' }}
>
<span>
{sharePct}% of calls · {m.sessions.toLocaleString()} sessions
</span>
<span>{formatCost(m.cost)}</span>
</div>
</li>
)
})}
</ul>
</div>
)
}

View File

@@ -6,8 +6,13 @@ import { OpsStrip } from './components/ops-strip'
import { ModelInfoCard } from './components/model-info-card'
import { AchievementsCard } from './components/achievements-card'
import { HeroMetrics } from './components/hero-metrics'
import { AnalyticsHeroCard } from './components/analytics-hero-card'
import {
AnalyticsChartCard,
type AnalyticsPeriod,
} from './components/analytics-chart-card'
import { TopModelsCard } from './components/top-models-card'
import { LogsTailCard } from './components/logs-tail-card'
import { AttentionCard } from './components/attention-card'
import {
Area,
AreaChart,
@@ -349,14 +354,18 @@ function ActivityChart({
// ── Skills Widget ────────────────────────────────────────────────
function SkillsWidget({ palette }: { palette: ReturnType<typeof readDashboardPalette> }) {
function SkillsWidget({
palette,
onOpen,
}: {
palette: ReturnType<typeof readDashboardPalette>
onOpen: () => void
}) {
const skillsAvailable = useFeatureAvailable('skills')
const skillsQuery = useQuery({
queryKey: ['claude-skills'],
queryFn: async () => {
const res = await fetch(
'/api/skills?tab=installed&limit=8&summary=search',
)
const res = await fetch('/api/skills?tab=installed&limit=200&summary=search')
if (!res.ok) return []
const data = await res.json()
return (data?.skills ?? []) as Array<Record<string, unknown>>
@@ -376,39 +385,88 @@ function SkillsWidget({ palette }: { palette: ReturnType<typeof readDashboardPal
)
}
// Summary view per Hermes Agent feedback: 'dont enumerate, summarise.'
const installed = skills.length
const enabled = skills.filter((s) => s.enabled !== false).length
const top = skills.find((s) => s.enabled !== false) ?? skills[0]
const topName = top ? String(top.name ?? '—') : '—'
return (
<GlassCard
title="Skills"
titleRight={
<span className="text-[10px] text-muted">
{skills.length} installed
</span>
}
accentColor={palette.warning}
<button
type="button"
onClick={onOpen}
className="group relative flex w-full flex-col gap-1.5 overflow-hidden rounded-xl border px-4 py-3 text-left transition-colors hover:bg-[var(--theme-card)]/80"
style={{
background: 'var(--theme-card)',
borderColor: 'var(--theme-border)',
}}
>
{skills.length === 0 ? (
<div className="text-xs text-neutral-400 py-4 text-center">
No skills installed
</div>
) : (
<div className="space-y-1.5">
{skills.slice(0, 6).map((skill, i) => (
<div
key={String(skill.name ?? i)}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 hover:bg-[var(--theme-card2)] transition-colors"
>
<span className="text-xs">📦</span>
<span className="text-xs font-medium text-ink truncate flex-1">
{String(skill.name ?? 'Unnamed')}
</span>
{skill.enabled !== false && (
<span className="size-1.5 rounded-full bg-emerald-500/60" />
)}
</div>
))}
</div>
)}
</GlassCard>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[2px]"
style={{
background: `linear-gradient(90deg, ${palette.warning}, ${palette.warning}50, transparent)`,
}}
/>
<div className="flex items-center justify-between">
<h3
className="text-[10px] font-semibold uppercase tracking-[0.18em]"
style={{ color: 'var(--theme-muted)' }}
>
Skills
</h3>
<span
className="font-mono text-[9px] uppercase tracking-[0.15em]"
style={{ color: 'var(--theme-muted)' }}
>
manage
</span>
</div>
<div
className="font-mono text-2xl font-bold tabular-nums leading-none"
style={{ color: 'var(--theme-text)' }}
>
{installed}
</div>
<div
className="font-mono text-[10px] uppercase tracking-[0.1em]"
style={{ color: 'var(--theme-muted)' }}
>
{installed === 0
? 'no skills installed'
: `${enabled} enabled · top: ${topName}`}
</div>
</button>
)
}
// ── Secondary action (smaller, monochrome) ─────────────────────
function SecondaryAction({
label,
icon,
onClick,
disabled,
}: {
label: string
icon: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="flex items-center gap-1.5 rounded-lg border px-2.5 py-2 text-xs font-medium transition-colors hover:bg-[var(--theme-card)]/80 disabled:cursor-not-allowed disabled:opacity-50"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
}}
>
<span aria-hidden>{icon}</span>
<span>{label}</span>
</button>
)
}
@@ -600,14 +658,34 @@ export function DashboardScreen() {
return max
}, [recentSessions])
// Period selector for analytics; persists across navigation via
// localStorage so refreshes don't reset the operator's preference.
const [period, setPeriod] = useState<AnalyticsPeriod>(() => {
if (typeof window === 'undefined') return 30
const stored = window.localStorage.getItem('dashboard.analyticsPeriod')
const n = Number(stored)
if (n === 7 || n === 14 || n === 30) return n
return 30
})
useEffect(() => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(
'dashboard.analyticsPeriod',
String(period),
)
}
}, [period])
// 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'],
queryKey: ['dashboard', 'overview', period],
queryFn: async () => {
const res = await fetch('/api/dashboard/overview')
const res = await fetch(
`/api/dashboard/overview?days=${period}`,
)
if (!res.ok) throw new Error(`overview ${res.status}`)
return (await res.json()) as DashboardOverview
},
@@ -692,38 +770,52 @@ export function DashboardScreen() {
</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="💬"
accentColor={palette.accent}
{/* Action row: hierarchy per Hermes Agent review.
New Chat is primary (full button + accent), Terminal +
Skills are secondary, Settings collapses to icon-only. */}
<div className="flex w-full flex-wrap items-center justify-end gap-2 lg:max-w-xl">
<button
type="button"
onClick={() =>
navigate({
to: '/chat/$sessionKey',
params: { sessionKey: 'new' },
})
}
/>
<QuickAction
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.02] active:scale-[0.99]"
style={{
background: `linear-gradient(135deg, ${palette.accent}22, ${palette.accentSecondary}22)`,
borderColor: 'color-mix(in srgb, var(--theme-accent) 40%, transparent)',
color: 'var(--theme-text)',
}}
>
<span aria-hidden>💬</span>
<span>New Chat</span>
</button>
<SecondaryAction
label="Terminal"
icon="💻"
accentColor={palette.success}
onClick={() => navigate({ to: '/terminal' })}
/>
<QuickAction
<SecondaryAction
label="Skills"
icon="🧩"
accentColor={palette.warning}
onClick={() => navigate({ to: '/skills' })}
disabled={!skillsAvailable}
badge={!skillsAvailable ? 'Enhanced' : undefined}
/>
<QuickAction
label="Settings"
icon="⚙️"
accentColor={palette.accentSecondary}
<button
type="button"
aria-label="Settings"
title="Settings"
onClick={() => navigate({ to: '/settings', search: {} })}
/>
className="flex size-9 items-center justify-center rounded-lg border transition-colors hover:bg-[var(--theme-card)]/80"
style={{
borderColor: 'var(--theme-border)',
color: 'var(--theme-muted)',
}}
>
</button>
</div>
</div>
@@ -745,8 +837,22 @@ export function DashboardScreen() {
}}
/>
{/* ── Analytics hero (daily mix + top models, modal expand) ── */}
<AnalyticsHeroCard analytics={overview?.analytics ?? null} />
{/* ── Analytics chart (left) + Top models card (right) ── */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<div className="lg:col-span-8">
<AnalyticsChartCard
analytics={overview?.analytics ?? null}
cron={overview?.cron ?? null}
status={overview?.status ?? null}
period={period}
onPeriodChange={setPeriod}
loading={overviewQuery.isFetching}
/>
</div>
<div className="lg:col-span-4">
<TopModelsCard analytics={overview?.analytics ?? null} />
</div>
</div>
{/* ── Primary content: Activity chart + side rail ── */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
@@ -762,11 +868,16 @@ export function DashboardScreen() {
<LogsTailCard logs={overview?.logs ?? null} />
</div>
<div className="flex flex-col gap-3 lg:col-span-4">
<AttentionCard overview={overview} />
<ModelInfoCard
modelInfo={overview?.modelInfo ?? null}
analytics={overview?.analytics ?? null}
palette={palette}
/>
<SkillsWidget palette={palette} />
<SkillsWidget
palette={palette}
onOpen={() => navigate({ to: '/skills' })}
/>
<AchievementsCard achievements={overview?.achievements ?? null} />
</div>
</div>

View File

@@ -0,0 +1,107 @@
/**
* Lightweight client-side insights derived from the analytics payload.
*
* The native dashboard doesn't (yet) emit precomputed callouts, so we
* compute them here from `analytics.daily` + `analytics.topModels`. The
* goal is "command summary, not chart wall" — three short sentences
* the operator can read in two seconds before scanning the chart.
*
* We deliberately keep these defensive — if there isn't enough data we
* return fewer lines instead of producing nonsense.
*/
import type { DashboardOverview } from '@/server/dashboard-aggregator'
import { formatModelName } from '@/screens/dashboard/lib/formatters'
export type Insight = {
text: string
tone: 'info' | 'positive' | 'warn'
}
function formatTokens(n: number): string {
if (!n || n <= 0) return '0'
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`
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 shortDate(day: string): string {
const ts = Date.parse(day)
if (!Number.isFinite(ts)) return day
return new Date(ts).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}
export function buildInsights(
analytics: DashboardOverview['analytics'],
cron: DashboardOverview['cron'],
status: DashboardOverview['status'],
): Array<Insight> {
const out: Array<Insight> = []
if (!analytics || analytics.source !== 'analytics') return out
// 1. Peak day
const daily = analytics.daily
if (daily.length >= 3) {
let peakIdx = 0
let peakVal = 0
for (let i = 0; i < daily.length; i += 1) {
const total = daily[i].inputTokens + daily[i].outputTokens
if (total > peakVal) {
peakVal = total
peakIdx = i
}
}
if (peakVal > 0) {
const top = analytics.topModels[0]
const driver = top ? `, driven by ${formatModelName(top.id)}` : ''
out.push({
tone: 'info',
text: `Usage peaked ${shortDate(daily[peakIdx].day)} (${formatTokens(peakVal)} tokens)${driver}.`,
})
}
}
// 2. Cache vs prior period (if window is at least 14 days)
if (daily.length >= 14) {
const mid = Math.floor(daily.length / 2)
let priorCache = 0
let recentCache = 0
for (let i = 0; i < mid; i += 1) priorCache += daily[i].cacheReadTokens
for (let i = mid; i < daily.length; i += 1) recentCache += daily[i].cacheReadTokens
if (priorCache > 0) {
const delta = ((recentCache - priorCache) / priorCache) * 100
if (Math.abs(delta) >= 5) {
out.push({
tone: delta > 0 ? 'positive' : 'warn',
text: `Cache reads ${delta > 0 ? 'up' : 'down'} ${Math.abs(delta).toFixed(0)}% vs prior period.`,
})
}
}
}
// 3. Operational signal: stale cron + active runs + restart pending
const ops: Array<string> = []
if (cron && cron.nextRunAt) {
const nextMs = Date.parse(cron.nextRunAt)
if (Number.isFinite(nextMs) && nextMs - Date.now() < -7 * 86_400_000) {
ops.push(
`${cron.total} stale cron job${cron.total === 1 ? '' : 's'}`,
)
}
}
if (status && status.gatewayState === 'running' && status.activeAgents === 0) {
ops.push('no active runs')
}
if (status?.restartRequested) ops.push('restart pending')
if (ops.length > 0) {
out.push({
tone: ops.length >= 2 ? 'warn' : 'info',
text: ops.join(' · ') + '.',
})
}
return out
}