Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/app/(dashboard)/dashboard/usage/components/OverviewCards.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import Card from "@/shared/components/Card";

const fmt = (n) => new Intl.NumberFormat().format(n || 0);
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
const fmtPct = (n) => `${(n * 100).toFixed(1)}%`;

export default function OverviewCards({ stats }) {
const cacheHitRatio = stats.totalPromptTokens > 0 ? stats.totalCacheReadTokens / stats.totalPromptTokens : 0;

return (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-4 sm:gap-4">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 sm:gap-4">
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
Expand All @@ -21,6 +24,14 @@ export default function OverviewCards({ stats }) {
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
</Card>
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span>
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCacheReadTokens || 0)}</span>
</Card>
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Cache Hit %</span>
<span className="truncate text-2xl font-bold text-success">{fmtPct(cacheHitRatio)}</span>
</Card>
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Est. Cost</span>
<span className="truncate text-2xl font-bold text-warning">~{fmtCost(stats.totalCost)}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ function getInputTokens(tokens) {
return prompt < cache ? cache : prompt;
}

function getCacheReadTokens(tokens) {
return tokens?.cache_read_input_tokens || tokens?.cached_tokens || 0;
}

function getCacheHitPercent(tokens) {
const prompt = tokens?.prompt_tokens || tokens?.input_tokens || 0;
const cache = tokens?.cache_read_input_tokens || tokens?.cached_tokens || 0;
return prompt > 0 ? (cache / prompt * 100) : 0;
}

export default function RequestDetailsTab() {
const [details, setDetails] = useState([]);
const [pagination, setPagination] = useState({
Expand Down Expand Up @@ -246,14 +256,16 @@ export default function RequestDetailsTab() {
<th className="text-left p-4 text-sm font-semibold text-text-main">Provider</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Output Tokens</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Cached Tokens</th>
<th className="text-right p-4 text-sm font-semibold text-text-main">Cache Hit %</th>
<th className="text-left p-4 text-sm font-semibold text-text-main">Latency</th>
<th className="text-center p-4 text-sm font-semibold text-text-main">Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan="7" className="p-8 text-center text-text-muted">
<td colSpan="9" className="p-8 text-center text-text-muted">
<div className="flex items-center justify-center gap-2">
<span className="material-symbols-outlined animate-spin text-[20px]">progress_activity</span>
Loading...
Expand All @@ -262,7 +274,7 @@ export default function RequestDetailsTab() {
</tr>
) : details.length === 0 ? (
<tr>
<td colSpan="7" className="p-8 text-center text-text-muted">
<td colSpan="9" className="p-8 text-center text-text-muted">
No request details found
</td>
</tr>
Expand All @@ -289,6 +301,12 @@ export default function RequestDetailsTab() {
<td className="p-4 text-sm text-text-main text-right font-mono">
{detail.tokens?.completion_tokens?.toLocaleString() || 0}
</td>
<td className="p-4 text-sm text-text-main text-right font-mono">
{getCacheReadTokens(detail.tokens).toLocaleString()}
</td>
<td className="p-4 text-sm text-text-main text-right font-mono">
{getCacheHitPercent(detail.tokens).toFixed(1)}%
</td>
<td className="p-4 text-sm text-text-muted">
<div className="flex flex-col gap-0.5">
<div>TTFT: <span className="font-mono">{detail.latency?.ttft || 0}ms</span></div>
Expand Down Expand Up @@ -376,6 +394,18 @@ export default function RequestDetailsTab() {
{selectedDetail.tokens?.completion_tokens?.toLocaleString() || 0}
</span>
</div>
<div>
<span className="text-text-muted">Cached Tokens:</span>{" "}
<span className="text-text-main font-mono">
{getCacheReadTokens(selectedDetail.tokens).toLocaleString()}
</span>
</div>
<div>
<span className="text-text-muted">Cache Hit %:</span>{" "}
<span className="text-text-main font-mono">
{getCacheHitPercent(selectedDetail.tokens).toFixed(1)}%
</span>
</div>
</div>

<div className="space-y-4">
Expand Down
191 changes: 116 additions & 75 deletions src/app/(dashboard)/dashboard/usage/components/UsageChart.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,19 @@ const fmtTokens = (n) => {
};

const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
const fmtPct = (n) => `${(n * 100).toFixed(1)}%`;
const GROUP_COLORS = ["#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", "#f97316", "#84cc16"];

export default function UsageChart({ period = "7d" }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState("tokens");
const [filterBy, setFilterBy] = useState("all");

const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/usage/chart?period=${period}`);
const res = await fetch(`/api/usage/chart?period=${period}&filterBy=${filterBy}`);
if (res.ok) {
const json = await res.json();
setData(json);
Expand All @@ -40,29 +43,60 @@ export default function UsageChart({ period = "7d" }) {
} finally {
setLoading(false);
}
}, [period]);
}, [period, filterBy]);

useEffect(() => {
fetchData();
}, [fetchData]);

const hasData = data.some((d) => d.tokens > 0 || d.cost > 0);
const isGrouped = data && data.grouped;
const chartData = isGrouped ? data.groups : { all: data };
const hasData = isGrouped ? Object.keys(data.groups || {}).length > 0 : data.some((d) => d.tokens > 0 || d.cost > 0 || d.cachedTokens > 0);

return (
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
<div className="grid w-full grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:w-auto sm:self-start">
<button
onClick={() => setViewMode("tokens")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("cost")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "cost" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Cost
</button>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid w-full grid-cols-4 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:w-auto sm:self-start">
<button
onClick={() => setViewMode("tokens")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("cached")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "cached" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Cached
</button>
<button
onClick={() => setViewMode("cacheHit")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "cacheHit" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Cache %
</button>
<button
onClick={() => setViewMode("cost")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "cost" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Cost
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">Filter:</span>
<select
value={filterBy}
onChange={(e) => setFilterBy(e.target.value)}
className="rounded-lg border border-border bg-surface px-2 py-1 text-xs font-medium text-text-main focus:outline-none focus:ring-2 focus:ring-primary/50"
style={{ colorScheme: 'auto' }}
>
<option value="all">All</option>
<option value="model">By Model</option>
<option value="account">By Account</option>
<option value="apiKey">By API Key</option>
<option value="endpoint">By Endpoint</option>
</select>
</div>
</div>

{loading ? (
Expand All @@ -71,65 +105,72 @@ export default function UsageChart({ period = "7d" }) {
<div className="h-48 flex items-center justify-center text-text-muted text-sm">No data for this period</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradTokens" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.25} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradCost" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.25} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
tickLine={false}
axisLine={false}
tickFormatter={viewMode === "tokens" ? fmtTokens : fmtCost}
width={50}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--color-bg)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value, name) =>
name === "tokens" ? [fmtTokens(value), "Tokens"] : [fmtCost(value), "Cost"]
}
/>
{viewMode === "tokens" ? (
<Area
type="monotone"
dataKey="tokens"
stroke="#6366f1"
strokeWidth={2}
fill="url(#gradTokens)"
dot={false}
activeDot={{ r: 4 }}
/>
) : (
<Area
type="monotone"
dataKey="cost"
stroke="#f59e0b"
strokeWidth={2}
fill="url(#gradCost)"
dot={false}
activeDot={{ r: 4 }}
/>
)}
</AreaChart>
{isGrouped ? (
(() => {
const groupNames = Object.keys(chartData);
const merged = {};
groupNames.forEach((name) => {
(chartData[name] || []).forEach((d) => {
if (!merged[d.label]) merged[d.label] = { label: d.label };
merged[d.label][name] = viewMode === "tokens" ? d.tokens : viewMode === "cached" ? d.cachedTokens : viewMode === "cacheHit" ? d.cacheHitRatio : d.cost;
});
});
const mergedArr = Object.values(merged);
return (
<AreaChart data={mergedArr} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }} tickLine={false} axisLine={false} tickFormatter={viewMode === "cost" ? fmtCost : viewMode === "cacheHit" ? fmtPct : fmtTokens} width={50} />
<Tooltip contentStyle={{ backgroundColor: "var(--color-bg)", border: "1px solid var(--color-border)", borderRadius: "8px", fontSize: "12px" }} formatter={(value, name) => [viewMode === "cost" ? fmtCost(value) : viewMode === "cacheHit" ? fmtPct(value) : fmtTokens(value), name]} />
{groupNames.map((name, i) => (
<Area key={name} type="monotone" dataKey={name} name={name} stroke={GROUP_COLORS[i % GROUP_COLORS.length]} strokeWidth={2} fill="none" dot={false} activeDot={{ r: 4 }} />
))}
</AreaChart>
);
})()
) : (
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradTokens" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.25} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradCached" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradCost" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.25} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }} tickLine={false} axisLine={false} tickFormatter={viewMode === "cost" ? fmtCost : viewMode === "cacheHit" ? fmtPct : fmtTokens} width={50} />
<Tooltip contentStyle={{ backgroundColor: "var(--color-bg)", border: "1px solid var(--color-border)", borderRadius: "8px", fontSize: "12px" }} formatter={(value, name) => {
if (name === "tokens") return [fmtTokens(value), "Tokens"];
if (name === "cachedTokens") return [fmtTokens(value), "Cached Tokens"];
if (name === "cacheHitRatio") return [fmtPct(value), "Cache Hit %"];
if (name === "cost") return [fmtCost(value), "Cost"];
return [value, name];
}} />
{viewMode === "tokens" && (
<Area type="monotone" dataKey="tokens" stroke="#6366f1" strokeWidth={2} fill="url(#gradTokens)" dot={false} activeDot={{ r: 4 }} />
)}
{viewMode === "cached" && (
<>
<Area type="monotone" dataKey="tokens" stroke="#6366f1" strokeWidth={1} fill="url(#gradTokens)" dot={false} activeDot={{ r: 4 }} name="tokens" />
<Area type="monotone" dataKey="cachedTokens" stroke="#10b981" strokeWidth={2} fill="url(#gradCached)" dot={false} activeDot={{ r: 4 }} />
</>
)}
{viewMode === "cacheHit" && (
<Area type="monotone" dataKey="cacheHitRatio" stroke="#10b981" strokeWidth={2} fill="url(#gradCached)" dot={false} activeDot={{ r: 4 }} />
)}
{viewMode === "cost" && (
<Area type="monotone" dataKey="cost" stroke="#f59e0b" strokeWidth={2} fill="url(#gradCost)" dot={false} activeDot={{ r: 4 }} />
)}
</AreaChart>
)}
</ResponsiveContainer>
)}
</Card>
Expand Down
9 changes: 9 additions & 0 deletions src/app/(dashboard)/dashboard/usage/components/UsageTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ SortIcon.propTypes = {
*/
function ValueCells({ item, viewMode, isSummary = false }) {
if (viewMode === "tokens") {
const cacheHitPct = item.promptTokens > 0 ? ((item.cacheReadTokens || 0) / item.promptTokens * 100) : 0;
return (
<>
<td className="px-6 py-3 text-right text-text-muted">
Expand All @@ -41,6 +42,12 @@ function ValueCells({ item, viewMode, isSummary = false }) {
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.cacheReadTokens === undefined ? "—" : fmt(item.cacheReadTokens || 0)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.cacheReadTokens === undefined ? "—" : `${cacheHitPct.toFixed(1)}%`}
</td>
<td className="px-6 py-3 text-right font-medium">
{fmt(item.totalTokens)}
</td>
Expand Down Expand Up @@ -134,6 +141,8 @@ export default function UsageTable({
return [
{ field: "promptTokens", label: "Input Tokens" },
{ field: "completionTokens", label: "Output Tokens" },
{ field: "cacheReadTokens", label: "Cached Tokens" },
{ field: "cacheHitRatio", label: "Cache Hit %" },
{ field: "totalTokens", label: "Total Tokens" },
];
}
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/usage/chart/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const period = searchParams.get("period") || "7d";
const filterBy = searchParams.get("filterBy") || "all";

if (!VALID_PERIODS.has(period)) {
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
}

const data = await getChartData(period);
const data = await getChartData(period, filterBy);
return NextResponse.json(data);
} catch (error) {
console.error("[API] Failed to get chart data:", error);
Expand Down
Loading