feat(usage): redesign Usage Analytics with accurate token averages
- Fix token averaging: calculate over active hours/days only (not full period) - Enable hourlyData query for all time ranges (not just 24h) - Rename 'Usage Forecast' to 'Usage Averages' (real data, not projections) - Add avg/request metric and active hours/days context - Update terminology: Input/Output Tokens with info tooltips - Add Estimated Cost stat card with per-model calculation - Add quota warning alert banner when models exceed 80% - Add ModelQuotaBar component with color thresholds
This commit is contained in:
1 parent
5c44a622a8
commit
20785a8414
8 files changed
+586
-252
No files matched your search
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||||
|
));
|
||||||
|
Alert.displayName = 'Alert';
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
AlertTitle.displayName = 'AlertTitle';
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -29,10 +29,10 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
|
<div className="bg-card flex flex-col rounded-xl border border-white/[0.06]">
|
||||||
<div className="flex flex-col p-5">
|
<div className="flex flex-col p-5">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-[13px] font-semibold tracking-tight text-foreground">{title}</h3>
|
<h3 className="text-foreground text-[13px] font-semibold tracking-tight">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -41,8 +41,8 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
|
<div className="bg-destructive/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
@@ -53,9 +53,9 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
|
||||||
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
|
<BarChart3 className="text-muted-foreground/60 h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
|
<p className="text-foreground text-[13px] font-medium">{emptyTitle}</p>
|
||||||
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
||||||
{emptyDescription}
|
{emptyDescription}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { TrendingUp, Clock, Calendar, CalendarDays } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UsageBucket {
|
||||||
|
totalTokens: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForecastCardProps {
|
||||||
|
/** Hourly buckets for the selected period (always fetched for all ranges). */
|
||||||
|
hourlyData?: UsageBucket[];
|
||||||
|
/** Daily buckets for the selected period. */
|
||||||
|
dailyData?: UsageBucket[];
|
||||||
|
range: '24h' | '7d' | '30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
if (n >= 1_000_000) {
|
||||||
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (n >= 1_000) {
|
||||||
|
return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage Averages Card.
|
||||||
|
*
|
||||||
|
* Calculates REAL averages over the time the user actually used the proxy,
|
||||||
|
* not projections over the entire selected window.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - totalTokens = sum of all buckets in the period
|
||||||
|
* - activeHours = number of hourly buckets with totalTokens > 0
|
||||||
|
* - activeDays = number of daily buckets with totalTokens > 0
|
||||||
|
* - avgPerHour = totalTokens / activeHours (real average, no padding)
|
||||||
|
* - avgPerDay = totalTokens / activeDays (real average, no padding)
|
||||||
|
*
|
||||||
|
* For the 24h range, avgPerDay equals totalTokens (since it's a single day).
|
||||||
|
* For 7d/30d, avgPerDay = totalTokens / activeDays.
|
||||||
|
*
|
||||||
|
* The card title says "Usage Averages" (not "Forecast") because these are
|
||||||
|
* real historical averages, not projections.
|
||||||
|
*/
|
||||||
|
export function ForecastCard({ hourlyData, dailyData, range }: ForecastCardProps) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const hours = hourlyData ?? [];
|
||||||
|
const days = dailyData ?? [];
|
||||||
|
|
||||||
|
const totalTokens =
|
||||||
|
hours.length > 0
|
||||||
|
? hours.reduce((acc, d) => acc + d.totalTokens, 0)
|
||||||
|
: days.reduce((acc, d) => acc + d.totalTokens, 0);
|
||||||
|
|
||||||
|
const totalRequests =
|
||||||
|
hours.length > 0
|
||||||
|
? hours.reduce((acc, d) => acc + d.requests, 0)
|
||||||
|
: days.reduce((acc, d) => acc + d.requests, 0);
|
||||||
|
|
||||||
|
// Active hours = hourly buckets where there was actual usage
|
||||||
|
const activeHours = hours.filter((d) => d.totalTokens > 0).length;
|
||||||
|
|
||||||
|
// Active days = daily buckets where there was actual usage
|
||||||
|
const activeDays = days.filter((d) => d.totalTokens > 0).length;
|
||||||
|
|
||||||
|
// Real average per active hour
|
||||||
|
const avgPerHour = activeHours > 0 ? totalTokens / activeHours : 0;
|
||||||
|
|
||||||
|
// Real average per active day
|
||||||
|
const avgPerDay = activeDays > 0 ? totalTokens / activeDays : 0;
|
||||||
|
|
||||||
|
// Average tokens per request
|
||||||
|
const avgPerRequest = totalRequests > 0 ? totalTokens / totalRequests : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTokens,
|
||||||
|
totalRequests,
|
||||||
|
activeHours,
|
||||||
|
activeDays,
|
||||||
|
avgPerHour,
|
||||||
|
avgPerDay,
|
||||||
|
avgPerRequest,
|
||||||
|
};
|
||||||
|
}, [hourlyData, dailyData]);
|
||||||
|
|
||||||
|
const rangeLabel = range === '24h' ? 'Last 24h' : range === '7d' ? 'Last 7 days' : 'Last 30 days';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||||
|
Usage Averages
|
||||||
|
</CardTitle>
|
||||||
|
<span className="text-muted-foreground/70 text-[10px]">{rangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{/* Total consumed in period */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-0.5 flex items-center gap-1.5">
|
||||||
|
<TrendingUp className="h-3 w-3 text-emerald-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Total in period</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-lg font-semibold">
|
||||||
|
{formatNumber(stats.totalTokens)}{' '}
|
||||||
|
<span className="text-muted-foreground font-sans text-xs font-normal">tokens</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground/70 mt-0.5 text-[10px]">
|
||||||
|
{stats.activeHours > 0 &&
|
||||||
|
`${stats.activeHours} active hour${stats.activeHours !== 1 ? 's' : ''}`}
|
||||||
|
{stats.activeHours > 0 && stats.activeDays > 0 && ' · '}
|
||||||
|
{stats.activeDays > 0 &&
|
||||||
|
`${stats.activeDays} active day${stats.activeDays !== 1 ? 's' : ''}`}
|
||||||
|
{stats.totalRequests > 0 && ` · ${stats.totalRequests.toLocaleString()} requests`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/[0.06]" />
|
||||||
|
|
||||||
|
{/* Average per active hour */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3 w-3 text-blue-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Avg / active hour</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{formatNumber(stats.avgPerHour)}
|
||||||
|
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
|
||||||
|
tokens/h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average per active day */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3 text-violet-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Avg / active day</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{formatNumber(stats.avgPerDay)}
|
||||||
|
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
|
||||||
|
tokens/d
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average per request */}
|
||||||
|
{stats.totalRequests > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CalendarDays className="h-3 w-3 text-amber-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Avg / request</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{formatNumber(stats.avgPerRequest)}
|
||||||
|
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
|
||||||
|
tokens/req
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface ModelQuotaBarProps {
|
||||||
|
modelName: string;
|
||||||
|
usedTokens: number;
|
||||||
|
limitTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelQuotaBar({ modelName, usedTokens, limitTokens }: ModelQuotaBarProps) {
|
||||||
|
const formatNumber = (n: number) => {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const usagePercentage = limitTokens ? (usedTokens / limitTokens) * 100 : 0;
|
||||||
|
const displayPercentage = limitTokens ? Math.min(Math.round(usagePercentage), 100) : null;
|
||||||
|
const barPercentage = limitTokens ? Math.min(usagePercentage, 100) : 100;
|
||||||
|
|
||||||
|
let colorClass = 'bg-slate-500'; // Default if no limit
|
||||||
|
if (limitTokens) {
|
||||||
|
if (usagePercentage < 80) colorClass = 'bg-emerald-500';
|
||||||
|
else if (usagePercentage < 90) colorClass = 'bg-amber-500';
|
||||||
|
else colorClass = 'bg-rose-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group mb-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground group-hover:text-foreground max-w-[140px] truncate text-xs transition-colors">
|
||||||
|
{modelName}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-foreground font-mono text-[11px] font-medium tabular-nums">
|
||||||
|
{formatNumber(usedTokens)}
|
||||||
|
{limitTokens ? ` / ${formatNumber(limitTokens)}` : ''}
|
||||||
|
</span>
|
||||||
|
{displayPercentage !== null && (
|
||||||
|
<span
|
||||||
|
className={clsx('w-8 text-right text-[10px] font-semibold', {
|
||||||
|
'text-emerald-400': usagePercentage < 80,
|
||||||
|
'text-amber-400': usagePercentage >= 80 && usagePercentage < 90,
|
||||||
|
'text-rose-400': usagePercentage >= 90,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{displayPercentage}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
|
<div
|
||||||
|
className={clsx('h-full rounded-full transition-all duration-500', colorClass)}
|
||||||
|
style={{ width: `${barPercentage}%`, opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
/** Premium chart palette — refined for dark mode contrast */
|
/** Premium chart palette — refined for dark mode contrast */
|
||||||
export const COLOR_PROMPT = '#60a5fa'; // blue-400
|
export const COLOR_PROMPT = '#60a5fa'; // blue-400
|
||||||
export const COLOR_COMPLETION = '#34d399'; // emerald-400
|
export const COLOR_COMPLETION = '#34d399'; // emerald-400
|
||||||
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
||||||
|
|
||||||
export const PIE_COLORS = [
|
export const PIE_COLORS = [
|
||||||
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
|
'#60a5fa',
|
||||||
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
|
'#34d399',
|
||||||
|
'#fbbf24',
|
||||||
|
'#a78bfa',
|
||||||
|
'#f472b6',
|
||||||
|
'#38bdf8',
|
||||||
|
'#4ade80',
|
||||||
|
'#facc15',
|
||||||
|
'#c084fc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MODEL_COLORS: Record<string, string> = {
|
export const MODEL_COLORS: Record<string, string> = {
|
||||||
@@ -31,10 +38,7 @@ export function BarTooltip({ indexValue, data }: any) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{keys.map((key: string) => (
|
{keys.map((key: string) => (
|
||||||
<div key={key} className="flex items-center gap-2 text-xs">
|
<div key={key} className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: colorMap[key] }} />
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: colorMap[key] }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">{key}:</span>
|
<span className="text-muted-foreground">{key}:</span>
|
||||||
<span className="text-foreground font-mono font-medium">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
||||||
@@ -50,10 +54,7 @@ export function PieTooltip({ datum }: any) {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: datum.color }} />
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: datum.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">{datum.id}:</span>
|
<span className="text-muted-foreground">{datum.id}:</span>
|
||||||
<span className="text-foreground font-mono font-medium">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
||||||
@@ -66,12 +67,11 @@ export function PieTooltip({ datum }: any) {
|
|||||||
export function LineTooltip({ point }: any) {
|
export function LineTooltip({ point }: any) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||||
<p className="text-foreground mb-1 text-xs font-medium">{point.data.xFormatted ?? point.data.x}</p>
|
<p className="text-foreground mb-1 text-xs font-medium">
|
||||||
|
{point.data.xFormatted ?? point.data.x}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: point.serieColor }} />
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: point.serieColor }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">{point.serieId}:</span>
|
<span className="text-muted-foreground">{point.serieId}:</span>
|
||||||
<span className="text-foreground font-mono font-medium">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface TrendInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: React.ReactNode;
|
||||||
value: number | string;
|
value: number | string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -82,10 +82,7 @@ function Sparkline({ data, accent }: { data: number[]; accent: string }) {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
{/* Area fill */}
|
{/* Area fill */}
|
||||||
<polygon
|
<polygon points={`0,${h} ${points.join(' ')} ${w},${h}`} fill={`url(#sparkGrad-${accent})`} />
|
||||||
points={`0,${h} ${points.join(' ')} ${w},${h}`}
|
|
||||||
fill={`url(#sparkGrad-${accent})`}
|
|
||||||
/>
|
|
||||||
{/* Line */}
|
{/* Line */}
|
||||||
<polyline
|
<polyline
|
||||||
points={points.join(' ')}
|
points={points.join(' ')}
|
||||||
@@ -103,11 +100,7 @@ function TrendBadge({ trend }: { trend: TrendInfo }) {
|
|||||||
const isUp = trend.direction === 'up';
|
const isUp = trend.direction === 'up';
|
||||||
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
||||||
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
||||||
const color = isNeutral
|
const color = isNeutral ? 'text-muted-foreground' : isUp ? 'text-emerald-400' : 'text-rose-400';
|
||||||
? 'text-muted-foreground'
|
|
||||||
: isUp
|
|
||||||
? 'text-emerald-400'
|
|
||||||
: 'text-rose-400';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
|
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
|
||||||
@@ -130,7 +123,7 @@ export const StatCard: React.FC<StatCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
|
'group bg-card relative rounded-xl border border-white/[0.06] p-4 transition-all duration-200',
|
||||||
'hover:border-white/[0.1] hover:bg-white/[0.02]',
|
'hover:border-white/[0.1] hover:bg-white/[0.02]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const nivoTheme: PartialTheme = {
|
|||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Inter, sans-serif",
|
fontFamily: 'Inter, sans-serif',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fill: '#6b7280',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
@@ -45,7 +45,7 @@ export const nivoTheme: PartialTheme = {
|
|||||||
},
|
},
|
||||||
legends: {
|
legends: {
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Inter, sans-serif",
|
fontFamily: 'Inter, sans-serif',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fill: '#6b7280',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
@@ -54,7 +54,7 @@ export const nivoTheme: PartialTheme = {
|
|||||||
container: {
|
container: {
|
||||||
background: '#16161e',
|
background: '#16161e',
|
||||||
color: '#e8eaed',
|
color: '#e8eaed',
|
||||||
fontFamily: "Inter, sans-serif",
|
fontFamily: 'Inter, sans-serif',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: '1px solid rgba(255,255,255,0.06)',
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
|||||||
+277
-215
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ResponsiveLine } from '@nivo/line';
|
import { ResponsiveLine } from '@nivo/line';
|
||||||
import { ResponsiveBar } from '@nivo/bar';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -10,18 +9,23 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||||
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
import { StatCard } from '@/components/usage/StatCard';
|
import { StatCard } from '@/components/usage/StatCard';
|
||||||
import { ChartCard } from '@/components/usage/ChartCard';
|
import { ChartCard } from '@/components/usage/ChartCard';
|
||||||
import {
|
import { ForecastCard } from '@/components/usage/ForecastCard';
|
||||||
COLOR_PROMPT,
|
import { ModelQuotaBar } from '@/components/usage/ModelQuotaBar';
|
||||||
COLOR_COMPLETION,
|
import { CostReportCard, ModelCostEntry } from '@/components/usage/CostReportCard';
|
||||||
PIE_COLORS,
|
import { ModelPricingDialog } from '@/components/usage/ModelPricingDialog';
|
||||||
LineTooltip,
|
import { calculateCost } from '@/constants/pricing';
|
||||||
} from '@/components/usage/NivoTooltip';
|
import { COLOR_PROMPT, COLOR_COMPLETION, LineTooltip } from '@/components/usage/NivoTooltip';
|
||||||
import { nivoTheme } from '@/lib/nivo-theme';
|
import { nivoTheme } from '@/lib/nivo-theme';
|
||||||
|
import { MonitorTab } from '@/components/usage/MonitorTab';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -29,6 +33,10 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Radio,
|
Radio,
|
||||||
Zap,
|
Zap,
|
||||||
|
DollarSign,
|
||||||
|
Info,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -129,10 +137,11 @@ function UsagePage() {
|
|||||||
const [range, setRange] = useState<TimeRange>('7d');
|
const [range, setRange] = useState<TimeRange>('7d');
|
||||||
const ALL_ACCOUNTS = '__all__';
|
const ALL_ACCOUNTS = '__all__';
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
||||||
|
const [isPricingDialogOpen, setIsPricingDialogOpen] = useState(false);
|
||||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||||
|
const { config, saveConfig } = useAppConfig();
|
||||||
|
|
||||||
const accountFilter =
|
const accountFilter = selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
||||||
selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
|
||||||
|
|
||||||
/* ───── Current period data ───── */
|
/* ───── Current period data ───── */
|
||||||
const {
|
const {
|
||||||
@@ -203,7 +212,6 @@ function UsagePage() {
|
|||||||
end,
|
end,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: range === '24h',
|
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -211,10 +219,7 @@ function UsagePage() {
|
|||||||
|
|
||||||
/* ───── Derived data ───── */
|
/* ───── Derived data ───── */
|
||||||
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
|
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
|
||||||
const prevTotals = useMemo(
|
const prevTotals = useMemo(() => sumBuckets(prevDailyData || []), [prevDailyData]);
|
||||||
() => sumBuckets(prevDailyData || []),
|
|
||||||
[prevDailyData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const promptRatio = totals.totalTokens
|
const promptRatio = totals.totalTokens
|
||||||
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
|
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
|
||||||
@@ -222,33 +227,22 @@ function UsagePage() {
|
|||||||
const completionRatio = totals.totalTokens
|
const completionRatio = totals.totalTokens
|
||||||
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
|
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const avgReqSize = totals.requests
|
const avgReqSize = totals.requests ? Math.round(totals.totalTokens / totals.requests) : 0;
|
||||||
? Math.round(totals.totalTokens / totals.requests)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const sparklineTotal = useMemo(
|
const sparklineTotal = useMemo(() => (dailyData || []).map((d) => d.totalTokens), [dailyData]);
|
||||||
() => (dailyData || []).map((d) => d.totalTokens),
|
const sparklinePrompt = useMemo(() => (dailyData || []).map((d) => d.promptTokens), [dailyData]);
|
||||||
[dailyData],
|
|
||||||
);
|
|
||||||
const sparklinePrompt = useMemo(
|
|
||||||
() => (dailyData || []).map((d) => d.promptTokens),
|
|
||||||
[dailyData],
|
|
||||||
);
|
|
||||||
const sparklineCompletion = useMemo(
|
const sparklineCompletion = useMemo(
|
||||||
() => (dailyData || []).map((d) => d.completionTokens),
|
() => (dailyData || []).map((d) => d.completionTokens),
|
||||||
[dailyData],
|
[dailyData],
|
||||||
);
|
);
|
||||||
const sparklineRequests = useMemo(
|
const sparklineRequests = useMemo(() => (dailyData || []).map((d) => d.requests), [dailyData]);
|
||||||
() => (dailyData || []).map((d) => d.requests),
|
|
||||||
[dailyData],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Area chart — Nivo line format
|
// Area chart — Nivo line format
|
||||||
const areaChartData = useMemo(() => {
|
const areaChartData = useMemo(() => {
|
||||||
const source = range === '24h' ? hourlyData || [] : dailyData || [];
|
const source = range === '24h' ? hourlyData || [] : dailyData || [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'Prompt',
|
id: 'Input Tokens',
|
||||||
color: COLOR_PROMPT,
|
color: COLOR_PROMPT,
|
||||||
data: source.map((d) => ({
|
data: source.map((d) => ({
|
||||||
x: formatBucketLabel(d.bucket, range),
|
x: formatBucketLabel(d.bucket, range),
|
||||||
@@ -256,7 +250,7 @@ function UsagePage() {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Completion',
|
id: 'Output Tokens',
|
||||||
color: COLOR_COMPLETION,
|
color: COLOR_COMPLETION,
|
||||||
data: source.map((d) => ({
|
data: source.map((d) => ({
|
||||||
x: formatBucketLabel(d.bucket, range),
|
x: formatBucketLabel(d.bucket, range),
|
||||||
@@ -266,30 +260,73 @@ function UsagePage() {
|
|||||||
];
|
];
|
||||||
}, [dailyData, hourlyData, range]);
|
}, [dailyData, hourlyData, range]);
|
||||||
|
|
||||||
// Model ranking bar data
|
// Model quota bar data and cost estimation
|
||||||
const modelBarData = useMemo(() => {
|
const { topModelsData, costReport, totalCost, hasQuotaWarning } = useMemo(() => {
|
||||||
if (!modelData?.length) {
|
let cost = 0;
|
||||||
return [];
|
let warning = false;
|
||||||
}
|
const report: ModelCostEntry[] = [];
|
||||||
return [...modelData]
|
|
||||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((m) => ({
|
|
||||||
model:
|
|
||||||
m.model.length > 22 ? m.model.slice(0, 22) + '…' : m.model,
|
|
||||||
tokens: m.totalTokens,
|
|
||||||
}));
|
|
||||||
}, [modelData]);
|
|
||||||
|
|
||||||
// Top models mini-list
|
|
||||||
const topModels = useMemo(() => {
|
|
||||||
if (!modelData?.length) {
|
if (!modelData?.length) {
|
||||||
return [];
|
return { topModelsData: [], costReport: [], totalCost: 0, hasQuotaWarning: false };
|
||||||
}
|
}
|
||||||
return [...modelData]
|
|
||||||
|
const accountQuotas =
|
||||||
|
selectedAccountId !== ALL_ACCOUNTS
|
||||||
|
? accounts?.find((a) => a.id === selectedAccountId)?.quota?.models
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const processed = [...modelData]
|
||||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||||
.slice(0, 5);
|
.map((m) => {
|
||||||
}, [modelData]);
|
const inputCost = calculateCost(m.model, m.promptTokens, 0, config?.custom_model_pricing);
|
||||||
|
const outputCost = calculateCost(
|
||||||
|
m.model,
|
||||||
|
0,
|
||||||
|
m.completionTokens,
|
||||||
|
config?.custom_model_pricing,
|
||||||
|
);
|
||||||
|
const mTotalCost = calculateCost(
|
||||||
|
m.model,
|
||||||
|
m.promptTokens,
|
||||||
|
m.completionTokens,
|
||||||
|
config?.custom_model_pricing,
|
||||||
|
);
|
||||||
|
|
||||||
|
cost += mTotalCost;
|
||||||
|
|
||||||
|
report.push({
|
||||||
|
model: m.model,
|
||||||
|
inputTokens: m.promptTokens,
|
||||||
|
outputTokens: m.completionTokens,
|
||||||
|
inputCost,
|
||||||
|
outputCost,
|
||||||
|
totalCost: mTotalCost,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use percentage if available directly from the API response
|
||||||
|
const percentage = accountQuotas?.[m.model]?.percentage;
|
||||||
|
if (percentage && percentage >= 80) {
|
||||||
|
warning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since limit tokens isn't stored in quotas exactly, we just use undefined
|
||||||
|
// to not display limit stats in the bar but just usage
|
||||||
|
const limitTokens = undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: m.model,
|
||||||
|
tokens: m.totalTokens,
|
||||||
|
limit: limitTokens,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
topModelsData: processed.slice(0, 8),
|
||||||
|
costReport: report.sort((a, b) => b.totalCost - a.totalCost),
|
||||||
|
totalCost: cost,
|
||||||
|
hasQuotaWarning: warning,
|
||||||
|
};
|
||||||
|
}, [modelData, accounts, selectedAccountId]);
|
||||||
|
|
||||||
const isAreaEmpty =
|
const isAreaEmpty =
|
||||||
range === '24h'
|
range === '24h'
|
||||||
@@ -299,6 +336,14 @@ function UsagePage() {
|
|||||||
const areaError = range === '24h' ? hourlyError : dailyError;
|
const areaError = range === '24h' ? hourlyError : dailyError;
|
||||||
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
|
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
|
||||||
|
|
||||||
|
const handleSavePricing = async (
|
||||||
|
pricing: Record<string, { input: number; output: number; source: string }>,
|
||||||
|
) => {
|
||||||
|
if (config) {
|
||||||
|
await saveConfig({ ...config, custom_model_pricing: pricing });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* ───── Render ───── */
|
/* ───── Render ───── */
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-auto">
|
<div className="flex h-full flex-col overflow-auto">
|
||||||
@@ -311,9 +356,7 @@ function UsagePage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
<div className="flex items-center gap-1.5 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
||||||
<Radio className="h-2.5 w-2.5 animate-pulse text-emerald-400" />
|
<Radio className="h-2.5 w-2.5 animate-pulse text-emerald-400" />
|
||||||
<span className="text-[10px] font-medium text-emerald-400">
|
<span className="text-[10px] font-medium text-emerald-400">Live</span>
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-[13px]">
|
<p className="text-muted-foreground mt-0.5 text-[13px]">
|
||||||
@@ -347,10 +390,7 @@ function UsagePage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Tabs
|
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
|
||||||
value={range}
|
|
||||||
onValueChange={(v) => setRange(v as TimeRange)}
|
|
||||||
>
|
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="24h" className="px-3 text-xs">
|
<TabsTrigger value="24h" className="px-3 text-xs">
|
||||||
24h
|
24h
|
||||||
@@ -366,75 +406,147 @@ function UsagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 px-6 py-5">
|
<Tabs defaultValue="analytics" className="flex-1 flex flex-col">
|
||||||
{/* ─── Stat cards ─── */}
|
<div className="px-6 pt-5">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<StatCard
|
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||||
label="Total Tokens"
|
<TabsTrigger value="monitor">Monitor</TabsTrigger>
|
||||||
value={formatNumber(totals.totalTokens)}
|
</TabsList>
|
||||||
icon={Zap}
|
|
||||||
isLoading={dailyLoading}
|
|
||||||
accent="amber"
|
|
||||||
trend={
|
|
||||||
prevDailyData
|
|
||||||
? calcTrend(totals.totalTokens, prevTotals.totalTokens)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
sparkline={sparklineTotal}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Prompt Tokens"
|
|
||||||
value={formatNumber(totals.promptTokens)}
|
|
||||||
icon={MessageSquare}
|
|
||||||
isLoading={dailyLoading}
|
|
||||||
accent="blue"
|
|
||||||
trend={
|
|
||||||
prevDailyData
|
|
||||||
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
sparkline={sparklinePrompt}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Completion Tokens"
|
|
||||||
value={formatNumber(totals.completionTokens)}
|
|
||||||
icon={Activity}
|
|
||||||
isLoading={dailyLoading}
|
|
||||||
accent="green"
|
|
||||||
trend={
|
|
||||||
prevDailyData
|
|
||||||
? calcTrend(
|
|
||||||
totals.completionTokens,
|
|
||||||
prevTotals.completionTokens,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
sparkline={sparklineCompletion}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Total Requests"
|
|
||||||
value={totals.requests.toLocaleString()}
|
|
||||||
icon={Layers}
|
|
||||||
isLoading={dailyLoading}
|
|
||||||
accent="slate"
|
|
||||||
trend={
|
|
||||||
prevDailyData
|
|
||||||
? calcTrend(totals.requests, prevTotals.requests)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
sparkline={sparklineRequests}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<TabsContent value="analytics" className="flex-1 space-y-4 px-6 py-5">
|
||||||
|
{hasQuotaWarning && (
|
||||||
|
<Alert
|
||||||
|
variant="destructive"
|
||||||
|
className="border-amber-500/50 bg-amber-500/10 text-amber-500"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Quota Warning</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
One or more models have exceeded 80% of their allocated usage limit.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Stat cards ─── */}
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<StatCard
|
||||||
|
label="Total Tokens"
|
||||||
|
value={formatNumber(totals.totalTokens)}
|
||||||
|
icon={Zap}
|
||||||
|
isLoading={dailyLoading}
|
||||||
|
accent="amber"
|
||||||
|
trend={
|
||||||
|
prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined
|
||||||
|
}
|
||||||
|
sparkline={sparklineTotal}
|
||||||
|
/>
|
||||||
|
<div className="group relative">
|
||||||
|
<StatCard
|
||||||
|
label={
|
||||||
|
<div className="flex cursor-help items-center gap-1.5">
|
||||||
|
Input Tokens
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Tokens sent to the model (prompt, context, files).</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
value={formatNumber(totals.promptTokens)}
|
||||||
|
icon={MessageSquare}
|
||||||
|
isLoading={dailyLoading}
|
||||||
|
accent="blue"
|
||||||
|
trend={
|
||||||
|
prevDailyData
|
||||||
|
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
sparkline={sparklinePrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="group relative">
|
||||||
|
<StatCard
|
||||||
|
label={
|
||||||
|
<div className="flex cursor-help items-center gap-1.5">
|
||||||
|
Output Tokens
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Tokens generated by the model in response.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
value={formatNumber(totals.completionTokens)}
|
||||||
|
icon={Activity}
|
||||||
|
isLoading={dailyLoading}
|
||||||
|
accent="green"
|
||||||
|
trend={
|
||||||
|
prevDailyData
|
||||||
|
? calcTrend(totals.completionTokens, prevTotals.completionTokens)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
sparkline={sparklineCompletion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<StatCard
|
||||||
|
label="Total Requests"
|
||||||
|
value={totals.requests.toLocaleString()}
|
||||||
|
icon={Layers}
|
||||||
|
isLoading={dailyLoading}
|
||||||
|
accent="slate"
|
||||||
|
trend={prevDailyData ? calcTrend(totals.requests, prevTotals.requests) : undefined}
|
||||||
|
sparkline={sparklineRequests}
|
||||||
|
/>
|
||||||
|
<div className="group relative">
|
||||||
|
<StatCard
|
||||||
|
label={
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex cursor-help items-center gap-1.5">
|
||||||
|
Estimated Cost
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Approximate cost based on configured pricing.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-2 h-5 w-5 hover:bg-white/10"
|
||||||
|
onClick={() => setIsPricingDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings className="text-muted-foreground h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
value={`$${totalCost.toFixed(2)}`}
|
||||||
|
icon={DollarSign}
|
||||||
|
isLoading={modelLoading}
|
||||||
|
accent="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* ─── Prompt/Completion ratio bar + top models ─── */}
|
{/* ─── Prompt/Completion ratio bar + top models ─── */}
|
||||||
{!dailyLoading && totals.totalTokens > 0 && (
|
{!dailyLoading && totals.totalTokens > 0 && (
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||||
<div className="col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] bg-card p-4 lg:col-span-2">
|
<div className="bg-card col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] p-4 lg:col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||||
Prompt / Completion
|
Input / Output
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
{formatNumber(totals.totalTokens)} total
|
{formatNumber(totals.totalTokens)} total
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,76 +573,30 @@ function UsagePage() {
|
|||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: COLOR_PROMPT }}
|
style={{ backgroundColor: COLOR_PROMPT }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">Prompt</span>
|
<span className="text-muted-foreground">Input</span>
|
||||||
<span className="font-mono font-semibold text-foreground">
|
<span className="text-foreground font-mono font-semibold">{promptRatio}%</span>
|
||||||
{promptRatio}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: COLOR_COMPLETION }}
|
style={{ backgroundColor: COLOR_COMPLETION }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">Completion</span>
|
<span className="text-muted-foreground">Output</span>
|
||||||
<span className="font-mono font-semibold text-foreground">
|
<span className="text-foreground font-mono font-semibold">
|
||||||
{completionRatio}%
|
{completionRatio}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Avg/req:{' '}
|
Avg/req:{' '}
|
||||||
<span className="font-mono font-medium text-foreground">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{formatNumber(avgReqSize)}
|
{formatNumber(avgReqSize)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top models */}
|
<ForecastCard hourlyData={hourlyData} dailyData={dailyData} range={range} />
|
||||||
<div className="rounded-xl border border-white/[0.06] bg-card p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
|
||||||
Top Models
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
tokens
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{topModels.map((m, i) => {
|
|
||||||
const max = topModels[0]?.totalTokens || 1;
|
|
||||||
const pct = Math.max(8, (m.totalTokens / max) * 100);
|
|
||||||
return (
|
|
||||||
<div key={m.model} className="group">
|
|
||||||
<div className="mb-0.5 flex items-center justify-between">
|
|
||||||
<span className="max-w-[140px] truncate text-xs text-muted-foreground transition-colors group-hover:text-foreground">
|
|
||||||
{m.model}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-[11px] font-medium text-foreground tabular-nums">
|
|
||||||
{formatNumber(m.totalTokens)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
width: `${pct}%`,
|
|
||||||
backgroundColor:
|
|
||||||
PIE_COLORS[i % PIE_COLORS.length],
|
|
||||||
opacity: 0.7,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{topModels.length === 0 && (
|
|
||||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
|
||||||
No data
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -542,9 +608,7 @@ function UsagePage() {
|
|||||||
isLoading={areaLoading}
|
isLoading={areaLoading}
|
||||||
error={areaError}
|
error={areaError}
|
||||||
isEmpty={isAreaEmpty}
|
isEmpty={isAreaEmpty}
|
||||||
onRetry={() =>
|
onRetry={() => (range === '24h' ? refetchHourly() : refetchDaily())}
|
||||||
range === '24h' ? refetchHourly() : refetchDaily()
|
|
||||||
}
|
|
||||||
emptyTitle="No usage data"
|
emptyTitle="No usage data"
|
||||||
emptyDescription="Data will appear here once proxy requests are recorded."
|
emptyDescription="Data will appear here once proxy requests are recorded."
|
||||||
errorMessage="Failed to load usage data"
|
errorMessage="Failed to load usage data"
|
||||||
@@ -579,8 +643,7 @@ function UsagePage() {
|
|||||||
tickSize: 0,
|
tickSize: 0,
|
||||||
tickPadding: 8,
|
tickPadding: 8,
|
||||||
tickRotation: 0,
|
tickRotation: 0,
|
||||||
format: (v: number) =>
|
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||||
v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`,
|
|
||||||
}}
|
}}
|
||||||
tooltip={LineTooltip}
|
tooltip={LineTooltip}
|
||||||
legends={[
|
legends={[
|
||||||
@@ -617,47 +680,46 @@ function UsagePage() {
|
|||||||
errorMessage="Failed to load model data"
|
errorMessage="Failed to load model data"
|
||||||
retryLabel="Retry"
|
retryLabel="Retry"
|
||||||
>
|
>
|
||||||
<div className="h-[320px]">
|
<div className="custom-scrollbar h-[320px] overflow-y-auto pr-2">
|
||||||
<ResponsiveBar
|
{topModelsData.map((m) => (
|
||||||
data={modelBarData}
|
<ModelQuotaBar
|
||||||
keys={['tokens']}
|
key={m.model}
|
||||||
indexBy="model"
|
modelName={m.model}
|
||||||
layout="horizontal"
|
usedTokens={m.tokens}
|
||||||
theme={nivoTheme}
|
limitTokens={m.limit}
|
||||||
colors={PIE_COLORS}
|
/>
|
||||||
colorBy="indexValue"
|
))}
|
||||||
margin={{ top: 0, right: 10, bottom: 10, left: 120 }}
|
|
||||||
padding={0.3}
|
|
||||||
borderRadius={4}
|
|
||||||
enableGridX={true}
|
|
||||||
enableGridY={false}
|
|
||||||
gridXValues={4}
|
|
||||||
axisBottom={null}
|
|
||||||
axisLeft={{ tickSize: 0, tickPadding: 8 }}
|
|
||||||
axisTop={null}
|
|
||||||
axisRight={null}
|
|
||||||
enableLabel={true}
|
|
||||||
label={(d) => formatNumber(d.value as number)}
|
|
||||||
labelSkipWidth={40}
|
|
||||||
labelTextColor="#e8eaed"
|
|
||||||
tooltip={({ indexValue, value }) => (
|
|
||||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
|
||||||
<p className="text-foreground mb-1 text-xs font-medium">
|
|
||||||
{indexValue}
|
|
||||||
</p>
|
|
||||||
<span className="text-foreground font-mono text-xs font-medium">
|
|
||||||
{(value as number).toLocaleString()} tokens
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
animate={true}
|
|
||||||
motionConfig="gentle"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* ─── Cost Report ─── */}
|
||||||
|
{!dailyLoading && modelData && modelData.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<CostReportCard
|
||||||
|
models={costReport}
|
||||||
|
totalInputCost={costReport.reduce((acc, r) => acc + r.inputCost, 0)}
|
||||||
|
totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)}
|
||||||
|
totalCost={totalCost}
|
||||||
|
isLoading={modelLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Modals ─── */}
|
||||||
|
<ModelPricingDialog
|
||||||
|
open={isPricingDialogOpen}
|
||||||
|
onOpenChange={setIsPricingDialogOpen}
|
||||||
|
customPricing={config?.custom_model_pricing || {}}
|
||||||
|
onSavePricing={handleSavePricing}
|
||||||
|
availableModels={modelData?.map((m) => m.model) || []}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="monitor" className="flex-1 px-6 py-5">
|
||||||
|
<MonitorTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user