import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { ArrowDown, ArrowUp, ArrowUpDown, BarChart3, Brain, Cpu, RefreshCw, TrendingUp, } from "lucide-react"; import { api } from "@/lib/api"; import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry, } from "@/lib/api"; import { timeAgo } from "@/lib/utils"; import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Stats } from "@nous-research/ui/ui/components/stats"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; const PERIODS = [ { label: "7d", days: 7 }, { label: "30d", days: 30 }, { label: "90d", days: 90 }, ] as const; const CHART_HEIGHT_PX = 160; function formatTokens(n: number): string { 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 formatDate(day: string): string { try { const d = new Date(day + "T00:00:00"); return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } catch { return day; } } // --------------------------------------------------------------------------- // Sorting // --------------------------------------------------------------------------- function useTableSort( data: T[], defaultKey: keyof T & string, defaultDir: "asc" | "desc" = "desc", ) { const [sortKey, setSortKey] = useState(defaultKey); const [sortDir, setSortDir] = useState<"asc" | "desc">(defaultDir); const sorted = useMemo(() => { return [...data].sort((a, b) => { const aVal = a[sortKey as keyof T]; const bVal = b[sortKey as keyof T]; // Nulls always last regardless of direction if (aVal === null || aVal === undefined) return 1; if (bVal === null || bVal === undefined) return -1; if (aVal === bVal) return 0; const cmp = aVal > bVal ? 1 : -1; return sortDir === "asc" ? cmp : -cmp; }); }, [data, sortKey, sortDir]); const toggle = useCallback( (key: string) => { if (key === sortKey) { setSortDir((d) => (d === "asc" ? "desc" : "asc")); } else { setSortKey(key); setSortDir("desc"); } }, [sortKey], ); return { sorted, sortKey, sortDir, toggle }; } function SortHeader({ label, col, sortKey, sortDir, toggle, className, }: { label: string; col: string; sortKey: string; sortDir: "asc" | "desc"; toggle: (key: string) => void; className?: string; }) { const active = col === sortKey; return ( toggle(col)} className={`cursor-pointer select-none ${className ?? ""}`} > {label} {active ? ( sortDir === "asc" ? ( ) : ( ) ) : ( )} ); } function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) { const { t } = useI18n(); if (daily.length === 0) return null; const maxTokens = Math.max( ...daily.map((d) => d.input_tokens + d.output_tokens), 1, ); return (
{t.analytics.dailyTokenUsage}
{t.analytics.input}
{t.analytics.output}
{daily.map((d) => { const total = d.input_tokens + d.output_tokens; const inputH = Math.round( (d.input_tokens / maxTokens) * CHART_HEIGHT_PX, ); const outputH = Math.round( (d.output_tokens / maxTokens) * CHART_HEIGHT_PX, ); return (
{formatDate(d.day)}
{t.analytics.input}: {formatTokens(d.input_tokens)}
{t.analytics.output}: {formatTokens(d.output_tokens)}
{t.analytics.total}: {formatTokens(total)}
0 ? 1 : 0) }} />
0 ? 1 : 0), }} />
); })}
{daily.length > 0 ? formatDate(daily[0].day) : ""} {daily.length > 2 && ( {formatDate(daily[Math.floor(daily.length / 2)].day)} )} {daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
); } function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { const { t } = useI18n(); const { sorted, sortKey, sortDir, toggle } = useTableSort(daily, "day", "desc"); if (daily.length === 0) return null; return (
{t.analytics.dailyBreakdown}
{sorted.map((d) => ( ))}
{formatDate(d.day)} {d.sessions} {formatTokens(d.input_tokens)} {formatTokens(d.output_tokens)}
); } function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { const { t } = useI18n(); const { sorted, sortKey, sortDir, toggle } = useTableSort(models, "input_tokens", "desc"); if (models.length === 0) return null; return (
{t.analytics.perModelBreakdown}
{sorted.map((m) => ( ))}
{m.model} {m.sessions} {formatTokens(m.input_tokens)} {" / "} {formatTokens(m.output_tokens)}
); } function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) { const { t } = useI18n(); const { sorted, sortKey, sortDir, toggle } = useTableSort(skills, "total_count", "desc"); if (skills.length === 0) return null; return (
{t.analytics.topSkills}
{sorted.map((skill) => ( ))}
{skill.skill} {skill.view_count} {skill.manage_count} {skill.total_count} {skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
); } export default function AnalyticsPage() { const [days, setDays] = useState(30); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Gated on `dashboard.show_token_analytics` (default off). When off the // page renders an explanation card instead of fetching analytics — the // local token counts exclude auxiliary calls and provider retries, so // they diverge from provider billing in ways that mislead users. const [showTokens, setShowTokens] = useState(null); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); useEffect(() => { api .getConfig() .then((cfg) => { const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; setShowTokens(dash.show_token_analytics === true); }) .catch(() => setShowTokens(false)); }, []); const load = useCallback(() => { if (!showTokens) return; setLoading(true); setError(null); api .getAnalytics(days) .then(setData) .catch((err) => setError(String(err))) .finally(() => setLoading(false)); }, [days, showTokens]); useLayoutEffect(() => { const periodLabel = PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; setAfterTitle( {loading && } {periodLabel} , ); setEnd( showTokens === false ? null : (
{PERIODS.map((p) => ( ))}
), ); return () => { setAfterTitle(null); setEnd(null); }; }, [days, loading, load, setAfterTitle, setEnd, t.common.refresh, showTokens]); useEffect(() => { load(); }, [load]); return (
{showTokens === false && (

Token analytics hidden

The token, cost, and per-day analytics on this page are a local debug estimate. They only count successful main-agent responses with a usable usage{" "} block, and silently exclude auxiliary calls (context compression, title generation, vision, session search, web extract, smart approvals, MCP routing, plugin LLM access) plus provider-side retries and fallback attempts. Cache writes are missing entirely.

On models with heavy auxiliary traffic (Kimi K2.6, MiniMax M2.7) the local total can be 10x–100x lower than what your provider bills. Hiding these numbers is safer than letting them look authoritative.

Check your provider dashboard (OpenRouter, Anthropic, etc.) for actual usage and billing. To re-enable the local debug estimate anyway, set{" "} dashboard.show_token_analytics: true {" "} in Config.

)} {showTokens && loading && !data && (
)} {showTokens && error && (

{error}

)} {showTokens && data && ( <>
sum + d.sessions, 0), ), }, ]} />
)} {data && data.daily.length === 0 && data.by_model.length === 0 && data.skills.top_skills.length === 0 && (

{t.analytics.noUsageData}

{t.analytics.startSession}

)}
); }