153 lines
6.1 KiB
TypeScript
153 lines
6.1 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { getGatewayKeyUsage } from '../../api';
|
||
|
||
interface Props {
|
||
keyId: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function fmtTokens(n: number): string {
|
||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||
return n.toLocaleString();
|
||
}
|
||
|
||
/**
|
||
* Per-key usage detail. Shows current-month stats (with a progress bar
|
||
* vs budget) and a simple bar chart of the last 6-12 months of token
|
||
* usage. No external chart library — pure CSS bars keep the UI bundle
|
||
* lean.
|
||
*/
|
||
export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
|
||
const { t } = useTranslation('settings');
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ['gateway-key-usage', keyId],
|
||
queryFn: () => getGatewayKeyUsage(keyId),
|
||
staleTime: 5_000,
|
||
});
|
||
|
||
const maxHistTokens = data
|
||
? Math.max(1, ...data.history.map(h => h.tokensIn + h.tokensOut))
|
||
: 1;
|
||
|
||
const pctUsed =
|
||
data && data.tokensBudget !== null && data.tokensBudget > 0
|
||
? Math.min(100, (data.tokensTotal / data.tokensBudget) * 100)
|
||
: null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||
<div className="bg-surface rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6">
|
||
<div className="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-slate-800">{t('gateway.usagePanel.title')}</h3>
|
||
<p className="text-xs text-slate-500 font-mono">{keyId}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="text-slate-400 hover:text-slate-700 text-xl leading-none"
|
||
aria-label="Close"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{isLoading && <div className="text-sm text-slate-500">Loading…</div>}
|
||
{error && (
|
||
<div className="text-sm text-red-600">{t('gateway.keys.fetchError', { msg: String((error as Error).message ?? error) })}</div>
|
||
)}
|
||
|
||
{data && (
|
||
<>
|
||
{/* Current period summary */}
|
||
<div className="border border-hairline rounded p-3 mb-4">
|
||
<div className="flex justify-between items-baseline mb-2">
|
||
<span className="text-xs font-medium text-slate-600 uppercase tracking-wide">
|
||
{t('gateway.usagePanel.thisMonth', { period: data.currentPeriod })}
|
||
</span>
|
||
<span className="text-xs text-slate-500">
|
||
Requests: {data.requestsThisMonth.toLocaleString()}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||
<div>
|
||
<div className="text-xs text-slate-500">Input tokens</div>
|
||
<div className="font-mono">{fmtTokens(data.tokensIn)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500">Output tokens</div>
|
||
<div className="font-mono">{fmtTokens(data.tokensOut)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500">Total / Budget</div>
|
||
<div className="font-mono">
|
||
{fmtTokens(data.tokensTotal)}{' '}
|
||
<span className="text-slate-400">
|
||
/ {data.tokensBudget !== null ? fmtTokens(data.tokensBudget) : '∞'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{pctUsed !== null && (
|
||
<div className="mt-3">
|
||
<div className="h-2 rounded bg-slate-100 overflow-hidden">
|
||
<div
|
||
className={`h-full ${pctUsed >= 100 ? 'bg-red-500' : pctUsed >= 80 ? 'bg-amber-500' : 'bg-accent'}`}
|
||
style={{ width: `${pctUsed}%` }}
|
||
/>
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1 text-right">
|
||
{pctUsed.toFixed(1)}% used
|
||
{data.remaining !== null && ` · ${fmtTokens(data.remaining)} remaining`}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{data.rateLimitRpm !== null && (
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
Rate limit: {data.rateLimitRpm} rpm
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* History bars */}
|
||
<div className="border border-hairline rounded p-3">
|
||
<div className="text-xs font-medium text-slate-600 uppercase tracking-wide mb-2">
|
||
{t('gateway.usagePanel.past12')}
|
||
</div>
|
||
{data.history.length === 0 ? (
|
||
<div className="text-sm text-slate-400 italic">{t('gateway.usagePanel.noHistory')}</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
{data.history.map((h) => {
|
||
const total = h.tokensIn + h.tokensOut;
|
||
const widthPct = (total / maxHistTokens) * 100;
|
||
return (
|
||
<div key={h.period} className="flex items-center gap-2 text-xs">
|
||
<span className="font-mono w-16 text-slate-500">{h.period}</span>
|
||
<div className="flex-1 h-3 bg-slate-100 rounded overflow-hidden">
|
||
<div
|
||
className="h-full bg-accent/70"
|
||
style={{ width: `${Math.max(2, widthPct)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="font-mono w-20 text-right text-slate-600">
|
||
{fmtTokens(total)}
|
||
</span>
|
||
<span className="font-mono w-12 text-right text-slate-400">
|
||
{h.requests.toLocaleString()} rq
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|