mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Fixed Codex
This commit is contained in:
@@ -16,6 +16,8 @@ const nextConfig = {
|
||||
path: false,
|
||||
};
|
||||
}
|
||||
// Stop watching logs directory to prevent HMR during streaming
|
||||
config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next)[\\/]/ };
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
|
||||
@@ -906,8 +906,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
&& !isDroidCLI;
|
||||
|
||||
if (needsCodexTranslation) {
|
||||
log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`);
|
||||
transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
// For claude clients, translate directly to claude format
|
||||
// For openai/openai-responses clients, translate to openai (responsesHandler will re-add event: lines)
|
||||
const codexTarget = sourceFormat === FORMATS.CLAUDE ? FORMATS.CLAUDE : FORMATS.OPENAI;
|
||||
log?.debug?.("STREAM", `Codex translation mode: openai-responses → ${codexTarget}`);
|
||||
transformStream = createSSETransformStreamWithLogger('openai-responses', codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
} else if (needsTranslation(targetFormat, sourceFormat)) {
|
||||
log?.debug?.("STREAM", `Translation mode: ${targetFormat} → ${sourceFormat}`);
|
||||
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
|
||||
@@ -100,6 +100,7 @@ export function generateProjectId() {
|
||||
}
|
||||
|
||||
// Helper: Remove unsupported keywords recursively from object/array
|
||||
// Also strips all vendor extension fields (x- prefixed) not supported by Gemini
|
||||
function removeUnsupportedKeywords(obj, keywords) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
@@ -108,13 +109,11 @@ function removeUnsupportedKeywords(obj, keywords) {
|
||||
removeUnsupportedKeywords(item, keywords);
|
||||
}
|
||||
} else {
|
||||
// Delete unsupported keys at current level
|
||||
for (const keyword of keywords) {
|
||||
if (keyword in obj) {
|
||||
delete obj[keyword];
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (keywords.includes(key) || key.startsWith("x-")) {
|
||||
delete obj[key];
|
||||
}
|
||||
}
|
||||
// Recurse into remaining values
|
||||
for (const value of Object.values(obj)) {
|
||||
if (value && typeof value === "object") {
|
||||
removeUnsupportedKeywords(value, keywords);
|
||||
|
||||
@@ -415,8 +415,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function call started
|
||||
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
|
||||
// Function call started (standard function_call or custom_tool_call)
|
||||
if (eventType === "response.output_item.added" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) {
|
||||
const item = data.item;
|
||||
state.currentToolCallId = item.call_id || `call_${Date.now()}`;
|
||||
|
||||
@@ -443,8 +443,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
};
|
||||
}
|
||||
|
||||
// Function call arguments delta
|
||||
if (eventType === "response.function_call_arguments.delta") {
|
||||
// Function call arguments delta (standard or custom_tool_call variant)
|
||||
if (eventType === "response.function_call_arguments.delta" || eventType === "response.custom_tool_call_input.delta") {
|
||||
const argsDelta = data.delta || "";
|
||||
if (!argsDelta) return null;
|
||||
|
||||
@@ -466,8 +466,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
};
|
||||
}
|
||||
|
||||
// Function call done
|
||||
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
|
||||
// Function call done (standard or custom_tool_call variant)
|
||||
if (eventType === "response.output_item.done" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) {
|
||||
state.toolCallIndex++;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"express": "^5.2.1",
|
||||
@@ -25,6 +26,7 @@
|
||||
"ora": "^9.1.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.7.0",
|
||||
"selfsigned": "^5.5.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"undici": "^7.19.2",
|
||||
|
||||
@@ -18,6 +18,19 @@ export default function ProviderDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [providerNode, setProviderNode] = useState(null);
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
||||
|
||||
// Auto-reopen OAuthModal if pending auth exists (survives HMR/reload)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("oauth_pending_auth");
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw);
|
||||
if (data.provider === providerId && Date.now() - data.timestamp < 300000) {
|
||||
setShowOAuthModal(true);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [providerId]);
|
||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import PropTypes from "prop-types";
|
||||
import Card from "@/shared/components/Card";
|
||||
|
||||
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
||||
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
||||
|
||||
export default function OverviewCards({ stats }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
|
||||
<span className="text-2xl font-bold">{fmt(stats.totalRequests)}</span>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
|
||||
<span className="text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
|
||||
<span className="text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
|
||||
</Card>
|
||||
<Card className="px-4 py-3 flex flex-col gap-1">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Cost</span>
|
||||
<span className="text-2xl font-bold text-warning">{fmtCost(stats.totalCost)}</span>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OverviewCards.propTypes = {
|
||||
stats: PropTypes.object.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
ReactFlow,
|
||||
Handle,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
function getProviderConfig(providerId) {
|
||||
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
|
||||
}
|
||||
|
||||
// Use local provider images from /public/providers/
|
||||
function getProviderImageUrl(providerId) {
|
||||
return `/providers/${providerId}.png`;
|
||||
}
|
||||
|
||||
// Custom provider node - rectangle with image + name
|
||||
function ProviderNode({ data }) {
|
||||
const { label, color, imageUrl, textIcon, active } = data;
|
||||
const [imgError, setImgError] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 rounded-lg border-2 transition-all duration-300 bg-bg"
|
||||
style={{
|
||||
borderColor: active ? color : "var(--color-border)",
|
||||
boxShadow: active ? `0 0 16px ${color}40` : "none",
|
||||
minWidth: "150px",
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="target" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="target" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="target" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
|
||||
{/* Provider icon */}
|
||||
<div
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${color}15` }}
|
||||
>
|
||||
{!imgError ? (
|
||||
<img src={imageUrl} alt={label} className="w-6 h-6 rounded-sm object-contain" onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<span className="text-sm font-bold" style={{ color }}>{textIcon}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider name */}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: active ? color : "var(--color-text)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Active indicator */}
|
||||
{active && (
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" style={{ backgroundColor: color }} />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2" style={{ backgroundColor: color }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderNode.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
// Center 9Router node
|
||||
function RouterNode({ data }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-5 py-3 rounded-xl border-2 border-primary bg-primary/5 shadow-md min-w-[130px]">
|
||||
<Handle type="source" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="source" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="source" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
|
||||
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" />
|
||||
<span className="text-sm font-bold text-primary">9Router</span>
|
||||
{data.activeCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">
|
||||
{data.activeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RouterNode.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const nodeTypes = { provider: ProviderNode, router: RouterNode };
|
||||
|
||||
// Place N nodes evenly along an ellipse around the router center.
|
||||
function buildLayout(providers, activeSet, lastSet) {
|
||||
const nodeW = 180;
|
||||
const nodeH = 30;
|
||||
const routerW = 120;
|
||||
const routerH = 44;
|
||||
const nodeGap = 24;
|
||||
|
||||
const count = providers.length;
|
||||
|
||||
// Compute rx so arc spacing between nodes >= nodeW + nodeGap
|
||||
const minRx = ((nodeW + nodeGap) * count) / (2 * Math.PI);
|
||||
const rx = Math.max(320, minRx);
|
||||
const ry = Math.max(200, rx * 0.55); // ellipse ratio ~0.55
|
||||
if (count === 0) {
|
||||
return {
|
||||
nodes: [{ id: "router", type: "router", position: { x: 0, y: 0 }, data: { activeCount: 0 }, draggable: false }],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
|
||||
nodes.push({
|
||||
id: "router",
|
||||
type: "router",
|
||||
position: { x: -routerW / 2, y: -routerH / 2 },
|
||||
data: { activeCount: activeSet.size },
|
||||
draggable: false,
|
||||
});
|
||||
|
||||
const edgeStyle = (active, last, color) => {
|
||||
if (active) return { stroke: color, strokeWidth: 2, opacity: 0.8 };
|
||||
if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 };
|
||||
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
|
||||
};
|
||||
|
||||
providers.forEach((p, i) => {
|
||||
const config = getProviderConfig(p.provider);
|
||||
const active = activeSet.has(p.provider?.toLowerCase());
|
||||
const last = !active && lastSet.has(p.provider?.toLowerCase());
|
||||
const nodeId = `provider-${p.provider}`;
|
||||
const data = {
|
||||
label: config.name || p.name || p.provider,
|
||||
color: config.color || "#6b7280",
|
||||
imageUrl: getProviderImageUrl(p.provider),
|
||||
textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(),
|
||||
active,
|
||||
};
|
||||
|
||||
// Distribute evenly starting from top (−π/2), clockwise
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / count;
|
||||
const cx = rx * Math.cos(angle);
|
||||
const cy = ry * Math.sin(angle);
|
||||
|
||||
// Pick router handle closest to the node direction
|
||||
let sourceHandle, targetHandle;
|
||||
if (Math.abs(angle + Math.PI / 2) < Math.PI / 4 || Math.abs(angle - 3 * Math.PI / 2) < Math.PI / 4) {
|
||||
sourceHandle = "top"; targetHandle = "bottom";
|
||||
} else if (Math.abs(angle - Math.PI / 2) < Math.PI / 4) {
|
||||
sourceHandle = "bottom"; targetHandle = "top";
|
||||
} else if (cx > 0) {
|
||||
sourceHandle = "right"; targetHandle = "left";
|
||||
} else {
|
||||
sourceHandle = "left"; targetHandle = "right";
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: "provider",
|
||||
position: { x: cx - nodeW / 2, y: cy - nodeH / 2 },
|
||||
data,
|
||||
draggable: false,
|
||||
});
|
||||
|
||||
edges.push({
|
||||
id: `e-${nodeId}`,
|
||||
source: "router",
|
||||
sourceHandle,
|
||||
target: nodeId,
|
||||
targetHandle,
|
||||
animated: active,
|
||||
style: edgeStyle(active, last, config.color),
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "" }) {
|
||||
const activeSet = useMemo(
|
||||
() => new Set(activeRequests.map((r) => r.provider?.toLowerCase()).filter(Boolean)),
|
||||
[activeRequests]
|
||||
);
|
||||
|
||||
// lastSet: providers that finished most recently (not currently active)
|
||||
const lastSet = useMemo(
|
||||
() => new Set(lastProvider ? [lastProvider.toLowerCase()] : []),
|
||||
[lastProvider]
|
||||
);
|
||||
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => buildLayout(providers, activeSet, lastSet),
|
||||
[providers, activeSet, lastSet]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-bg-subtle/30" style={{ height: 480 }}>
|
||||
{providers.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-text-muted text-sm">
|
||||
No providers connected
|
||||
</div>
|
||||
) : (
|
||||
<ReactFlow
|
||||
nodes={initialNodes}
|
||||
edges={initialEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
onInit={(instance) => setTimeout(() => instance.fitView({ padding: 0.3 }), 50)}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
panOnDrag={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
preventScrolling={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderTopology.propTypes = {
|
||||
providers: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
provider: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
activeRequests: PropTypes.arrayOf(PropTypes.shape({
|
||||
provider: PropTypes.string,
|
||||
model: PropTypes.string,
|
||||
account: PropTypes.string,
|
||||
})),
|
||||
lastProvider: PropTypes.string,
|
||||
};
|
||||
160
src/app/(dashboard)/dashboard/usage/components/UsageChart.js
Normal file
160
src/app/(dashboard)/dashboard/usage/components/UsageChart.js
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} 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`;
|
||||
return String(n || 0);
|
||||
};
|
||||
|
||||
const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
|
||||
|
||||
export default function UsageChart() {
|
||||
const [period, setPeriod] = useState("7d");
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState("tokens");
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/usage/chart?period=${period}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch chart data:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const hasData = data.some((d) => d.tokens > 0 || d.cost > 0);
|
||||
|
||||
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>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-48 flex items-center justify-center text-text-muted text-sm">Loading...</div>
|
||||
) : !hasData ? (
|
||||
<div className="h-48 flex items-center justify-center text-text-muted text-sm">No data for this period</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
UsageChart.propTypes = {};
|
||||
247
src/app/(dashboard)/dashboard/usage/components/UsageTable.js
Normal file
247
src/app/(dashboard)/dashboard/usage/components/UsageTable.js
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, Fragment } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
|
||||
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
||||
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return "Never";
|
||||
const diffMins = Math.floor((Date.now() - new Date(iso)) / 60000);
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentSort, currentOrder }) {
|
||||
if (currentSort !== field) return <span className="ml-1 opacity-20">↕</span>;
|
||||
return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>;
|
||||
}
|
||||
|
||||
SortIcon.propTypes = {
|
||||
field: PropTypes.string.isRequired,
|
||||
currentSort: PropTypes.string.isRequired,
|
||||
currentOrder: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Render 3 token or cost cells based on viewMode
|
||||
*/
|
||||
function ValueCells({ item, viewMode, isSummary = false }) {
|
||||
if (viewMode === "tokens") {
|
||||
return (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)}
|
||||
</td>
|
||||
<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 font-medium">
|
||||
{fmt(item.totalTokens)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||
{fmtCost(item.totalCost || item.cost)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ValueCells.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
viewMode: PropTypes.string.isRequired,
|
||||
isSummary: PropTypes.bool,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable sortable usage table with expandable group rows.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Table title
|
||||
* @param {Array} props.columns - Column definitions [{field, label}]
|
||||
* @param {Array} props.groupedData - Grouped data from groupDataByKey
|
||||
* @param {string} props.tableType - Table type key for sort URL params
|
||||
* @param {string} props.sortBy - Current sort field
|
||||
* @param {string} props.sortOrder - Current sort order
|
||||
* @param {function} props.onToggleSort - Sort toggle handler
|
||||
* @param {string} props.viewMode - "tokens" or "costs"
|
||||
* @param {string} props.storageKey - localStorage key for expanded state
|
||||
* @param {function} props.renderGroupLabel - Render group summary first cell content
|
||||
* @param {function} props.renderDetailCells - Render detail row custom cells (before value cells)
|
||||
* @param {function} props.renderSummaryCells - Render summary row cells after group label (placeholder cols)
|
||||
* @param {string} props.emptyMessage - Empty state message
|
||||
*/
|
||||
export default function UsageTable({
|
||||
title,
|
||||
columns,
|
||||
groupedData,
|
||||
tableType,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onToggleSort,
|
||||
viewMode,
|
||||
storageKey,
|
||||
renderDetailCells,
|
||||
renderSummaryCells,
|
||||
emptyMessage,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(new Set());
|
||||
|
||||
// Load expanded state from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) setExpanded(new Set(JSON.parse(saved)));
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${storageKey}:`, e);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
// Save expanded state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify([...expanded]));
|
||||
} catch (e) {
|
||||
console.error(`Failed to save ${storageKey}:`, e);
|
||||
}
|
||||
}, [expanded, storageKey]);
|
||||
|
||||
const toggleGroup = useCallback((groupKey) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(groupKey) ? next.delete(groupKey) : next.add(groupKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const valueColumns = useMemo(() => {
|
||||
if (viewMode === "tokens") {
|
||||
return [
|
||||
{ field: "promptTokens", label: "Input Tokens" },
|
||||
{ field: "completionTokens", label: "Output Tokens" },
|
||||
{ field: "totalTokens", label: "Total Tokens" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ field: "promptTokens", label: "Input Cost" },
|
||||
{ field: "completionTokens", label: "Output Cost" },
|
||||
{ field: "cost", label: "Total Cost" },
|
||||
];
|
||||
}, [viewMode]);
|
||||
|
||||
const totalColSpan = columns.length + valueColumns.length;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className={`px-6 py-3 cursor-pointer hover:bg-bg-subtle/50 ${col.align === "right" ? "text-right" : ""}`}
|
||||
onClick={() => onToggleSort(tableType, col.field)}
|
||||
>
|
||||
{col.label}{" "}
|
||||
<SortIcon field={col.field} currentSort={sortBy} currentOrder={sortOrder} />
|
||||
</th>
|
||||
))}
|
||||
{valueColumns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||
onClick={() => onToggleSort(tableType, col.field)}
|
||||
>
|
||||
{col.label}{" "}
|
||||
<SortIcon field={col.field} currentSort={sortBy} currentOrder={sortOrder} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{groupedData.map((group) => (
|
||||
<Fragment key={group.groupKey}>
|
||||
{/* Group summary row */}
|
||||
<tr
|
||||
className="group-summary cursor-pointer hover:bg-bg-subtle/50 transition-colors"
|
||||
onClick={() => toggleGroup(group.groupKey)}
|
||||
>
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[18px] text-text-muted transition-transform ${expanded.has(group.groupKey) ? "rotate-90" : ""}`}>
|
||||
chevron_right
|
||||
</span>
|
||||
<span className={`font-medium transition-colors ${group.summary.pending > 0 ? "text-primary" : ""}`}>
|
||||
{group.groupKey}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{renderSummaryCells(group)}
|
||||
<ValueCells item={group.summary} viewMode={viewMode} isSummary />
|
||||
</tr>
|
||||
{/* Detail rows */}
|
||||
{expanded.has(group.groupKey) && group.items.map((item) => (
|
||||
<tr
|
||||
key={`detail-${item.key}`}
|
||||
className="group-detail hover:bg-bg-subtle/20 transition-colors"
|
||||
>
|
||||
{renderDetailCells(item)}
|
||||
<ValueCells item={item} viewMode={viewMode} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
{groupedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={totalColSpan} className="px-6 py-8 text-center text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
UsageTable.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.shape({
|
||||
field: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
align: PropTypes.string,
|
||||
})).isRequired,
|
||||
groupedData: PropTypes.array.isRequired,
|
||||
tableType: PropTypes.string.isRequired,
|
||||
sortBy: PropTypes.string.isRequired,
|
||||
sortOrder: PropTypes.string.isRequired,
|
||||
onToggleSort: PropTypes.func.isRequired,
|
||||
viewMode: PropTypes.string.isRequired,
|
||||
storageKey: PropTypes.string.isRequired,
|
||||
renderDetailCells: PropTypes.func.isRequired,
|
||||
renderSummaryCells: PropTypes.func.isRequired,
|
||||
emptyMessage: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
// Re-export utilities for use in UsageStats orchestrator
|
||||
export { fmt, fmtCost, fmtTime };
|
||||
@@ -18,8 +18,8 @@ function UsagePageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState(searchParams.get("tab") || "overview");
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
|
||||
// Sync tab with URL on mount and when URL changes
|
||||
useEffect(() => {
|
||||
const tabFromUrl = searchParams.get("tab");
|
||||
if (tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)) {
|
||||
@@ -27,12 +27,15 @@ function UsagePageContent() {
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Update URL when tab changes
|
||||
const handleTabChange = (value) => {
|
||||
if (value === activeTab) return;
|
||||
setTabLoading(true);
|
||||
setActiveTab(value);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("tab", value);
|
||||
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
|
||||
// Brief loading flash so user sees feedback
|
||||
setTimeout(() => setTabLoading(false), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -40,7 +43,6 @@ function UsagePageContent() {
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "logs", label: "Logger" },
|
||||
{ value: "limits", label: "Limits" },
|
||||
{ value: "details", label: "Details" },
|
||||
]}
|
||||
@@ -48,19 +50,24 @@ function UsagePageContent() {
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === "overview" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageStats />
|
||||
</Suspense>
|
||||
{tabLoading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{activeTab === "overview" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageStats />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "logs" && <RequestLogger />}
|
||||
{activeTab === "limits" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderLimits />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "details" && <RequestDetailsTab />}
|
||||
</>
|
||||
)}
|
||||
{activeTab === "logs" && <RequestLogger />}
|
||||
{activeTab === "limits" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderLimits />
|
||||
</Suspense>
|
||||
)}
|
||||
{activeTab === "details" && <RequestDetailsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/app/api/usage/chart/route.js
Normal file
21
src/app/api/usage/chart/route.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getChartData } from "@/lib/usageDb";
|
||||
|
||||
const VALID_PERIODS = new Set(["24h", "7d", "30d", "60d"]);
|
||||
|
||||
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 data = await getChartData(period);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("[API] Failed to get chart data:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch chart data" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
58
src/app/api/usage/stream/route.js
Normal file
58
src/app/api/usage/stream/route.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getUsageStats, statsEmitter } from "@/lib/usageDb";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const encoder = new TextEncoder();
|
||||
const state = { closed: false, keepalive: null, send: null };
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
state.send = async () => {
|
||||
if (state.closed) return;
|
||||
try {
|
||||
const stats = await getUsageStats();
|
||||
if (stats.activeRequests?.length > 0) {
|
||||
console.log(`[SSE] Push | active=${stats.activeRequests.length} | ${stats.activeRequests.map(r => r.provider).join(",")}`);
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
||||
} catch {
|
||||
// Controller closed → self-cleanup
|
||||
state.closed = true;
|
||||
statsEmitter.off("update", state.send);
|
||||
clearInterval(state.keepalive);
|
||||
}
|
||||
};
|
||||
|
||||
await state.send();
|
||||
console.log(`[SSE] Client connected | listeners=${statsEmitter.listenerCount("update") + 1}`);
|
||||
|
||||
statsEmitter.on("update", state.send);
|
||||
|
||||
state.keepalive = setInterval(() => {
|
||||
if (state.closed) { clearInterval(state.keepalive); return; }
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
state.closed = true;
|
||||
clearInterval(state.keepalive);
|
||||
}
|
||||
}, 25000);
|
||||
},
|
||||
|
||||
cancel() {
|
||||
state.closed = true;
|
||||
statsEmitter.off("update", state.send);
|
||||
clearInterval(state.keepalive);
|
||||
console.log("[SSE] Client disconnected");
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,43 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
const OAUTH_SESSION_KEY = "oauth_pending_auth";
|
||||
|
||||
/**
|
||||
* Direct exchange: callback page calls exchange API itself
|
||||
* when relay to opener fails (e.g. HMR reload destroyed listeners)
|
||||
*/
|
||||
async function directExchange(code, state) {
|
||||
try {
|
||||
const raw = localStorage.getItem(OAUTH_SESSION_KEY);
|
||||
if (!raw) return false;
|
||||
|
||||
const session = JSON.parse(raw);
|
||||
// Expired (5 min)
|
||||
if (Date.now() - session.timestamp > 300000) {
|
||||
localStorage.removeItem(OAUTH_SESSION_KEY);
|
||||
return false;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/oauth/${session.provider}/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
redirectUri: session.redirectUri,
|
||||
codeVerifier: session.codeVerifier,
|
||||
state,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
localStorage.removeItem(OAUTH_SESSION_KEY);
|
||||
return res.ok && data.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth Callback Page Content
|
||||
*/
|
||||
@@ -24,7 +61,7 @@ function CallbackContent() {
|
||||
fullUrl: window.location.href,
|
||||
};
|
||||
|
||||
let sent = false;
|
||||
let relayed = false;
|
||||
|
||||
// Check if this callback is from expected origin/port
|
||||
const expectedOrigins = [
|
||||
@@ -35,8 +72,8 @@ function CallbackContent() {
|
||||
// Method 1: postMessage to opener (popup mode)
|
||||
if (window.opener) {
|
||||
try {
|
||||
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev
|
||||
sent = true;
|
||||
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*");
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("postMessage failed:", e);
|
||||
}
|
||||
@@ -47,7 +84,7 @@ function CallbackContent() {
|
||||
const channel = new BroadcastChannel("oauth_callback");
|
||||
channel.postMessage(callbackData);
|
||||
channel.close();
|
||||
sent = true;
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("BroadcastChannel failed:", e);
|
||||
}
|
||||
@@ -55,25 +92,53 @@ function CallbackContent() {
|
||||
// Method 3: localStorage event (fallback)
|
||||
try {
|
||||
localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() }));
|
||||
sent = true;
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("localStorage failed:", e);
|
||||
}
|
||||
|
||||
if (sent && (code || error)) {
|
||||
// Use setTimeout to avoid synchronous setState in effect
|
||||
if (!(code || error)) {
|
||||
setTimeout(() => setStatus("manual"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setTimeout(() => {
|
||||
setStatus("success");
|
||||
// Auto close after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
// If can't close (not a popup), show success message
|
||||
setTimeout(() => setStatus("done"), 500);
|
||||
}, 1500);
|
||||
}, 0);
|
||||
} else {
|
||||
setTimeout(() => setStatus("manual"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try direct exchange FIRST (before relay may be lost to HMR)
|
||||
// Then relay as backup for normal flow
|
||||
const handleExchange = async () => {
|
||||
const pending = localStorage.getItem(OAUTH_SESSION_KEY);
|
||||
if (pending) {
|
||||
// Direct exchange - works even if opener was destroyed by HMR
|
||||
const ok = await directExchange(code, state);
|
||||
if (ok) {
|
||||
setStatus("success");
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
setTimeout(() => setStatus("done"), 500);
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: relay succeeded and OAuthModal handled it
|
||||
setStatus("success");
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
setTimeout(() => setStatus("done"), 500);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
handleExchange();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Low } from "lowdb";
|
||||
import { JSONFile } from "lowdb/node";
|
||||
import { EventEmitter } from "events";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
@@ -71,11 +72,18 @@ const defaultData = {
|
||||
// Singleton instance
|
||||
let dbInstance = null;
|
||||
|
||||
// Track in-flight requests in memory
|
||||
const pendingRequests = {
|
||||
byModel: {},
|
||||
byAccount: {}
|
||||
};
|
||||
// Use global to share pending state across Next.js route modules
|
||||
if (!global._pendingRequests) {
|
||||
global._pendingRequests = { byModel: {}, byAccount: {} };
|
||||
}
|
||||
const pendingRequests = global._pendingRequests;
|
||||
|
||||
// Use global to share singleton across Next.js route modules
|
||||
if (!global._statsEmitter) {
|
||||
global._statsEmitter = new EventEmitter();
|
||||
global._statsEmitter.setMaxListeners(50);
|
||||
}
|
||||
export const statsEmitter = global._statsEmitter;
|
||||
|
||||
/**
|
||||
* Track a pending request
|
||||
@@ -93,11 +101,14 @@ export function trackPendingRequest(model, provider, connectionId, started) {
|
||||
|
||||
// Track by account
|
||||
if (connectionId) {
|
||||
const accountKey = connectionId; // We use connectionId as key here
|
||||
const accountKey = connectionId;
|
||||
if (!pendingRequests.byAccount[accountKey]) pendingRequests.byAccount[accountKey] = {};
|
||||
if (!pendingRequests.byAccount[accountKey][modelKey]) pendingRequests.byAccount[accountKey][modelKey] = 0;
|
||||
pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1));
|
||||
}
|
||||
|
||||
console.log(`[PENDING] ${started ? "START" : "END"} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("update")}`);
|
||||
statsEmitter.emit("update");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,12 +170,15 @@ export async function saveRequestUsage(entry) {
|
||||
db.data.history = [];
|
||||
}
|
||||
|
||||
const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens);
|
||||
entry.cost = entryCost;
|
||||
db.data.history.push(entry);
|
||||
|
||||
// Optional: Limit history size if needed in future
|
||||
// if (db.data.history.length > 10000) db.data.history.shift();
|
||||
|
||||
await db.write();
|
||||
statsEmitter.emit("update");
|
||||
} catch (error) {
|
||||
console.error("Failed to save usage stats:", error);
|
||||
}
|
||||
@@ -387,6 +401,34 @@ export async function getUsageStats() {
|
||||
};
|
||||
}
|
||||
|
||||
// 20 most recent requests from history (always in sync with SSE emit)
|
||||
const seen = new Set();
|
||||
const recentRequests = [...history]
|
||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
.map((e) => {
|
||||
const t = e.tokens || {};
|
||||
const promptTokens = t.prompt_tokens || t.input_tokens || 0;
|
||||
const completionTokens = t.completion_tokens || t.output_tokens || 0;
|
||||
return {
|
||||
timestamp: e.timestamp,
|
||||
model: e.model,
|
||||
provider: e.provider || "",
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
status: e.status || "ok",
|
||||
};
|
||||
})
|
||||
.filter((e) => {
|
||||
if (e.promptTokens === 0 && e.completionTokens === 0) return false;
|
||||
// Deduplicate: same model+provider+tokens within same minute
|
||||
const minute = e.timestamp ? e.timestamp.slice(0, 16) : "";
|
||||
const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 20);
|
||||
|
||||
const stats = {
|
||||
totalRequests: history.length,
|
||||
totalPromptTokens: 0,
|
||||
@@ -399,7 +441,8 @@ export async function getUsageStats() {
|
||||
byEndpoint: {},
|
||||
last10Minutes: [],
|
||||
pending: pendingRequests,
|
||||
activeRequests: []
|
||||
activeRequests: [],
|
||||
recentRequests,
|
||||
};
|
||||
|
||||
// Build active requests list from pending counts
|
||||
@@ -618,5 +661,54 @@ export async function getUsageStats() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time-series chart data for a given period
|
||||
* @param {"24h"|"7d"|"30d"|"60d"} period
|
||||
* @returns {Promise<Array<{label: string, tokens: number, cost: number}>>}
|
||||
*/
|
||||
export async function getChartData(period = "7d") {
|
||||
const db = await getUsageDb();
|
||||
const history = db.data.history || [];
|
||||
const now = Date.now();
|
||||
|
||||
let bucketCount, bucketMs, labelFn;
|
||||
if (period === "24h") {
|
||||
bucketCount = 24;
|
||||
bucketMs = 3600000; // 1 hour
|
||||
labelFn = (ts) => new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
} else if (period === "7d") {
|
||||
bucketCount = 7;
|
||||
bucketMs = 86400000;
|
||||
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
} else if (period === "30d") {
|
||||
bucketCount = 30;
|
||||
bucketMs = 86400000;
|
||||
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
} else {
|
||||
bucketCount = 60;
|
||||
bucketMs = 86400000;
|
||||
labelFn = (ts) => new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const startTime = now - bucketCount * bucketMs;
|
||||
const buckets = Array.from({ length: bucketCount }, (_, i) => {
|
||||
const ts = startTime + i * bucketMs;
|
||||
return { label: labelFn(ts), tokens: 0, cost: 0, _ts: ts };
|
||||
});
|
||||
|
||||
for (const entry of history) {
|
||||
const entryTime = new Date(entry.timestamp).getTime();
|
||||
if (entryTime < startTime || entryTime > now) continue;
|
||||
const idx = Math.min(Math.floor((entryTime - startTime) / bucketMs), bucketCount - 1);
|
||||
const promptTokens = entry.tokens?.prompt_tokens || 0;
|
||||
const completionTokens = entry.tokens?.completion_tokens || 0;
|
||||
buckets[idx].tokens += promptTokens + completionTokens;
|
||||
// Use pre-stored cost if available, else 0
|
||||
buckets[idx].cost += entry.cost || 0;
|
||||
}
|
||||
|
||||
return buckets.map(({ label, tokens, cost }) => ({ label, tokens, cost }));
|
||||
}
|
||||
|
||||
// Re-export request details functions from new SQLite-based module
|
||||
export { saveRequestDetail, getRequestDetails, getRequestDetailById } from "./requestDetailsDb.js";
|
||||
|
||||
@@ -5,6 +5,34 @@ import PropTypes from "prop-types";
|
||||
import { Modal, Button, Input } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const OAUTH_SESSION_KEY = "oauth_pending_auth";
|
||||
|
||||
function saveAuthSession(provider, data) {
|
||||
try {
|
||||
localStorage.setItem(OAUTH_SESSION_KEY, JSON.stringify({ provider, ...data, timestamp: Date.now() }));
|
||||
} catch { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
function loadAuthSession(provider) {
|
||||
try {
|
||||
const raw = localStorage.getItem(OAUTH_SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw);
|
||||
// Only restore if same provider and within 5 minutes
|
||||
if (data.provider !== provider || Date.now() - data.timestamp > 300000) {
|
||||
localStorage.removeItem(OAUTH_SESSION_KEY);
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuthSession() {
|
||||
try { localStorage.removeItem(OAUTH_SESSION_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth Modal Component
|
||||
* - Localhost: Auto callback via popup message
|
||||
@@ -56,9 +84,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
clearAuthSession();
|
||||
setStep("success");
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
clearAuthSession();
|
||||
setError(err.message);
|
||||
setStep("error");
|
||||
}
|
||||
@@ -152,6 +182,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
setAuthData({ ...data, redirectUri });
|
||||
saveAuthSession(provider, { codeVerifier: data.codeVerifier, redirectUri, state: data.state });
|
||||
|
||||
// For Codex or non-localhost: use manual input mode
|
||||
if (provider === "codex" || !isLocalhost) {
|
||||
@@ -176,13 +207,25 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
// Reset state and start OAuth when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && provider) {
|
||||
// Try restore pending auth from localStorage (survives HMR/reload)
|
||||
const saved = loadAuthSession(provider);
|
||||
if (saved) {
|
||||
setAuthData({ codeVerifier: saved.codeVerifier, redirectUri: saved.redirectUri, state: saved.state });
|
||||
setStep("waiting");
|
||||
setCallbackUrl("");
|
||||
setError(null);
|
||||
setIsDeviceCode(false);
|
||||
setDeviceData(null);
|
||||
setPolling(false);
|
||||
return; // Don't restart OAuth — just re-listen for callback
|
||||
}
|
||||
|
||||
setAuthData(null);
|
||||
setCallbackUrl("");
|
||||
setError(null);
|
||||
setIsDeviceCode(false);
|
||||
setDeviceData(null);
|
||||
setPolling(false);
|
||||
// Auto start OAuth
|
||||
startOAuthFlow();
|
||||
}
|
||||
}, [isOpen, provider, startOAuthFlow]);
|
||||
@@ -206,6 +249,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
}
|
||||
|
||||
if (code) {
|
||||
// Skip if callback page already handled exchange (localStorage cleared)
|
||||
const stillPending = localStorage.getItem(OAUTH_SESSION_KEY);
|
||||
if (!stillPending) {
|
||||
callbackProcessedRef.current = true;
|
||||
setStep("success");
|
||||
onSuccess?.();
|
||||
return;
|
||||
}
|
||||
callbackProcessedRef.current = true;
|
||||
await exchangeTokens(code, state);
|
||||
}
|
||||
@@ -293,10 +344,16 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
}
|
||||
};
|
||||
|
||||
// Clear session on modal close
|
||||
const handleClose = useCallback(() => {
|
||||
clearAuthSession();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!provider || !providerInfo) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={onClose} size="lg">
|
||||
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={handleClose} size="lg">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Waiting Step (Localhost - popup mode) */}
|
||||
{step === "waiting" && !isDeviceCode && (
|
||||
@@ -389,7 +446,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -406,7 +463,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Your {providerInfo.name} account has been connected.
|
||||
</p>
|
||||
<Button onClick={onClose} fullWidth>
|
||||
<Button onClick={handleClose} fullWidth>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
@@ -424,7 +481,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,4 +102,4 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
|
||||
}, {});
|
||||
|
||||
// Providers that support usage/quota API
|
||||
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"];
|
||||
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex"];
|
||||
|
||||
Reference in New Issue
Block a user