"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers";
// Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds
function isLLMProvider(id) {
const p = AI_PROVIDERS[id];
if (!p?.serviceKinds) return true;
return p.serviceKinds.includes("llm");
}
import Badge from "./Badge";
import Card from "./Card";
import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards";
import UsageTable, { fmt, fmtTime } from "@/app/(dashboard)/dashboard/usage/components/UsageTable";
import ProviderTopology from "@/app/(dashboard)/dashboard/usage/components/ProviderTopology";
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart";
function timeAgo(timestamp) {
const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
// Auto-update time display every second without re-rendering parent
function TimeAgo({ timestamp }) {
const [, setTick] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(timer);
}, []);
return <>{timeAgo(timestamp)}>;
}
function RecentRequests({ requests = [] }) {
return (
{/* Header */}
Recent Requests
{!requests.length ? (
No requests yet.
) : (
|
Model |
In / Out |
When |
{requests.map((r, i) => {
const ok = !r.status || r.status === "ok" || r.status === "success";
return (
|
|
{r.model} |
{fmt(r.promptTokens)}↑
{" "}
{fmt(r.completionTokens)}↓
|
|
);
})}
)}
);
}
function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) {
return Object.entries(dataMap || {})
.map(([key, data]) => {
const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0);
const totalCost = data.cost || 0;
const inputCost = totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0;
const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0;
return { ...data, key, totalTokens, totalCost, inputCost, outputCost, pending: pendingMap[key] || 0 };
})
.sort((a, b) => {
let valA = a[sortBy];
let valB = b[sortBy];
if (typeof valA === "string") valA = valA.toLowerCase();
if (typeof valB === "string") valB = valB.toLowerCase();
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
}
function getGroupKey(item, keyField) {
switch (keyField) {
case "rawModel": return item.rawModel || "Unknown Model";
case "accountName": return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || "Unknown Account";
case "keyName": return item.keyName || "Unknown Key";
case "endpoint": return item.endpoint || "Unknown Endpoint";
default: return item[keyField] || "Unknown";
}
}
function groupDataByKey(data, keyField) {
if (!Array.isArray(data)) return [];
const groups = {};
data.forEach((item) => {
const gk = getGroupKey(item, keyField);
if (!groups[gk]) {
groups[gk] = {
groupKey: gk,
summary: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, outputCost: 0, lastUsed: null, pending: 0 },
items: [],
};
}
const s = groups[gk].summary;
s.requests += item.requests || 0;
s.promptTokens += item.promptTokens || 0;
s.completionTokens += item.completionTokens || 0;
s.totalTokens += item.totalTokens || 0;
s.cost += item.cost || 0;
s.inputCost += item.inputCost || 0;
s.outputCost += item.outputCost || 0;
s.pending += item.pending || 0;
if (item.lastUsed && (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))) {
s.lastUsed = item.lastUsed;
}
groups[gk].items.push(item);
});
return Object.values(groups);
}
const MODEL_COLUMNS = [
{ field: "rawModel", label: "Model" },
{ field: "provider", label: "Provider" },
{ field: "requests", label: "Requests", align: "right" },
{ field: "lastUsed", label: "Last Used", align: "right" },
];
const ACCOUNT_COLUMNS = [
{ field: "rawModel", label: "Model" },
{ field: "provider", label: "Provider" },
{ field: "accountName", label: "Account" },
{ field: "requests", label: "Requests", align: "right" },
{ field: "lastUsed", label: "Last Used", align: "right" },
];
const API_KEY_COLUMNS = [
{ field: "keyName", label: "API Key Name" },
{ field: "rawModel", label: "Model" },
{ field: "provider", label: "Provider" },
{ field: "requests", label: "Requests", align: "right" },
{ field: "lastUsed", label: "Last Used", align: "right" },
];
const ENDPOINT_COLUMNS = [
{ field: "endpoint", label: "Endpoint" },
{ field: "rawModel", label: "Model" },
{ field: "provider", label: "Provider" },
{ field: "requests", label: "Requests", align: "right" },
{ field: "lastUsed", label: "Last Used", align: "right" },
];
const TABLE_OPTIONS = [
{ value: "model", label: "Usage by Model" },
{ value: "account", label: "Usage by Account" },
{ value: "apiKey", label: "Usage by API Key" },
{ value: "endpoint", label: "Usage by Endpoint" },
];
const PERIODS = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "60d", label: "60D" },
];
export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) {
const router = useRouter();
const searchParams = useSearchParams();
const sortBy = searchParams.get("sortBy") || "rawModel";
const sortOrder = searchParams.get("sortOrder") || "asc";
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false);
const [tableView, setTableView] = useState("model");
const [viewMode, setViewMode] = useState("costs");
const [providers, setProviders] = useState([]);
const [periodLocal, setPeriodLocal] = useState("7d");
const period = periodProp ?? periodLocal;
const setPeriod = setPeriodProp ?? setPeriodLocal;
// Fetch connected providers once, deduplicate by provider type
// Always include noAuth free providers (e.g. opencode) regardless of connections
useEffect(() => {
fetch("/api/providers")
.then((r) => r.ok ? r.json() : null)
.then((d) => {
const seen = new Set();
const unique = (d?.connections || []).filter((c) => {
if (c.isActive === false) return false;
if (!isLLMProvider(c.provider)) return false;
if (seen.has(c.provider)) return false;
seen.add(c.provider);
return true;
});
const noAuthProviders = Object.values(FREE_PROVIDERS)
.filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id))
.map((p) => ({ provider: p.id, name: p.name }));
setProviders([...unique, ...noAuthProviders]);
})
.catch(() => {});
}, []);
// Fetch filtered stats via REST when period changes
useEffect(() => {
// First load: show full spinner; subsequent: show subtle fetching indicator
if (!stats) setLoading(true);
else setFetching(true);
fetch(`/api/usage/stats?period=${period}`)
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (data) setStats((prev) => ({ ...prev, ...data }));
})
.catch(() => {})
.finally(() => {
setLoading(false);
setFetching(false);
});
}, [period]); // eslint-disable-line react-hooks/exhaustive-deps
// SSE connection - real-time updates for activeRequests + recentRequests only
useEffect(() => {
const es = new EventSource("/api/usage/stream");
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
// Always merge only real-time fields, never overwrite full stats from REST
setStats((prev) => ({
...(prev || {}),
activeRequests: data.activeRequests,
recentRequests: data.recentRequests,
errorProvider: data.errorProvider,
pending: data.pending,
}));
setLoading(false);
} catch (err) {
console.error("[SSE CLIENT] parse error:", err);
}
};
es.onerror = () => setLoading(false);
return () => es.close();
}, []);
const toggleSort = useCallback((tableType, field) => {
const params = new URLSearchParams(searchParams.toString());
if (params.get("sortBy") === field) {
params.set("sortOrder", params.get("sortOrder") === "asc" ? "desc" : "asc");
} else {
params.set("sortBy", field);
params.set("sortOrder", "asc");
}
router.replace(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
// Compute active table data
const activeTableConfig = useMemo(() => {
if (!stats) return null;
switch (tableView) {
case "model": {
const pendingMap = stats.pending?.byModel || {};
return {
columns: MODEL_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byModel, pendingMap, sortBy, sortOrder), "rawModel"),
storageKey: "usage-stats:expanded-models",
emptyMessage: "No usage recorded yet.",
renderSummaryCells: (group) => (
<>
— |
{fmt(group.summary.requests)} |
{fmtTime(group.summary.lastUsed)} |
>
),
renderDetailCells: (item) => (
<>
0 ? "text-primary" : ""}`}>{item.rawModel} |
0 ? "primary" : "neutral"} size="sm">{item.provider} |
{fmt(item.requests)} |
{fmtTime(item.lastUsed)} |
>
),
};
}
case "account": {
const pendingMap = {};
if (stats?.pending?.byAccount) {
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => {
const connPending = stats.pending.byAccount[data.connectionId];
if (connPending) {
const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel;
pendingMap[accountKey] = connPending[modelKey] || 0;
}
});
}
return {
columns: ACCOUNT_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byAccount, pendingMap, sortBy, sortOrder), "accountName"),
storageKey: "usage-stats:expanded-accounts",
emptyMessage: "No account-specific usage recorded yet.",
renderSummaryCells: (group) => (
<>
— |
— |
{fmt(group.summary.requests)} |
{fmtTime(group.summary.lastUsed)} |
>
),
renderDetailCells: (item) => (
<>
0 ? "text-primary" : ""}`}>{item.accountName || `Account ${item.connectionId?.slice(0, 8)}...`} |
0 ? "text-primary" : ""}`}>{item.rawModel} |
0 ? "primary" : "neutral"} size="sm">{item.provider} |
{fmt(item.requests)} |
{fmtTime(item.lastUsed)} |
>
),
};
}
case "apiKey": {
return {
columns: API_KEY_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byApiKey, {}, sortBy, sortOrder), "keyName"),
storageKey: "usage-stats:expanded-apikeys",
emptyMessage: "No API key usage recorded yet.",
renderSummaryCells: (group) => (
<>
— |
— |
{fmt(group.summary.requests)} |
{fmtTime(group.summary.lastUsed)} |
>
),
renderDetailCells: (item) => (
<>
{item.keyName} |
{item.rawModel} |
{item.provider} |
{fmt(item.requests)} |
{fmtTime(item.lastUsed)} |
>
),
};
}
case "endpoint":
default: {
return {
columns: ENDPOINT_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byEndpoint, {}, sortBy, sortOrder), "endpoint"),
storageKey: "usage-stats:expanded-endpoints",
emptyMessage: "No endpoint usage recorded yet.",
renderSummaryCells: (group) => (
<>
— |
— |
{fmt(group.summary.requests)} |
{fmtTime(group.summary.lastUsed)} |
>
),
renderDetailCells: (item) => (
<>
{item.endpoint} |
{item.rawModel} |
{item.provider} |
{fmt(item.requests)} |
{fmtTime(item.lastUsed)} |
>
),
};
}
}
}, [stats, tableView, sortBy, sortOrder]);
if (!stats && !loading) return Failed to load usage statistics.
;
const spinner = (
progress_activity
);
return (
{/* Period selector (hidden when controlled by parent) */}
{!hidePeriodSelector && (
{PERIODS.map((p) => (
))}
{fetching && (
progress_activity
)}
)}
{/* Overview cards */}
{loading ? spinner :
}
{/* Provider topology + Recent Requests */}
{loading ? spinner : (
)}
{/* Token / Cost chart - sync period */}
{loading ? spinner :
}
{/* Table with dropdown selector */}
{loading ? spinner : activeTableConfig && (
)}
);
}