Fixed Codex

This commit is contained in:
decolua
2026-02-21 14:36:06 +07:00
parent f2025cc776
commit adf57aa0c9
18 changed files with 1419 additions and 1627 deletions
@@ -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,
};
@@ -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 = {};
@@ -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 };