mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Fixed Codex
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user