From bc3986f1e411af72013cb684bcee3946d6adf36e Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 23 May 2026 20:47:55 +0530 Subject: [PATCH 01/13] feat(*): analytics dashboard page --- app/(main)/dashboard/page.tsx | 260 +++++++++++ app/api/analytics/monthly/chart/route.ts | 21 + app/components/Sidebar.tsx | 2 + .../analytics/AnalyticsChartCard.tsx | 421 ++++++++++++++++++ app/components/analytics/index.ts | 1 + app/components/icons/index.tsx | 1 + app/components/icons/sidebar/ChartBarIcon.tsx | 21 + app/hooks/useAnalyticsChart.ts | 69 +++ app/lib/navConfig.ts | 6 + app/lib/types/analytics.ts | 38 ++ package-lock.json | 387 +++++++++++++++- package.json | 1 + 12 files changed, 1226 insertions(+), 2 deletions(-) create mode 100644 app/(main)/dashboard/page.tsx create mode 100644 app/api/analytics/monthly/chart/route.ts create mode 100644 app/components/analytics/AnalyticsChartCard.tsx create mode 100644 app/components/analytics/index.ts create mode 100644 app/components/icons/sidebar/ChartBarIcon.tsx create mode 100644 app/hooks/useAnalyticsChart.ts create mode 100644 app/lib/types/analytics.ts diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx new file mode 100644 index 00000000..d8b133d4 --- /dev/null +++ b/app/(main)/dashboard/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { PageHeader, Sidebar } from "@/app/components"; +import { Button, Select, SelectOption } from "@/app/components/ui"; +import { CloseIcon } from "@/app/components/icons"; +import { useApp } from "@/app/lib/context/AppContext"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useAnalyticsChart } from "@/app/hooks/useAnalyticsChart"; +import { AnalyticsChartCard } from "@/app/components/analytics"; +import { PROVIDES_OPTIONS } from "@/app/lib/constants"; +import { + AnalyticsChartFilters, + AnalyticsGroupBy, + AnalyticsMetric, + AnalyticsModality, +} from "@/app/lib/types/analytics"; + +const METRIC_OPTIONS: { value: AnalyticsMetric; label: string }[] = [ + { value: "requests", label: "Requests" }, + { value: "cost", label: "Cost" }, + { value: "eval_runs", label: "Eval runs" }, + { value: "eval_cost", label: "Eval cost" }, +]; + +const GROUP_BY_OPTIONS: { value: AnalyticsGroupBy; label: string }[] = [ + { value: "total", label: "Total" }, + { value: "provider", label: "Provider" }, + { value: "modality", label: "Modality" }, + { value: "modality_provider", label: "Modality + Provider" }, +]; + +const MODALITY_OPTIONS: { value: AnalyticsModality; label: string }[] = [ + { value: "T-FS-T", label: "Text → Text" }, + { value: "S-FS-S", label: "Speech → Speech" }, + { value: "STT", label: "Speech to Text" }, + { value: "TTS", label: "Text to Speech" }, + { value: "OTHER", label: "Other" }, +]; + +function toSelectOptions( + items: { value: T; label: string }[], +): SelectOption[] { + return items.map((i) => ({ value: i.value, label: i.label })); +} + +const MONTH_OPTIONS: SelectOption[] = [ + { value: "01", label: "January" }, + { value: "02", label: "February" }, + { value: "03", label: "March" }, + { value: "04", label: "April" }, + { value: "05", label: "May" }, + { value: "06", label: "June" }, + { value: "07", label: "July" }, + { value: "08", label: "August" }, + { value: "09", label: "September" }, + { value: "10", label: "October" }, + { value: "11", label: "November" }, + { value: "12", label: "December" }, +]; + +const YEAR_OPTIONS: SelectOption[] = (() => { + const now = new Date().getFullYear(); + return Array.from({ length: 2 }, (_, i) => { + const y = String(now - i); + return { value: y, label: y }; + }); +})(); + +interface MonthYearPickerProps { + label: string; + value: string; + onChange: (iso: string) => void; +} + +function MonthYearPicker({ label, value, onChange }: MonthYearPickerProps) { + const [year, setYear] = useState(value ? value.slice(0, 4) : ""); + const [month, setMonth] = useState(value ? value.slice(5, 7) : ""); + + useEffect(() => { + setYear(value ? value.slice(0, 4) : ""); + setMonth(value ? value.slice(5, 7) : ""); + }, [value]); + + const flush = (y: string, m: string) => { + if (y && m) onChange(`${y}-${m}-01`); + else if (!y && !m) onChange(""); + }; + + const handleMonth = (m: string) => { + setMonth(m); + flush(year, m); + }; + const handleYear = (y: string) => { + setYear(y); + flush(y, month); + }; + + return ( +
+ +
+ handleYear(e.target.value)} + options={YEAR_OPTIONS} + placeholder="Year" + /> +
+
+ ); +} + +export default function AnalyticsPage() { + const { sidebarCollapsed } = useApp(); + const { isAuthenticated, isHydrated } = useAuth(); + const [metric, setMetric] = useState("cost"); + const [groupBy, setGroupBy] = useState("provider"); + const [modality, setModality] = useState(""); + const [provider, setProvider] = useState(""); + const [fromMonth, setFromMonth] = useState(""); + const [toMonth, setToMonth] = useState(""); + + const filters: AnalyticsChartFilters = useMemo( + () => ({ + metric, + group_by: groupBy, + modality: modality || undefined, + provider: provider || undefined, + from_month: fromMonth || undefined, + to_month: toMonth || undefined, + }), + [metric, groupBy, modality, provider, fromMonth, toMonth], + ); + + const { data, isLoading, error } = useAnalyticsChart(filters); + + const metricLabel = + METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric; + + const isReady = isHydrated; + + return ( +
+
+ + +
+ + + {!isReady ? null : !isAuthenticated ? ( +
+

+ Log in to view analytics. +

+
+ ) : ( +
+
+
+
+ + + setGroupBy(e.target.value as AnalyticsGroupBy) + } + options={toSelectOptions(GROUP_BY_OPTIONS)} + /> +
+
+ + setProvider(e.target.value)} + options={PROVIDES_OPTIONS} + placeholder="All providers" + /> +
+
+
+ + + {(fromMonth || toMonth) && ( + + )} +
+
+ + +
+ )} +
+
+
+ ); +} diff --git a/app/api/analytics/monthly/chart/route.ts b/app/api/analytics/monthly/chart/route.ts new file mode 100644 index 00000000..127ae73d --- /dev/null +++ b/app/api/analytics/monthly/chart/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const qs = searchParams.toString(); + const endpoint = `/api/v1/analytics/monthly/chart${qs ? `?${qs}` : ""}`; + const { status, data } = await apiClient(request, endpoint); + return NextResponse.json(data, { status }); + } catch (error: unknown) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + data: null, + }, + { status: 500 }, + ); + } +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index afb30f12..34029af7 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -20,6 +20,7 @@ import { ChevronRightIcon, ChevronLeftIcon, ChatIcon, + ChartBarIcon, } from "@/app/components/icons"; import { LoginModal } from "@/app/components/auth"; import { Branding, UserMenuPopover } from "@/app/components/user-menu"; @@ -119,6 +120,7 @@ export default function Sidebar({ gear: , shield: , sliders: , + chart: , }; const navItems: MenuItem[] = NAV_ITEMS.filter( diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx new file mode 100644 index 00000000..c9c18b2c --- /dev/null +++ b/app/components/analytics/AnalyticsChartCard.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useMemo } from "react"; +import { + Area, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Loader } from "@/app/components/ui"; +import { AnalyticsChartData, AnalyticsMetric } from "@/app/lib/types/analytics"; + +const SERIES_COLORS = [ + "#1f4496", + "#16a34a", + "#f59e0b", + "#dc2626", + "#8b5cf6", + "#0891b2", + "#db2777", + "#65a30d", + "#ea580c", + "#475569", +]; + +const CURRENCY_METRICS: AnalyticsMetric[] = ["cost", "eval_cost"]; + +function formatMonthLabel(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +} + +function formatValue(value: number, metric: AnalyticsMetric): string { + if (CURRENCY_METRICS.includes(metric)) { + return value.toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } + return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatTokens(n: number): string { + if (!Number.isFinite(n)) return "0"; + const abs = Math.abs(n); + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatAxisValue(value: number, metric: AnalyticsMetric): string { + const abs = Math.abs(value); + const compact = + abs >= 1_000_000 + ? `${(value / 1_000_000).toFixed(1)}M` + : abs >= 1_000 + ? `${(value / 1_000).toFixed(1)}k` + : value.toLocaleString("en-US", { maximumFractionDigits: 0 }); + return CURRENCY_METRICS.includes(metric) ? `$${compact}` : compact; +} + +interface ChartRow { + month: string; + monthIso: string; + __total: number; + __range: [number, number]; + [seriesName: string]: string | number | [number, number]; +} + +function buildRows(chart: AnalyticsChartData): ChartRow[] { + return chart.labels.map((label, i) => { + const values = chart.series.map((s) => { + const raw = s.data[i]; + const num = raw === undefined || raw === null ? 0 : Number(raw); + return Number.isFinite(num) ? num : 0; + }); + const total = values.reduce((acc, v) => acc + v, 0); + const min = values.length ? Math.min(...values) : 0; + const max = values.length ? Math.max(...values) : 0; + const row: ChartRow = { + month: formatMonthLabel(label), + monthIso: label, + __total: total, + __range: [min, max], + }; + chart.series.forEach((s, idx) => { + row[s.name] = values[idx]; + }); + return row; + }); +} + +interface TooltipEntry { + dataKey?: string | number; + value?: number | string; + color?: string; +} + +interface TooltipRenderProps { + active?: boolean; + payload?: TooltipEntry[]; + label?: string | number; + metric: AnalyticsMetric; +} + +function ChartTooltip({ active, payload, label, metric }: TooltipRenderProps) { + if (!active || !payload?.length) return null; + const totalEntry = payload.find((e) => e.dataKey === "__total"); + const seriesEntries = payload.filter( + (e) => e.dataKey !== "__total" && e.dataKey !== "__range", + ); + return ( +
+

{label}

+ {totalEntry && ( +
+ Total + + {formatValue(Number(totalEntry.value ?? 0), metric)} + +
+ )} + {seriesEntries.length > 0 && ( +
+ {seriesEntries.map((entry) => ( +
+ + + {String(entry.dataKey)} + + + {formatValue(Number(entry.value ?? 0), metric)} + +
+ ))} +
+ )} +
+ ); +} + +interface AnalyticsChartCardProps { + data: AnalyticsChartData | null; + isLoading: boolean; + error: string | null; + metricLabel: string; +} + +export default function AnalyticsChartCard({ + data, + isLoading, + error, + metricLabel, +}: AnalyticsChartCardProps) { + const activeData = useMemo(() => { + if (!data) return null; + const filtered = data.series.filter((s) => { + const dataSum = s.data.reduce((acc, v) => acc + (Number(v) || 0), 0); + const tokenSum = + (s.total_input_tokens ?? 0) + + (s.total_output_tokens ?? 0) + + (s.total_tokens ?? 0); + return dataSum > 0 || tokenSum > 0; + }); + return { ...data, series: filtered }; + }, [data]); + + const rows = useMemo( + () => (activeData ? buildRows(activeData) : []), + [activeData], + ); + const hasSeries = !!activeData && activeData.series.length > 0; + + const totals = useMemo(() => { + if (!activeData) return []; + return activeData.series.map((s, i) => { + const sum = s.data.reduce((acc, v) => acc + (Number(v) || 0), 0); + return { + name: s.name, + total: sum, + color: SERIES_COLORS[i % SERIES_COLORS.length], + inputTokens: s.total_input_tokens, + outputTokens: s.total_output_tokens, + totalTokens: s.total_tokens, + }; + }); + }, [activeData]); + + const tokenSummary = useMemo(() => { + if (!activeData) return null; + let inputSum = 0; + let outputSum = 0; + let totalSum = 0; + let anyDefined = false; + for (const s of activeData.series) { + if ( + s.total_input_tokens !== undefined || + s.total_output_tokens !== undefined || + s.total_tokens !== undefined + ) { + anyDefined = true; + } + inputSum += s.total_input_tokens ?? 0; + outputSum += s.total_output_tokens ?? 0; + totalSum += s.total_tokens ?? 0; + } + if (!anyDefined) return null; + return { inputSum, outputSum, totalSum }; + }, [activeData]); + + return ( +
+
+
+

+ {metricLabel} +

+

+ Monthly trend + {activeData ? ` · grouped by ${activeData.group_by}` : ""} +

+
+ {totals.length > 0 && ( +
+ {totals.map((t) => ( +
+ + {t.name} +
+ ))} +
+ )} +
+ + {totals.length > 0 && ( +
+ {totals.map((t) => { + const hasTokens = + t.inputTokens !== undefined || + t.outputTokens !== undefined || + t.totalTokens !== undefined; + return ( +
+
+ + + {t.name} + +
+

+ {formatValue(t.total, activeData!.metric)} +

+ {hasTokens && ( +

+ + {formatTokens(t.totalTokens ?? 0)} + {" "} + tokens + + {" "} + · in {formatTokens(t.inputTokens ?? 0)} · out{" "} + {formatTokens(t.outputTokens ?? 0)} + +

+ )} +
+ ); + })} +
+ )} + +
+ {isLoading && !data ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : !hasSeries ? ( +
+

+ No data for the selected filters yet. +

+
+ ) : ( + + + + + + + + + + + + formatAxisValue(Number(v), activeData!.metric) + } + /> + } + cursor={{ + stroke: "#a3a3a3", + strokeWidth: 1, + strokeDasharray: "3 3", + }} + /> + {activeData!.series.length > 1 && ( + + )} + {activeData!.series.map((s, i) => ( + + ))} + + + + )} +
+ + {tokenSummary && ( +
+ + + +
+ )} +
+ ); +} + +function TokenStat({ label, value }: { label: string; value: number }) { + return ( +
+

+ {label} +

+

+ {formatTokens(value)} +

+

+ {value.toLocaleString("en-US")} exact +

+
+ ); +} diff --git a/app/components/analytics/index.ts b/app/components/analytics/index.ts new file mode 100644 index 00000000..5fc1477d --- /dev/null +++ b/app/components/analytics/index.ts @@ -0,0 +1 @@ +export { default as AnalyticsChartCard } from "./AnalyticsChartCard"; diff --git a/app/components/icons/index.tsx b/app/components/icons/index.tsx index 15abcf21..a1d893df 100644 --- a/app/components/icons/index.tsx +++ b/app/components/icons/index.tsx @@ -48,6 +48,7 @@ export { default as ShieldCheckIcon } from "./sidebar/ShieldCheckIcon"; export { default as LogoutIcon } from "./sidebar/LogoutIcon"; export { default as ChatIcon } from "./sidebar/ChatIcon"; export { default as SendIcon } from "./sidebar/SendIcon"; +export { default as ChartBarIcon } from "./sidebar/ChartBarIcon"; // Prompt Editor Icons export { default as ChevronRightIcon } from "./prompt-editor/ChevronRightIcon"; diff --git a/app/components/icons/sidebar/ChartBarIcon.tsx b/app/components/icons/sidebar/ChartBarIcon.tsx new file mode 100644 index 00000000..021e0d75 --- /dev/null +++ b/app/components/icons/sidebar/ChartBarIcon.tsx @@ -0,0 +1,21 @@ +interface IconProps { + className?: string; +} + +export default function ChartBarIcon({ className }: IconProps) { + return ( + + + + ); +} diff --git a/app/hooks/useAnalyticsChart.ts b/app/hooks/useAnalyticsChart.ts new file mode 100644 index 00000000..7ac68053 --- /dev/null +++ b/app/hooks/useAnalyticsChart.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { apiFetch } from "@/app/lib/apiClient"; +import { + AnalyticsChartData, + AnalyticsChartFilters, + AnalyticsChartResponse, +} from "@/app/lib/types/analytics"; + +function buildQuery(filters: AnalyticsChartFilters): string { + const params = new URLSearchParams(); + params.set("metric", filters.metric); + params.set("group_by", filters.group_by); + if (filters.modality) params.set("modality", filters.modality); + if (filters.provider) params.set("provider", filters.provider); + if (filters.project_id !== undefined) + params.set("project_id", String(filters.project_id)); + if (filters.from_month) params.set("from_month", filters.from_month); + if (filters.to_month) params.set("to_month", filters.to_month); + return params.toString(); +} + +export interface UseAnalyticsChartResult { + data: AnalyticsChartData | null; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +export function useAnalyticsChart( + filters: AnalyticsChartFilters, +): UseAnalyticsChartResult { + const { activeKey, isAuthenticated } = useAuth(); + const apiKey = activeKey?.key ?? ""; + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const query = buildQuery(filters); + + const fetchChart = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoading(true); + setError(null); + try { + const result = await apiFetch( + `/api/analytics/monthly/chart?${query}`, + apiKey, + ); + if (result.success && result.data) { + setData(result.data); + } else { + setError(result.error ?? "Failed to load analytics"); + setData(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load analytics"); + setData(null); + } finally { + setIsLoading(false); + } + }, [apiKey, isAuthenticated, query]); + + useEffect(() => { + void fetchChart(); + }, [fetchChart]); + + return { data, isLoading, error, refetch: fetchChart }; +} diff --git a/app/lib/navConfig.ts b/app/lib/navConfig.ts index f68d5a9a..de8e1870 100644 --- a/app/lib/navConfig.ts +++ b/app/lib/navConfig.ts @@ -11,6 +11,12 @@ export const SETTINGS_NAV: SettingsNavSection[] = [ ]; export const NAV_ITEMS: NavItemConfig[] = [ + { + name: "Dashboard", + route: "/dashboard", + icon: "chart", + gateDescription: "Log in to view the analytics dashboard.", + }, { name: "Documents", route: "/document", diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts new file mode 100644 index 00000000..82035629 --- /dev/null +++ b/app/lib/types/analytics.ts @@ -0,0 +1,38 @@ +import { APIEnvelope } from "@/app/lib/types/chat"; + +export type AnalyticsMetric = "requests" | "cost" | "eval_runs" | "eval_cost"; + +export type AnalyticsGroupBy = + | "total" + | "provider" + | "modality" + | "modality_provider"; + +export type AnalyticsModality = "T-FS-T" | "S-FS-S" | "STT" | "TTS" | "OTHER"; + +export interface AnalyticsSeriesPoint { + name: string; + data: string[]; + total_input_tokens?: number; + total_output_tokens?: number; + total_tokens?: number; +} + +export interface AnalyticsChartData { + metric: AnalyticsMetric; + group_by: AnalyticsGroupBy; + labels: string[]; + series: AnalyticsSeriesPoint[]; +} + +export interface AnalyticsChartFilters { + metric: AnalyticsMetric; + group_by: AnalyticsGroupBy; + modality?: AnalyticsModality; + provider?: string; + project_id?: number; + from_month?: string; + to_month?: string; +} + +export type AnalyticsChartResponse = APIEnvelope; diff --git a/package-lock.json b/package-lock.json index 6510ab32..28bf6022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "react-dom": "19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.9.5", + "recharts": "^3.8.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "swr": "^2.3.6", @@ -1267,6 +1268,42 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1274,6 +1311,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1565,6 +1614,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1662,6 +1774,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.49.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", @@ -2803,6 +2921,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2894,6 +3021,127 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2991,6 +3239,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3325,6 +3579,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3875,7 +4139,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/extend": { @@ -4464,6 +4727,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4512,6 +4785,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -7217,7 +7499,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-markdown": { @@ -7247,6 +7528,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.9.5", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", @@ -7285,6 +7589,51 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/refa": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", @@ -7439,6 +7788,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8242,6 +8597,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -8747,6 +9108,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index fcdd3765..47811637 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-dom": "19.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.9.5", + "recharts": "^3.8.1", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "swr": "^2.3.6", From 221a34c35cc68e633e36c44fa409598be9e99a47 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 23 May 2026 22:33:51 +0530 Subject: [PATCH 02/13] fix(metrics): added the totals summary --- app/(main)/dashboard/page.tsx | 33 +++- .../analytics/AnalyticsChartCard.tsx | 110 +++++++++++-- .../analytics/AnalyticsTotalsRow.tsx | 145 ++++++++++++++++++ app/components/analytics/index.ts | 1 + app/hooks/useAnalyticsTotals.ts | 119 ++++++++++++++ 5 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 app/components/analytics/AnalyticsTotalsRow.tsx create mode 100644 app/hooks/useAnalyticsTotals.ts diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index d8b133d4..ac2f200b 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -7,7 +7,11 @@ import { CloseIcon } from "@/app/components/icons"; import { useApp } from "@/app/lib/context/AppContext"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useAnalyticsChart } from "@/app/hooks/useAnalyticsChart"; -import { AnalyticsChartCard } from "@/app/components/analytics"; +import { useAnalyticsTotals } from "@/app/hooks/useAnalyticsTotals"; +import { + AnalyticsChartCard, + AnalyticsTotalsRow, +} from "@/app/components/analytics"; import { PROVIDES_OPTIONS } from "@/app/lib/constants"; import { AnalyticsChartFilters, @@ -17,10 +21,10 @@ import { } from "@/app/lib/types/analytics"; const METRIC_OPTIONS: { value: AnalyticsMetric; label: string }[] = [ - { value: "requests", label: "Requests" }, - { value: "cost", label: "Cost" }, + { value: "requests", label: "Requests (LLM call + LLM chain)" }, + { value: "cost", label: "Cost (LLM cost USD)" }, { value: "eval_runs", label: "Eval runs" }, - { value: "eval_cost", label: "Eval cost" }, + { value: "eval_cost", label: "Eval cost (USD)" }, ]; const GROUP_BY_OPTIONS: { value: AnalyticsGroupBy; label: string }[] = [ @@ -143,6 +147,21 @@ export default function AnalyticsPage() { const { data, isLoading, error } = useAnalyticsChart(filters); + const totalsFilters = useMemo( + () => ({ + modality: modality || undefined, + provider: provider || undefined, + from_month: fromMonth || undefined, + to_month: toMonth || undefined, + }), + [modality, provider, fromMonth, toMonth], + ); + const { + totals, + isLoading: isTotalsLoading, + error: totalsError, + } = useAnalyticsTotals(totalsFilters); + const metricLabel = METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric; @@ -251,6 +270,12 @@ export default function AnalyticsPage() { error={error} metricLabel={metricLabel} /> + + )} diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx index c9c18b2c..ae770897 100644 --- a/app/components/analytics/AnalyticsChartCard.tsx +++ b/app/components/analytics/AnalyticsChartCard.tsx @@ -11,7 +11,7 @@ import { XAxis, YAxis, } from "recharts"; -import { Loader } from "@/app/components/ui"; +import { InfoTooltip, Loader } from "@/app/components/ui"; import { AnalyticsChartData, AnalyticsMetric } from "@/app/lib/types/analytics"; const SERIES_COLORS = [ @@ -225,8 +225,19 @@ export default function AnalyticsChartCard({
-

+

{metricLabel} + + Chart values are the selected metric ( + {metricLabel}) summed per month and broken + out by the chosen Group by. The thick line is + the total across all groups; the band is the spread between + the lowest and highest group at each month. + + } + />

Monthly trend @@ -268,24 +279,41 @@ export default function AnalyticsChartCard({ className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: t.color }} /> - + {t.name} + + Sum of {metricLabel} for{" "} + {t.name} across the visible months. + + } + />

{formatValue(t.total, activeData!.metric)}

{hasTokens && ( -

+

{formatTokens(t.totalTokens ?? 0)} - {" "} - tokens - - {" "} + + tokens + · in {formatTokens(t.inputTokens ?? 0)} · out{" "} {formatTokens(t.outputTokens ?? 0)} + + Tokens billed to this group during the chart window. +
+ in = input/prompt tokens,{" "} + out = output/completion tokens. + + } + />

)}
@@ -394,26 +422,76 @@ export default function AnalyticsChartCard({
{tokenSummary && ( -
- - - +
+
+
+

+ Tokens for this chart + + Tokens reported by the response for{" "} + {metricLabel} with the current filters. + Only models that actually consumed tokens contribute to + this sum. Use this to gauge how much volume produced the + numbers above. +
+
+ This is not a global total —{" "} + production and eval tokens are shown + separately in the "All-time totals" row at the + bottom of the page. + + } + /> +

+

+ Aggregated across the groups shown in the chart above +

+
+
+
+ + + +
)}
); } -function TokenStat({ label, value }: { label: string; value: number }) { +function TokenStat({ + label, + value, + tooltip, +}: { + label: string; + value: number; + tooltip: string; +}) { return (
-

+

{label} +

-

+

{formatTokens(value)}

-

+

{value.toLocaleString("en-US")} exact

diff --git a/app/components/analytics/AnalyticsTotalsRow.tsx b/app/components/analytics/AnalyticsTotalsRow.tsx new file mode 100644 index 00000000..9aedd75c --- /dev/null +++ b/app/components/analytics/AnalyticsTotalsRow.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { ReactNode } from "react"; +import { InfoTooltip, Loader } from "@/app/components/ui"; +import { AnalyticsTotalsMap } from "@/app/hooks/useAnalyticsTotals"; + +interface AnalyticsTotalsRowProps { + totals: AnalyticsTotalsMap | null; + isLoading: boolean; + error: string | null; +} + +function formatTokens(n: number): string { + if (!Number.isFinite(n)) return "0"; + const abs = Math.abs(n); + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function formatCurrency(n: number): string { + return n.toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +function formatCount(n: number): string { + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +export default function AnalyticsTotalsRow({ + totals, + isLoading, + error, +}: AnalyticsTotalsRowProps) { + if (isLoading && !totals) { + return ( +
+ +
+ ); + } + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!totals) return null; + + return ( +
+
+

+ All-time totals +

+

+ Aggregated across the selected filter window. Each card is one backend + metric — they do not add up to one another. +

+
+
+ + + USD spent on production LLM calls (not evals) in + the filter window. Backend metric: cost. The hint + shows the tokens billed for these calls. + + } + /> + + + USD spent on LLM calls made by eval runs in the + filter window. Backend metric: eval_cost. The + hint shows the tokens billed for these eval calls. + + } + /> + + +
+
+ ); +} + +function StatCard({ + label, + value, + hint, + tooltip, +}: { + label: string; + value: string; + hint?: string; + tooltip?: ReactNode; +}) { + return ( +
+

+ {label} + {tooltip && } +

+

+ {value} +

+ {hint && ( +

+ {hint} +

+ )} +
+ ); +} diff --git a/app/components/analytics/index.ts b/app/components/analytics/index.ts index 5fc1477d..a75a612f 100644 --- a/app/components/analytics/index.ts +++ b/app/components/analytics/index.ts @@ -1 +1,2 @@ export { default as AnalyticsChartCard } from "./AnalyticsChartCard"; +export { default as AnalyticsTotalsRow } from "./AnalyticsTotalsRow"; diff --git a/app/hooks/useAnalyticsTotals.ts b/app/hooks/useAnalyticsTotals.ts new file mode 100644 index 00000000..ad0cf70e --- /dev/null +++ b/app/hooks/useAnalyticsTotals.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { apiFetch } from "@/app/lib/apiClient"; +import { + AnalyticsChartFilters, + AnalyticsChartResponse, + AnalyticsMetric, +} from "@/app/lib/types/analytics"; + +const TOTAL_METRICS: AnalyticsMetric[] = [ + "requests", + "cost", + "eval_runs", + "eval_cost", +]; + +export interface AnalyticsTotalsValue { + value: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; +} + +export type AnalyticsTotalsMap = Record; + +function buildQuery( + metric: AnalyticsMetric, + filters: Omit, +) { + const params = new URLSearchParams(); + params.set("metric", metric); + params.set("group_by", "total"); + if (filters.modality) params.set("modality", filters.modality); + if (filters.provider) params.set("provider", filters.provider); + if (filters.project_id !== undefined) + params.set("project_id", String(filters.project_id)); + if (filters.from_month) params.set("from_month", filters.from_month); + if (filters.to_month) params.set("to_month", filters.to_month); + return params.toString(); +} + +function emptyValue(): AnalyticsTotalsValue { + return { value: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0 }; +} + +export interface UseAnalyticsTotalsResult { + totals: AnalyticsTotalsMap | null; + isLoading: boolean; + error: string | null; +} + +export function useAnalyticsTotals( + filters: Omit, +): UseAnalyticsTotalsResult { + const { activeKey, isAuthenticated } = useAuth(); + const apiKey = activeKey?.key ?? ""; + const [totals, setTotals] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const filtersKey = JSON.stringify({ + modality: filters.modality, + provider: filters.provider, + project_id: filters.project_id, + from_month: filters.from_month, + to_month: filters.to_month, + }); + + const fetchTotals = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoading(true); + setError(null); + try { + const results = await Promise.all( + TOTAL_METRICS.map(async (metric) => { + const query = buildQuery(metric, filters); + const res = await apiFetch( + `/api/analytics/monthly/chart?${query}`, + apiKey, + ); + if (!res.success || !res.data) return { metric, value: emptyValue() }; + let sum = 0; + let totalTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + for (const s of res.data.series) { + sum += s.data.reduce((a, v) => a + (Number(v) || 0), 0); + totalTokens += s.total_tokens ?? 0; + inputTokens += s.total_input_tokens ?? 0; + outputTokens += s.total_output_tokens ?? 0; + } + return { + metric, + value: { value: sum, totalTokens, inputTokens, outputTokens }, + }; + }), + ); + const map: AnalyticsTotalsMap = { + requests: emptyValue(), + cost: emptyValue(), + eval_runs: emptyValue(), + eval_cost: emptyValue(), + }; + for (const { metric, value } of results) map[metric] = value; + setTotals(map); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load totals"); + setTotals(null); + } finally { + setIsLoading(false); + } + }, [apiKey, isAuthenticated, filtersKey]); + + useEffect(() => { + void fetchTotals(); + }, [fetchTotals]); + + return { totals, isLoading, error }; +} From 48032dfeabfae3f2b633ce3dc9b6dd04b0f959a6 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 26 May 2026 00:00:27 +0530 Subject: [PATCH 03/13] fix(analytics): implement the changes as per the suggestion --- app/(main)/dashboard/page.tsx | 191 ++++++++++-------- .../analytics/AnalyticsChartCard.tsx | 4 +- app/lib/utils/analytics/normalizeSeries.ts | 74 +++++++ 3 files changed, 183 insertions(+), 86 deletions(-) create mode 100644 app/lib/utils/analytics/normalizeSeries.ts diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index ac2f200b..0b01b161 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -21,24 +21,24 @@ import { } from "@/app/lib/types/analytics"; const METRIC_OPTIONS: { value: AnalyticsMetric; label: string }[] = [ - { value: "requests", label: "Requests (LLM call + LLM chain)" }, - { value: "cost", label: "Cost (LLM cost USD)" }, - { value: "eval_runs", label: "Eval runs" }, + { value: "requests", label: "Number of requests" }, + { value: "cost", label: "LLM cost (USD)" }, + { value: "eval_runs", label: "Number of eval runs" }, { value: "eval_cost", label: "Eval cost (USD)" }, ]; const GROUP_BY_OPTIONS: { value: AnalyticsGroupBy; label: string }[] = [ - { value: "total", label: "Total" }, + { value: "total", label: "Total (no breakdown)" }, { value: "provider", label: "Provider" }, - { value: "modality", label: "Modality" }, - { value: "modality_provider", label: "Modality + Provider" }, + { value: "modality", label: "Request type" }, + { value: "modality_provider", label: "Request type + Provider" }, ]; const MODALITY_OPTIONS: { value: AnalyticsModality; label: string }[] = [ { value: "T-FS-T", label: "Text → Text" }, { value: "S-FS-S", label: "Speech → Speech" }, - { value: "STT", label: "Speech to Text" }, - { value: "TTS", label: "Text to Speech" }, + { value: "STT", label: "Speech → Text" }, + { value: "TTS", label: "Text → Speech" }, { value: "OTHER", label: "Other" }, ]; @@ -186,96 +186,117 @@ export default function AnalyticsPage() { ) : (
-
-
-
- - + setMetric(e.target.value as AnalyticsMetric) + } + options={toSelectOptions(METRIC_OPTIONS)} + /> +
+
+ + - setGroupBy(e.target.value as AnalyticsGroupBy) - } - options={toSelectOptions(GROUP_BY_OPTIONS)} - /> + + +
+

+ Filters +

+

+ Narrow what counts toward the totals above and the chart + below. +

+
+
+ + setProvider(e.target.value)} + options={PROVIDES_OPTIONS} + placeholder="All providers" + /> +
-
- - setProvider(e.target.value)} - options={PROVIDES_OPTIONS} - placeholder="All providers" + + {(fromMonth || toMonth) && ( + + )}
-
-
- - - {(fromMonth || toMonth) && ( - - )} -
+
+ + - -
)}
diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx index ae770897..94545553 100644 --- a/app/components/analytics/AnalyticsChartCard.tsx +++ b/app/components/analytics/AnalyticsChartCard.tsx @@ -13,6 +13,7 @@ import { } from "recharts"; import { InfoTooltip, Loader } from "@/app/components/ui"; import { AnalyticsChartData, AnalyticsMetric } from "@/app/lib/types/analytics"; +import { normalizeAndMergeSeries } from "@/app/lib/utils/analytics/normalizeSeries"; const SERIES_COLORS = [ "#1f4496", @@ -167,7 +168,8 @@ export default function AnalyticsChartCard({ }: AnalyticsChartCardProps) { const activeData = useMemo(() => { if (!data) return null; - const filtered = data.series.filter((s) => { + const merged = normalizeAndMergeSeries(data.series); + const filtered = merged.filter((s) => { const dataSum = s.data.reduce((acc, v) => acc + (Number(v) || 0), 0); const tokenSum = (s.total_input_tokens ?? 0) + diff --git a/app/lib/utils/analytics/normalizeSeries.ts b/app/lib/utils/analytics/normalizeSeries.ts new file mode 100644 index 00000000..c212e1fe --- /dev/null +++ b/app/lib/utils/analytics/normalizeSeries.ts @@ -0,0 +1,74 @@ +import { AnalyticsSeriesPoint } from "@/app/lib/types/analytics"; + +const PROVIDER_DISPLAY: Record = { + openai: "OpenAI", + google: "Google", + anthropic: "Anthropic", + sarvamai: "Sarvam AI", + elevenlabs: "ElevenLabs", + azure: "Azure", + cohere: "Cohere", +}; + +function canonicalProviderKey(raw: string): string { + return raw + .toLowerCase() + .trim() + .replace(/[-_\s]?native$/i, "") + .replace(/[-_\s]+/g, ""); +} + +function formatProvider(raw: string): string { + const key = canonicalProviderKey(raw); + return PROVIDER_DISPLAY[key] ?? raw.trim(); +} + +function normalizeName(name: string): string { + const parts = name.split("·").map((p) => p.trim()); + if (parts.length === 1) return formatProvider(parts[0]); + return parts + .map((p, i) => (i === parts.length - 1 ? formatProvider(p) : p)) + .join(" · "); +} + +function sumNumericArrays(a: string[], b: string[]): string[] { + const len = Math.max(a.length, b.length); + const out: string[] = []; + for (let i = 0; i < len; i++) { + const left = Number(a[i] ?? 0) || 0; + const right = Number(b[i] ?? 0) || 0; + out.push(String(left + right)); + } + return out; +} + +function mergeSeries( + a: AnalyticsSeriesPoint, + b: AnalyticsSeriesPoint, +): AnalyticsSeriesPoint { + return { + name: a.name, + data: sumNumericArrays(a.data, b.data), + total_input_tokens: + (a.total_input_tokens ?? 0) + (b.total_input_tokens ?? 0), + total_output_tokens: + (a.total_output_tokens ?? 0) + (b.total_output_tokens ?? 0), + total_tokens: (a.total_tokens ?? 0) + (b.total_tokens ?? 0), + }; +} + +export function normalizeAndMergeSeries( + series: AnalyticsSeriesPoint[], +): AnalyticsSeriesPoint[] { + const byName = new Map(); + for (const s of series) { + const displayName = normalizeName(s.name); + const existing = byName.get(displayName); + if (!existing) { + byName.set(displayName, { ...s, name: displayName }); + } else { + byName.set(displayName, mergeSeries(existing, s)); + } + } + return Array.from(byName.values()); +} From 23aaf452a4659a7b906728861d0f75fac9e17c1e Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 26 May 2026 10:23:08 +0530 Subject: [PATCH 04/13] fix(analytics): cleanups --- app/(main)/dashboard/page.tsx | 62 +++------- .../analytics/AnalyticsChartCard.tsx | 107 ++---------------- app/components/analytics/ChartTooltip.tsx | 51 +++++++++ app/components/analytics/index.ts | 1 + app/lib/constants.ts | 61 ++++++++++ app/lib/types/analytics.ts | 13 +++ app/lib/utils/analytics/formatValue.ts | 46 ++++++++ 7 files changed, 195 insertions(+), 146 deletions(-) create mode 100644 app/components/analytics/ChartTooltip.tsx create mode 100644 app/lib/utils/analytics/formatValue.ts diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 0b01b161..30dbee79 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -12,7 +12,14 @@ import { AnalyticsChartCard, AnalyticsTotalsRow, } from "@/app/components/analytics"; -import { PROVIDES_OPTIONS } from "@/app/lib/constants"; +import { + ANALYTICS_GROUP_BY_OPTIONS, + ANALYTICS_METRIC_OPTIONS, + ANALYTICS_MODALITY_OPTIONS, + MONTH_OPTIONS, + PROVIDES_OPTIONS, + getRecentYearOptions, +} from "@/app/lib/constants"; import { AnalyticsChartFilters, AnalyticsGroupBy, @@ -20,56 +27,13 @@ import { AnalyticsModality, } from "@/app/lib/types/analytics"; -const METRIC_OPTIONS: { value: AnalyticsMetric; label: string }[] = [ - { value: "requests", label: "Number of requests" }, - { value: "cost", label: "LLM cost (USD)" }, - { value: "eval_runs", label: "Number of eval runs" }, - { value: "eval_cost", label: "Eval cost (USD)" }, -]; - -const GROUP_BY_OPTIONS: { value: AnalyticsGroupBy; label: string }[] = [ - { value: "total", label: "Total (no breakdown)" }, - { value: "provider", label: "Provider" }, - { value: "modality", label: "Request type" }, - { value: "modality_provider", label: "Request type + Provider" }, -]; - -const MODALITY_OPTIONS: { value: AnalyticsModality; label: string }[] = [ - { value: "T-FS-T", label: "Text → Text" }, - { value: "S-FS-S", label: "Speech → Speech" }, - { value: "STT", label: "Speech → Text" }, - { value: "TTS", label: "Text → Speech" }, - { value: "OTHER", label: "Other" }, -]; - function toSelectOptions( items: { value: T; label: string }[], ): SelectOption[] { return items.map((i) => ({ value: i.value, label: i.label })); } -const MONTH_OPTIONS: SelectOption[] = [ - { value: "01", label: "January" }, - { value: "02", label: "February" }, - { value: "03", label: "March" }, - { value: "04", label: "April" }, - { value: "05", label: "May" }, - { value: "06", label: "June" }, - { value: "07", label: "July" }, - { value: "08", label: "August" }, - { value: "09", label: "September" }, - { value: "10", label: "October" }, - { value: "11", label: "November" }, - { value: "12", label: "December" }, -]; - -const YEAR_OPTIONS: SelectOption[] = (() => { - const now = new Date().getFullYear(); - return Array.from({ length: 2 }, (_, i) => { - const y = String(now - i); - return { value: y, label: y }; - }); -})(); +const YEAR_OPTIONS: SelectOption[] = getRecentYearOptions(2); interface MonthYearPickerProps { label: string; @@ -163,7 +127,7 @@ export default function AnalyticsPage() { } = useAnalyticsTotals(totalsFilters); const metricLabel = - METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric; + ANALYTICS_METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric; const isReady = isHydrated; @@ -204,7 +168,7 @@ export default function AnalyticsPage() { onChange={(e) => setMetric(e.target.value as AnalyticsMetric) } - options={toSelectOptions(METRIC_OPTIONS)} + options={toSelectOptions(ANALYTICS_METRIC_OPTIONS)} />
@@ -216,7 +180,7 @@ export default function AnalyticsPage() { onChange={(e) => setGroupBy(e.target.value as AnalyticsGroupBy) } - options={toSelectOptions(GROUP_BY_OPTIONS)} + options={toSelectOptions(ANALYTICS_GROUP_BY_OPTIONS)} />
@@ -240,7 +204,7 @@ export default function AnalyticsPage() { onChange={(e) => setModality(e.target.value as AnalyticsModality | "") } - options={toSelectOptions(MODALITY_OPTIONS)} + options={toSelectOptions(ANALYTICS_MODALITY_OPTIONS)} placeholder="All request types" /> diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx index 94545553..2ed3c8c8 100644 --- a/app/components/analytics/AnalyticsChartCard.tsx +++ b/app/components/analytics/AnalyticsChartCard.tsx @@ -12,8 +12,15 @@ import { YAxis, } from "recharts"; import { InfoTooltip, Loader } from "@/app/components/ui"; -import { AnalyticsChartData, AnalyticsMetric } from "@/app/lib/types/analytics"; +import { AnalyticsChartData } from "@/app/lib/types/analytics"; import { normalizeAndMergeSeries } from "@/app/lib/utils/analytics/normalizeSeries"; +import { + formatCompactMetric, + formatMetricValue, + formatMonthLabel, + formatTokens, +} from "@/app/lib/utils/analytics/formatValue"; +import ChartTooltip from "./ChartTooltip"; const SERIES_COLORS = [ "#1f4496", @@ -28,45 +35,6 @@ const SERIES_COLORS = [ "#475569", ]; -const CURRENCY_METRICS: AnalyticsMetric[] = ["cost", "eval_cost"]; - -function formatMonthLabel(iso: string): string { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return iso; - return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); -} - -function formatValue(value: number, metric: AnalyticsMetric): string { - if (CURRENCY_METRICS.includes(metric)) { - return value.toLocaleString("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - } - return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); -} - -function formatTokens(n: number): string { - if (!Number.isFinite(n)) return "0"; - const abs = Math.abs(n); - if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); -} - -function formatAxisValue(value: number, metric: AnalyticsMetric): string { - const abs = Math.abs(value); - const compact = - abs >= 1_000_000 - ? `${(value / 1_000_000).toFixed(1)}M` - : abs >= 1_000 - ? `${(value / 1_000).toFixed(1)}k` - : value.toLocaleString("en-US", { maximumFractionDigits: 0 }); - return CURRENCY_METRICS.includes(metric) ? `$${compact}` : compact; -} - interface ChartRow { month: string; monthIso: string; @@ -98,61 +66,6 @@ function buildRows(chart: AnalyticsChartData): ChartRow[] { }); } -interface TooltipEntry { - dataKey?: string | number; - value?: number | string; - color?: string; -} - -interface TooltipRenderProps { - active?: boolean; - payload?: TooltipEntry[]; - label?: string | number; - metric: AnalyticsMetric; -} - -function ChartTooltip({ active, payload, label, metric }: TooltipRenderProps) { - if (!active || !payload?.length) return null; - const totalEntry = payload.find((e) => e.dataKey === "__total"); - const seriesEntries = payload.filter( - (e) => e.dataKey !== "__total" && e.dataKey !== "__range", - ); - return ( -
-

{label}

- {totalEntry && ( -
- Total - - {formatValue(Number(totalEntry.value ?? 0), metric)} - -
- )} - {seriesEntries.length > 0 && ( -
- {seriesEntries.map((entry) => ( -
- - - {String(entry.dataKey)} - - - {formatValue(Number(entry.value ?? 0), metric)} - -
- ))} -
- )} -
- ); -} - interface AnalyticsChartCardProps { data: AnalyticsChartData | null; isLoading: boolean; @@ -294,7 +207,7 @@ export default function AnalyticsChartCard({

- {formatValue(t.total, activeData!.metric)} + {formatMetricValue(t.total, activeData!.metric)}

{hasTokens && (

@@ -371,7 +284,7 @@ export default function AnalyticsChartCard({ tickMargin={6} width={56} tickFormatter={(v) => - formatAxisValue(Number(v), activeData!.metric) + formatCompactMetric(Number(v), activeData!.metric) } /> e.dataKey === "__total"); + const seriesEntries = payload.filter( + (e) => e.dataKey !== "__total" && e.dataKey !== "__range", + ); + return ( +

+

{label}

+ {totalEntry && ( +
+ Total + + {formatMetricValue(Number(totalEntry.value ?? 0), metric)} + +
+ )} + {seriesEntries.length > 0 && ( +
+ {seriesEntries.map((entry) => ( +
+ + + {String(entry.dataKey)} + + + {formatMetricValue(Number(entry.value ?? 0), metric)} + +
+ ))} +
+ )} +
+ ); +} diff --git a/app/components/analytics/index.ts b/app/components/analytics/index.ts index a75a612f..264ddb74 100644 --- a/app/components/analytics/index.ts +++ b/app/components/analytics/index.ts @@ -1,2 +1,3 @@ export { default as AnalyticsChartCard } from "./AnalyticsChartCard"; export { default as AnalyticsTotalsRow } from "./AnalyticsTotalsRow"; +export { default as ChartTooltip } from "./ChartTooltip"; diff --git a/app/lib/constants.ts b/app/lib/constants.ts index a359e6b0..3ff84db9 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -4,6 +4,11 @@ import { ConfigBlob } from "@/app/lib/types/promptEditor"; import { ToastType } from "@/app/components/ui"; +import { + AnalyticsGroupBy, + AnalyticsMetric, + AnalyticsModality, +} from "@/app/lib/types/analytics"; export const APP_NAME = "Kaapi Konsole"; @@ -50,6 +55,62 @@ export const PROVIDES_OPTIONS = [ { value: "google", label: "Google" }, ]; +export const ANALYTICS_METRIC_OPTIONS: { + value: AnalyticsMetric; + label: string; +}[] = [ + { value: "requests", label: "Number of requests" }, + { value: "cost", label: "LLM cost (USD)" }, + { value: "eval_runs", label: "Number of eval runs" }, + { value: "eval_cost", label: "Eval cost (USD)" }, +]; + +export const ANALYTICS_GROUP_BY_OPTIONS: { + value: AnalyticsGroupBy; + label: string; +}[] = [ + { value: "total", label: "Total (no breakdown)" }, + { value: "provider", label: "Provider" }, + { value: "modality", label: "Request type" }, + { value: "modality_provider", label: "Request type + Provider" }, +]; + +export const ANALYTICS_MODALITY_OPTIONS: { + value: AnalyticsModality; + label: string; +}[] = [ + { value: "T-FS-T", label: "Text → Text" }, + { value: "S-FS-S", label: "Speech → Speech" }, + { value: "STT", label: "Speech → Text" }, + { value: "TTS", label: "Text → Speech" }, + { value: "OTHER", label: "Other" }, +]; + +export const MONTH_OPTIONS: { value: string; label: string }[] = [ + { value: "01", label: "January" }, + { value: "02", label: "February" }, + { value: "03", label: "March" }, + { value: "04", label: "April" }, + { value: "05", label: "May" }, + { value: "06", label: "June" }, + { value: "07", label: "July" }, + { value: "08", label: "August" }, + { value: "09", label: "September" }, + { value: "10", label: "October" }, + { value: "11", label: "November" }, + { value: "12", label: "December" }, +]; + +export function getRecentYearOptions( + count = 2, +): { value: string; label: string }[] { + const now = new Date().getFullYear(); + return Array.from({ length: count }, (_, i) => { + const y = String(now - i); + return { value: y, label: y }; + }); +} + export const PROVIDER_TYPES = [ { value: "text", diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts index 82035629..84ca9e0a 100644 --- a/app/lib/types/analytics.ts +++ b/app/lib/types/analytics.ts @@ -36,3 +36,16 @@ export interface AnalyticsChartFilters { } export type AnalyticsChartResponse = APIEnvelope; + +export interface AnalyticsTooltipEntry { + dataKey?: string | number; + value?: number | string; + color?: string; +} + +export interface AnalyticsTooltipRenderProps { + active?: boolean; + payload?: AnalyticsTooltipEntry[]; + label?: string | number; + metric: AnalyticsMetric; +} diff --git a/app/lib/utils/analytics/formatValue.ts b/app/lib/utils/analytics/formatValue.ts new file mode 100644 index 00000000..b9640be5 --- /dev/null +++ b/app/lib/utils/analytics/formatValue.ts @@ -0,0 +1,46 @@ +import { AnalyticsMetric } from "@/app/lib/types/analytics"; + +export const CURRENCY_METRICS: AnalyticsMetric[] = ["cost", "eval_cost"]; + +export function formatMetricValue( + value: number, + metric: AnalyticsMetric, +): string { + if (CURRENCY_METRICS.includes(metric)) { + return value.toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } + return value.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +export function formatCompactMetric( + value: number, + metric: AnalyticsMetric, +): string { + const abs = Math.abs(value); + const compact = + abs >= 1_000_000 + ? `${(value / 1_000_000).toFixed(1)}M` + : abs >= 1_000 + ? `${(value / 1_000).toFixed(1)}k` + : value.toLocaleString("en-US", { maximumFractionDigits: 0 }); + return CURRENCY_METRICS.includes(metric) ? `$${compact}` : compact; +} + +export function formatTokens(n: number): string { + if (!Number.isFinite(n)) return "0"; + const abs = Math.abs(n); + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +export function formatMonthLabel(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +} From 2131a90dfbf3340dceaf0e304a5f3c4b2d6c7968 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 26 May 2026 10:36:53 +0530 Subject: [PATCH 05/13] fix(analytics): cleanups --- app/(main)/dashboard/page.tsx | 59 +----------------- app/components/analytics/MonthYearPicker.tsx | 65 ++++++++++++++++++++ app/components/analytics/index.ts | 1 + 3 files changed, 68 insertions(+), 57 deletions(-) create mode 100644 app/components/analytics/MonthYearPicker.tsx diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 30dbee79..c548a946 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { PageHeader, Sidebar } from "@/app/components"; import { Button, Select, SelectOption } from "@/app/components/ui"; import { CloseIcon } from "@/app/components/icons"; @@ -11,14 +11,13 @@ import { useAnalyticsTotals } from "@/app/hooks/useAnalyticsTotals"; import { AnalyticsChartCard, AnalyticsTotalsRow, + MonthYearPicker, } from "@/app/components/analytics"; import { ANALYTICS_GROUP_BY_OPTIONS, ANALYTICS_METRIC_OPTIONS, ANALYTICS_MODALITY_OPTIONS, - MONTH_OPTIONS, PROVIDES_OPTIONS, - getRecentYearOptions, } from "@/app/lib/constants"; import { AnalyticsChartFilters, @@ -33,60 +32,6 @@ function toSelectOptions( return items.map((i) => ({ value: i.value, label: i.label })); } -const YEAR_OPTIONS: SelectOption[] = getRecentYearOptions(2); - -interface MonthYearPickerProps { - label: string; - value: string; - onChange: (iso: string) => void; -} - -function MonthYearPicker({ label, value, onChange }: MonthYearPickerProps) { - const [year, setYear] = useState(value ? value.slice(0, 4) : ""); - const [month, setMonth] = useState(value ? value.slice(5, 7) : ""); - - useEffect(() => { - setYear(value ? value.slice(0, 4) : ""); - setMonth(value ? value.slice(5, 7) : ""); - }, [value]); - - const flush = (y: string, m: string) => { - if (y && m) onChange(`${y}-${m}-01`); - else if (!y && !m) onChange(""); - }; - - const handleMonth = (m: string) => { - setMonth(m); - flush(year, m); - }; - const handleYear = (y: string) => { - setYear(y); - flush(y, month); - }; - - return ( -
- -
- handleYear(e.target.value)} - options={YEAR_OPTIONS} - placeholder="Year" - /> -
-
- ); -} - export default function AnalyticsPage() { const { sidebarCollapsed } = useApp(); const { isAuthenticated, isHydrated } = useAuth(); diff --git a/app/components/analytics/MonthYearPicker.tsx b/app/components/analytics/MonthYearPicker.tsx new file mode 100644 index 00000000..48219378 --- /dev/null +++ b/app/components/analytics/MonthYearPicker.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Select } from "@/app/components/ui"; +import { MONTH_OPTIONS, getRecentYearOptions } from "@/app/lib/constants"; + +interface MonthYearPickerProps { + label: string; + value: string; + onChange: (iso: string) => void; + yearCount?: number; +} + +export default function MonthYearPicker({ + label, + value, + onChange, + yearCount = 2, +}: MonthYearPickerProps) { + const [year, setYear] = useState(value ? value.slice(0, 4) : ""); + const [month, setMonth] = useState(value ? value.slice(5, 7) : ""); + + useEffect(() => { + setYear(value ? value.slice(0, 4) : ""); + setMonth(value ? value.slice(5, 7) : ""); + }, [value]); + + const yearOptions = getRecentYearOptions(yearCount); + + const flush = (y: string, m: string) => { + if (y && m) onChange(`${y}-${m}-01`); + else if (!y && !m) onChange(""); + }; + + const handleMonth = (m: string) => { + setMonth(m); + flush(year, m); + }; + const handleYear = (y: string) => { + setYear(y); + flush(y, month); + }; + + return ( +
+ +
+ handleYear(e.target.value)} + options={yearOptions} + placeholder="Year" + /> +
+
+ ); +} diff --git a/app/components/analytics/index.ts b/app/components/analytics/index.ts index 264ddb74..2d5050d5 100644 --- a/app/components/analytics/index.ts +++ b/app/components/analytics/index.ts @@ -1,3 +1,4 @@ export { default as AnalyticsChartCard } from "./AnalyticsChartCard"; export { default as AnalyticsTotalsRow } from "./AnalyticsTotalsRow"; export { default as ChartTooltip } from "./ChartTooltip"; +export { default as MonthYearPicker } from "./MonthYearPicker"; From 4cf661dcb8ecc6123616039fb53a6b8fb8c07157 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 26 May 2026 10:41:29 +0530 Subject: [PATCH 06/13] fix(analytics): move the types inside the analytics types files --- .../analytics/AnalyticsTotalsRow.tsx | 8 +------ app/hooks/useAnalyticsTotals.ts | 18 +++------------- app/lib/types/analytics.ts | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/components/analytics/AnalyticsTotalsRow.tsx b/app/components/analytics/AnalyticsTotalsRow.tsx index 9aedd75c..4206ea83 100644 --- a/app/components/analytics/AnalyticsTotalsRow.tsx +++ b/app/components/analytics/AnalyticsTotalsRow.tsx @@ -2,13 +2,7 @@ import { ReactNode } from "react"; import { InfoTooltip, Loader } from "@/app/components/ui"; -import { AnalyticsTotalsMap } from "@/app/hooks/useAnalyticsTotals"; - -interface AnalyticsTotalsRowProps { - totals: AnalyticsTotalsMap | null; - isLoading: boolean; - error: string | null; -} +import { AnalyticsTotalsRowProps } from "@/app/lib/types/analytics"; function formatTokens(n: number): string { if (!Number.isFinite(n)) return "0"; diff --git a/app/hooks/useAnalyticsTotals.ts b/app/hooks/useAnalyticsTotals.ts index ad0cf70e..232bd4cd 100644 --- a/app/hooks/useAnalyticsTotals.ts +++ b/app/hooks/useAnalyticsTotals.ts @@ -5,6 +5,9 @@ import { AnalyticsChartFilters, AnalyticsChartResponse, AnalyticsMetric, + AnalyticsTotalsMap, + AnalyticsTotalsValue, + UseAnalyticsTotalsResult, } from "@/app/lib/types/analytics"; const TOTAL_METRICS: AnalyticsMetric[] = [ @@ -14,15 +17,6 @@ const TOTAL_METRICS: AnalyticsMetric[] = [ "eval_cost", ]; -export interface AnalyticsTotalsValue { - value: number; - totalTokens: number; - inputTokens: number; - outputTokens: number; -} - -export type AnalyticsTotalsMap = Record; - function buildQuery( metric: AnalyticsMetric, filters: Omit, @@ -43,12 +37,6 @@ function emptyValue(): AnalyticsTotalsValue { return { value: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0 }; } -export interface UseAnalyticsTotalsResult { - totals: AnalyticsTotalsMap | null; - isLoading: boolean; - error: string | null; -} - export function useAnalyticsTotals( filters: Omit, ): UseAnalyticsTotalsResult { diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts index 84ca9e0a..99b8bf46 100644 --- a/app/lib/types/analytics.ts +++ b/app/lib/types/analytics.ts @@ -49,3 +49,24 @@ export interface AnalyticsTooltipRenderProps { label?: string | number; metric: AnalyticsMetric; } + +export interface AnalyticsTotalsValue { + value: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; +} + +export type AnalyticsTotalsMap = Record; + +export interface UseAnalyticsTotalsResult { + totals: AnalyticsTotalsMap | null; + isLoading: boolean; + error: string | null; +} + +export interface AnalyticsTotalsRowProps { + totals: AnalyticsTotalsMap | null; + isLoading: boolean; + error: string | null; +} From 535c59d68df1af2079f71ff47437961fca4b5b5c Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:51:45 +0530 Subject: [PATCH 07/13] fix(dashboard): few cleanups --- app/(main)/dashboard/page.tsx | 9 ++------- app/components/analytics/AnalyticsChartCard.tsx | 17 ++++++----------- app/components/prompt-editor/DiffView.tsx | 3 ++- app/components/ui/Select.tsx | 6 +----- app/components/ui/index.ts | 1 - app/hooks/useAnalyticsChart.ts | 8 +------- app/lib/types/analytics.ts | 15 +++++++++++++++ app/lib/types/ui.ts | 4 ++++ app/lib/utils/selectOptions.ts | 7 +++++++ 9 files changed, 38 insertions(+), 32 deletions(-) create mode 100644 app/lib/types/ui.ts create mode 100644 app/lib/utils/selectOptions.ts diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index c548a946..5e664b02 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -2,7 +2,8 @@ import { useMemo, useState } from "react"; import { PageHeader, Sidebar } from "@/app/components"; -import { Button, Select, SelectOption } from "@/app/components/ui"; +import { Button, Select } from "@/app/components/ui"; +import { toSelectOptions } from "@/app/lib/utils/selectOptions"; import { CloseIcon } from "@/app/components/icons"; import { useApp } from "@/app/lib/context/AppContext"; import { useAuth } from "@/app/lib/context/AuthContext"; @@ -26,12 +27,6 @@ import { AnalyticsModality, } from "@/app/lib/types/analytics"; -function toSelectOptions( - items: { value: T; label: string }[], -): SelectOption[] { - return items.map((i) => ({ value: i.value, label: i.label })); -} - export default function AnalyticsPage() { const { sidebarCollapsed } = useApp(); const { isAuthenticated, isHydrated } = useAuth(); diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx index 2ed3c8c8..00ae7711 100644 --- a/app/components/analytics/AnalyticsChartCard.tsx +++ b/app/components/analytics/AnalyticsChartCard.tsx @@ -12,7 +12,10 @@ import { YAxis, } from "recharts"; import { InfoTooltip, Loader } from "@/app/components/ui"; -import { AnalyticsChartData } from "@/app/lib/types/analytics"; +import { + AnalyticsChartData, + AnalyticsChartRow, +} from "@/app/lib/types/analytics"; import { normalizeAndMergeSeries } from "@/app/lib/utils/analytics/normalizeSeries"; import { formatCompactMetric, @@ -35,15 +38,7 @@ const SERIES_COLORS = [ "#475569", ]; -interface ChartRow { - month: string; - monthIso: string; - __total: number; - __range: [number, number]; - [seriesName: string]: string | number | [number, number]; -} - -function buildRows(chart: AnalyticsChartData): ChartRow[] { +function buildRows(chart: AnalyticsChartData): AnalyticsChartRow[] { return chart.labels.map((label, i) => { const values = chart.series.map((s) => { const raw = s.data[i]; @@ -53,7 +48,7 @@ function buildRows(chart: AnalyticsChartData): ChartRow[] { const total = values.reduce((acc, v) => acc + v, 0); const min = values.length ? Math.min(...values) : 0; const max = values.length ? Math.max(...values) : 0; - const row: ChartRow = { + const row: AnalyticsChartRow = { month: formatMonthLabel(label), monthIso: label, __total: total, diff --git a/app/components/prompt-editor/DiffView.tsx b/app/components/prompt-editor/DiffView.tsx index 13fef4ae..bae4ee63 100644 --- a/app/components/prompt-editor/DiffView.tsx +++ b/app/components/prompt-editor/DiffView.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from "react"; import PromptDiffPane from "./PromptDiffPane"; import ConfigDiffPane from "./ConfigDiffPane"; -import { Button, Select, type SelectOption } from "@/app/components/ui"; +import { Button, Select } from "@/app/components/ui"; +import type { SelectOption } from "@/app/lib/types/ui"; import { VersionPill } from "@/app/components"; import { ArrowLeftIcon, ChevronRightIcon } from "@/app/components/icons"; import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs"; diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx index 86824a0d..87f5f53a 100644 --- a/app/components/ui/Select.tsx +++ b/app/components/ui/Select.tsx @@ -1,11 +1,7 @@ "use client"; import { SelectHTMLAttributes } from "react"; - -export interface SelectOption { - value: string; - label: string; -} +import { SelectOption } from "@/app/lib/types/ui"; interface SelectProps extends SelectHTMLAttributes { options: SelectOption[]; diff --git a/app/components/ui/index.ts b/app/components/ui/index.ts index 28588b81..de2e2c67 100644 --- a/app/components/ui/index.ts +++ b/app/components/ui/index.ts @@ -1,7 +1,6 @@ export { default as Button } from "./Button"; export { default as Field } from "./Field"; export { default as Select } from "./Select"; -export type { SelectOption } from "./Select"; export { default as MultiSelect } from "./MultiSelect"; export { default as Modal } from "./Modal"; export { default as Loader, LoaderBox } from "./Loader"; diff --git a/app/hooks/useAnalyticsChart.ts b/app/hooks/useAnalyticsChart.ts index 7ac68053..a4aff114 100644 --- a/app/hooks/useAnalyticsChart.ts +++ b/app/hooks/useAnalyticsChart.ts @@ -5,6 +5,7 @@ import { AnalyticsChartData, AnalyticsChartFilters, AnalyticsChartResponse, + UseAnalyticsChartResult, } from "@/app/lib/types/analytics"; function buildQuery(filters: AnalyticsChartFilters): string { @@ -20,13 +21,6 @@ function buildQuery(filters: AnalyticsChartFilters): string { return params.toString(); } -export interface UseAnalyticsChartResult { - data: AnalyticsChartData | null; - isLoading: boolean; - error: string | null; - refetch: () => void; -} - export function useAnalyticsChart( filters: AnalyticsChartFilters, ): UseAnalyticsChartResult { diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts index 99b8bf46..66b6245a 100644 --- a/app/lib/types/analytics.ts +++ b/app/lib/types/analytics.ts @@ -65,8 +65,23 @@ export interface UseAnalyticsTotalsResult { error: string | null; } +export interface UseAnalyticsChartResult { + data: AnalyticsChartData | null; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + export interface AnalyticsTotalsRowProps { totals: AnalyticsTotalsMap | null; isLoading: boolean; error: string | null; } + +export interface AnalyticsChartRow { + month: string; + monthIso: string; + __total: number; + __range: [number, number]; + [seriesName: string]: string | number | [number, number]; +} diff --git a/app/lib/types/ui.ts b/app/lib/types/ui.ts new file mode 100644 index 00000000..9aca28df --- /dev/null +++ b/app/lib/types/ui.ts @@ -0,0 +1,4 @@ +export interface SelectOption { + value: string; + label: string; +} diff --git a/app/lib/utils/selectOptions.ts b/app/lib/utils/selectOptions.ts new file mode 100644 index 00000000..70d5afe8 --- /dev/null +++ b/app/lib/utils/selectOptions.ts @@ -0,0 +1,7 @@ +import { SelectOption } from "@/app/lib/types/ui"; + +export function toSelectOptions( + items: { value: T; label: string }[], +): SelectOption[] { + return items.map((i) => ({ value: i.value, label: i.label })); +} From dc840b8a8d0aa99d7353d9c778b21a204d8b8360 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:48:45 +0530 Subject: [PATCH 08/13] feat(analytics): UI improvement and added the pie chart graph --- app/(main)/analytics/page.tsx | 216 ++++++++++++++ app/(main)/dashboard/page.tsx | 210 ------------- .../analytics/AnalyticsChartCard.tsx | 227 +++----------- .../analytics/AnalyticsTotalsRow.tsx | 120 +++++--- app/components/analytics/BreakdownPanel.tsx | 271 +++++++++++++++++ app/components/analytics/LineTrendChart.tsx | 156 ++++++++++ app/components/analytics/MonthYearPicker.tsx | 277 +++++++++++++++--- app/components/icons/common/TooltipIcon.tsx | 19 ++ app/components/icons/index.tsx | 1 + app/components/ui/InfoTooltip.tsx | 5 +- app/components/ui/Select.tsx | 44 ++- app/globals.css | 24 ++ app/hooks/useAnalyticsChart.ts | 48 ++- app/lib/constants.ts | 6 +- app/lib/navConfig.ts | 12 +- app/lib/types/analytics.ts | 26 +- app/lib/types/ui.ts | 6 + app/lib/utils/analytics/formatValue.ts | 6 +- app/lib/utils/analytics/mergeChartData.ts | 90 ++++++ app/lib/utils/selectOptions.ts | 8 +- 20 files changed, 1266 insertions(+), 506 deletions(-) create mode 100644 app/(main)/analytics/page.tsx delete mode 100644 app/(main)/dashboard/page.tsx create mode 100644 app/components/analytics/BreakdownPanel.tsx create mode 100644 app/components/analytics/LineTrendChart.tsx create mode 100644 app/components/icons/common/TooltipIcon.tsx create mode 100644 app/lib/utils/analytics/mergeChartData.ts diff --git a/app/(main)/analytics/page.tsx b/app/(main)/analytics/page.tsx new file mode 100644 index 00000000..248b7db5 --- /dev/null +++ b/app/(main)/analytics/page.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { PageHeader, Sidebar } from "@/app/components"; +import { Select } from "@/app/components/ui"; +import { toSelectOptions } from "@/app/lib/utils/selectOptions"; +import { useApp } from "@/app/lib/context/AppContext"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useAnalyticsChart } from "@/app/hooks/useAnalyticsChart"; +import { useAnalyticsTotals } from "@/app/hooks/useAnalyticsTotals"; +import { + AnalyticsChartCard, + AnalyticsTotalsRow, + MonthYearPicker, +} from "@/app/components/analytics"; +import { + ANALYTICS_GROUP_BY_OPTIONS, + ANALYTICS_METRIC_OPTIONS, + ANALYTICS_MODALITY_OPTIONS, + PROVIDES_OPTIONS, +} from "@/app/lib/constants"; +import { + AnalyticsChartFilters, + AnalyticsGroupBy, + AnalyticsMetric, + AnalyticsModality, +} from "@/app/lib/types/analytics"; + +function FilterField({ + label, + className, + children, +}: { + label: string; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +export default function AnalyticsPage() { + const { sidebarCollapsed } = useApp(); + const { isAuthenticated, isHydrated } = useAuth(); + const [metric, setMetric] = useState("cost_all"); + const [groupBy, setGroupBy] = useState("provider"); + const [modality, setModality] = useState(""); + const [provider, setProvider] = useState(""); + const [fromMonth, setFromMonth] = useState(""); + const [toMonth, setToMonth] = useState(""); + + const filters: AnalyticsChartFilters = useMemo( + () => ({ + metric, + group_by: groupBy, + modality: modality || undefined, + provider: provider || undefined, + from_month: fromMonth || undefined, + to_month: toMonth || undefined, + }), + [metric, groupBy, modality, provider, fromMonth, toMonth], + ); + + const { data, isLoading, error } = useAnalyticsChart(filters); + + const totalsFilters = useMemo( + () => ({ + modality: modality || undefined, + provider: provider || undefined, + from_month: fromMonth || undefined, + to_month: toMonth || undefined, + }), + [modality, provider, fromMonth, toMonth], + ); + const { + totals, + isLoading: isTotalsLoading, + error: totalsError, + } = useAnalyticsTotals(totalsFilters); + + const metricLabel = + ANALYTICS_METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric; + + const isReady = isHydrated; + + return ( +
+
+ + +
+ + + {!isReady ? null : !isAuthenticated ? ( +
+

+ Log in to view analytics. +

+
+ ) : ( +
+
+
+ + + setGroupBy(e.target.value as AnalyticsGroupBy) + } + options={toSelectOptions(ANALYTICS_GROUP_BY_OPTIONS)} + /> + + + setProvider(e.target.value)} + options={PROVIDES_OPTIONS} + placeholder="All" + /> + + + + + + + + {(fromMonth || toMonth) && ( + + )} +
+
+ +
+ + + +
+
+ )} +
+
+
+ ); +} diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx deleted file mode 100644 index 5e664b02..00000000 --- a/app/(main)/dashboard/page.tsx +++ /dev/null @@ -1,210 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { PageHeader, Sidebar } from "@/app/components"; -import { Button, Select } from "@/app/components/ui"; -import { toSelectOptions } from "@/app/lib/utils/selectOptions"; -import { CloseIcon } from "@/app/components/icons"; -import { useApp } from "@/app/lib/context/AppContext"; -import { useAuth } from "@/app/lib/context/AuthContext"; -import { useAnalyticsChart } from "@/app/hooks/useAnalyticsChart"; -import { useAnalyticsTotals } from "@/app/hooks/useAnalyticsTotals"; -import { - AnalyticsChartCard, - AnalyticsTotalsRow, - MonthYearPicker, -} from "@/app/components/analytics"; -import { - ANALYTICS_GROUP_BY_OPTIONS, - ANALYTICS_METRIC_OPTIONS, - ANALYTICS_MODALITY_OPTIONS, - PROVIDES_OPTIONS, -} from "@/app/lib/constants"; -import { - AnalyticsChartFilters, - AnalyticsGroupBy, - AnalyticsMetric, - AnalyticsModality, -} from "@/app/lib/types/analytics"; - -export default function AnalyticsPage() { - const { sidebarCollapsed } = useApp(); - const { isAuthenticated, isHydrated } = useAuth(); - const [metric, setMetric] = useState("cost"); - const [groupBy, setGroupBy] = useState("provider"); - const [modality, setModality] = useState(""); - const [provider, setProvider] = useState(""); - const [fromMonth, setFromMonth] = useState(""); - const [toMonth, setToMonth] = useState(""); - - const filters: AnalyticsChartFilters = useMemo( - () => ({ - metric, - group_by: groupBy, - modality: modality || undefined, - provider: provider || undefined, - from_month: fromMonth || undefined, - to_month: toMonth || undefined, - }), - [metric, groupBy, modality, provider, fromMonth, toMonth], - ); - - const { data, isLoading, error } = useAnalyticsChart(filters); - - const totalsFilters = useMemo( - () => ({ - modality: modality || undefined, - provider: provider || undefined, - from_month: fromMonth || undefined, - to_month: toMonth || undefined, - }), - [modality, provider, fromMonth, toMonth], - ); - const { - totals, - isLoading: isTotalsLoading, - error: totalsError, - } = useAnalyticsTotals(totalsFilters); - - const metricLabel = - ANALYTICS_METRIC_OPTIONS.find((m) => m.value === metric)?.label ?? metric; - - const isReady = isHydrated; - - return ( -
-
- - -
- - - {!isReady ? null : !isAuthenticated ? ( -
-

- Log in to view analytics. -

-
- ) : ( -
-
-
-

- Chart settings -

-

- Pick what the chart below plots and how to break it down. -

-
-
- - - setGroupBy(e.target.value as AnalyticsGroupBy) - } - options={toSelectOptions(ANALYTICS_GROUP_BY_OPTIONS)} - /> -
-
-
- -
-

- Filters -

-

- Narrow what counts toward the totals above and the chart - below. -

-
-
- - setProvider(e.target.value)} - options={PROVIDES_OPTIONS} - placeholder="All providers" - /> -
-
-
- - - {(fromMonth || toMonth) && ( - - )} -
-
-
- - - - -
- )} -
-
-
- ); -} diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx index 00ae7711..44d29781 100644 --- a/app/components/analytics/AnalyticsChartCard.tsx +++ b/app/components/analytics/AnalyticsChartCard.tsx @@ -1,29 +1,18 @@ "use client"; import { useMemo } from "react"; -import { - Area, - CartesianGrid, - ComposedChart, - Line, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { InfoTooltip, Loader } from "@/app/components/ui"; +import { InfoTooltip } from "@/app/components/ui"; import { AnalyticsChartData, AnalyticsChartRow, } from "@/app/lib/types/analytics"; import { normalizeAndMergeSeries } from "@/app/lib/utils/analytics/normalizeSeries"; import { - formatCompactMetric, - formatMetricValue, formatMonthLabel, formatTokens, } from "@/app/lib/utils/analytics/formatValue"; -import ChartTooltip from "./ChartTooltip"; +import BreakdownPanel from "./BreakdownPanel"; +import LineTrendChart from "./LineTrendChart"; const SERIES_COLORS = [ "#1f4496", @@ -88,15 +77,22 @@ export default function AnalyticsChartCard({ return { ...data, series: filtered }; }, [data]); - const rows = useMemo( - () => (activeData ? buildRows(activeData) : []), - [activeData], - ); + const rows = useMemo(() => { + if (!activeData) return []; + const all = buildRows(activeData); + // Auto-trim leading + trailing months where every series is zero + let start = 0; + let end = all.length - 1; + const isEmpty = (r: AnalyticsChartRow) => r.__total === 0; + while (start <= end && isEmpty(all[start])) start++; + while (end >= start && isEmpty(all[end])) end--; + return all.slice(start, end + 1); + }, [activeData]); const hasSeries = !!activeData && activeData.series.length > 0; const totals = useMemo(() => { if (!activeData) return []; - return activeData.series.map((s, i) => { + const raw = activeData.series.map((s, i) => { const sum = s.data.reduce((acc, v) => acc + (Number(v) || 0), 0); return { name: s.name, @@ -107,6 +103,13 @@ export default function AnalyticsChartCard({ totalTokens: s.total_tokens, }; }); + const grandTotal = raw.reduce((acc, t) => acc + t.total, 0); + return raw + .map((t) => ({ + ...t, + sharePct: grandTotal > 0 ? (t.total / grandTotal) * 100 : 0, + })) + .sort((a, b) => b.total - a.total); }, [activeData]); const tokenSummary = useMemo(() => { @@ -154,182 +157,26 @@ export default function AnalyticsChartCard({ {activeData ? ` · grouped by ${activeData.group_by}` : ""}

- {totals.length > 0 && ( -
- {totals.map((t) => ( -
- - {t.name} -
- ))} -
- )} {totals.length > 0 && ( -
- {totals.map((t) => { - const hasTokens = - t.inputTokens !== undefined || - t.outputTokens !== undefined || - t.totalTokens !== undefined; - return ( -
-
- - - {t.name} - - Sum of {metricLabel} for{" "} - {t.name} across the visible months. - - } - /> - -
-

- {formatMetricValue(t.total, activeData!.metric)} -

- {hasTokens && ( -

- - {formatTokens(t.totalTokens ?? 0)} - - tokens - - · in {formatTokens(t.inputTokens ?? 0)} · out{" "} - {formatTokens(t.outputTokens ?? 0)} - - - Tokens billed to this group during the chart window. -
- in = input/prompt tokens,{" "} - out = output/completion tokens. - - } - /> -

- )} -
- ); - })} -
+ )} -
- {isLoading && !data ? ( -
- -
- ) : error ? ( -
-

{error}

-
- ) : !hasSeries ? ( -
-

- No data for the selected filters yet. -

-
- ) : ( - - - - - - - - - - - - formatCompactMetric(Number(v), activeData!.metric) - } - /> - } - cursor={{ - stroke: "#a3a3a3", - strokeWidth: 1, - strokeDasharray: "3 3", - }} - /> - {activeData!.series.length > 1 && ( - - )} - {activeData!.series.map((s, i) => ( - - ))} - - - - )} -
+ {tokenSummary && (
diff --git a/app/components/analytics/AnalyticsTotalsRow.tsx b/app/components/analytics/AnalyticsTotalsRow.tsx index 4206ea83..6c3d41fe 100644 --- a/app/components/analytics/AnalyticsTotalsRow.tsx +++ b/app/components/analytics/AnalyticsTotalsRow.tsx @@ -47,90 +47,140 @@ export default function AnalyticsTotalsRow({ if (!totals) return null; return ( -
-
-

+
+
+

All-time totals

-

- Aggregated across the selected filter window. Each card is one backend - metric — they do not add up to one another. +

+ Each row pairs production with eval.

-
- + + - USD spent on production LLM calls (not evals) in - the filter window. Backend metric: cost. The hint - shows the tokens billed for these calls. + USD spent on production LLM calls in the filter + window. Backend metric: cost. } /> - - USD spent on LLM calls made by eval runs in the - filter window. Backend metric: eval_cost. The - hint shows the tokens billed for these eval calls. + USD spent on LLM calls made by eval runs. Backend + metric: eval_cost. } /> + + + -
+ + + + + +
); } +type Accent = "cost" | "usage" | "activity"; + +const ACCENT_DOT: Record = { + cost: "bg-status-success", + usage: "bg-accent-primary", + activity: "bg-status-warning", +}; + +const ACCENT_CARD: Record = { + cost: "bg-status-success-bg/40 border-status-success-border/40", + usage: "bg-accent-primary/5 border-accent-primary/20", + activity: "bg-status-warning-bg/40 border-status-warning-border/40", +}; + +function TotalsSection({ + title, + accent, + children, +}: { + title: string; + accent: Accent; + children: ReactNode; +}) { + return ( +
+

+ + {title} +

+
{children}
+
+ ); +} + function StatCard({ label, value, hint, tooltip, + accent, }: { label: string; value: string; hint?: string; tooltip?: ReactNode; + accent: Accent; }) { return ( -
-

+

+

{label} {tooltip && }

-

+

{value}

{hint && ( -

+

{hint}

)} diff --git a/app/components/analytics/BreakdownPanel.tsx b/app/components/analytics/BreakdownPanel.tsx new file mode 100644 index 00000000..09758670 --- /dev/null +++ b/app/components/analytics/BreakdownPanel.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useState } from "react"; +import { Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; +import { AnalyticsGroupBy, AnalyticsMetric } from "@/app/lib/types/analytics"; +import { + formatCompactMetric, + formatMetricValue, + formatTokens, +} from "@/app/lib/utils/analytics/formatValue"; + +export interface BreakdownEntry { + name: string; + total: number; + color: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + sharePct: number; +} + +const GROUP_BY_HEADER: Record = { + total: "Series", + provider: "Provider", + modality: "Request type", + modality_provider: "Request type · Provider", +}; + +interface SegmentLabelProps { + cx?: number; + cy?: number; + midAngle?: number; + innerRadius?: number; + outerRadius?: number; + percent?: number; +} + +function renderSegmentLabel(props: SegmentLabelProps) { + const { + cx = 0, + cy = 0, + midAngle = 0, + innerRadius = 0, + outerRadius = 0, + percent = 0, + } = props; + if (percent < 0.05) return null; + const r = innerRadius + (outerRadius - innerRadius) * 0.55; + const x = cx + r * Math.cos((-midAngle * Math.PI) / 180); + const y = cy + r * Math.sin((-midAngle * Math.PI) / 180); + return ( + + {`${(percent * 100).toFixed(0)}%`} + + ); +} + +interface PieTooltipPayload { + payload?: { name?: string; value?: number; fill?: string }; +} + +interface PieTooltipProps { + active?: boolean; + payload?: PieTooltipPayload[]; + metric: AnalyticsMetric; + grandTotal: number; +} + +function PieTooltip({ active, payload, metric, grandTotal }: PieTooltipProps) { + if (!active || !payload?.length) return null; + const item = payload[0]?.payload; + if (!item) return null; + const share = grandTotal > 0 ? ((item.value ?? 0) / grandTotal) * 100 : 0; + return ( +
+
+ + + {item.name} + +
+
+ Total + + {formatMetricValue(item.value ?? 0, metric)} + +
+
+ Share + {share.toFixed(1)}% +
+
+ ); +} + +interface BreakdownPanelProps { + totals: BreakdownEntry[]; + metric: AnalyticsMetric; + metricLabel: string; + groupBy: AnalyticsGroupBy; +} + +export default function BreakdownPanel({ + totals, + metric, + metricLabel, + groupBy, +}: BreakdownPanelProps) { + const [isSliceHovered, setIsSliceHovered] = useState(false); + const hasAnyTokens = totals.some( + (t) => + (t.totalTokens ?? 0) > 0 || + (t.inputTokens ?? 0) > 0 || + (t.outputTokens ?? 0) > 0, + ); + const grandTotal = totals.reduce((acc, t) => acc + t.total, 0); + const donutData = totals + .filter((t) => t.total > 0) + .map((t) => ({ name: t.name, value: t.total, fill: t.color })); + const groupHeader = GROUP_BY_HEADER[groupBy]; + + return ( +
+
+
+

+ {groupHeader}-wise {metricLabel} +

+
+
+ + + + + + + {hasAnyTokens && ( + + )} + + + + {totals.map((t, i) => { + const isEmpty = t.total === 0; + return ( + + + + + {hasAnyTokens && ( + + )} + + ); + })} + +
+ {groupHeader} + + {metricLabel} + Share + Tokens +
+ + + + {t.name} + + + + {formatMetricValue(t.total, metric)} + + {!isEmpty ? `${t.sharePct.toFixed(1)}%` : "—"} + + {(t.totalTokens ?? 0) > 0 + ? formatTokens(t.totalTokens ?? 0) + : "—"} +
+
+
+ +
+
+

+ Share of {metricLabel} +

+
+
+
+ {donutData.length > 0 ? ( + <> + + + setIsSliceHovered(true)} + onMouseLeave={() => setIsSliceHovered(false)} + /> + + } + /> + + + {!isSliceHovered && ( +
+

+ {formatCompactMetric(grandTotal, metric)} +

+

+ Total +

+
+ )} + + ) : ( +
+ No data +
+ )} +
+ {donutData.length > 0 && ( +
+ {donutData.map((d) => ( + + + {d.name} + + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/components/analytics/LineTrendChart.tsx b/app/components/analytics/LineTrendChart.tsx new file mode 100644 index 00000000..2ebc6d0b --- /dev/null +++ b/app/components/analytics/LineTrendChart.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Loader } from "@/app/components/ui"; +import { + AnalyticsChartData, + AnalyticsChartRow, +} from "@/app/lib/types/analytics"; +import { formatCompactMetric } from "@/app/lib/utils/analytics/formatValue"; +import ChartTooltip from "./ChartTooltip"; + +interface LineTrendChartProps { + rows: AnalyticsChartRow[]; + activeData: AnalyticsChartData | null; + hasSeries: boolean; + hasInitialData: boolean; + isLoading: boolean; + error: string | null; + seriesColors: string[]; +} + +export default function LineTrendChart({ + rows, + activeData, + hasSeries, + hasInitialData, + isLoading, + error, + seriesColors, +}: LineTrendChartProps) { + return ( + <> +
+ {isLoading && !hasInitialData ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : !hasSeries || !activeData ? ( +
+

+ No data for the selected filters yet. +

+
+ ) : ( + + + + + + formatCompactMetric(Number(v), activeData.metric) + } + /> + } + cursor={{ + stroke: "#a3a3a3", + strokeWidth: 1, + strokeDasharray: "3 3", + }} + /> + {activeData.series.length > 1 && ( + + )} + {activeData.series.map((s, i) => { + const color = seriesColors[i % seriesColors.length]; + return ( + + ); + })} + + + )} +
+ {hasSeries && activeData && ( +
+ {activeData.series.map((s, i) => ( + + + {s.name} + + ))} + {activeData.series.length > 1 && ( + + + )} +
+ )} + + ); +} diff --git a/app/components/analytics/MonthYearPicker.tsx b/app/components/analytics/MonthYearPicker.tsx index 48219378..5f349d0d 100644 --- a/app/components/analytics/MonthYearPicker.tsx +++ b/app/components/analytics/MonthYearPicker.tsx @@ -1,64 +1,265 @@ "use client"; -import { useEffect, useState } from "react"; -import { Select } from "@/app/components/ui"; -import { MONTH_OPTIONS, getRecentYearOptions } from "@/app/lib/constants"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronDownIcon, ChevronLeftIcon } from "@/app/components/icons"; interface MonthYearPickerProps { - label: string; + /** Label to render above the trigger. Pass empty/omit to skip (e.g. when wrapping in your own field). */ + label?: string; + /** ISO date in `YYYY-MM-01` form (empty string when unset). */ value: string; onChange: (iso: string) => void; - yearCount?: number; + /** How many months back to expose (default 24 → 2 years). */ + monthsBack?: number; + placeholder?: string; +} + +const MONTH_LABELS_SHORT = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +interface AllowedRange { + /** All `{ year, monthIdx }` pairs that are selectable, newest first. */ + pairs: { year: number; monthIdx: number }[]; + /** Sorted list of years that contain at least one allowed month, newest first. */ + years: number[]; + /** Map of year → Set of allowed month indices. */ + monthsByYear: Map>; +} + +function buildAllowedRange(monthsBack: number): AllowedRange { + const now = new Date(); + const startYear = now.getFullYear(); + const startMonth = now.getMonth(); + const pairs: { year: number; monthIdx: number }[] = []; + for (let i = 0; i < monthsBack; i++) { + const monthIdx = (((startMonth - i) % 12) + 12) % 12; + const yearDelta = Math.floor((startMonth - i) / 12); + pairs.push({ year: startYear + yearDelta, monthIdx }); + } + const monthsByYear = new Map>(); + for (const { year, monthIdx } of pairs) { + if (!monthsByYear.has(year)) monthsByYear.set(year, new Set()); + monthsByYear.get(year)!.add(monthIdx); + } + const years = Array.from(monthsByYear.keys()).sort((a, b) => b - a); + return { pairs, years, monthsByYear }; +} + +function formatLabel(iso: string): string { + const y = iso.slice(0, 4); + const m = parseInt(iso.slice(5, 7), 10) - 1; + if (Number.isNaN(m) || m < 0 || m > 11) return iso; + return `${MONTH_LABELS_SHORT[m]} ${y}`; } export default function MonthYearPicker({ label, value, onChange, - yearCount = 2, + monthsBack = 24, + placeholder = "Select month", }: MonthYearPickerProps) { - const [year, setYear] = useState(value ? value.slice(0, 4) : ""); - const [month, setMonth] = useState(value ? value.slice(5, 7) : ""); + const containerRef = useRef(null); + const [open, setOpen] = useState(false); + const [view, setView] = useState<"year" | "month">("year"); + const [pendingYear, setPendingYear] = useState(null); - useEffect(() => { - setYear(value ? value.slice(0, 4) : ""); - setMonth(value ? value.slice(5, 7) : ""); - }, [value]); + const range = useMemo(() => buildAllowedRange(monthsBack), [monthsBack]); + + const selectedYear = value ? parseInt(value.slice(0, 4), 10) : null; + const selectedMonthIdx = value ? parseInt(value.slice(5, 7), 10) - 1 : null; - const yearOptions = getRecentYearOptions(yearCount); + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!containerRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); - const flush = (y: string, m: string) => { - if (y && m) onChange(`${y}-${m}-01`); - else if (!y && !m) onChange(""); + const openPopover = () => { + setOpen(true); + if (selectedYear != null) { + setPendingYear(selectedYear); + setView("month"); + } else { + setPendingYear(null); + setView("year"); + } }; - const handleMonth = (m: string) => { - setMonth(m); - flush(year, m); + const handleYearPick = (year: number) => { + setPendingYear(year); + setView("month"); }; - const handleYear = (y: string) => { - setYear(y); - flush(y, month); + + const handleMonthPick = (monthIdx: number) => { + if (pendingYear == null) return; + const mm = String(monthIdx + 1).padStart(2, "0"); + onChange(`${pendingYear}-${mm}-01`); + setOpen(false); }; return ( -
- -
- handleYear(e.target.value)} - options={yearOptions} - placeholder="Year" +
+ {label && ( + + )} + + + {open && ( +
+ {view === "year" ? ( + + ) : ( + setView("year")} + onSelect={handleMonthPick} + /> + )} +
+ )} +
+ ); +} + +function YearGrid({ + years, + selectedYear, + onSelect, +}: { + years: number[]; + selectedYear: number | null; + onSelect: (year: number) => void; +}) { + return ( +
+

+ Select year +

+
+ {years.map((y) => { + const isSelected = y === selectedYear; + return ( + + ); + })} +
+
+ ); +} + +function MonthGrid({ + year, + allowedMonths, + selectedMonth, + onBack, + onSelect, +}: { + year: number; + allowedMonths: Set; + selectedMonth: number | null; + onBack: () => void; + onSelect: (monthIdx: number) => void; +}) { + return ( +
+
+ + + {year} + +
+
+ {MONTH_LABELS_SHORT.map((label, monthIdx) => { + const allowed = allowedMonths.has(monthIdx); + const isSelected = allowed && selectedMonth === monthIdx; + return ( + + ); + })}
); diff --git a/app/components/icons/common/TooltipIcon.tsx b/app/components/icons/common/TooltipIcon.tsx new file mode 100644 index 00000000..70624f14 --- /dev/null +++ b/app/components/icons/common/TooltipIcon.tsx @@ -0,0 +1,19 @@ +interface IconProps { + className?: string; + style?: React.CSSProperties; +} + +export default function TooltipIcon({ className, style }: IconProps) { + return ( + + ); +} diff --git a/app/components/icons/index.tsx b/app/components/icons/index.tsx index a1d893df..0ed575e2 100644 --- a/app/components/icons/index.tsx +++ b/app/components/icons/index.tsx @@ -1,5 +1,6 @@ // Common Icons (shared across multiple pages) export { default as ArrowLeftIcon } from "./common/ArrowLeftIcon"; +export { default as TooltipIcon } from "./common/TooltipIcon"; export { default as ChevronDownIcon } from "./common/ChevronDownIcon"; export { default as CheckIcon } from "./common/CheckIcon"; export { default as CheckLineIcon } from "./common/CheckLineIcon"; diff --git a/app/components/ui/InfoTooltip.tsx b/app/components/ui/InfoTooltip.tsx index eda02b78..eeb711b7 100644 --- a/app/components/ui/InfoTooltip.tsx +++ b/app/components/ui/InfoTooltip.tsx @@ -8,6 +8,7 @@ import { useCallback, } from "react"; import { createPortal } from "react-dom"; +import { TooltipIcon } from "@/app/components/icons"; interface InfoTooltipProps { text: ReactNode; @@ -62,9 +63,9 @@ export default function InfoTooltip({ text }: InfoTooltipProps) { onMouseLeave={() => setVisible(false)} onFocus={() => setVisible(true)} onBlur={() => setVisible(false)} - className="w-4 h-4 rounded-full text-[10px] font-bold flex items-center justify-center leading-none select-none bg-neutral-200 text-neutral-600 border border-neutral-300 cursor-pointer hover:bg-neutral-300 hover:text-neutral-700 transition-colors" + className="w-4 h-4 rounded-full text-[10px] font-bold flex items-center justify-center leading-none select-none cursor-pointer" > - i + {visible && createPortal( diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx index 87f5f53a..2ad57555 100644 --- a/app/components/ui/Select.tsx +++ b/app/components/ui/Select.tsx @@ -13,17 +13,51 @@ export default function Select({ placeholder, ...props }: SelectProps) { + const isGrouped = options.some((o) => o.group !== undefined); + + const groups: { label: string | null; options: SelectOption[] }[] = []; + if (isGrouped) { + const seen = new Map(); + for (const opt of options) { + const key = opt.group ?? null; + if (!seen.has(key)) { + const bucket: SelectOption[] = []; + seen.set(key, bucket); + groups.push({ label: key, options: bucket }); + } + seen.get(key)!.push(opt); + } + } + return ( ); } diff --git a/app/globals.css b/app/globals.css index 5726abae..66fb0d6f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -249,3 +249,27 @@ a { opacity: 1; cursor: not-allowed; } + +/** + * Accent-colored thin scrollbar for compact data tables. + * Applied via className="custom-scroll-accent" on a scroll container. + */ +.custom-scroll-accent::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.custom-scroll-accent::-webkit-scrollbar-track { + background: transparent; +} +.custom-scroll-accent::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--color-accent-primary) 60%, white); + border-radius: 3px; +} +.custom-scroll-accent::-webkit-scrollbar-thumb:hover { + background-color: var(--color-accent-primary); +} +.custom-scroll-accent { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--color-accent-primary) 60%, white) + transparent; +} diff --git a/app/hooks/useAnalyticsChart.ts b/app/hooks/useAnalyticsChart.ts index a4aff114..ded6cbe9 100644 --- a/app/hooks/useAnalyticsChart.ts +++ b/app/hooks/useAnalyticsChart.ts @@ -1,12 +1,24 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/app/lib/context/AuthContext"; import { apiFetch } from "@/app/lib/apiClient"; import { AnalyticsChartData, AnalyticsChartFilters, AnalyticsChartResponse, + AnalyticsMetric, UseAnalyticsChartResult, } from "@/app/lib/types/analytics"; +import { mergeChartData } from "@/app/lib/utils/analytics/mergeChartData"; + +/** + * Virtual metrics live in the frontend only — the backend doesn't know about + * them. The hook expands each into the atomic metrics shown here, fires one + * request per atom in parallel, and merges the responses before returning. + */ +const VIRTUAL_METRICS: Partial> = { + cost_all: ["cost", "eval_cost"], + volume: ["requests", "eval_runs"], +}; function buildQuery(filters: AnalyticsChartFilters): string { const params = new URLSearchParams(); @@ -21,6 +33,20 @@ function buildQuery(filters: AnalyticsChartFilters): string { return params.toString(); } +async function fetchSingleChart( + filters: AnalyticsChartFilters, + apiKey: string, +): Promise { + const result = await apiFetch( + `/api/analytics/monthly/chart?${buildQuery(filters)}`, + apiKey, + ); + if (!result.success || !result.data) { + throw new Error(result.error ?? "Failed to load analytics"); + } + return result.data; +} + export function useAnalyticsChart( filters: AnalyticsChartFilters, ): UseAnalyticsChartResult { @@ -30,22 +56,21 @@ export function useAnalyticsChart( const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const query = buildQuery(filters); + const filtersKey = useMemo(() => JSON.stringify(filters), [filters]); const fetchChart = useCallback(async () => { if (!isAuthenticated) return; setIsLoading(true); setError(null); try { - const result = await apiFetch( - `/api/analytics/monthly/chart?${query}`, - apiKey, - ); - if (result.success && result.data) { - setData(result.data); + const parts = VIRTUAL_METRICS[filters.metric]; + if (parts) { + const [first, second] = await Promise.all( + parts.map((m) => fetchSingleChart({ ...filters, metric: m }, apiKey)), + ); + setData(mergeChartData(first, second, filters.metric)); } else { - setError(result.error ?? "Failed to load analytics"); - setData(null); + setData(await fetchSingleChart(filters, apiKey)); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to load analytics"); @@ -53,7 +78,8 @@ export function useAnalyticsChart( } finally { setIsLoading(false); } - }, [apiKey, isAuthenticated, query]); + + }, [apiKey, isAuthenticated, filtersKey]); useEffect(() => { void fetchChart(); diff --git a/app/lib/constants.ts b/app/lib/constants.ts index 3ff84db9..4b4f392c 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -59,10 +59,8 @@ export const ANALYTICS_METRIC_OPTIONS: { value: AnalyticsMetric; label: string; }[] = [ - { value: "requests", label: "Number of requests" }, - { value: "cost", label: "LLM cost (USD)" }, - { value: "eval_runs", label: "Number of eval runs" }, - { value: "eval_cost", label: "Eval cost (USD)" }, + { value: "cost_all", label: "Cost (USD)" }, + { value: "volume", label: "Volume" }, ]; export const ANALYTICS_GROUP_BY_OPTIONS: { diff --git a/app/lib/navConfig.ts b/app/lib/navConfig.ts index de8e1870..03730755 100644 --- a/app/lib/navConfig.ts +++ b/app/lib/navConfig.ts @@ -11,12 +11,6 @@ export const SETTINGS_NAV: SettingsNavSection[] = [ ]; export const NAV_ITEMS: NavItemConfig[] = [ - { - name: "Dashboard", - route: "/dashboard", - icon: "chart", - gateDescription: "Log in to view the analytics dashboard.", - }, { name: "Documents", route: "/document", @@ -62,4 +56,10 @@ export const NAV_ITEMS: NavItemConfig[] = [ gateDescription: "Log in to compare model response quality across different configs.", }, + { + name: "Analytics", + route: "/analytics", + icon: "chart", + gateDescription: "Log in to view the analytics dashboard.", + }, ]; diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts index 66b6245a..9bf968f5 100644 --- a/app/lib/types/analytics.ts +++ b/app/lib/types/analytics.ts @@ -1,6 +1,19 @@ import { APIEnvelope } from "@/app/lib/types/chat"; -export type AnalyticsMetric = "requests" | "cost" | "eval_runs" | "eval_cost"; +/** + * Atomic backend metrics map 1:1 to a single `/analytics/monthly/chart` call. + * Virtual metrics (`cost_all`, `volume`) are frontend-only: the chart hook + * fans them out into two atomic calls and sums the responses before plotting. + * - `cost_all` = `cost` + `eval_cost` (production + eval spend) + * - `volume` = `requests` + `eval_runs` (production calls + eval batches) + */ +export type AnalyticsMetric = + | "requests" + | "cost" + | "eval_runs" + | "eval_cost" + | "cost_all" + | "volume"; export type AnalyticsGroupBy = | "total" @@ -57,7 +70,16 @@ export interface AnalyticsTotalsValue { outputTokens: number; } -export type AnalyticsTotalsMap = Record; +/** Atomic backend metrics — the ones that map 1:1 to a backend call. */ +export type AnalyticsBackendMetric = Exclude< + AnalyticsMetric, + "cost_all" | "volume" +>; + +export type AnalyticsTotalsMap = Record< + AnalyticsBackendMetric, + AnalyticsTotalsValue +>; export interface UseAnalyticsTotalsResult { totals: AnalyticsTotalsMap | null; diff --git a/app/lib/types/ui.ts b/app/lib/types/ui.ts index 9aca28df..7dadc5ec 100644 --- a/app/lib/types/ui.ts +++ b/app/lib/types/ui.ts @@ -1,4 +1,10 @@ export interface SelectOption { value: string; label: string; + /** + * Optional group label. When any option in a ` + How the metric is broken down in the chart and table. +
+ Total = a single combined line.{" "} + Provider /{" "} + Request type/ their combination splits + the metric into one series per group. + + } > void; - /** How many months back to expose (default 24 → 2 years). */ monthsBack?: number; placeholder?: string; } @@ -29,16 +27,7 @@ const MONTH_LABELS_SHORT = [ "Dec", ]; -interface AllowedRange { - /** All `{ year, monthIdx }` pairs that are selectable, newest first. */ - pairs: { year: number; monthIdx: number }[]; - /** Sorted list of years that contain at least one allowed month, newest first. */ - years: number[]; - /** Map of year → Set of allowed month indices. */ - monthsByYear: Map>; -} - -function buildAllowedRange(monthsBack: number): AllowedRange { +function buildAllowedRange(monthsBack: number): MonthYearAllowedRange { const now = new Date(); const startYear = now.getFullYear(); const startMonth = now.getMonth(); diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts index 9bf968f5..e061f0ae 100644 --- a/app/lib/types/analytics.ts +++ b/app/lib/types/analytics.ts @@ -107,3 +107,12 @@ export interface AnalyticsChartRow { __range: [number, number]; [seriesName: string]: string | number | [number, number]; } + +export interface MonthYearAllowedRange { + /** All `{ year, monthIdx }` pairs that are selectable, newest first. */ + pairs: { year: number; monthIdx: number }[]; + /** Sorted list of years that contain at least one allowed month, newest first. */ + years: number[]; + /** Map of year → Set of allowed month indices (0-based). */ + monthsByYear: Map>; +} From 92549135b36b8a8808a26f02a947b2757d1ced88 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:04:19 +0530 Subject: [PATCH 12/13] fix(analytics): define types --- app/lib/types/analytics.ts | 11 +++++++--- app/lib/utils/analytics/mergeChartData.ts | 25 +++++------------------ 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/app/lib/types/analytics.ts b/app/lib/types/analytics.ts index e061f0ae..7577ec47 100644 --- a/app/lib/types/analytics.ts +++ b/app/lib/types/analytics.ts @@ -108,11 +108,16 @@ export interface AnalyticsChartRow { [seriesName: string]: string | number | [number, number]; } +export interface AnalyticsSeriesAggregate { + data: number[]; + totalTokens: number; + inputTokens: number; + outputTokens: number; + hasTokens: boolean; +} + export interface MonthYearAllowedRange { - /** All `{ year, monthIdx }` pairs that are selectable, newest first. */ pairs: { year: number; monthIdx: number }[]; - /** Sorted list of years that contain at least one allowed month, newest first. */ years: number[]; - /** Map of year → Set of allowed month indices (0-based). */ monthsByYear: Map>; } diff --git a/app/lib/utils/analytics/mergeChartData.ts b/app/lib/utils/analytics/mergeChartData.ts index 9fb84ee6..e7e802fa 100644 --- a/app/lib/utils/analytics/mergeChartData.ts +++ b/app/lib/utils/analytics/mergeChartData.ts @@ -1,29 +1,14 @@ import { AnalyticsChartData, AnalyticsMetric, + AnalyticsSeriesAggregate, AnalyticsSeriesPoint, } from "@/app/lib/types/analytics"; -interface SeriesAggregate { - data: number[]; - totalTokens: number; - inputTokens: number; - outputTokens: number; - hasTokens: boolean; -} - /** - * Sums two `AnalyticsChartData` responses into one synthetic series set. - * - * Used to back the "virtual" metrics (`cost_all`, `volume`) — we fire one - * backend call per atomic metric and combine the responses here, aligning - * series by name and label by month. - * - * - Labels are the union of both responses' labels, sorted lexicographically - * (which sorts correctly because they are `YYYY-MM-DD` strings). - * - Series are matched by `name`. If a series appears in only one response, - * its values are kept and the other response contributes 0 for those months. - * - Token totals are summed across both responses. + * Sums two `AnalyticsChartData` responses into one — labels unioned, + * series matched by name, values and token totals added per month. + * Backs the virtual metrics (`cost_all`, `volume`). */ export function mergeChartData( a: AnalyticsChartData, @@ -33,7 +18,7 @@ export function mergeChartData( const labels = Array.from(new Set([...a.labels, ...b.labels])).sort(); const labelIndex = new Map(labels.map((l, i) => [l, i])); - const map = new Map(); + const map = new Map(); const ingest = (src: AnalyticsChartData) => { for (const s of src.series) { From 3351a5ef994760895744baca0d8a03f4921d0581 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:19:14 +0530 Subject: [PATCH 13/13] fix(guardrails): few updates and ui alignment --- app/(main)/analytics/page.tsx | 33 +++---- .../analytics/AnalyticsChartCard.tsx | 47 +++++----- .../analytics/AnalyticsTotalsRow.tsx | 90 +++++-------------- app/components/analytics/BreakdownPanel.tsx | 6 +- app/components/analytics/LineTrendChart.tsx | 2 +- 5 files changed, 70 insertions(+), 108 deletions(-) diff --git a/app/(main)/analytics/page.tsx b/app/(main)/analytics/page.tsx index dae9ab05..c16f7c28 100644 --- a/app/(main)/analytics/page.tsx +++ b/app/(main)/analytics/page.tsx @@ -118,11 +118,11 @@ export default function AnalyticsPage() { className="flex-1 basis-40 min-w-40" info={ <> - What the chart and breakdown measure. + What the chart shows.
- Cost = USD spent (production + eval - combined). Volume = number of LLM - requests + eval runs. + Cost = money spent on AI (real users + + quality checks combined). Volume = how + busy your AI was (number of requests). } > @@ -139,12 +139,12 @@ export default function AnalyticsPage() { className="flex-1 basis-40 min-w-40" info={ <> - How the metric is broken down in the chart and table. + How the chart is split up.
- Total = a single combined line.{" "} - Provider /{" "} - Request type/ their combination splits - the metric into one series per group. + Total = everything as one line. Pick{" "} + Provider (who supplies the AI) or{" "} + Request type (what the AI was used for) + to see one line per group. } > @@ -161,14 +161,15 @@ export default function AnalyticsPage() { className="flex-1 basis-40 min-w-40" info={ <> - Filter to a specific call modality: + What the AI was used for. Pick one to see numbers for + just that kind of work:
- Text → Text (chat),{" "} - Speech → Speech,{" "} - Speech → Text (transcription),{" "} - Text → Speech (TTS), or{" "} - Other. Leave as All to include - every type. + Text → Text (chat / written replies),{" "} + Speech → Speech (voice conversations),{" "} + Speech → Text (turning audio into + written text), Text → Speech (reading + text aloud), or Other. Leave as{" "} + All to count everything. } > diff --git a/app/components/analytics/AnalyticsChartCard.tsx b/app/components/analytics/AnalyticsChartCard.tsx index 44d29781..52438b40 100644 --- a/app/components/analytics/AnalyticsChartCard.tsx +++ b/app/components/analytics/AnalyticsChartCard.tsx @@ -15,16 +15,22 @@ import BreakdownPanel from "./BreakdownPanel"; import LineTrendChart from "./LineTrendChart"; const SERIES_COLORS = [ - "#1f4496", + "#2563eb", + "#e11d48", "#16a34a", "#f59e0b", - "#dc2626", - "#8b5cf6", + "#7c3aed", "#0891b2", + "#ea580c", "#db2777", "#65a30d", - "#ea580c", + "#4f46e5", + "#dc2626", + "#0d9488", + "#c026d3", + "#ca8a04", "#475569", + "#84cc16", ]; function buildRows(chart: AnalyticsChartData): AnalyticsChartRow[] { @@ -143,11 +149,13 @@ export default function AnalyticsChartCard({ - Chart values are the selected metric ( - {metricLabel}) summed per month and broken - out by the chosen Group by. The thick line is - the total across all groups; the band is the spread between - the lowest and highest group at each month. + Shows {metricLabel} month by month, split by + what you chose under Group by (for example, one line + per provider). +
+
+ The dotted line is the overall total — adding all the coloured + lines together each month. } /> @@ -187,17 +195,14 @@ export default function AnalyticsChartCard({ - Tokens reported by the response for{" "} - {metricLabel} with the current filters. - Only models that actually consumed tokens contribute to - this sum. Use this to gauge how much volume produced the - numbers above. + The amount of text the AI processed to produce the numbers + above. Tokens are how AI providers measure work and bill + you — roughly one word ≈ 1.3 tokens.

- This is not a global total —{" "} - production and eval tokens are shown - separately in the "All-time totals" row at the - bottom of the page. + This only counts the data shown in the chart. For a full + real-users-vs-quality-checks split, see the "All-time + totals" section at the top. } /> @@ -211,17 +216,17 @@ export default function AnalyticsChartCard({
diff --git a/app/components/analytics/AnalyticsTotalsRow.tsx b/app/components/analytics/AnalyticsTotalsRow.tsx index 6c3d41fe..9eb24e9b 100644 --- a/app/components/analytics/AnalyticsTotalsRow.tsx +++ b/app/components/analytics/AnalyticsTotalsRow.tsx @@ -53,110 +53,66 @@ export default function AnalyticsTotalsRow({ All-time totals

- Each row pairs production with eval. + Real users on the left, quality checks on the right.

- +
- USD spent on production LLM calls in the filter - window. Backend metric: cost. - - } + hint="Real users using your AI" + tooltip="Money spent on AI requests made by your real users (excludes any testing or quality checks you ran)." /> - - USD spent on LLM calls made by eval runs. Backend - metric: eval_cost. - - } - /> - - - + + - - - - - +
); } type Accent = "cost" | "usage" | "activity"; -const ACCENT_DOT: Record = { - cost: "bg-status-success", - usage: "bg-accent-primary", - activity: "bg-status-warning", -}; - const ACCENT_CARD: Record = { cost: "bg-status-success-bg/40 border-status-success-border/40", usage: "bg-accent-primary/5 border-accent-primary/20", activity: "bg-status-warning-bg/40 border-status-warning-border/40", }; -function TotalsSection({ - title, - accent, - children, -}: { - title: string; - accent: Accent; - children: ReactNode; -}) { - return ( -
-

- - {title} -

-
{children}
-
- ); -} - function StatCard({ label, value, diff --git a/app/components/analytics/BreakdownPanel.tsx b/app/components/analytics/BreakdownPanel.tsx index 09758670..1a8d0f3a 100644 --- a/app/components/analytics/BreakdownPanel.tsx +++ b/app/components/analytics/BreakdownPanel.tsx @@ -83,7 +83,7 @@ function PieTooltip({ active, payload, metric, grandTotal }: PieTooltipProps) {
@@ -169,7 +169,7 @@ export default function BreakdownPanel({ @@ -256,7 +256,7 @@ export default function BreakdownPanel({ className="inline-flex items-center gap-1.5 text-[12px] text-text-secondary" > {d.name} diff --git a/app/components/analytics/LineTrendChart.tsx b/app/components/analytics/LineTrendChart.tsx index 2ebc6d0b..42521923 100644 --- a/app/components/analytics/LineTrendChart.tsx +++ b/app/components/analytics/LineTrendChart.tsx @@ -134,7 +134,7 @@ export default function LineTrendChart({ className="inline-flex items-center gap-1.5 text-[12px] text-text-secondary" > {s.name}