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,
|
||||
}) => {
|
||||
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="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 className="flex-1">
|
||||
{isLoading ? (
|
||||
@@ -41,8 +41,8 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||
</div>
|
||||
) : error ? (
|
||||
<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">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
<div className="bg-destructive/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
@@ -53,9 +53,9 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||
) : isEmpty ? (
|
||||
<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]">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
|
||||
<BarChart3 className="text-muted-foreground/60 h-5 w-5" />
|
||||
</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">
|
||||
{emptyDescription}
|
||||
</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 */
|
||||
export const COLOR_PROMPT = '#60a5fa'; // blue-400
|
||||
export const COLOR_PROMPT = '#60a5fa'; // blue-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 = [
|
||||
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
|
||||
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
|
||||
'#60a5fa',
|
||||
'#34d399',
|
||||
'#fbbf24',
|
||||
'#a78bfa',
|
||||
'#f472b6',
|
||||
'#38bdf8',
|
||||
'#4ade80',
|
||||
'#facc15',
|
||||
'#c084fc',
|
||||
];
|
||||
|
||||
export const MODEL_COLORS: Record<string, string> = {
|
||||
@@ -31,10 +38,7 @@ export function BarTooltip({ indexValue, data }: any) {
|
||||
<div className="flex flex-col gap-1">
|
||||
{keys.map((key: string) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: colorMap[key] }}
|
||||
/>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: colorMap[key] }} />
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
||||
@@ -50,10 +54,7 @@ export function PieTooltip({ datum }: any) {
|
||||
return (
|
||||
<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">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: datum.color }}
|
||||
/>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: datum.color }} />
|
||||
<span className="text-muted-foreground">{datum.id}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
||||
@@ -66,12 +67,11 @@ export function PieTooltip({ datum }: any) {
|
||||
export function LineTooltip({ point }: any) {
|
||||
return (
|
||||
<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">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: point.serieColor }}
|
||||
/>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: point.serieColor }} />
|
||||
<span className="text-muted-foreground">{point.serieId}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface TrendInfo {
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
@@ -82,10 +82,7 @@ function Sparkline({ data, accent }: { data: number[]; accent: string }) {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Area fill */}
|
||||
<polygon
|
||||
points={`0,${h} ${points.join(' ')} ${w},${h}`}
|
||||
fill={`url(#sparkGrad-${accent})`}
|
||||
/>
|
||||
<polygon points={`0,${h} ${points.join(' ')} ${w},${h}`} fill={`url(#sparkGrad-${accent})`} />
|
||||
{/* Line */}
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
@@ -103,11 +100,7 @@ function TrendBadge({ trend }: { trend: TrendInfo }) {
|
||||
const isUp = trend.direction === 'up';
|
||||
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
||||
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
||||
const color = isNeutral
|
||||
? 'text-muted-foreground'
|
||||
: isUp
|
||||
? 'text-emerald-400'
|
||||
: 'text-rose-400';
|
||||
const color = isNeutral ? 'text-muted-foreground' : isUp ? 'text-emerald-400' : 'text-rose-400';
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<div
|
||||
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]',
|
||||
className,
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user