mirror of
https://github.com/mfakbar127/Claude-Samurai
synced 2026-06-21 13:57:42 +00:00
423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
import {
|
|
format,
|
|
startOfDay,
|
|
startOfMonth,
|
|
startOfWeek,
|
|
subDays,
|
|
subHours,
|
|
} from "date-fns";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AreaChart } from "@/components/ui/area-chart";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import type { ProjectUsageRecord } from "@/lib/query";
|
|
import { formatLargeNumber } from "@/lib/utils";
|
|
|
|
interface TokenUsageChartProps {
|
|
data: ProjectUsageRecord[];
|
|
onFilteredDataChange?: (filteredData: ProjectUsageRecord[]) => void;
|
|
}
|
|
|
|
type TimeRange = "5h" | "today" | "7d" | "week" | "month" | "all";
|
|
|
|
export function TokenUsageChart({
|
|
data,
|
|
onFilteredDataChange,
|
|
}: TokenUsageChartProps) {
|
|
const { t } = useTranslation();
|
|
const [selectedModel, setSelectedModel] = useState<string>("all");
|
|
const [timeRange, setTimeRange] = useState<TimeRange>("5h");
|
|
const [activeCategories, setActiveCategories] = useState<string[]>([
|
|
"Input Tokens",
|
|
"Output Tokens",
|
|
]);
|
|
|
|
// Get unique models from data
|
|
const availableModels = useMemo(() => {
|
|
const models = new Set<string>();
|
|
data.forEach((record) => {
|
|
if (record.model) {
|
|
models.add(record.model);
|
|
}
|
|
});
|
|
return Array.from(models).sort();
|
|
}, [data]);
|
|
|
|
// Filter data based on selected model and time range
|
|
const filteredData = useMemo(() => {
|
|
let filtered = data;
|
|
|
|
// Filter by model
|
|
if (selectedModel !== "all") {
|
|
filtered = filtered.filter((record) => record.model === selectedModel);
|
|
}
|
|
|
|
// Filter by time range
|
|
const now = new Date();
|
|
let startTime: Date;
|
|
|
|
switch (timeRange) {
|
|
case "5h":
|
|
startTime = subHours(now, 5);
|
|
break;
|
|
case "today":
|
|
startTime = startOfDay(now);
|
|
break;
|
|
case "7d":
|
|
startTime = subDays(now, 6);
|
|
break;
|
|
case "week":
|
|
startTime = startOfWeek(now);
|
|
break;
|
|
case "month":
|
|
startTime = startOfMonth(now);
|
|
break;
|
|
case "all":
|
|
// For "all time", find the earliest timestamp in the data
|
|
if (filtered.length > 0) {
|
|
const earliestTime = new Date(
|
|
Math.min(
|
|
...filtered.map((record) => new Date(record.timestamp).getTime()),
|
|
),
|
|
);
|
|
startTime = earliestTime;
|
|
} else {
|
|
startTime = new Date(0);
|
|
}
|
|
break;
|
|
default:
|
|
startTime = subHours(now, 5);
|
|
}
|
|
|
|
filtered = filtered.filter(
|
|
(record) => new Date(record.timestamp) >= startTime,
|
|
);
|
|
|
|
return filtered;
|
|
}, [data, selectedModel, timeRange]);
|
|
|
|
// Notify parent component when filtered data changes
|
|
useEffect(() => {
|
|
if (onFilteredDataChange) {
|
|
onFilteredDataChange(filteredData);
|
|
}
|
|
}, [filteredData, onFilteredDataChange]);
|
|
|
|
// Handle category toggling
|
|
const handleCategoryToggle = (value: any) => {
|
|
if (value && value.categoryClicked) {
|
|
setActiveCategories((prev) => {
|
|
if (prev.includes(value.categoryClicked)) {
|
|
return prev.filter((cat) => cat !== value.categoryClicked);
|
|
} else {
|
|
return [...prev, value.categoryClicked];
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Toggle category function for direct use
|
|
const toggleCategory = (category: string) => {
|
|
handleCategoryToggle({ categoryClicked: category });
|
|
};
|
|
// Group data based on time range
|
|
const groupDataByInterval = (records: ProjectUsageRecord[]) => {
|
|
const intervals: {
|
|
[key: string]: { input: number; output: number; cache: number };
|
|
} = {};
|
|
const now = new Date();
|
|
|
|
if (timeRange === "all") {
|
|
// For all time, group by week
|
|
const earliestTime =
|
|
records.length > 0
|
|
? new Date(
|
|
Math.min(
|
|
...records.map((record) =>
|
|
new Date(record.timestamp).getTime(),
|
|
),
|
|
),
|
|
)
|
|
: new Date();
|
|
|
|
// Get start of the week for earliest time
|
|
let currentWeekStart = startOfWeek(earliestTime);
|
|
const nowWeekStart = startOfWeek(now);
|
|
|
|
// Generate weekly intervals from earliest week to current week
|
|
while (currentWeekStart <= nowWeekStart) {
|
|
intervals[currentWeekStart.getTime()] = {
|
|
input: 0,
|
|
output: 0,
|
|
cache: 0,
|
|
};
|
|
// Move to next week (7 days)
|
|
currentWeekStart = new Date(
|
|
currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000,
|
|
);
|
|
}
|
|
|
|
// Group records into weekly intervals
|
|
records.forEach((record) => {
|
|
const recordTime = new Date(record.timestamp);
|
|
const weekStart = startOfWeek(recordTime);
|
|
const weekKey = weekStart.getTime();
|
|
|
|
if (intervals[weekKey]) {
|
|
intervals[weekKey].input += record.usage?.input_tokens || 0;
|
|
intervals[weekKey].output += record.usage?.output_tokens || 0;
|
|
intervals[weekKey].cache +=
|
|
record.usage?.cache_read_input_tokens || 0;
|
|
}
|
|
});
|
|
} else if (timeRange === "5h") {
|
|
// Group by 30-minute intervals for 5h time range
|
|
const intervalMs = 30 * 60 * 1000; // 30 minutes in milliseconds
|
|
|
|
// Round current time down to nearest 30-minute boundary (epoch-based)
|
|
const currentIntervalKey =
|
|
Math.floor(now.getTime() / intervalMs) * intervalMs;
|
|
|
|
// Generate intervals (10 intervals for 5 hours)
|
|
for (let i = 0; i < 10; i++) {
|
|
const intervalKey = currentIntervalKey - i * intervalMs;
|
|
intervals[intervalKey] = { input: 0, output: 0, cache: 0 };
|
|
}
|
|
|
|
// Group records into 30-minute intervals
|
|
records.forEach((record) => {
|
|
const recordTime = new Date(record.timestamp);
|
|
const recordIntervalKey =
|
|
Math.floor(recordTime.getTime() / intervalMs) * intervalMs;
|
|
|
|
if (intervals[recordIntervalKey]) {
|
|
intervals[recordIntervalKey].input += record.usage?.input_tokens || 0;
|
|
intervals[recordIntervalKey].output +=
|
|
record.usage?.output_tokens || 0;
|
|
intervals[recordIntervalKey].cache +=
|
|
record.usage?.cache_read_input_tokens || 0;
|
|
}
|
|
});
|
|
} else if (timeRange === "today") {
|
|
// Group by hour for today
|
|
const startOfToday = startOfDay(now);
|
|
const currentHour = now.getHours();
|
|
|
|
for (let i = 0; i <= currentHour; i++) {
|
|
const hourTime = new Date(startOfToday.getTime() + i * 60 * 60 * 1000);
|
|
intervals[hourTime.getTime()] = { input: 0, output: 0, cache: 0 };
|
|
}
|
|
|
|
records.forEach((record) => {
|
|
const recordTime = new Date(record.timestamp);
|
|
const hourStart = new Date(recordTime);
|
|
hourStart.setMinutes(0, 0, 0);
|
|
const hourKey = hourStart.getTime();
|
|
|
|
if (intervals[hourKey]) {
|
|
intervals[hourKey].input += record.usage?.input_tokens || 0;
|
|
intervals[hourKey].output += record.usage?.output_tokens || 0;
|
|
intervals[hourKey].cache +=
|
|
record.usage?.cache_read_input_tokens || 0;
|
|
}
|
|
});
|
|
} else {
|
|
// Group by day for longer periods (7d, week, month)
|
|
let startDate: Date;
|
|
let days: number;
|
|
|
|
if (timeRange === "week") {
|
|
startDate = startOfWeek(now);
|
|
// Calculate actual days in the current week so far (from start of week to today)
|
|
const todayStart = startOfDay(now);
|
|
days =
|
|
Math.floor(
|
|
(todayStart.getTime() - startDate.getTime()) /
|
|
(24 * 60 * 60 * 1000),
|
|
) + 1;
|
|
} else if (timeRange === "month") {
|
|
startDate = startOfMonth(now);
|
|
// Calculate actual days in the current month so far (from start of month to today)
|
|
const todayStart = startOfDay(now);
|
|
days =
|
|
Math.floor(
|
|
(todayStart.getTime() - startDate.getTime()) /
|
|
(24 * 60 * 60 * 1000),
|
|
) + 1;
|
|
} else {
|
|
// For 7d, start from (days-1) days ago to include today
|
|
days = 7;
|
|
startDate = startOfDay(subDays(now, days - 1));
|
|
}
|
|
|
|
for (let i = 0; i < days; i++) {
|
|
const dayTime = startOfDay(
|
|
new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000),
|
|
);
|
|
intervals[dayTime.getTime()] = { input: 0, output: 0, cache: 0 };
|
|
}
|
|
|
|
records.forEach((record) => {
|
|
const recordTime = new Date(record.timestamp);
|
|
const dayStart = new Date(recordTime);
|
|
dayStart.setHours(0, 0, 0, 0);
|
|
const dayKey = dayStart.getTime();
|
|
|
|
if (intervals[dayKey]) {
|
|
intervals[dayKey].input += record.usage?.input_tokens || 0;
|
|
intervals[dayKey].output += record.usage?.output_tokens || 0;
|
|
intervals[dayKey].cache += record.usage?.cache_read_input_tokens || 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
return intervals;
|
|
};
|
|
|
|
const groupedData = groupDataByInterval(filteredData);
|
|
|
|
// Prepare chart data for Recharts
|
|
const chartData = Object.keys(groupedData)
|
|
.map(Number)
|
|
.sort((a, b) => a - b)
|
|
.map((timestamp) => {
|
|
const date = new Date(timestamp);
|
|
let label: string;
|
|
if (timeRange === "all") {
|
|
label = format(date, "MMM dd, yyyy");
|
|
} else if (timeRange === "today") {
|
|
label = format(date, "HH:mm");
|
|
} else if (timeRange === "5h") {
|
|
label = format(date, "HH:mm");
|
|
} else {
|
|
label = format(date, "MMM dd");
|
|
}
|
|
|
|
return {
|
|
time: label,
|
|
timestamp,
|
|
"Input Tokens": groupedData[timestamp].input,
|
|
"Output Tokens": groupedData[timestamp].output,
|
|
"Cache Read Tokens": groupedData[timestamp].cache,
|
|
};
|
|
});
|
|
|
|
if (!data || data.length === 0) {
|
|
return (
|
|
<div className="h-96 flex items-center justify-center border rounded-lg bg-muted/20">
|
|
<p className="text-muted-foreground">{t("usageChart.noData")}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 w-full min-w-0">
|
|
{/* Filter Controls */}
|
|
<div className="flex gap-4 items-center flex-wrap pb-5">
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="model-filter" className="text-sm font-medium">
|
|
{t("usageChart.modelFilter")}
|
|
</label>
|
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
|
<SelectTrigger id="model-filter" className="w-48">
|
|
<SelectValue placeholder={t("usageChart.allModels")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t("usageChart.allModels")}</SelectItem>
|
|
{availableModels.map((model) => (
|
|
<SelectItem key={model} value={model}>
|
|
{model}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<label htmlFor="time-range" className="text-sm font-medium">
|
|
{t("usageChart.timeRange")}
|
|
</label>
|
|
<Select
|
|
value={timeRange}
|
|
onValueChange={(value: TimeRange) => setTimeRange(value)}
|
|
>
|
|
<SelectTrigger id="time-range" className="w-48">
|
|
<SelectValue placeholder={t("usageChart.selectTimeRange")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="5h">{t("usageChart.last5Hours")}</SelectItem>
|
|
<SelectItem value="today">
|
|
{t("usageChart.startOfToday")}
|
|
</SelectItem>
|
|
<SelectItem value="7d">{t("usageChart.last7Days")}</SelectItem>
|
|
<SelectItem value="week">
|
|
{t("usageChart.startOfWeek")}
|
|
</SelectItem>
|
|
<SelectItem value="month">
|
|
{t("usageChart.startOfMonth")}
|
|
</SelectItem>
|
|
<SelectItem value="all">{t("usageChart.allTime")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Legend */}
|
|
<div className="flex gap-6 items-center justify-center pb-4">
|
|
{[
|
|
{ key: "Input Tokens", label: t("usage.inputTokens") },
|
|
{ key: "Output Tokens", label: t("usage.outputTokens") },
|
|
{ key: "Cache Read Tokens", label: t("usage.cacheReadTokens") },
|
|
].map(({ key, label }) => {
|
|
const isActive = activeCategories.includes(key);
|
|
const color =
|
|
key === "Input Tokens"
|
|
? "bg-blue-500"
|
|
: key === "Output Tokens"
|
|
? "bg-emerald-500"
|
|
: "bg-amber-500";
|
|
return (
|
|
<button
|
|
key={key}
|
|
onClick={() => toggleCategory(key)}
|
|
className={`flex items-center gap-2 px-3 py-1 rounded-md text-sm transition-all ${
|
|
isActive
|
|
? "opacity-100 hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
: "opacity-40 hover:opacity-60"
|
|
}`}
|
|
>
|
|
<span className={`w-3 h-3 rounded-full ${color}`} />
|
|
<span className="text-gray-700 dark:text-gray-300">{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
<div className="h-[320px] w-full min-w-0">
|
|
<AreaChart
|
|
data={chartData}
|
|
index="time"
|
|
categories={activeCategories}
|
|
colors={activeCategories.map((cat) => {
|
|
if (cat === "Input Tokens") return "blue";
|
|
if (cat === "Output Tokens") return "emerald";
|
|
if (cat === "Cache Read Tokens") return "amber";
|
|
return "blue";
|
|
})}
|
|
valueFormatter={formatLargeNumber}
|
|
fill="gradient"
|
|
className="h-full"
|
|
showLegend={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|