2api
feat: configurable stream stall timeout + per-provider UI
88c4c60
Raw
History Blame Contribute Delete
8.89 kB
"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 };