diff --git a/client/src/pages/AnalyticsPage.tsx b/client/src/pages/AnalyticsPage.tsx index cc1b42715..7bc53c55e 100644 --- a/client/src/pages/AnalyticsPage.tsx +++ b/client/src/pages/AnalyticsPage.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, @@ -14,6 +15,84 @@ import { useI18n } from '@/i18n' type TimeRange = '24h' | '7d' | '30d' +const TIME_RANGES: readonly TimeRange[] = ['24h', '7d', '30d'] +const STORAGE_KEY = 'freellmapi.analytics.range' +const DEFAULT_RANGE: TimeRange = '7d' + +// Read the previously-selected range from localStorage. Invalid or missing +// values fall back to the 7d default so a corrupted entry never bricks the +// page; SSR-safety follows the same try/catch shape as `lib/api.ts`. +function loadStoredRange(): TimeRange { + if (typeof window === 'undefined') return DEFAULT_RANGE + try { + const stored = window.localStorage.getItem(STORAGE_KEY) + if (stored && (TIME_RANGES as readonly string[]).includes(stored)) { + return stored as TimeRange + } + } catch { + /* localStorage unavailable (private mode, etc.) — use default */ + } + return DEFAULT_RANGE +} + +// Per-model table sort state. The `pinned` column is intentionally NOT +// sortable — it renders "—" for zero-pinned rows and the unsortable `0`/`>0` +// distinction would confuse the indicator. 3-state cycle per column: +// null → asc → desc → null. Switching to a new column resets to asc. +type SortColumn = 'model' | 'provider' | 'requests' | 'success' | 'latency' | 'inTokens' | 'outTokens' | 'saved' +type SortDirection = 'asc' | 'desc' +type SortState = { column: SortColumn; direction: SortDirection } | null + +const SORT_COLUMNS: readonly SortColumn[] = [ + 'model', 'provider', 'requests', 'success', 'latency', 'inTokens', 'outTokens', 'saved', +] +const SORT_STORAGE_KEY = 'freellmapi.analytics.byModelSort' + +function loadStoredSort(): SortState { + if (typeof window === 'undefined') return null + try { + const raw = window.localStorage.getItem(SORT_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as { column?: unknown; direction?: unknown } + if ( + typeof parsed.column === 'string' && + (SORT_COLUMNS as readonly string[]).includes(parsed.column) && + (parsed.direction === 'asc' || parsed.direction === 'desc') + ) { + return { column: parsed.column as SortColumn, direction: parsed.direction } + } + } catch { + /* corrupted JSON or storage unavailable — fall through to null */ + } + return null +} + +// Numeric accessor for a sortable column. Returns null for values the API +// didn't include so they sort to the bottom on asc and top on desc. +function sortValue(row: any, col: SortColumn): number | string | null { + switch (col) { + case 'model': return row.displayName ?? null + case 'provider': return row.platform ?? null + case 'requests': return row.requests ?? null + case 'success': return row.successRate ?? null + case 'latency': return row.avgLatencyMs ?? null + case 'inTokens': return row.totalInputTokens ?? null + case 'outTokens': return row.totalOutputTokens ?? null + case 'saved': return row.estimatedCost ?? null + } +} + +function compareRows(a: any, b: any, col: SortColumn): number { + const av = sortValue(a, col) + const bv = sortValue(b, col) + // Nulls always last regardless of direction (Excel/Sheets convention). + if (av === null && bv === null) return 0 + if (av === null) return 1 + if (bv === null) return -1 + if (typeof av === 'number' && typeof bv === 'number') return av - bv + return String(av).localeCompare(String(bv)) +} + function formatTokens(n?: number): string { if (!n) return '0' if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` @@ -48,9 +127,92 @@ const axisStyle = { fontSize: 11, fill: 'var(--muted-foreground)' } as const const gridStyle = 'var(--border)' const primaryFill = 'var(--foreground)' +// Sortable header cell. Renders the label + a state-aware indicator: +// unsorted → ChevronsUpDown (faded), asc → ArrowUp, desc → ArrowDown. +// Right-aligned columns flip the indicator order so it sits to the LEFT +// of the label, keeping the label closest to the data. +function SortableHeader({ + column, + label, + align, + extraClass, + sort, + onClick, +}: { + column: SortColumn + label: string + align: 'left' | 'right' + extraClass?: string + sort: SortState + onClick: (col: SortColumn) => void +}) { + const active = sort?.column === column + const direction = active ? sort.direction : null + const indicator = direction === 'asc' + ? + : direction === 'desc' + ? + : + const alignClass = align === 'right' ? 'text-right' : '' + const headClass = [alignClass, extraClass].filter(Boolean).join(' ') + return ( + + + + ) +} + export default function AnalyticsPage() { const { t } = useI18n() - const [range, setRange] = useState('7d') + const [range, setRange] = useState(loadStoredRange) + + // Remember the last-selected range across page reloads / new sessions so + // the user lands back on the window they were inspecting. Same + // localStorage shape as `theme` and `freellmapi.locale`. + useEffect(() => { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_KEY, range) + } catch { + /* ignore — storage quota / private mode */ + } + }, [range]) + + // Per-model table sort. Cycle: null → asc → desc → null. Switching column + // starts at asc on the new column. Persisted so the next visit lands on + // the user's preferred sort. + const [sort, setSort] = useState(loadStoredSort) + useEffect(() => { + if (typeof window === 'undefined') return + try { + if (sort === null) window.localStorage.removeItem(SORT_STORAGE_KEY) + else window.localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sort)) + } catch { + /* ignore */ + } + }, [sort]) + + const onHeaderClick = (col: SortColumn) => { + setSort((current) => { + if (!current || current.column !== col) return { column: col, direction: 'asc' } + if (current.direction === 'asc') return { column: col, direction: 'desc' } + return null // third click on the same column → restore API order + }) + } const { data: summary } = useQuery({ queryKey: ['analytics', 'summary', range], @@ -72,6 +234,21 @@ export default function AnalyticsPage() { queryFn: () => apiFetch(`/api/analytics/by-model?range=${range}`), }) + // Apply the user's sort to byModel. When sort is null we render the rows + // in API-returned order; the API's natural ordering (requests DESC) is + // the right default. The sort is stable within the comparator because we + // fall back to insertion order for equal values (Array.prototype.sort is + // stable in all modern engines). + const sortedByModel = useMemo(() => { + if (!sort) return byModel + const copy = byModel.slice() + copy.sort((a, b) => { + const primary = compareRows(a, b, sort.column) + return sort.direction === 'asc' ? primary : -primary + }) + return copy + }, [byModel, sort]) + const { data: errors = [] } = useQuery({ queryKey: ['analytics', 'errors', range], queryFn: () => apiFetch(`/api/analytics/errors?range=${range}`), @@ -217,19 +394,19 @@ export default function AnalyticsPage() { - {t('common.model')} - {t('common.provider')} - {t('analytics.requests')} + + + {t('analytics.pinned')} - {t('common.success')} - {t('analytics.latency')} - {t('analytics.inTokens')} - {t('analytics.outTokens')} - {t('analytics.saved')} + + + + + - {byModel.map((m: any, i: number) => ( + {sortedByModel.map((m: any, i: number) => ( {m.displayName} {m.platform}