@@ -172,12 +201,49 @@ const chartVars = `
.dark .analytics-viz { --series-a: #3987e5; --series-b: #199e70; }
`
+// One filter matches if the query (trimmed, lower-cased) appears anywhere in the
+// joined, lower-cased haystack. Empty query short-circuits to "match all" so
+// the underlying array stays untouched. Case-insensitive substring (not fuzzy,
+// not regex) — same shape FallbackPage uses (#343), so power users get one
+// mental model across the dashboard.
+type MatchesFn = (row: T, q: string) => boolean
+function makeMatches(getHaystack: (row: T) => string): MatchesFn {
+ return (row, q) => {
+ if (!q) return true
+ return getHaystack(row).toLowerCase().includes(q)
+ }
+}
+
export default function AnalyticsPage() {
const { t } = useI18n()
const [range, setRange] = useState('7d')
// Capture "now" once at mount so the savings extrapolation below stays a pure
// render (calling Date.now() during render is impure and non-deterministic).
const [now] = useState(() => Date.now())
+ // Page-wide filter. Filtered client-side over already-fetched rows so
+ // keystrokes don't re-hit the API (the useQuery keys stay range-only).
+ // NOT persisted: the search box resets to empty on every page mount, same
+ // shape as FallbackPage and ProviderList. The user is more often
+ // investigating a one-off pattern than narrowing repeatedly across visits,
+ // and persistence makes the page land in a half-filtered state from a
+ // deep link with no context.
+ //
+ // The `scope` chip selects which tables the search applies to. Default
+ // 'all' preserves the prior behavior; the other values narrow the
+ // filter to a single table so typing in the box doesn't shrink panels
+ // the user isn't looking at. Tables outside the active scope render
+ // unfiltered.
+ //
+ // The query is trimmed + lowered once at the top of the filter
+ // pipeline — passing it through unchanged means each `matches` impl
+ // stays pure, but the trim+lower happens in every render where the
+ // user typed. Cheap (4 memoized filters, total dataset < few hundred
+ // rows).
+ type SearchScope = 'all' | 'models' | 'calls' | 'errors' | 'keys'
+ const [search, setSearch] = useState('')
+ const [scope, setScope] = useState('all')
+ const trimmedQuery = search.trim().toLowerCase()
+ const hasQuery = trimmedQuery.length > 0
const { data: summary, isLoading: summaryLoading } = useQuery({
queryKey: ['analytics', 'summary', range],
@@ -230,6 +296,87 @@ export default function AnalyticsPage() {
queryKey: ['analytics', 'summary', '30d'],
queryFn: () => apiFetch(`/api/analytics/summary?range=30d`),
})
+ // ----- Page-wide filter ----------------------------------------------------
+ // One matches() builder per table, joined so the haystack is computed once
+ // per row in the filter pass (instead of three times via three separate
+ // predicates). For rows whose every field is a string primitive this is just
+ // template-literal concatenation. The four tables each carry slightly
+ // different searchable fields, so the haystack text is bespoke per row
+ // type. Charts are deliberately NOT filtered — they aggregate over the
+ // selected window and filtering them would misrepresent totals; the
+ // summary stat cards and time-series charts already stay unfiltered.
+ const matchesByModel = useMemo(
+ () => makeMatches((r) => `${r.displayName} ${r.platform} ${r.modelId}`),
+ []
+ )
+ const matchesByKey = useMemo(
+ () => makeMatches((r) => `${r.label ?? ''} ${r.platform ?? ''} #${r.keyId}`),
+ []
+ )
+ const matchesRecentCall = useMemo(
+ () => makeMatches((r) =>
+ `${r.clientIp ?? ''} ${r.clientUserAgent ?? ''} ${r.modelId} ${r.platform} ` +
+ `${r.requestedModel ?? ''} ${r.status} ${r.error ?? ''} ${r.requestType}`
+ ),
+ []
+ )
+ const matchesRecentError = useMemo(
+ () => makeMatches((r) => `${r.platform} ${r.modelId} ${r.error}`),
+ []
+ )
+ // Filtered versions of every list-driven surface. Each uses .filter + the
+ // `matches` helper so an empty query returns the array unmodified (no copy
+ // when there is no filter — saves an allocation per render in the common
+ // case). useMemo deps include trimmedQuery so a keystroke recomputes only
+ // what's affected; the dependencies on the source arrays keep the filter
+ // in sync with re-fetches when range changes.
+ // Per-table filtered arrays. The filter only applies when:
+ // 1. there's a non-empty query, AND
+ // 2. the active scope includes the table.
+ // A table outside the active scope renders unfiltered even with text in the
+ // box — that's the whole point of the scope chip. Empty query short-circuits
+ // to the source array (no allocation in the common no-filter case). The
+ // `highlight*` flags drive the panel ring + match-count display: a panel
+ // highlights only when it's in scope AND the filter produced ≥1 match.
+ // Zero matches in a panel = no highlight, no count, full table (the panel
+ // is a no-op for this query and renders as if there were no filter).
+ const inScope = (kind: 'models' | 'calls' | 'errors' | 'keys') =>
+ scope === 'all' || scope === kind
+ const visibleByModel = useMemo(
+ () => hasQuery && inScope('models')
+ ? byModel.filter((r) => matchesByModel(r, trimmedQuery))
+ : byModel,
+ [byModel, matchesByModel, trimmedQuery, hasQuery, scope]
+ )
+ const visibleErrors = useMemo(
+ () => hasQuery && inScope('errors')
+ ? errors.filter((r) => matchesRecentError(r, trimmedQuery))
+ : errors,
+ [errors, matchesRecentError, trimmedQuery, hasQuery, scope]
+ )
+ const visibleByKey = useMemo(
+ () => hasQuery && inScope('keys')
+ ? byKey.filter((r) => matchesByKey(r, trimmedQuery))
+ : byKey,
+ [byKey, matchesByKey, trimmedQuery, hasQuery, scope]
+ )
+ const visibleRecentCalls = useMemo(() => {
+ const rows = recentCalls?.rows
+ if (!rows) return rows
+ return hasQuery && inScope('calls')
+ ? rows.filter((r) => matchesRecentCall(r, trimmedQuery))
+ : rows
+ }, [recentCalls?.rows, matchesRecentCall, trimmedQuery, hasQuery, scope])
+ // Single source of truth for "is this panel's filter active right now?" —
+ // any of the four tables highlighting pulls the search-row container
+ // into the same highlighted state, so the input area and the filtered
+ // table read as one visual unit.
+ const highlightByModel = hasQuery && inScope('models') && byModel.length > 0 && visibleByModel.length > 0
+ const highlightErrors = hasQuery && inScope('errors') && errors.length > 0 && visibleErrors.length > 0
+ const highlightByKey = hasQuery && inScope('keys') && byKey.length > 0 && visibleByKey.length > 0
+ const highlightByCalls = hasQuery && inScope('calls') && (recentCalls?.rows?.length ?? 0) > 0 && (visibleRecentCalls?.length ?? 0) > 0
+ const anyPanelHighlighted = highlightByModel || highlightErrors || highlightByKey || highlightByCalls
+
const actualSavings = summary?.estimatedCostSavings ?? 0
const baseSavings = summary30?.estimatedCostSavings ?? 0
const spanDays = (() => {
@@ -284,15 +431,85 @@ export default function AnalyticsPage() {
title={t('analytics.title')}
description={t('analytics.description')}
actions={
- ({
- value: r,
- label: t(r === '24h' ? 'analytics.range24h' : r === '7d' ? 'analytics.range7d' : r === '30d' ? 'analytics.range30d' : 'analytics.range90d'),
- }))}
- ariaLabel={t('analytics.title')}
- />
+
+ {/* Search row: input + scope chips, all sharing one rounded border.
+ Mirrors the FallbackPage toolbar shape (#343). When any panel
+ below is actively filtering (highlighted), the same
+ primary-tinted ring animates around this whole container so
+ the input area reads as part of the same visual unit. Same
+ shadow + transition as the table panels, so the eye groups
+ them as one. `w-56` covers ~25 chars of model/IP query. */}
+
+ {/* Scope chip strip. `role="group"` + `aria-label` so screen
+ readers announce "Filter scope" and treat the chips as a
+ single radiogroup. Each chip is a real
@@ -462,7 +685,7 @@ export default function AnalyticsPage() {
- {errors.slice(0, 20).map((e) => (
+ {visibleErrors.slice(0, 20).map((e) => (
{e.platform}{e.error}
@@ -481,9 +704,15 @@ export default function AnalyticsPage() {
user agent. All local clients share the unified key, so this is
the only view that answers "who is hitting the router". */}