Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useState, useMemo, useCallback, useRef, useEffect } from "react"; | |
| import { useLocale, useTranslations } from "next-intl"; | |
| import Card from "../Card"; | |
| import { getModelColor } from "@/shared/constants/colors"; | |
| import { PROVIDER_COLORS } from "./chartColors"; | |
| import { | |
| fmtCompact as fmt, | |
| fmtFull, | |
| fmtCost, | |
| formatApiKeyLabel as maskApiKeyLabel, | |
| } from "@/shared/utils/formatting"; | |
| import { | |
| getServiceTierDisplayLabel, | |
| translateCostText, | |
| type TranslationFn, | |
| } from "@/shared/utils/serviceTierLabels"; | |
| function createDateFormatter(locale: string, options: Intl.DateTimeFormatOptions) { | |
| try { | |
| return new Intl.DateTimeFormat(locale, options); | |
| } catch { | |
| return new Intl.DateTimeFormat(undefined, options); | |
| } | |
| } | |
| // ── Sort Indicator (shared by tables) ────────────────────────────────────── | |
| export function SortIndicator({ active, sortOrder }: { active: boolean; sortOrder: string }) { | |
| if (!active) { | |
| return ( | |
| <span className="material-symbols-outlined text-[12px] opacity-0 group-hover:opacity-30"> | |
| unfold_more | |
| </span> | |
| ); | |
| } | |
| return ( | |
| <span className="material-symbols-outlined text-[12px] text-primary"> | |
| {sortOrder === "asc" ? "expand_less" : "expand_more"} | |
| </span> | |
| ); | |
| } | |
| // ── StatCard (primary KPI) ───────────────────────────────────────────────── | |
| export function StatCard({ | |
| icon, | |
| label, | |
| value, | |
| subValue, | |
| color = "text-text-main", | |
| }: { | |
| icon: any; | |
| label: any; | |
| value: any; | |
| subValue?: any; | |
| color?: string; | |
| }) { | |
| return ( | |
| <Card className="px-4 py-3 flex flex-col gap-1 min-w-0"> | |
| <div className="flex items-center gap-1.5 text-text-muted text-[11px] uppercase font-semibold tracking-wide min-w-0"> | |
| <span className="material-symbols-outlined text-[14px] shrink-0">{icon}</span> | |
| <span className="truncate">{label}</span> | |
| </div> | |
| <span className={`text-2xl font-bold ${color} truncate`} title={String(value)}> | |
| {value} | |
| </span> | |
| {subValue && <span className="text-xs text-text-muted truncate">{subValue}</span>} | |
| </Card> | |
| ); | |
| } | |
| // ── CompactStatGrid (secondary metrics in a single card, grouped) ───────── | |
| export type CompactStatSection = { | |
| title: string; | |
| items: Array<{ icon: string; label: string; value: any; color?: string }>; | |
| /** On mobile use 1 column instead of 2 — useful when values can be long (model names, etc.) */ | |
| wideValues?: boolean; | |
| }; | |
| export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }) { | |
| return ( | |
| <Card className="px-5 py-4"> | |
| <div className="flex flex-col gap-3"> | |
| {sections.map((section, si) => ( | |
| <div key={si}> | |
| {si > 0 && ( | |
| <div className="border-t border-black/[0.06] dark:border-white/[0.06] mb-3" /> | |
| )} | |
| <div className="text-[10px] uppercase font-semibold tracking-widest text-text-muted/50 mb-2"> | |
| {section.title} | |
| </div> | |
| <div | |
| className={ | |
| section.wideValues | |
| ? "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-x-5 gap-y-2" | |
| : "grid grid-cols-2 md:grid-cols-4 gap-x-5 gap-y-2" | |
| } | |
| > | |
| {section.items.map((stat, i) => ( | |
| <div key={i} className="flex items-center justify-between gap-2 min-w-0 py-0.5"> | |
| <div | |
| className={`flex items-center gap-1.5 ${section.wideValues ? "shrink-0" : "min-w-0"}`} | |
| > | |
| <span className="material-symbols-outlined text-[14px] text-text-muted shrink-0"> | |
| {stat.icon} | |
| </span> | |
| <span | |
| className={`text-[11px] uppercase font-semibold tracking-wide text-text-muted ${section.wideValues ? "whitespace-nowrap" : "truncate"}`} | |
| > | |
| {stat.label} | |
| </span> | |
| </div> | |
| <span | |
| className={`text-sm font-bold text-right ${section.wideValues ? "truncate min-w-0" : "shrink-0"} ${stat.color || "text-text-main"}`} | |
| title={String(stat.value)} | |
| > | |
| {stat.value} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| // ── ActivityHeatmap ──────────────────────────────────────────────────────── | |
| export function ActivityHeatmap({ activityMap }) { | |
| const t = useTranslations("analytics"); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const cells = useMemo(() => { | |
| const today = new Date(); | |
| const days = []; | |
| let maxVal = 0; | |
| for (let i = 364; i >= 0; i--) { | |
| const d = new Date(today); | |
| d.setDate(d.getDate() - i); | |
| const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; | |
| const val = activityMap?.[key] || 0; | |
| if (val > maxVal) maxVal = val; | |
| days.push({ date: key, value: val, dayOfWeek: d.getDay() }); | |
| } | |
| return { days, maxVal }; | |
| }, [activityMap]); | |
| const weeks = useMemo(() => { | |
| const w = []; | |
| let current = []; | |
| const firstDay = cells.days[0]?.dayOfWeek || 0; | |
| for (let i = 0; i < firstDay; i++) { | |
| current.push(null); | |
| } | |
| for (const day of cells.days) { | |
| current.push(day); | |
| if (current.length === 7) { | |
| w.push(current); | |
| current = []; | |
| } | |
| } | |
| if (current.length > 0) w.push(current); | |
| return w; | |
| }, [cells]); | |
| // Auto-scroll to the right edge so the current date is visible | |
| useEffect(() => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollLeft = scrollRef.current.scrollWidth; | |
| } | |
| }, [weeks]); | |
| const monthLabels = useMemo(() => { | |
| const labels = []; | |
| let lastMonth = -1; | |
| weeks.forEach((week, weekIdx) => { | |
| const firstDay = week.find((d) => d !== null); | |
| if (firstDay) { | |
| const m = new Date(firstDay.date).getMonth(); | |
| if (m !== lastMonth) { | |
| const monthNames = [ | |
| "Jan", | |
| "Feb", | |
| "Mar", | |
| "Apr", | |
| "May", | |
| "Jun", | |
| "Jul", | |
| "Aug", | |
| "Sep", | |
| "Oct", | |
| "Nov", | |
| "Dec", | |
| ]; | |
| labels.push({ weekIdx, label: monthNames[m] }); | |
| lastMonth = m; | |
| } | |
| } | |
| }); | |
| return labels; | |
| }, [weeks]); | |
| function getCellColor(value) { | |
| if (!value || value === 0) return "bg-white/[0.04]"; | |
| const intensity = Math.min(value / (cells.maxVal || 1), 1); | |
| if (intensity < 0.25) return "bg-primary/20"; | |
| if (intensity < 0.5) return "bg-primary/40"; | |
| if (intensity < 0.75) return "bg-primary/60"; | |
| return "bg-primary/90"; | |
| } | |
| return ( | |
| <Card className="p-4 h-full min-w-0 overflow-hidden"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider"> | |
| {t("overview")} | |
| </h3> | |
| <span className="text-xs text-text-muted"> | |
| {Object.keys(activityMap || {}).length} active days ·{" "} | |
| {fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens | |
| · 365 days | |
| </span> | |
| </div> | |
| <div ref={scrollRef} className="overflow-x-auto"> | |
| <div className="w-max"> | |
| <div className="flex gap-[3px] mb-1 ml-6" style={{ fontSize: "10px" }}> | |
| {monthLabels.map((m, i) => ( | |
| <span | |
| key={i} | |
| className="text-text-muted" | |
| style={{ | |
| position: "relative", | |
| left: `${m.weekIdx * 13}px`, | |
| marginLeft: i === 0 ? 0 : "-20px", | |
| }} | |
| > | |
| {m.label} | |
| </span> | |
| ))} | |
| </div> | |
| <div className="flex gap-[3px]"> | |
| <div className="flex flex-col gap-[3px] shrink-0 text-[10px] text-text-muted pr-1 sticky left-0 z-10 bg-surface"> | |
| <span className="h-[10px]"></span> | |
| <span className="h-[10px] leading-[10px]">Mon</span> | |
| <span className="h-[10px]"></span> | |
| <span className="h-[10px] leading-[10px]">Wed</span> | |
| <span className="h-[10px]"></span> | |
| <span className="h-[10px] leading-[10px]">Fri</span> | |
| <span className="h-[10px]"></span> | |
| </div> | |
| {weeks.map((week, wi) => ( | |
| <div key={wi} className="flex flex-col gap-[3px]"> | |
| {week.map((day, di) => ( | |
| <div | |
| key={di} | |
| title={day ? `${day.date}: ${fmtFull(day.value)} tokens` : ""} | |
| className={`w-[10px] h-[10px] rounded-[2px] ${day ? getCellColor(day.value) : "bg-transparent"}`} | |
| /> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1 mt-2 ml-6 text-[10px] text-text-muted"> | |
| <span>Less</span> | |
| <div className="w-[10px] h-[10px] rounded-[2px] bg-white/[0.04]" /> | |
| <div className="w-[10px] h-[10px] rounded-[2px] bg-primary/20" /> | |
| <div className="w-[10px] h-[10px] rounded-[2px] bg-primary/40" /> | |
| <div className="w-[10px] h-[10px] rounded-[2px] bg-primary/60" /> | |
| <div className="w-[10px] h-[10px] rounded-[2px] bg-primary/90" /> | |
| <span>More</span> | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| export function ApiKeyTable({ byApiKey }) { | |
| const t = useTranslations("analytics"); | |
| const [query, setQuery] = useState(""); | |
| const [sortBy, setSortBy] = useState("totalTokens"); | |
| const [sortOrder, setSortOrder] = useState("desc"); | |
| const data = useMemo(() => byApiKey || [], [byApiKey]); | |
| const filtered = useMemo(() => { | |
| const q = query.trim().toLowerCase(); | |
| if (!q) return data; | |
| return data.filter( | |
| (row) => | |
| (row.apiKeyName || "").toLowerCase().includes(q) || | |
| (row.apiKeyId || "").toLowerCase().includes(q) | |
| ); | |
| }, [data, query]); | |
| const sorted = useMemo(() => { | |
| const arr = [...filtered]; | |
| arr.sort((a, b) => { | |
| const va = a[sortBy] ?? 0; | |
| const vb = b[sortBy] ?? 0; | |
| if (typeof va === "string") { | |
| return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); | |
| } | |
| return sortOrder === "asc" ? va - vb : vb - va; | |
| }); | |
| return arr; | |
| }, [filtered, sortBy, sortOrder]); | |
| const toggleSort = useCallback( | |
| (field) => { | |
| if (sortBy === field) { | |
| setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); | |
| return; | |
| } | |
| setSortBy(field); | |
| setSortOrder("desc"); | |
| }, | |
| [sortBy] | |
| ); | |
| const hasData = data.length > 0; | |
| if (!hasData) { | |
| return ( | |
| <Card className="p-4 flex-1"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3"> | |
| API Key Breakdown | |
| </h3> | |
| <div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div> | |
| </Card> | |
| ); | |
| } | |
| return ( | |
| <Card className="overflow-hidden"> | |
| <div className="p-4 border-b border-border flex items-center justify-between gap-3"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider"> | |
| API Key Breakdown | |
| </h3> | |
| <input | |
| type="text" | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| placeholder={t("filterSearchKeys")} | |
| className="w-full max-w-[220px] px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary" | |
| /> | |
| </div> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]"> | |
| <tr> | |
| <th | |
| className="px-4 py-2.5 text-left cursor-pointer group" | |
| onClick={() => toggleSort("apiKeyName")} | |
| > | |
| API Key <SortIndicator active={sortBy === "apiKeyName"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("requests")} | |
| > | |
| {t("chartRequests")}{" "} | |
| <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("promptTokens")} | |
| > | |
| {t("chartInput")}{" "} | |
| <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("completionTokens")} | |
| > | |
| {t("chartOutput")}{" "} | |
| <SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("totalTokens")} | |
| > | |
| {t("chartTotal")}{" "} | |
| <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("cost")} | |
| > | |
| {t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} /> | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-border"> | |
| {sorted.map((row, i) => ( | |
| <tr | |
| key={`${row.apiKeyId || row.apiKeyName || "key"}-${i}`} | |
| className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors" | |
| > | |
| <td className="px-4 py-2.5"> | |
| <span className="font-medium" title={row.apiKeyName || row.apiKeyId || "unknown"}> | |
| {maskApiKeyLabel(row.apiKeyName, row.apiKeyId)} | |
| </span> | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-text-muted"> | |
| {fmtFull(row.requests)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-primary"> | |
| {fmt(row.promptTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-emerald-500"> | |
| {fmt(row.completionTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono font-semibold"> | |
| {fmt(row.totalTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-amber-500"> | |
| {fmtCost(row.cost)} | |
| </td> | |
| </tr> | |
| ))} | |
| {sorted.length === 0 && ( | |
| <tr> | |
| <td colSpan={6} className="px-4 py-8 text-center text-text-muted"> | |
| {t("filterNoKeysMatch")} | |
| </td> | |
| </tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| export function MostActiveDay7d({ activityMap }) { | |
| const locale = useLocale(); | |
| const weekdayFormatter = useMemo( | |
| () => createDateFormatter(locale, { weekday: "long" }), | |
| [locale] | |
| ); | |
| const dateFormatter = useMemo( | |
| () => createDateFormatter(locale, { month: "short", day: "numeric" }), | |
| [locale] | |
| ); | |
| const data = useMemo(() => { | |
| if (!activityMap) return null; | |
| const today = new Date(); | |
| let peakKey = null; | |
| let peakVal = 0; | |
| for (let i = 0; i < 7; i++) { | |
| const d = new Date(today); | |
| d.setDate(d.getDate() - i); | |
| const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; | |
| const val = activityMap[key] || 0; | |
| if (val > peakVal) { | |
| peakVal = val; | |
| peakKey = key; | |
| } | |
| } | |
| if (!peakKey || peakVal === 0) return null; | |
| const peakDate = new Date(peakKey + "T12:00:00"); | |
| return { | |
| weekday: weekdayFormatter.format(peakDate), | |
| label: dateFormatter.format(peakDate), | |
| tokens: peakVal, | |
| }; | |
| }, [activityMap, dateFormatter, weekdayFormatter]); | |
| return ( | |
| <Card className="p-4 flex flex-col justify-center" style={{ flex: 1, minHeight: 0 }}> | |
| <h3 | |
| className="text-xs font-semibold uppercase tracking-wider mb-2" | |
| style={{ color: "var(--color-text-muted)" }} | |
| > | |
| Most Active Day | |
| </h3> | |
| {data ? ( | |
| <> | |
| <span className="text-xl font-bold capitalize" style={{ lineHeight: 1.2 }}> | |
| {data.weekday} | |
| </span> | |
| <span className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}> | |
| {data.label} · {fmt(data.tokens)} tokens | |
| </span> | |
| </> | |
| ) : ( | |
| <span className="text-xs" style={{ color: "var(--color-text-muted)" }}> | |
| No data in the last 7 days | |
| </span> | |
| )} | |
| </Card> | |
| ); | |
| } | |
| // ── WeeklySquares7d ──────────────────────────────────────────────────────── | |
| export function WeeklySquares7d({ activityMap }) { | |
| const t = useTranslations("analytics"); | |
| const locale = useLocale(); | |
| const weekdayFormatter = useMemo( | |
| () => createDateFormatter(locale, { weekday: "short" }), | |
| [locale] | |
| ); | |
| const dateFormatter = useMemo( | |
| () => createDateFormatter(locale, { month: "short", day: "numeric" }), | |
| [locale] | |
| ); | |
| const days = useMemo(() => { | |
| if (!activityMap) return []; | |
| const today = new Date(); | |
| const result = []; | |
| let maxVal = 0; | |
| for (let i = 6; i >= 0; i--) { | |
| const d = new Date(today); | |
| d.setDate(d.getDate() - i); | |
| const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; | |
| const val = activityMap[key] || 0; | |
| if (val > maxVal) maxVal = val; | |
| result.push({ | |
| key, | |
| val, | |
| label: weekdayFormatter.format(d), | |
| dateLabel: dateFormatter.format(d), | |
| }); | |
| } | |
| return result.map((d) => ({ ...d, intensity: maxVal > 0 ? d.val / maxVal : 0 })); | |
| }, [activityMap, dateFormatter, weekdayFormatter]); | |
| function getSquareStyle(intensity) { | |
| if (intensity === 0) return { background: "rgba(255,255,255,0.04)" }; | |
| const opacity = 0.15 + intensity * 0.75; | |
| return { background: `rgba(229, 77, 94, ${opacity.toFixed(2)})` }; | |
| } | |
| return ( | |
| <Card className="p-4 flex flex-col justify-center" style={{ flex: 1, minHeight: 0 }}> | |
| <h3 | |
| className="text-xs font-semibold uppercase tracking-wider mb-3" | |
| style={{ color: "var(--color-text-muted)" }} | |
| > | |
| {t("chartWeekly")} | |
| </h3> | |
| <div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}> | |
| {days.map((d, i) => ( | |
| <div | |
| key={d.key} | |
| style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }} | |
| > | |
| <div | |
| title={`${d.dateLabel}: ${fmtFull(d.val)} tokens`} | |
| style={{ | |
| width: 36, | |
| height: 36, | |
| borderRadius: 8, | |
| ...getSquareStyle(d.intensity), | |
| transition: "all 0.2s", | |
| cursor: "default", | |
| }} | |
| /> | |
| <span | |
| style={{ | |
| fontSize: 9, | |
| fontWeight: 600, | |
| color: "var(--color-text-muted)", | |
| letterSpacing: "0.03em", | |
| }} | |
| > | |
| {d.label} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| // ── ModelTable ────────────────────────────────────────────────────────────── | |
| export function ModelTable({ byModel, summary }) { | |
| const t = useTranslations("analytics"); | |
| const [sortBy, setSortBy] = useState("totalTokens"); | |
| const [sortOrder, setSortOrder] = useState("desc"); | |
| const toggleSort = useCallback( | |
| (field) => { | |
| if (sortBy === field) { | |
| setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); | |
| } else { | |
| setSortBy(field); | |
| setSortOrder("desc"); | |
| } | |
| }, | |
| [sortBy] | |
| ); | |
| const sorted = useMemo(() => { | |
| const arr = [...(byModel || [])]; | |
| arr.sort((a, b) => { | |
| const va = a[sortBy] ?? 0; | |
| const vb = b[sortBy] ?? 0; | |
| if (typeof va === "string") | |
| return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); | |
| return sortOrder === "asc" ? va - vb : vb - va; | |
| }); | |
| return arr; | |
| }, [byModel, sortBy, sortOrder]); | |
| return ( | |
| <Card className="overflow-hidden"> | |
| <div className="p-4 border-b border-border"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider"> | |
| {t("chartModelBreakdown")} | |
| </h3> | |
| </div> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]"> | |
| <tr> | |
| <th | |
| className="px-4 py-2.5 text-left cursor-pointer group" | |
| onClick={() => toggleSort("model")} | |
| > | |
| {t("chartModel")}{" "} | |
| <SortIndicator active={sortBy === "model"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("requests")} | |
| > | |
| {t("chartRequests")}{" "} | |
| <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("promptTokens")} | |
| > | |
| {t("chartInput")}{" "} | |
| <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("completionTokens")} | |
| > | |
| {t("chartOutput")}{" "} | |
| <SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("totalTokens")} | |
| > | |
| {t("chartTotal")}{" "} | |
| <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("cost")} | |
| > | |
| {t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} /> | |
| </th> | |
| <th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-border"> | |
| {sorted.map((m, i) => ( | |
| <tr | |
| key={m.model} | |
| className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors" | |
| > | |
| <td className="px-4 py-2.5"> | |
| <div className="flex items-center gap-2"> | |
| <span | |
| className="w-2 h-2 rounded-full shrink-0" | |
| style={{ backgroundColor: getModelColor(i) }} | |
| /> | |
| <span className="font-medium">{m.model}</span> | |
| </div> | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-text-muted"> | |
| {fmtFull(m.requests)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-primary"> | |
| {fmt(m.promptTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-emerald-500"> | |
| {fmt(m.completionTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono font-semibold"> | |
| {fmt(m.totalTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-amber-500"> | |
| {fmtCost(m.cost)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right"> | |
| <div className="flex items-center gap-2 justify-end"> | |
| <div className="w-16 h-1.5 rounded-full bg-white/[0.06] overflow-hidden"> | |
| <div | |
| className="h-full rounded-full transition-all" | |
| style={{ width: `${m.pct}%`, backgroundColor: getModelColor(i) }} | |
| /> | |
| </div> | |
| <span className="text-xs font-mono text-text-muted w-10 text-right"> | |
| {m.pct}% | |
| </span> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| function getServiceTierIcon(serviceTier) { | |
| if (serviceTier === "priority") return "bolt"; | |
| if (serviceTier === "flex") return "savings"; | |
| return "speed"; | |
| } | |
| function getServiceTierIconClass(serviceTier) { | |
| if (serviceTier === "priority") return "text-sky-500"; | |
| if (serviceTier === "flex") return "text-emerald-500"; | |
| return "text-text-muted"; | |
| } | |
| function getServiceTierBarClass(serviceTier) { | |
| if (serviceTier === "priority") return "bg-sky-500"; | |
| if (serviceTier === "flex") return "bg-emerald-500"; | |
| return "bg-text-muted/50"; | |
| } | |
| function getServiceTierCostClass(serviceTier) { | |
| return serviceTier === "flex" ? "text-emerald-500" : "text-amber-500"; | |
| } | |
| export function ServiceTierBreakdown({ byServiceTier, summary }) { | |
| const t = useTranslations("costs") as TranslationFn; | |
| const data = useMemo(() => byServiceTier || [], [byServiceTier]); | |
| const totalRequests = Number(summary?.totalRequests || 0); | |
| const totalCost = Number(summary?.totalCost || 0); | |
| if (!data.length) { | |
| return null; | |
| } | |
| return ( | |
| <Card className="overflow-hidden"> | |
| <div className="p-4 border-b border-border flex items-center justify-between gap-3"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider"> | |
| {translateCostText(t, "serviceTierBreakdownTitle", "Service Tier")} | |
| </h3> | |
| <span className="text-[11px] text-text-muted"> | |
| {translateCostText(t, "serviceTierBreakdownSubtitle", "Fast / Flex / Standard split")} | |
| </span> | |
| </div> | |
| <div className="divide-y divide-border"> | |
| {data.map((tier) => { | |
| const isFlex = tier.serviceTier === "flex"; | |
| const tierLabel = getServiceTierDisplayLabel(t, tier.serviceTier, tier.label); | |
| const requestPct = | |
| totalRequests > 0 | |
| ? ((Number(tier.requests || 0) / totalRequests) * 100).toFixed(1) | |
| : "0"; | |
| const costPct = | |
| totalCost > 0 ? ((Number(tier.cost || 0) / totalCost) * 100).toFixed(1) : "0"; | |
| const usageSavings = Number(tier.usageSavingsTokens || 0); | |
| const costSavings = Number(tier.savings || 0); | |
| const usageSavingsText = | |
| isFlex && usageSavings > 0 | |
| ? ` · ${fmt(usageSavings)} ${translateCostText( | |
| t, | |
| "serviceTierUsageSaved", | |
| "usage saved" | |
| )}` | |
| : ""; | |
| const costDetailText = | |
| isFlex && costSavings > 0 | |
| ? `${fmtCost(costSavings)} ${translateCostText(t, "serviceTierCostSaved", "saved")}` | |
| : `${costPct}% ${translateCostText(t, "serviceTierCostShareSuffix", "of cost")}`; | |
| return ( | |
| <div key={tier.serviceTier} className="p-4 flex flex-col gap-3"> | |
| <div className="flex items-center justify-between gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <span | |
| className={`material-symbols-outlined text-[18px] ${getServiceTierIconClass( | |
| tier.serviceTier | |
| )}`} | |
| > | |
| {getServiceTierIcon(tier.serviceTier)} | |
| </span> | |
| <div> | |
| <div className="text-sm font-semibold text-text-main">{tierLabel}</div> | |
| <div className="text-xs text-text-muted"> | |
| {fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens | |
| {usageSavingsText} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="text-right"> | |
| <div | |
| className={`font-mono text-sm font-semibold ${getServiceTierCostClass( | |
| tier.serviceTier | |
| )}`} | |
| > | |
| {fmtCost(tier.cost)} | |
| </div> | |
| <div className="text-xs text-text-muted">{costDetailText}</div> | |
| </div> | |
| </div> | |
| <div className="h-1.5 rounded-full bg-black/5 dark:bg-white/10 overflow-hidden"> | |
| <div | |
| className={`h-full rounded-full ${getServiceTierBarClass(tier.serviceTier)}`} | |
| style={{ width: `${requestPct}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| // ── UsageDetail ──────────────────────────────────────────────────────────── | |
| export function UsageDetail({ summary }) { | |
| const t = useTranslations("analytics"); | |
| const items = [ | |
| { label: t("chartInput"), value: summary?.promptTokens, color: "text-primary" }, | |
| { label: t("chartCacheRead"), value: 0, color: "text-text-muted" }, | |
| { label: t("chartOutput"), value: summary?.completionTokens, color: "text-emerald-500" }, | |
| ]; | |
| return ( | |
| <Card className="p-4 flex-1"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3"> | |
| {t("chartUsageDetail")} | |
| </h3> | |
| <div className="flex flex-col gap-2"> | |
| {items.map((item, i) => ( | |
| <div key={i} className="flex items-center justify-between"> | |
| <span className={`text-sm ${item.color}`}>{item.label}</span> | |
| <span className="font-mono font-medium text-sm">{fmtFull(item.value)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </Card> | |
| ); | |
| } | |
| // ── ProviderTable ────────────────────────────────────────────────────────── | |
| export function ProviderTable({ byProvider }) { | |
| const t = useTranslations("analytics"); | |
| const [sortBy, setSortBy] = useState("totalTokens"); | |
| const [sortOrder, setSortOrder] = useState("desc"); | |
| const data = useMemo(() => byProvider || [], [byProvider]); | |
| const totalTokens = useMemo(() => data.reduce((acc, p) => acc + p.totalTokens, 0), [data]); | |
| const toggleSort = useCallback( | |
| (field) => { | |
| if (sortBy === field) { | |
| setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); | |
| } else { | |
| setSortBy(field); | |
| setSortOrder("desc"); | |
| } | |
| }, | |
| [sortBy] | |
| ); | |
| const sorted = useMemo(() => { | |
| const arr = [...data]; | |
| arr.sort((a, b) => { | |
| const va = a[sortBy] ?? 0; | |
| const vb = b[sortBy] ?? 0; | |
| if (typeof va === "string") | |
| return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); | |
| return sortOrder === "asc" ? va - vb : vb - va; | |
| }); | |
| return arr; | |
| }, [data, sortBy, sortOrder]); | |
| if (!data.length) { | |
| return ( | |
| <Card className="p-4"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3"> | |
| {t("chartProviderBreakdown")} | |
| </h3> | |
| <div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div> | |
| </Card> | |
| ); | |
| } | |
| return ( | |
| <Card className="overflow-hidden"> | |
| <div className="p-4 border-b border-border"> | |
| <h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider"> | |
| {t("chartProviderBreakdown")} | |
| </h3> | |
| </div> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]"> | |
| <tr> | |
| <th | |
| className="px-4 py-2.5 text-left cursor-pointer group" | |
| onClick={() => toggleSort("provider")} | |
| > | |
| {t("chartProvider")}{" "} | |
| <SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("requests")} | |
| > | |
| {t("chartRequests")}{" "} | |
| <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("promptTokens")} | |
| > | |
| {t("chartInput")}{" "} | |
| <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("completionTokens")} | |
| > | |
| {t("chartOutput")}{" "} | |
| <SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("totalTokens")} | |
| > | |
| {t("chartTotal")}{" "} | |
| <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} /> | |
| </th> | |
| <th | |
| className="px-4 py-2.5 text-right cursor-pointer group" | |
| onClick={() => toggleSort("cost")} | |
| > | |
| {t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} /> | |
| </th> | |
| <th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-border"> | |
| {sorted.map((p, i) => { | |
| const pct = totalTokens > 0 ? ((p.totalTokens / totalTokens) * 100).toFixed(1) : "0"; | |
| return ( | |
| <tr | |
| key={p.provider} | |
| className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors" | |
| > | |
| <td className="px-4 py-2.5"> | |
| <div className="flex items-center gap-2"> | |
| <span | |
| className="w-2 h-2 rounded-full shrink-0" | |
| style={{ backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length] }} | |
| /> | |
| <span className="font-medium capitalize">{p.provider}</span> | |
| </div> | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-text-muted"> | |
| {fmtFull(p.requests)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-primary"> | |
| {fmt(p.promptTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-emerald-500"> | |
| {fmt(p.completionTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono font-semibold"> | |
| {fmt(p.totalTokens)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right font-mono text-amber-500"> | |
| {fmtCost(p.cost)} | |
| </td> | |
| <td className="px-4 py-2.5 text-right"> | |
| <div className="flex items-center gap-2 justify-end"> | |
| <div className="w-16 h-1.5 rounded-full bg-white/[0.06] overflow-hidden"> | |
| <div | |
| className="h-full rounded-full transition-all" | |
| style={{ | |
| width: `${pct}%`, | |
| backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length], | |
| }} | |
| /> | |
| </div> | |
| <span className="text-xs font-mono text-text-muted w-10 text-right"> | |
| {pct}% | |
| </span> | |
| </div> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </Card> | |
| ); | |
| } | |