From 468f033776ad3a153a6f37fbf6182b9661c696ba Mon Sep 17 00:00:00 2001 From: jasnoorgill Date: Fri, 10 Jul 2026 10:05:48 +0100 Subject: [PATCH 1/2] feat(client): analytics search has scope chips and shared highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analytics-page search box used to filter every list-driven table at once. That made the page land in a half-filtered state from a deep link, and stacked empty states across four tables when the query had zero matches. Three changes: 1. Search input + scope chip strip share one rounded container. Five chips (All / Models / Calls / Errors / Keys, default All) drive which tables the active query applies to. Tables outside the active scope render unfiltered; the user is never surprised by a panel shrinking when they typed something. 2. The container and the four list panels share the same highlight ring + shadow + 200ms transition, so the input area and the filtered table read as one visual unit. A panel highlights only when its scope is active AND the filter produced ≥1 match; the highlight drives both the ring around the card and a small " of " count in the panel header. 3. The Panel wrapper grows an optional highlighted + countLabel prop, so charts and other non-list surfaces keep their old shape unchanged. The four list panels (per-model, recent-calls, recent- errors, usage-by-key) wire the new props + filtered array; the other eight panels ignore them. Filter applies client-side over already-fetched rows so keystrokes don't re-hit the API. Search is not persisted (resets to empty on mount) — same shape as FallbackPage and ProviderList, and avoids landing in a half-filtered state from a deep link. Charts, summary stat cards, and time-series are deliberately NOT filtered: they aggregate over the selected window and filtering them would misrepresent totals. The user's search is for inspecting specific rows, not the aggregate. i18n: nine new keys (searchAriaLabel, searchScopeAriaLabel, searchScopeAll/Models/Calls/Errors/Keys, matchedCount, noMatches) across all six locales (en, es, fr, it, pt-BR, zh-CN). This is the slim scope from the integration branch: 7 files, +353/-56. The per-model/byKey/recent-calls sortable column headers stay on the integration branch for a separate PR if desired. --- client/src/i18n/locales/en.json | 11 +- client/src/i18n/locales/es.json | 11 +- client/src/i18n/locales/fr.json | 11 +- client/src/i18n/locales/it.json | 11 +- client/src/i18n/locales/pt-BR.json | 11 +- client/src/i18n/locales/zh-CN.json | 11 +- client/src/pages/AnalyticsPage.tsx | 343 ++++++++++++++++++++++++----- 7 files changed, 353 insertions(+), 56 deletions(-) diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 6b72b3616..303a2d7fa 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -405,7 +405,16 @@ "recentCalls": "Recent calls", "clientIp": "Client IP", "clientAgent": "Client app", - "requestedModelHint": "Requested {model}, served by fallback" + "requestedModelHint": "Requested {model}, served by fallback", + "searchAriaLabel": "Filter tables", + "searchScopeAriaLabel": "Filter scope", + "searchScopeAll": "All", + "searchScopeModels": "Models", + "searchScopeCalls": "Calls", + "searchScopeErrors": "Errors", + "searchScopeKeys": "Keys", + "matchedCount": "{shown} of {total}", + "noMatches": "No matches" }, "playground": { "title": "Playground", diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index 5b0e94fe1..371074ceb 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -388,7 +388,16 @@ "recentCalls": "Llamadas recientes", "clientIp": "IP del cliente", "clientAgent": "Aplicación cliente", - "requestedModelHint": "Se solicitó {model}, servido por respaldo" + "requestedModelHint": "Se solicitó {model}, servido por respaldo", + "searchAriaLabel": "Filtrar tablas", + "searchScopeAriaLabel": "Alcance del filtro", + "searchScopeAll": "Todo", + "searchScopeModels": "Modelos", + "searchScopeCalls": "Llamadas", + "searchScopeErrors": "Errores", + "searchScopeKeys": "Claves", + "matchedCount": "{shown} de {total}", + "noMatches": "Sin coincidencias" }, "playground": { "title": "Entorno de pruebas", diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index 43a67eaae..5b42bbe99 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -388,7 +388,16 @@ "recentCalls": "Appels récents", "clientIp": "IP du client", "clientAgent": "Application cliente", - "requestedModelHint": "{model} demandé, servi par repli" + "requestedModelHint": "{model} demandé, servi par repli", + "searchAriaLabel": "Filtrer les tableaux", + "searchScopeAriaLabel": "Portée du filtre", + "searchScopeAll": "Tous", + "searchScopeModels": "Modèles", + "searchScopeCalls": "Appels", + "searchScopeErrors": "Erreurs", + "searchScopeKeys": "Clés", + "matchedCount": "{shown} sur {total}", + "noMatches": "Aucun résultat" }, "playground": { "title": "Bac à sable", diff --git a/client/src/i18n/locales/it.json b/client/src/i18n/locales/it.json index 1198c42e4..4784eeb33 100644 --- a/client/src/i18n/locales/it.json +++ b/client/src/i18n/locales/it.json @@ -388,7 +388,16 @@ "recentCalls": "Chiamate recenti", "clientIp": "IP del client", "clientAgent": "App client", - "requestedModelHint": "Richiesto {model}, servito dal fallback" + "requestedModelHint": "Richiesto {model}, servito dal fallback", + "searchAriaLabel": "Filtra tabelle", + "searchScopeAriaLabel": "Ambito del filtro", + "searchScopeAll": "Tutto", + "searchScopeModels": "Modelli", + "searchScopeCalls": "Chiamate", + "searchScopeErrors": "Errori", + "searchScopeKeys": "Chiavi", + "matchedCount": "{shown} di {total}", + "noMatches": "Nessun risultato" }, "playground": { "title": "Playground", diff --git a/client/src/i18n/locales/pt-BR.json b/client/src/i18n/locales/pt-BR.json index fe103e39b..e69a2b450 100644 --- a/client/src/i18n/locales/pt-BR.json +++ b/client/src/i18n/locales/pt-BR.json @@ -388,7 +388,16 @@ "recentCalls": "Chamadas recentes", "clientIp": "IP do cliente", "clientAgent": "Aplicativo cliente", - "requestedModelHint": "Solicitado {model}, servido por fallback" + "requestedModelHint": "Solicitado {model}, servido por fallback", + "searchAriaLabel": "Filtrar tabelas", + "searchScopeAriaLabel": "Escopo do filtro", + "searchScopeAll": "Todos", + "searchScopeModels": "Modelos", + "searchScopeCalls": "Chamadas", + "searchScopeErrors": "Erros", + "searchScopeKeys": "Chaves", + "matchedCount": "{shown} de {total}", + "noMatches": "Nenhum resultado" }, "playground": { "title": "Playground", diff --git a/client/src/i18n/locales/zh-CN.json b/client/src/i18n/locales/zh-CN.json index d182cc968..064d5cfa4 100644 --- a/client/src/i18n/locales/zh-CN.json +++ b/client/src/i18n/locales/zh-CN.json @@ -388,7 +388,16 @@ "recentCalls": "最近调用", "clientIp": "客户端 IP", "clientAgent": "客户端应用", - "requestedModelHint": "请求了 {model},由回退模型提供" + "requestedModelHint": "请求了 {model},由回退模型提供", + "searchAriaLabel": "筛选表格", + "searchScopeAriaLabel": "筛选范围", + "searchScopeAll": "全部", + "searchScopeModels": "模型", + "searchScopeCalls": "调用", + "searchScopeErrors": "错误", + "searchScopeKeys": "密钥", + "matchedCount": "{shown} / {total}", + "noMatches": "无匹配项" }, "playground": { "title": "试玩台", diff --git a/client/src/pages/AnalyticsPage.tsx b/client/src/pages/AnalyticsPage.tsx index eda91b84a..680063771 100644 --- a/client/src/pages/AnalyticsPage.tsx +++ b/client/src/pages/AnalyticsPage.tsx @@ -1,16 +1,17 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend, } from 'recharts' +import { Search, X } from 'lucide-react' import { apiFetch } from '@/lib/api' import { SegmentedControl } from '@/components/ui/segmented-control' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { PageHeader } from '@/components/page-header' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip as HoverTooltip } from '@/components/tooltip' -import { formatSqliteUtcToLocalTime } from '@/lib/utils' +import { cn, formatSqliteUtcToLocalTime } from '@/lib/utils' import { useI18n } from '@/i18n' type TimeRange = '24h' | '7d' | '30d' | '90d' @@ -143,11 +144,39 @@ function Stat({ label, value, hint, className }: { label: string; value: string return hint ? {card} : card } -function Panel({ title, children }: { title: string; children: React.ReactNode }) { +// Panel card. The same `highlighted` ring used on the toolbar (#fe1) wraps +// the card when its data is being filtered by the search box — same +// primary-tinted ring, same shadow, same transition duration so the input +// row and the table pulse in sync. `countLabel` is the pre-rendered top-right +// text (parent calls `t('analytics.matchedCount', …)` so this component stays +// presentational and doesn't need the i18n hook in scope). Same +// flex/justify-between keeps the title left + count right even when no +// count is rendered, so the title doesn't shift when a filter is applied. +function Panel({ + title, + children, + highlighted = false, + countLabel, +}: { + title: string + children: React.ReactNode + highlighted?: boolean + countLabel?: React.ReactNode +}) { return ( -
-
+
+

{title}

+ {highlighted && countLabel != null && ( + + {countLabel} + + )}
{children}
@@ -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. */} +
+
+ + setSearch(e.target.value)} + placeholder={t('models.searchPlaceholder')} + aria-label={t('analytics.searchAriaLabel')} + className="w-full bg-transparent py-1 pl-8 pr-7 text-sm outline-none" + /> + {search && ( + + )} +
+ {/* 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 + ) + })} +
+
+ ({ + value: r, + label: t(r === '24h' ? 'analytics.range24h' : r === '7d' ? 'analytics.range7d' : r === '30d' ? 'analytics.range30d' : 'analytics.range90d'), + }))} + ariaLabel={t('analytics.title')} + /> +
} /> @@ -448,9 +665,15 @@ export default function AnalyticsPage() { )} - + {errors.length === 0 ? (

{t('analytics.noErrors')}

+ ) : visibleErrors.length === 0 ? ( +

{t('analytics.noMatches')}

) : (
@@ -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". */}
- + {!recentCalls?.rows?.length ? (

{t('common.noData')}

+ ) : (visibleRecentCalls?.length ?? 0) === 0 ? ( +

{t('analytics.noMatches')}

) : (
@@ -501,7 +730,7 @@ export default function AnalyticsPage() { - {recentCalls.rows.map((r) => ( + {(visibleRecentCalls ?? []).map((r) => ( {formatSqliteUtcToLocalTime(r.createdAt, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })} @@ -531,9 +760,15 @@ export default function AnalyticsPage() {
- + {byModel.length === 0 ? (

{t('common.noData')}

+ ) : visibleByModel.length === 0 ? ( +

{t('analytics.noMatches')}

) : (
@@ -551,7 +786,7 @@ export default function AnalyticsPage() { - {byModel.map((m, i) => ( + {visibleByModel.map((m, i) => ( {m.displayName} {m.platform} @@ -574,37 +809,45 @@ export default function AnalyticsPage() { {/* Usage by key: only rendered when the endpoint returns rows. */} {byKey.length > 0 && (
- -
-
- - - {t('analytics.keyColumn')} - {t('common.provider')} - {t('analytics.requests')} - {t('common.success')} - {t('analytics.latency')} - {t('analytics.inTokens')} - {t('analytics.outTokens')} - - - - {byKey.map((k) => ( - - - {k.label || t('analytics.keyLabelFallback', { id: k.keyId })} - - {k.platform ?? '—'} - {k.requests} - {k.successRate}% - {k.avgLatencyMs} ms - {formatTokens(k.totalInputTokens)} - {formatTokens(k.totalOutputTokens)} + + {visibleByKey.length === 0 ? ( +

{t('analytics.noMatches')}

+ ) : ( +
+
+ + + {t('analytics.keyColumn')} + {t('common.provider')} + {t('analytics.requests')} + {t('common.success')} + {t('analytics.latency')} + {t('analytics.inTokens')} + {t('analytics.outTokens')} - ))} - -
-
+ + + {visibleByKey.map((k) => ( + + + {k.label || t('analytics.keyLabelFallback', { id: k.keyId })} + + {k.platform ?? '—'} + {k.requests} + {k.successRate}% + {k.avgLatencyMs} ms + {formatTokens(k.totalInputTokens)} + {formatTokens(k.totalOutputTokens)} + + ))} + + +
+ )} )} From 3039d4ce4612b1b8a608290fa1306e31b88c9a64 Mon Sep 17 00:00:00 2001 From: jasnoorgill Date: Fri, 10 Jul 2026 10:07:46 +0100 Subject: [PATCH 2/2] fix(client): bolder analytics search highlight Bump shared highlight on the search input and the four table panels from ring-2 ring-primary/50 + shadow-lg/10 to ring-4 ring-primary + shadow-xl/30. Both pulse in sync on the same 200ms transition so the input-area + matched panels read as a single grouping. Applied identically to the input row and the Panel wrapper so they stay visually matched; the only difference is the wrapper's rounded-3xl vs the input's rounded-xl which is intentional (the panels are larger cards). --- client/src/pages/AnalyticsPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/pages/AnalyticsPage.tsx b/client/src/pages/AnalyticsPage.tsx index 680063771..eeb1a1620 100644 --- a/client/src/pages/AnalyticsPage.tsx +++ b/client/src/pages/AnalyticsPage.tsx @@ -167,7 +167,7 @@ function Panel({
@@ -442,7 +442,7 @@ export default function AnalyticsPage() {