feat: implement request tracking and enhance usage stats display
This commit is contained in:
@@ -8,7 +8,7 @@ import { createRequestLogger } from "../utils/requestLogger.js";
|
|||||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||||
import { saveRequestUsage } from "@/lib/usageDb.js";
|
import { saveRequestUsage, trackPendingRequest } from "@/lib/usageDb.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract usage from non-streaming response body
|
* Extract usage from non-streaming response body
|
||||||
@@ -122,6 +122,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
const providerUrl = buildProviderUrl(provider, model, stream);
|
const providerUrl = buildProviderUrl(provider, model, stream);
|
||||||
const providerHeaders = buildProviderHeaders(provider, credentials, stream, translatedBody);
|
const providerHeaders = buildProviderHeaders(provider, credentials, stream, translatedBody);
|
||||||
|
|
||||||
|
// Track pending request
|
||||||
|
trackPendingRequest(model, provider, connectionId, true);
|
||||||
|
|
||||||
// 2. Log converted request to provider
|
// 2. Log converted request to provider
|
||||||
reqLogger.logConvertedRequest(providerUrl, providerHeaders, translatedBody);
|
reqLogger.logConvertedRequest(providerUrl, providerHeaders, translatedBody);
|
||||||
|
|
||||||
@@ -155,6 +158,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
signal: streamController.signal
|
signal: streamController.signal
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
streamController.handleError(error);
|
streamController.handleError(error);
|
||||||
return createErrorResult(499, "Request aborted");
|
return createErrorResult(499, "Request aborted");
|
||||||
@@ -248,6 +252,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
|
|
||||||
// Check provider response - return error info for fallback handling
|
// Check provider response - return error info for fallback handling
|
||||||
if (!providerResponse.ok) {
|
if (!providerResponse.ok) {
|
||||||
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
const { statusCode, message } = await parseUpstreamError(providerResponse);
|
const { statusCode, message } = await parseUpstreamError(providerResponse);
|
||||||
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||||
@@ -260,6 +265,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
|
|
||||||
// Non-streaming response
|
// Non-streaming response
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
const responseBody = await providerResponse.json();
|
const responseBody = await providerResponse.json();
|
||||||
|
|
||||||
// Notify success - caller can clear error status if needed
|
// Notify success - caller can clear error status if needed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { translateResponse, initState } from "../translator/index.js";
|
import { translateResponse, initState } from "../translator/index.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { saveRequestUsage } from "@/lib/usageDb.js";
|
import { saveRequestUsage, trackPendingRequest } from "@/lib/usageDb.js";
|
||||||
|
|
||||||
// Get HH:MM:SS timestamp
|
// Get HH:MM:SS timestamp
|
||||||
function getTimeString() {
|
function getTimeString() {
|
||||||
@@ -220,6 +220,7 @@ export function createSSEStream(options = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
flush(controller) {
|
flush(controller) {
|
||||||
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
try {
|
try {
|
||||||
const remaining = decoder.decode();
|
const remaining = decoder.decode();
|
||||||
if (remaining) buffer += remaining;
|
if (remaining) buffer += remaining;
|
||||||
|
|||||||
@@ -49,6 +49,35 @@ const defaultData = {
|
|||||||
// Singleton instance
|
// Singleton instance
|
||||||
let dbInstance = null;
|
let dbInstance = null;
|
||||||
|
|
||||||
|
// Track in-flight requests in memory
|
||||||
|
const pendingRequests = {
|
||||||
|
byModel: {},
|
||||||
|
byAccount: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a pending request
|
||||||
|
* @param {string} model
|
||||||
|
* @param {string} provider
|
||||||
|
* @param {string} connectionId
|
||||||
|
* @param {boolean} started - true if started, false if finished
|
||||||
|
*/
|
||||||
|
export function trackPendingRequest(model, provider, connectionId, started) {
|
||||||
|
const modelKey = provider ? `${model} (${provider})` : model;
|
||||||
|
|
||||||
|
// Track by model
|
||||||
|
if (!pendingRequests.byModel[modelKey]) pendingRequests.byModel[modelKey] = 0;
|
||||||
|
pendingRequests.byModel[modelKey] = Math.max(0, pendingRequests.byModel[modelKey] + (started ? 1 : -1));
|
||||||
|
|
||||||
|
// Track by account
|
||||||
|
if (connectionId) {
|
||||||
|
const accountKey = connectionId; // We use connectionId as key here
|
||||||
|
if (!pendingRequests.byAccount[accountKey]) pendingRequests.byAccount[accountKey] = {};
|
||||||
|
if (!pendingRequests.byAccount[accountKey][modelKey]) pendingRequests.byAccount[accountKey][modelKey] = 0;
|
||||||
|
pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get usage database instance (singleton)
|
* Get usage database instance (singleton)
|
||||||
*/
|
*/
|
||||||
@@ -170,9 +199,31 @@ export async function getUsageStats() {
|
|||||||
byProvider: {},
|
byProvider: {},
|
||||||
byModel: {},
|
byModel: {},
|
||||||
byAccount: {},
|
byAccount: {},
|
||||||
last10Minutes: []
|
last10Minutes: [],
|
||||||
|
pending: pendingRequests,
|
||||||
|
activeRequests: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build active requests list from pending counts
|
||||||
|
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
||||||
|
for (const [modelKey, count] of Object.entries(models)) {
|
||||||
|
if (count > 0) {
|
||||||
|
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
||||||
|
// modelKey is "model (provider)"
|
||||||
|
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
||||||
|
const modelName = match ? match[1] : modelKey;
|
||||||
|
const providerName = match ? match[2] : "unknown";
|
||||||
|
|
||||||
|
stats.activeRequests.push({
|
||||||
|
model: modelName,
|
||||||
|
provider: providerName,
|
||||||
|
account: accountName,
|
||||||
|
count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize 10-minute buckets using stable minute boundaries
|
// Initialize 10-minute buckets using stable minute boundaries
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// Floor to the start of the current minute
|
// Floor to the start of the current minute
|
||||||
@@ -232,12 +283,16 @@ export async function getUsageStats() {
|
|||||||
promptTokens: 0,
|
promptTokens: 0,
|
||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
rawModel: entry.model,
|
rawModel: entry.model,
|
||||||
provider: entry.provider
|
provider: entry.provider,
|
||||||
|
lastUsed: entry.timestamp
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
stats.byModel[modelKey].requests++;
|
stats.byModel[modelKey].requests++;
|
||||||
stats.byModel[modelKey].promptTokens += promptTokens;
|
stats.byModel[modelKey].promptTokens += promptTokens;
|
||||||
stats.byModel[modelKey].completionTokens += completionTokens;
|
stats.byModel[modelKey].completionTokens += completionTokens;
|
||||||
|
if (new Date(entry.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) {
|
||||||
|
stats.byModel[modelKey].lastUsed = entry.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
// By Account (model + oauth account)
|
// By Account (model + oauth account)
|
||||||
// Use connectionId if available, otherwise fallback to provider name
|
// Use connectionId if available, otherwise fallback to provider name
|
||||||
@@ -253,12 +308,16 @@ export async function getUsageStats() {
|
|||||||
rawModel: entry.model,
|
rawModel: entry.model,
|
||||||
provider: entry.provider,
|
provider: entry.provider,
|
||||||
connectionId: entry.connectionId,
|
connectionId: entry.connectionId,
|
||||||
accountName: accountName
|
accountName: accountName,
|
||||||
|
lastUsed: entry.timestamp
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
stats.byAccount[accountKey].requests++;
|
stats.byAccount[accountKey].requests++;
|
||||||
stats.byAccount[accountKey].promptTokens += promptTokens;
|
stats.byAccount[accountKey].promptTokens += promptTokens;
|
||||||
stats.byAccount[accountKey].completionTokens += completionTokens;
|
stats.byAccount[accountKey].completionTokens += completionTokens;
|
||||||
|
if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) {
|
||||||
|
stats.byAccount[accountKey].lastUsed = entry.timestamp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function UsageStats() {
|
|||||||
|
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
|
||||||
const toggleSort = (field) => {
|
const toggleSort = (field) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -49,12 +49,13 @@ export default function UsageStats() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
router.replace(`?${params.toString()}`, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortData = (dataMap) => {
|
const sortData = (dataMap, pendingMap = {}) => {
|
||||||
return Object.entries(dataMap || {})
|
return Object.entries(dataMap || {})
|
||||||
.map(([key, data]) => ({
|
.map(([key, data]) => ({
|
||||||
...data,
|
...data,
|
||||||
key,
|
key,
|
||||||
totalTokens: (data.promptTokens || 0) + (data.completionTokens || 0),
|
totalTokens: (data.promptTokens || 0) + (data.completionTokens || 0),
|
||||||
|
pending: pendingMap[key] || 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
let valA = a[sortBy];
|
let valA = a[sortBy];
|
||||||
@@ -71,13 +72,27 @@ export default function UsageStats() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortedModels = useMemo(
|
const sortedModels = useMemo(
|
||||||
() => sortData(stats?.byModel),
|
() => sortData(stats?.byModel, stats?.pending?.byModel),
|
||||||
[stats?.byModel, sortBy, sortOrder]
|
[stats?.byModel, stats?.pending?.byModel, sortBy, sortOrder]
|
||||||
);
|
|
||||||
const sortedAccounts = useMemo(
|
|
||||||
() => sortData(stats?.byAccount),
|
|
||||||
[stats?.byAccount, sortBy, sortOrder]
|
|
||||||
);
|
);
|
||||||
|
const sortedAccounts = useMemo(() => {
|
||||||
|
// For accounts, pendingMap is by connectionId, but dataMap is by accountKey
|
||||||
|
// We need to map connectionId pending counts to accountKeys
|
||||||
|
const accountPendingMap = {};
|
||||||
|
if (stats?.pending?.byAccount) {
|
||||||
|
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => {
|
||||||
|
const connPending = stats.pending.byAccount[data.connectionId];
|
||||||
|
if (connPending) {
|
||||||
|
// Get modelKey (rawModel (provider))
|
||||||
|
const modelKey = data.provider
|
||||||
|
? `${data.rawModel} (${data.provider})`
|
||||||
|
: data.rawModel;
|
||||||
|
accountPendingMap[accountKey] = connPending[modelKey] || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sortData(stats?.byAccount, accountPendingMap);
|
||||||
|
}, [stats?.byAccount, stats?.pending?.byAccount, sortBy, sortOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
@@ -118,6 +133,20 @@ export default function UsageStats() {
|
|||||||
// Format number with commas
|
// Format number with commas
|
||||||
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
const fmt = (n) => new Intl.NumberFormat().format(n || 0);
|
||||||
|
|
||||||
|
// Time format for "Last Used"
|
||||||
|
const fmtTime = (iso) => {
|
||||||
|
if (!iso) return "Never";
|
||||||
|
const date = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffMins = Math.floor(diffMs / 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 date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header with Auto Refresh Toggle */}
|
{/* Header with Auto Refresh Toggle */}
|
||||||
@@ -142,6 +171,40 @@ export default function UsageStats() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active Requests Summary */}
|
||||||
|
{(stats.activeRequests || []).length > 0 && (
|
||||||
|
<Card className="p-3 border-primary/20 bg-primary/5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-primary font-semibold text-sm uppercase tracking-wider">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||||
|
</span>
|
||||||
|
Active Requests
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{stats.activeRequests.map((req, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-bg-subtle border border-primary/20 text-xs font-mono shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="text-primary font-bold">{req.model}</span>
|
||||||
|
<span className="mx-1 text-text-muted">|</span>
|
||||||
|
<span className="text-text">{req.provider}</span>
|
||||||
|
<span className="mx-1 text-text-muted">|</span>
|
||||||
|
<span className="text-text font-medium">{req.account}</span>
|
||||||
|
{req.count > 1 && (
|
||||||
|
<span className="ml-2 px-1.5 py-0.5 rounded bg-primary text-white font-bold">
|
||||||
|
x{req.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Overview Cards */}
|
{/* Overview Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card className="p-4 flex flex-col gap-1">
|
<Card className="p-4 flex flex-col gap-1">
|
||||||
@@ -236,6 +299,17 @@ export default function UsageStats() {
|
|||||||
currentOrder={sortOrder}
|
currentOrder={sortOrder}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("lastUsed")}
|
||||||
|
>
|
||||||
|
Last Used{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="lastUsed"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
onClick={() => toggleSort("promptTokens")}
|
onClick={() => toggleSort("promptTokens")}
|
||||||
@@ -274,13 +348,25 @@ export default function UsageStats() {
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{sortedModels.map((data) => (
|
{sortedModels.map((data) => (
|
||||||
<tr key={data.key} className="hover:bg-bg-subtle/20">
|
<tr key={data.key} className="hover:bg-bg-subtle/20">
|
||||||
<td className="px-6 py-3 font-medium">{data.rawModel}</td>
|
<td
|
||||||
|
className={`px-6 py-3 font-medium transition-colors ${
|
||||||
|
data.pending > 0 ? "text-primary" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.rawModel}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<Badge variant="neutral" size="sm">
|
<Badge
|
||||||
|
variant={data.pending > 0 ? "primary" : "neutral"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{data.provider}
|
{data.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
|
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
||||||
|
{fmtTime(data.lastUsed)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-3 text-right text-text-muted">
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
{fmt(data.promptTokens)}
|
{fmt(data.promptTokens)}
|
||||||
</td>
|
</td>
|
||||||
@@ -295,7 +381,7 @@ export default function UsageStats() {
|
|||||||
{sortedModels.length === 0 && (
|
{sortedModels.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={8}
|
||||||
className="px-6 py-8 text-center text-text-muted"
|
className="px-6 py-8 text-center text-text-muted"
|
||||||
>
|
>
|
||||||
No usage recorded yet. Make some requests to see data here.
|
No usage recorded yet. Make some requests to see data here.
|
||||||
@@ -360,6 +446,17 @@ export default function UsageStats() {
|
|||||||
currentOrder={sortOrder}
|
currentOrder={sortOrder}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
|
onClick={() => toggleSort("lastUsed")}
|
||||||
|
>
|
||||||
|
Last Used{" "}
|
||||||
|
<SortIcon
|
||||||
|
field="lastUsed"
|
||||||
|
currentSort={sortBy}
|
||||||
|
currentOrder={sortOrder}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
|
||||||
onClick={() => toggleSort("promptTokens")}
|
onClick={() => toggleSort("promptTokens")}
|
||||||
@@ -398,19 +495,35 @@ export default function UsageStats() {
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{sortedAccounts.map((data) => (
|
{sortedAccounts.map((data) => (
|
||||||
<tr key={data.key} className="hover:bg-bg-subtle/20">
|
<tr key={data.key} className="hover:bg-bg-subtle/20">
|
||||||
<td className="px-6 py-3 font-medium">{data.rawModel}</td>
|
<td
|
||||||
|
className={`px-6 py-3 font-medium transition-colors ${
|
||||||
|
data.pending > 0 ? "text-primary" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.rawModel}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<Badge variant="neutral" size="sm">
|
<Badge
|
||||||
|
variant={data.pending > 0 ? "primary" : "neutral"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
{data.provider}
|
{data.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<span className="font-medium">
|
<span
|
||||||
|
className={`font-medium transition-colors ${
|
||||||
|
data.pending > 0 ? "text-primary" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{data.accountName ||
|
{data.accountName ||
|
||||||
`Account ${data.connectionId?.slice(0, 8)}...`}
|
`Account ${data.connectionId?.slice(0, 8)}...`}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
|
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
|
||||||
|
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
|
||||||
|
{fmtTime(data.lastUsed)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-3 text-right text-text-muted">
|
<td className="px-6 py-3 text-right text-text-muted">
|
||||||
{fmt(data.promptTokens)}
|
{fmt(data.promptTokens)}
|
||||||
</td>
|
</td>
|
||||||
@@ -425,7 +538,7 @@ export default function UsageStats() {
|
|||||||
{sortedAccounts.length === 0 && (
|
{sortedAccounts.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={7}
|
colSpan={9}
|
||||||
className="px-6 py-8 text-center text-text-muted"
|
className="px-6 py-8 text-center text-text-muted"
|
||||||
>
|
>
|
||||||
No account-specific usage recorded yet. Make requests using
|
No account-specific usage recorded yet. Make requests using
|
||||||
|
|||||||
Reference in New Issue
Block a user