mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat: add API endpoint dimension to usage statistics dashboard (#152)
- Tracks endpoints like /v1/chat/completions, /v1/messages, /v1/responses - New sortable/groupable table in usage dashboard with expandable groups - Enhanced usage database aggregation by endpoint + model + provider - Added endpoint tracking to all saveRequestUsage/saveRequestDetail calls - Maintains backward compatibility with existing data structure
This commit is contained in:
@@ -622,7 +622,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
thinking: null,
|
thinking: null,
|
||||||
finish_reason: jsonResponse.status || "unknown"
|
finish_reason: jsonResponse.status || "unknown"
|
||||||
},
|
},
|
||||||
status: "success"
|
status: "success",
|
||||||
|
endpoint: clientRawRequest?.endpoint || null
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -656,7 +657,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
tokens: usage,
|
tokens: usage,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
connectionId: connectionId || undefined,
|
connectionId: connectionId || undefined,
|
||||||
apiKey: apiKey || undefined
|
apiKey: apiKey || undefined,
|
||||||
|
endpoint: clientRawRequest?.endpoint || null
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,7 +678,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
thinking: parsed.choices?.[0]?.message?.reasoning_content || null,
|
thinking: parsed.choices?.[0]?.message?.reasoning_content || null,
|
||||||
finish_reason: parsed.choices?.[0]?.finish_reason || "unknown"
|
finish_reason: parsed.choices?.[0]?.finish_reason || "unknown"
|
||||||
},
|
},
|
||||||
status: "success"
|
status: "success",
|
||||||
|
endpoint: clientRawRequest?.endpoint || null
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -749,7 +752,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
tokens: usage,
|
tokens: usage,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
connectionId: connectionId || undefined,
|
connectionId: connectionId || undefined,
|
||||||
apiKey: apiKey || undefined
|
apiKey: apiKey || undefined,
|
||||||
|
endpoint: clientRawRequest?.endpoint || null
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error("Failed to save usage stats:", err.message);
|
console.error("Failed to save usage stats:", err.message);
|
||||||
});
|
});
|
||||||
@@ -808,7 +812,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
null,
|
null,
|
||||||
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
|
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
|
||||||
},
|
},
|
||||||
status: "success"
|
status: "success",
|
||||||
|
endpoint: clientRawRequest?.endpoint || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async save (don't block response)
|
// Async save (don't block response)
|
||||||
@@ -886,7 +891,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
tokens: usage,
|
tokens: usage,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
connectionId: connectionId || undefined,
|
connectionId: connectionId || undefined,
|
||||||
apiKey: apiKey || undefined
|
apiKey: apiKey || undefined,
|
||||||
|
endpoint: clientRawRequest?.endpoint || null
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error("Failed to save streaming usage stats:", err.message);
|
console.error("Failed to save streaming usage stats:", err.message);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ export async function getUsageStats() {
|
|||||||
byModel: {},
|
byModel: {},
|
||||||
byAccount: {},
|
byAccount: {},
|
||||||
byApiKey: {},
|
byApiKey: {},
|
||||||
|
byEndpoint: {},
|
||||||
last10Minutes: [],
|
last10Minutes: [],
|
||||||
pending: pendingRequests,
|
pending: pendingRequests,
|
||||||
activeRequests: []
|
activeRequests: []
|
||||||
@@ -587,6 +588,31 @@ export async function getUsageStats() {
|
|||||||
apiKeyEntry.lastUsed = entry.timestamp;
|
apiKeyEntry.lastUsed = entry.timestamp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// By Endpoint (endpoint + model + provider combination)
|
||||||
|
const endpoint = entry.endpoint || "Unknown";
|
||||||
|
const endpointModelKey = `${endpoint}|${entry.model}|${entry.provider || 'unknown'}`;
|
||||||
|
|
||||||
|
if (!stats.byEndpoint[endpointModelKey]) {
|
||||||
|
stats.byEndpoint[endpointModelKey] = {
|
||||||
|
requests: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
endpoint: endpoint,
|
||||||
|
rawModel: entry.model,
|
||||||
|
provider: entry.provider,
|
||||||
|
lastUsed: entry.timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const endpointEntry = stats.byEndpoint[endpointModelKey];
|
||||||
|
endpointEntry.requests++;
|
||||||
|
endpointEntry.promptTokens += promptTokens;
|
||||||
|
endpointEntry.completionTokens += completionTokens;
|
||||||
|
endpointEntry.cost += entryCost;
|
||||||
|
if (new Date(entry.timestamp) > new Date(endpointEntry.lastUsed)) {
|
||||||
|
endpointEntry.lastUsed = entry.timestamp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export default function UsageStats() {
|
|||||||
const accountSortOrder = searchParams.get("accountSortOrder") || "asc";
|
const accountSortOrder = searchParams.get("accountSortOrder") || "asc";
|
||||||
const apiKeySortBy = searchParams.get("apiKeySortBy") || "keyName";
|
const apiKeySortBy = searchParams.get("apiKeySortBy") || "keyName";
|
||||||
const apiKeySortOrder = searchParams.get("apiKeySortOrder") || "asc";
|
const apiKeySortOrder = searchParams.get("apiKeySortOrder") || "asc";
|
||||||
|
const endpointSortBy = searchParams.get("endpointSortBy") || "endpoint";
|
||||||
|
const endpointSortOrder = searchParams.get("endpointSortOrder") || "asc";
|
||||||
|
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -59,12 +61,14 @@ export default function UsageStats() {
|
|||||||
const [expandedModels, setExpandedModels] = useState(new Set());
|
const [expandedModels, setExpandedModels] = useState(new Set());
|
||||||
const [expandedAccounts, setExpandedAccounts] = useState(new Set());
|
const [expandedAccounts, setExpandedAccounts] = useState(new Set());
|
||||||
const [expandedApiKeys, setExpandedApiKeys] = useState(new Set());
|
const [expandedApiKeys, setExpandedApiKeys] = useState(new Set());
|
||||||
|
const [expandedEndpoints, setExpandedEndpoints] = useState(new Set());
|
||||||
|
|
||||||
const toggleSort = (tableType, field) => {
|
const toggleSort = (tableType, field) => {
|
||||||
const sortKeyMap = {
|
const sortKeyMap = {
|
||||||
model: { by: "modelSortBy", order: "modelSortOrder" },
|
model: { by: "modelSortBy", order: "modelSortOrder" },
|
||||||
account: { by: "accountSortBy", order: "accountSortOrder" },
|
account: { by: "accountSortBy", order: "accountSortOrder" },
|
||||||
apiKey: { by: "apiKeySortBy", order: "apiKeySortOrder" }
|
apiKey: { by: "apiKeySortBy", order: "apiKeySortOrder" },
|
||||||
|
endpoint: { by: "endpointSortBy", order: "endpointSortOrder" }
|
||||||
};
|
};
|
||||||
const sortKeys = sortKeyMap[tableType];
|
const sortKeys = sortKeyMap[tableType];
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -134,6 +138,8 @@ export default function UsageStats() {
|
|||||||
return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || 'Unknown Account';
|
return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || 'Unknown Account';
|
||||||
case 'keyName':
|
case 'keyName':
|
||||||
return item.keyName || 'Unknown Key';
|
return item.keyName || 'Unknown Key';
|
||||||
|
case 'endpoint':
|
||||||
|
return item.endpoint || 'Unknown Endpoint';
|
||||||
default:
|
default:
|
||||||
return item[keyField] || 'Unknown';
|
return item[keyField] || 'Unknown';
|
||||||
}
|
}
|
||||||
@@ -234,6 +240,16 @@ export default function UsageStats() {
|
|||||||
[sortedApiKeys, groupDataByKey]
|
[sortedApiKeys, groupDataByKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortedEndpoints = useMemo(
|
||||||
|
() => sortData(stats?.byEndpoint, {}, endpointSortBy, endpointSortOrder),
|
||||||
|
[stats?.byEndpoint, endpointSortBy, endpointSortOrder, sortData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedEndpoints = useMemo(
|
||||||
|
() => groupDataByKey(sortedEndpoints, 'endpoint'),
|
||||||
|
[sortedEndpoints, groupDataByKey]
|
||||||
|
);
|
||||||
|
|
||||||
const fetchStats = useCallback(async (showLoading = true) => {
|
const fetchStats = useCallback(async (showLoading = true) => {
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -321,6 +337,25 @@ export default function UsageStats() {
|
|||||||
}
|
}
|
||||||
}, [expandedApiKeys]);
|
}, [expandedApiKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('usage-stats:expanded-endpoints');
|
||||||
|
if (saved) {
|
||||||
|
setExpandedEndpoints(new Set(JSON.parse(saved)));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load expanded endpoints from localStorage:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('usage-stats:expanded-endpoints', JSON.stringify([...expandedEndpoints]));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save expanded endpoints to localStorage:", error);
|
||||||
|
}
|
||||||
|
}, [expandedEndpoints]);
|
||||||
|
|
||||||
const toggleModelGroup = useCallback((groupKey) => {
|
const toggleModelGroup = useCallback((groupKey) => {
|
||||||
setExpandedModels(prev => {
|
setExpandedModels(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -357,6 +392,18 @@ export default function UsageStats() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleEndpointGroup = useCallback((groupKey) => {
|
||||||
|
setExpandedEndpoints(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(groupKey)) {
|
||||||
|
next.delete(groupKey);
|
||||||
|
} else {
|
||||||
|
next.add(groupKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let intervalId;
|
let intervalId;
|
||||||
let isPageVisible = true;
|
let isPageVisible = true;
|
||||||
@@ -1315,6 +1362,259 @@ export default function UsageStats() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Usage by API Endpoint Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border bg-bg-subtle/50">
|
||||||
|
<h3 className="font-semibold">Usage by API Endpoint</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>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "endpoint")}
|
||||||
|
>
|
||||||
|
Endpoint{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="endpoint"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "rawModel")}
|
||||||
|
>
|
||||||
|
Model{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="rawModel"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "provider")}
|
||||||
|
>
|
||||||
|
Provider{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="provider"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "requests")}
|
||||||
|
>
|
||||||
|
Requests{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="requests"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "lastUsed")}
|
||||||
|
>
|
||||||
|
Last Used{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="lastUsed"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
{viewMode === "tokens" ? (
|
||||||
|
<>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "promptTokens")}
|
||||||
|
>
|
||||||
|
Input Tokens{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="promptTokens"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "completionTokens")}
|
||||||
|
>
|
||||||
|
Output Tokens{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="completionTokens"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "totalTokens")}
|
||||||
|
>
|
||||||
|
Total Tokens{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="totalTokens"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "promptTokens")}
|
||||||
|
>
|
||||||
|
Input Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="promptTokens"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "completionTokens")}
|
||||||
|
>
|
||||||
|
Output Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="completionTokens"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("endpoint", "cost")}
|
||||||
|
>
|
||||||
|
Total Cost{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="cost"
|
||||||
|
currentSort={endpointSortBy}
|
||||||
|
currentOrder={endpointSortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{groupedEndpoints.map((group) => (
|
||||||
|
<Fragment key={group.groupKey}>
|
||||||
|
<tr
|
||||||
|
key={`summary-${group.groupKey}`}
|
||||||
|
className="group-summary cursor-pointer hover:bg-bg-subtle/50 transition-colors"
|
||||||
|
onClick={() => toggleEndpointGroup(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 ${expandedEndpoints.has(group.groupKey) ? 'rotate-90' : ''}`}>
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
<span className="font-medium font-mono text-sm">
|
||||||
|
{group.groupKey}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-text-muted">—</td>
|
||||||
|
<td className="px-6 py-3 text-text-muted">—</td>
|
||||||
|
<td className="px-6 py-3 text-right">{fmt(group.summary.requests)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
||||||
|
{fmtTime(group.summary.lastUsed)}
|
||||||
|
</td>
|
||||||
|
{viewMode === "tokens" ? (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmt(group.summary.promptTokens)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmt(group.summary.completionTokens)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium">
|
||||||
|
{fmt(group.summary.totalTokens)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(group.summary.inputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(group.summary.outputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||||
|
{fmtCost(group.summary.totalCost)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{expandedEndpoints.has(group.groupKey) && group.items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={`detail-${item.key}`}
|
||||||
|
className="group-detail hover:bg-bg-subtle/20 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3 font-medium font-mono text-sm">
|
||||||
|
{item.endpoint}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{item.rawModel}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Badge variant="neutral" size="sm">
|
||||||
|
{item.provider}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
||||||
|
{fmtTime(item.lastUsed)}
|
||||||
|
</td>
|
||||||
|
{viewMode === "tokens" ? (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmt(item.promptTokens)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmt(item.completionTokens)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium">
|
||||||
|
{fmt(item.totalTokens)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(item.inputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
|
{fmtCost(item.outputCost)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right font-medium text-warning">
|
||||||
|
{fmtCost(item.totalCost)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{groupedEndpoints.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={9}
|
||||||
|
className="px-6 py-8 text-center text-text-muted"
|
||||||
|
>
|
||||||
|
No endpoint usage recorded yet. Make requests to see data here.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user