Fixed Codex
This commit is contained in:
parent
f2025cc776
commit
adf57aa0c9
18 changed files with 1419 additions and 1627 deletions
|
|
@ -16,6 +16,8 @@ const nextConfig = {
|
||||||
path: false,
|
path: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Stop watching logs directory to prevent HMR during streaming
|
||||||
|
config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next)[\\/]/ };
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
|
|
||||||
|
|
@ -906,8 +906,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||||
&& !isDroidCLI;
|
&& !isDroidCLI;
|
||||||
|
|
||||||
if (needsCodexTranslation) {
|
if (needsCodexTranslation) {
|
||||||
log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`);
|
// For claude clients, translate directly to claude format
|
||||||
transformStream = createSSETransformStreamWithLogger('openai-responses', 'openai', provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
// 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)) {
|
} else if (needsTranslation(targetFormat, sourceFormat)) {
|
||||||
log?.debug?.("STREAM", `Translation mode: ${targetFormat} → ${sourceFormat}`);
|
log?.debug?.("STREAM", `Translation mode: ${targetFormat} → ${sourceFormat}`);
|
||||||
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
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
|
// Helper: Remove unsupported keywords recursively from object/array
|
||||||
|
// Also strips all vendor extension fields (x- prefixed) not supported by Gemini
|
||||||
function removeUnsupportedKeywords(obj, keywords) {
|
function removeUnsupportedKeywords(obj, keywords) {
|
||||||
if (!obj || typeof obj !== "object") return;
|
if (!obj || typeof obj !== "object") return;
|
||||||
|
|
||||||
|
|
@ -108,13 +109,11 @@ function removeUnsupportedKeywords(obj, keywords) {
|
||||||
removeUnsupportedKeywords(item, keywords);
|
removeUnsupportedKeywords(item, keywords);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Delete unsupported keys at current level
|
for (const key of Object.keys(obj)) {
|
||||||
for (const keyword of keywords) {
|
if (keywords.includes(key) || key.startsWith("x-")) {
|
||||||
if (keyword in obj) {
|
delete obj[key];
|
||||||
delete obj[keyword];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Recurse into remaining values
|
|
||||||
for (const value of Object.values(obj)) {
|
for (const value of Object.values(obj)) {
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
removeUnsupportedKeywords(value, keywords);
|
removeUnsupportedKeywords(value, keywords);
|
||||||
|
|
|
||||||
|
|
@ -415,8 +415,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function call started
|
// Function call started (standard function_call or custom_tool_call)
|
||||||
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
|
if (eventType === "response.output_item.added" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) {
|
||||||
const item = data.item;
|
const item = data.item;
|
||||||
state.currentToolCallId = item.call_id || `call_${Date.now()}`;
|
state.currentToolCallId = item.call_id || `call_${Date.now()}`;
|
||||||
|
|
||||||
|
|
@ -443,8 +443,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function call arguments delta
|
// Function call arguments delta (standard or custom_tool_call variant)
|
||||||
if (eventType === "response.function_call_arguments.delta") {
|
if (eventType === "response.function_call_arguments.delta" || eventType === "response.custom_tool_call_input.delta") {
|
||||||
const argsDelta = data.delta || "";
|
const argsDelta = data.delta || "";
|
||||||
if (!argsDelta) return null;
|
if (!argsDelta) return null;
|
||||||
|
|
||||||
|
|
@ -466,8 +466,8 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function call done
|
// Function call done (standard or custom_tool_call variant)
|
||||||
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
|
if (eventType === "response.output_item.done" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) {
|
||||||
state.toolCallIndex++;
|
state.toolCallIndex++;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"ora": "^9.1.0",
|
"ora": "^9.1.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"socks-proxy-agent": "^8.0.5",
|
"socks-proxy-agent": "^8.0.5",
|
||||||
"undici": "^7.19.2",
|
"undici": "^7.19.2",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,19 @@ export default function ProviderDetailPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [providerNode, setProviderNode] = useState(null);
|
const [providerNode, setProviderNode] = useState(null);
|
||||||
const [showOAuthModal, setShowOAuthModal] = useState(false);
|
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 [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showEditNodeModal, setShowEditNodeModal] = 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 searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState(searchParams.get("tab") || "overview");
|
const [activeTab, setActiveTab] = useState(searchParams.get("tab") || "overview");
|
||||||
|
const [tabLoading, setTabLoading] = useState(false);
|
||||||
|
|
||||||
// Sync tab with URL on mount and when URL changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tabFromUrl = searchParams.get("tab");
|
const tabFromUrl = searchParams.get("tab");
|
||||||
if (tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)) {
|
if (tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)) {
|
||||||
|
|
@ -27,12 +27,15 @@ function UsagePageContent() {
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Update URL when tab changes
|
|
||||||
const handleTabChange = (value) => {
|
const handleTabChange = (value) => {
|
||||||
|
if (value === activeTab) return;
|
||||||
|
setTabLoading(true);
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
params.set("tab", value);
|
params.set("tab", value);
|
||||||
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
|
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
|
||||||
|
// Brief loading flash so user sees feedback
|
||||||
|
setTimeout(() => setTabLoading(false), 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -40,7 +43,6 @@ function UsagePageContent() {
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
options={[
|
options={[
|
||||||
{ value: "overview", label: "Overview" },
|
{ value: "overview", label: "Overview" },
|
||||||
{ value: "logs", label: "Logger" },
|
|
||||||
{ value: "limits", label: "Limits" },
|
{ value: "limits", label: "Limits" },
|
||||||
{ value: "details", label: "Details" },
|
{ value: "details", label: "Details" },
|
||||||
]}
|
]}
|
||||||
|
|
@ -48,19 +50,24 @@ function UsagePageContent() {
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
{tabLoading ? (
|
||||||
{activeTab === "overview" && (
|
<CardSkeleton />
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
) : (
|
||||||
<UsageStats />
|
<>
|
||||||
</Suspense>
|
{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>
|
</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 { Suspense, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
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
|
* OAuth Callback Page Content
|
||||||
*/
|
*/
|
||||||
|
|
@ -24,7 +61,7 @@ function CallbackContent() {
|
||||||
fullUrl: window.location.href,
|
fullUrl: window.location.href,
|
||||||
};
|
};
|
||||||
|
|
||||||
let sent = false;
|
let relayed = false;
|
||||||
|
|
||||||
// Check if this callback is from expected origin/port
|
// Check if this callback is from expected origin/port
|
||||||
const expectedOrigins = [
|
const expectedOrigins = [
|
||||||
|
|
@ -35,8 +72,8 @@ function CallbackContent() {
|
||||||
// Method 1: postMessage to opener (popup mode)
|
// Method 1: postMessage to opener (popup mode)
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
try {
|
try {
|
||||||
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev
|
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*");
|
||||||
sent = true;
|
relayed = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("postMessage failed:", e);
|
console.log("postMessage failed:", e);
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +84,7 @@ function CallbackContent() {
|
||||||
const channel = new BroadcastChannel("oauth_callback");
|
const channel = new BroadcastChannel("oauth_callback");
|
||||||
channel.postMessage(callbackData);
|
channel.postMessage(callbackData);
|
||||||
channel.close();
|
channel.close();
|
||||||
sent = true;
|
relayed = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("BroadcastChannel failed:", e);
|
console.log("BroadcastChannel failed:", e);
|
||||||
}
|
}
|
||||||
|
|
@ -55,25 +92,53 @@ function CallbackContent() {
|
||||||
// Method 3: localStorage event (fallback)
|
// Method 3: localStorage event (fallback)
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() }));
|
localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() }));
|
||||||
sent = true;
|
relayed = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("localStorage failed:", e);
|
console.log("localStorage failed:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sent && (code || error)) {
|
if (!(code || error)) {
|
||||||
// Use setTimeout to avoid synchronous setState in effect
|
setTimeout(() => setStatus("manual"), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
// Auto close after 1.5 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.close();
|
window.close();
|
||||||
// If can't close (not a popup), show success message
|
|
||||||
setTimeout(() => setStatus("done"), 500);
|
setTimeout(() => setStatus("done"), 500);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
return;
|
||||||
setTimeout(() => setStatus("manual"), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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]);
|
}, [searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Low } from "lowdb";
|
import { Low } from "lowdb";
|
||||||
import { JSONFile } from "lowdb/node";
|
import { JSONFile } from "lowdb/node";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
@ -71,11 +72,18 @@ const defaultData = {
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
let dbInstance = null;
|
let dbInstance = null;
|
||||||
|
|
||||||
// Track in-flight requests in memory
|
// Use global to share pending state across Next.js route modules
|
||||||
const pendingRequests = {
|
if (!global._pendingRequests) {
|
||||||
byModel: {},
|
global._pendingRequests = { byModel: {}, byAccount: {} };
|
||||||
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
|
* Track a pending request
|
||||||
|
|
@ -93,11 +101,14 @@ export function trackPendingRequest(model, provider, connectionId, started) {
|
||||||
|
|
||||||
// Track by account
|
// Track by account
|
||||||
if (connectionId) {
|
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]) pendingRequests.byAccount[accountKey] = {};
|
||||||
if (!pendingRequests.byAccount[accountKey][modelKey]) pendingRequests.byAccount[accountKey][modelKey] = 0;
|
if (!pendingRequests.byAccount[accountKey][modelKey]) pendingRequests.byAccount[accountKey][modelKey] = 0;
|
||||||
pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1));
|
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 = [];
|
db.data.history = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens);
|
||||||
|
entry.cost = entryCost;
|
||||||
db.data.history.push(entry);
|
db.data.history.push(entry);
|
||||||
|
|
||||||
// Optional: Limit history size if needed in future
|
// Optional: Limit history size if needed in future
|
||||||
// if (db.data.history.length > 10000) db.data.history.shift();
|
// if (db.data.history.length > 10000) db.data.history.shift();
|
||||||
|
|
||||||
await db.write();
|
await db.write();
|
||||||
|
statsEmitter.emit("update");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save usage stats:", 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 = {
|
const stats = {
|
||||||
totalRequests: history.length,
|
totalRequests: history.length,
|
||||||
totalPromptTokens: 0,
|
totalPromptTokens: 0,
|
||||||
|
|
@ -399,7 +441,8 @@ export async function getUsageStats() {
|
||||||
byEndpoint: {},
|
byEndpoint: {},
|
||||||
last10Minutes: [],
|
last10Minutes: [],
|
||||||
pending: pendingRequests,
|
pending: pendingRequests,
|
||||||
activeRequests: []
|
activeRequests: [],
|
||||||
|
recentRequests,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build active requests list from pending counts
|
// Build active requests list from pending counts
|
||||||
|
|
@ -618,5 +661,54 @@ export async function getUsageStats() {
|
||||||
return stats;
|
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
|
// Re-export request details functions from new SQLite-based module
|
||||||
export { saveRequestDetail, getRequestDetails, getRequestDetailById } from "./requestDetailsDb.js";
|
export { saveRequestDetail, getRequestDetails, getRequestDetailById } from "./requestDetailsDb.js";
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,34 @@ import PropTypes from "prop-types";
|
||||||
import { Modal, Button, Input } from "@/shared/components";
|
import { Modal, Button, Input } from "@/shared/components";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
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
|
* OAuth Modal Component
|
||||||
* - Localhost: Auto callback via popup message
|
* - Localhost: Auto callback via popup message
|
||||||
|
|
@ -56,9 +84,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error);
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
|
||||||
|
clearAuthSession();
|
||||||
setStep("success");
|
setStep("success");
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearAuthSession();
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setStep("error");
|
setStep("error");
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +182,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
if (!res.ok) throw new Error(data.error);
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
|
||||||
setAuthData({ ...data, redirectUri });
|
setAuthData({ ...data, redirectUri });
|
||||||
|
saveAuthSession(provider, { codeVerifier: data.codeVerifier, redirectUri, state: data.state });
|
||||||
|
|
||||||
// For Codex or non-localhost: use manual input mode
|
// For Codex or non-localhost: use manual input mode
|
||||||
if (provider === "codex" || !isLocalhost) {
|
if (provider === "codex" || !isLocalhost) {
|
||||||
|
|
@ -176,13 +207,25 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
// Reset state and start OAuth when modal opens
|
// Reset state and start OAuth when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && provider) {
|
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);
|
setAuthData(null);
|
||||||
setCallbackUrl("");
|
setCallbackUrl("");
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsDeviceCode(false);
|
setIsDeviceCode(false);
|
||||||
setDeviceData(null);
|
setDeviceData(null);
|
||||||
setPolling(false);
|
setPolling(false);
|
||||||
// Auto start OAuth
|
|
||||||
startOAuthFlow();
|
startOAuthFlow();
|
||||||
}
|
}
|
||||||
}, [isOpen, provider, startOAuthFlow]);
|
}, [isOpen, provider, startOAuthFlow]);
|
||||||
|
|
@ -206,6 +249,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code) {
|
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;
|
callbackProcessedRef.current = true;
|
||||||
await exchangeTokens(code, state);
|
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;
|
if (!provider || !providerInfo) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Waiting Step (Localhost - popup mode) */}
|
{/* Waiting Step (Localhost - popup mode) */}
|
||||||
{step === "waiting" && !isDeviceCode && (
|
{step === "waiting" && !isDeviceCode && (
|
||||||
|
|
@ -389,7 +446,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
|
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -406,7 +463,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
<p className="text-sm text-text-muted mb-4">
|
<p className="text-sm text-text-muted mb-4">
|
||||||
Your {providerInfo.name} account has been connected.
|
Your {providerInfo.name} account has been connected.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={onClose} fullWidth>
|
<Button onClick={handleClose} fullWidth>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -424,7 +481,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
|
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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
|
// 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"];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue