mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Refactor UsageChart and UsageStats components to support dynamic period selection
This commit is contained in:
@@ -14,13 +14,6 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import Card from "@/shared/components/Card";
|
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) => {
|
const fmtTokens = (n) => {
|
||||||
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
||||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
@@ -29,8 +22,7 @@ const fmtTokens = (n) => {
|
|||||||
|
|
||||||
const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
|
const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
|
||||||
|
|
||||||
export default function UsageChart() {
|
export default function UsageChart({ period = "7d" }) {
|
||||||
const [period, setPeriod] = useState("7d");
|
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [viewMode, setViewMode] = useState("tokens");
|
const [viewMode, setViewMode] = useState("tokens");
|
||||||
@@ -58,32 +50,19 @@ export default function UsageChart() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4 flex flex-col gap-3">
|
<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 self-start">
|
||||||
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
|
<button
|
||||||
<button
|
onClick={() => setViewMode("tokens")}
|
||||||
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"}`}
|
||||||
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
|
||||||
Tokens
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={() => setViewMode("cost")}
|
||||||
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"}`}
|
||||||
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
|
||||||
Cost
|
</button>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -157,4 +136,6 @@ export default function UsageChart() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
UsageChart.propTypes = {};
|
UsageChart.propTypes = {
|
||||||
|
period: PropTypes.string,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-4
@@ -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
|
* 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 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
|
// Import localDb to get provider connection names and API keys
|
||||||
const { getProviderConnections, getApiKeys, getProviderNodes } = await import("@/lib/localDb.js");
|
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 completionTokens = entry.tokens?.completion_tokens || 0;
|
||||||
const entryTime = new Date(entry.timestamp);
|
const entryTime = new Date(entry.timestamp);
|
||||||
|
|
||||||
// Calculate cost for this entry
|
// Use pre-stored cost (saved at request time), avoid recalculating
|
||||||
const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens);
|
const entryCost = entry.cost || 0;
|
||||||
|
|
||||||
stats.totalPromptTokens += promptTokens;
|
stats.totalPromptTokens += promptTokens;
|
||||||
stats.totalCompletionTokens += completionTokens;
|
stats.totalCompletionTokens += completionTokens;
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ async function checkCertInstalled(certPath) {
|
|||||||
function checkCertInstalledMac(certPath) {
|
function checkCertInstalledMac(certPath) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
const fingerprint = getCertFingerprint(certPath);
|
// security outputs fingerprint without colons (e.g. "078B6B5F..."), strip them before grep
|
||||||
exec(`security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, (error) => {
|
const fingerprint = getCertFingerprint(certPath).replace(/:/g, "");
|
||||||
resolve(!error);
|
exec(`security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, (error, stdout) => {
|
||||||
|
resolve(!error && !!stdout?.trim());
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
|
|||||||
@@ -161,6 +161,13 @@ const TABLE_OPTIONS = [
|
|||||||
{ value: "endpoint", label: "Usage by Endpoint" },
|
{ 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() {
|
export default function UsageStats() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -170,8 +177,10 @@ export default function UsageStats() {
|
|||||||
|
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
const [tableView, setTableView] = useState("model");
|
const [tableView, setTableView] = useState("model");
|
||||||
const [providers, setProviders] = useState([]);
|
const [providers, setProviders] = useState([]);
|
||||||
|
const [period, setPeriod] = useState("7d");
|
||||||
|
|
||||||
// Fetch connected providers once, deduplicate by provider type
|
// Fetch connected providers once, deduplicate by provider type
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,33 +199,48 @@ export default function UsageStats() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// SSE connection - no polling, event-driven
|
// Fetch filtered stats via REST when period changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[SSE CLIENT] connecting...");
|
// First load: show full spinner; subsequent: show subtle fetching indicator
|
||||||
const es = new EventSource("/api/usage/stream");
|
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) => {
|
es.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
console.log("[SSE CLIENT] message received | activeRequests:", data.activeRequests?.length || 0, "| providers:", data.activeRequests?.map(r => r.provider));
|
// Only update real-time fields from SSE, keep filtered stats intact
|
||||||
setStats(data);
|
setStats((prev) => prev ? {
|
||||||
|
...prev,
|
||||||
|
activeRequests: data.activeRequests,
|
||||||
|
recentRequests: data.recentRequests,
|
||||||
|
errorProvider: data.errorProvider,
|
||||||
|
pending: data.pending,
|
||||||
|
} : data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[SSE CLIENT] parse error:", err);
|
console.error("[SSE CLIENT] parse error:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = (e) => {
|
es.onerror = () => setLoading(false);
|
||||||
console.error("[SSE CLIENT] error | readyState:", es.readyState, e);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
return () => es.close();
|
||||||
console.log("[SSE CLIENT] closing");
|
|
||||||
es.close();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleSort = useCallback((tableType, field) => {
|
const toggleSort = useCallback((tableType, field) => {
|
||||||
@@ -357,6 +381,25 @@ export default function UsageStats() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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 */}
|
{/* Overview cards */}
|
||||||
{loading ? spinner : <OverviewCards stats={stats} />}
|
{loading ? spinner : <OverviewCards stats={stats} />}
|
||||||
|
|
||||||
@@ -373,8 +416,8 @@ export default function UsageStats() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Token / Cost chart */}
|
{/* Token / Cost chart - sync period */}
|
||||||
{loading ? spinner : <UsageChart />}
|
{loading ? spinner : <UsageChart period={period} />}
|
||||||
|
|
||||||
{/* Table with dropdown selector */}
|
{/* Table with dropdown selector */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user