Refactor UsageChart and UsageStats components to support dynamic period selection

This commit is contained in:
decolua
2026-03-03 16:19:44 +07:00
parent 11b2fcd643
commit 7195fee2f6
5 changed files with 116 additions and 59 deletions

View File

@@ -14,13 +14,6 @@ import {
} from "recharts";
import Card from "@/shared/components/Card";
const PERIODS = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "60d", label: "60D" },
];
const fmtTokens = (n) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
@@ -29,8 +22,7 @@ const fmtTokens = (n) => {
const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
export default function UsageChart() {
const [period, setPeriod] = useState("7d");
export default function UsageChart({ period = "7d" }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState("tokens");
@@ -58,32 +50,19 @@ export default function UsageChart() {
return (
<Card className="p-4 flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
<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>
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PERIODS.map((p) => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${period === p.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border 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>
{loading ? (
@@ -157,4 +136,6 @@ export default function UsageChart() {
);
}
UsageChart.propTypes = {};
UsageChart.propTypes = {
period: PropTypes.string,
};

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getUsageStats } from "@/lib/usageDb";
const VALID_PERIODS = new Set(["24h", "7d", "30d", "60d", "all"]);
export const dynamic = "force-dynamic";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const period = searchParams.get("period") || "7d";
if (!VALID_PERIODS.has(period)) {
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
}
const stats = await getUsageStats(period);
return NextResponse.json(stats);
} catch (error) {
console.error("[API] Failed to get usage stats:", error);
return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 });
}
}

View File

@@ -429,12 +429,21 @@ async function calculateCost(provider, model, tokens) {
}
}
const PERIOD_MS = { "24h": 86400000, "7d": 604800000, "30d": 2592000000, "60d": 5184000000 };
/**
* Get aggregated usage stats
* @param {"24h"|"7d"|"30d"|"60d"|"all"} period - Time period to filter
*/
export async function getUsageStats() {
export async function getUsageStats(period = "all") {
const db = await getUsageDb();
const history = db.data.history || [];
let history = db.data.history || [];
// Filter history by period
if (period && PERIOD_MS[period]) {
const cutoff = Date.now() - PERIOD_MS[period];
history = history.filter((e) => new Date(e.timestamp).getTime() >= cutoff);
}
// Import localDb to get provider connection names and API keys
const { getProviderConnections, getApiKeys, getProviderNodes } = await import("@/lib/localDb.js");
@@ -571,8 +580,8 @@ export async function getUsageStats() {
const completionTokens = entry.tokens?.completion_tokens || 0;
const entryTime = new Date(entry.timestamp);
// Calculate cost for this entry
const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens);
// Use pre-stored cost (saved at request time), avoid recalculating
const entryCost = entry.cost || 0;
stats.totalPromptTokens += promptTokens;
stats.totalCompletionTokens += completionTokens;

View File

@@ -26,9 +26,10 @@ async function checkCertInstalled(certPath) {
function checkCertInstalledMac(certPath) {
return new Promise((resolve) => {
try {
const fingerprint = getCertFingerprint(certPath);
exec(`security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, (error) => {
resolve(!error);
// security outputs fingerprint without colons (e.g. "078B6B5F..."), strip them before grep
const fingerprint = getCertFingerprint(certPath).replace(/:/g, "");
exec(`security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, (error, stdout) => {
resolve(!error && !!stdout?.trim());
});
} catch {
resolve(false);

View File

@@ -161,6 +161,13 @@ const TABLE_OPTIONS = [
{ 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() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -170,8 +177,10 @@ export default function UsageStats() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false);
const [tableView, setTableView] = useState("model");
const [providers, setProviders] = useState([]);
const [period, setPeriod] = useState("7d");
// Fetch connected providers once, deduplicate by provider type
useEffect(() => {
@@ -190,33 +199,48 @@ export default function UsageStats() {
.catch(() => {});
}, []);
// SSE connection - no polling, event-driven
// Fetch filtered stats via REST when period changes
useEffect(() => {
console.log("[SSE CLIENT] connecting...");
const es = new EventSource("/api/usage/stream");
// First load: show full spinner; subsequent: show subtle fetching indicator
if (!stats) setLoading(true);
else setFetching(true);
es.onopen = () => console.log("[SSE CLIENT] connected ✓");
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);
console.log("[SSE CLIENT] message received | activeRequests:", data.activeRequests?.length || 0, "| providers:", data.activeRequests?.map(r => r.provider));
setStats(data);
// Only update real-time fields from SSE, keep filtered stats intact
setStats((prev) => prev ? {
...prev,
activeRequests: data.activeRequests,
recentRequests: data.recentRequests,
errorProvider: data.errorProvider,
pending: data.pending,
} : data);
setLoading(false);
} catch (err) {
console.error("[SSE CLIENT] parse error:", err);
}
};
es.onerror = (e) => {
console.error("[SSE CLIENT] error | readyState:", es.readyState, e);
setLoading(false);
};
es.onerror = () => setLoading(false);
return () => {
console.log("[SSE CLIENT] closing");
es.close();
};
return () => es.close();
}, []);
const toggleSort = useCallback((tableType, field) => {
@@ -357,6 +381,25 @@ export default function UsageStats() {
return (
<div className="flex flex-col gap-6">
{/* Period selector */}
<div className="flex items-center gap-2 self-end">
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PERIODS.map((p) => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
disabled={fetching}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${period === p.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
{p.label}
</button>
))}
</div>
{fetching && (
<span className="material-symbols-outlined text-[16px] text-text-muted animate-spin">progress_activity</span>
)}
</div>
{/* Overview cards */}
{loading ? spinner : <OverviewCards stats={stats} />}
@@ -373,8 +416,8 @@ export default function UsageStats() {
</div>
)}
{/* Token / Cost chart */}
{loading ? spinner : <UsageChart />}
{/* Token / Cost chart - sync period */}
{loading ? spinner : <UsageChart period={period} />}
{/* Table with dropdown selector */}
<div className="flex flex-col gap-3">