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:
594
src/screens/dashboard/components/analytics-chart-card.tsx
Normal file
594
src/screens/dashboard/components/analytics-chart-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
261
src/screens/dashboard/components/attention-card.tsx
Normal file
261
src/screens/dashboard/components/attention-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
133
src/screens/dashboard/components/top-models-card.tsx
Normal file
133
src/screens/dashboard/components/top-models-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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: 'don’t 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>
|
||||
|
||||
107
src/screens/dashboard/lib/insights.ts
Normal file
107
src/screens/dashboard/lib/insights.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user