Restore dashboard analytics data path

This commit is contained in:
Evan Yang
2026-03-09 16:57:15 +08:00
parent aa700443d3
commit e20b20dbca
9 changed files with 3962 additions and 224 deletions
+4 -12
View File
@@ -940,9 +940,7 @@ async def get_usage_metrics(
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
) -> dict:
"""Get usage metrics aggregated by time interval."""
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@@ -951,9 +949,7 @@ async def get_usage_metrics(
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
async def get_usage_summary(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
) -> dict:
"""Get summary statistics for the specified time period."""
return log_manager.get_usage_summary(hours=hours)
@@ -962,9 +958,7 @@ async def get_usage_summary(
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
async def get_error_details(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
@@ -978,9 +972,7 @@ async def get_error_details(
)
async def get_revenue_by_model(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
hours: int = Query(default=24, ge=1, description="Hours of history to analyze"),
limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1021 -7
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { ErrorDetail } from '@/lib/api/services/admin';
interface ErrorDetailsTableProps {
errors: ErrorDetail[];
}
export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
if (errors.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground py-8 text-center'>
No errors found in the selected time period
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors ({errors.length})</CardTitle>
</CardHeader>
<CardContent>
<div className='max-h-[420px] max-w-full overflow-y-auto'>
<Table className='min-w-[640px] sm:min-w-[760px]'>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
<TableHead>Location</TableHead>
<TableHead>Request ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{errors.map((error, index) => (
<TableRow key={index}>
<TableCell className='font-mono text-xs'>
{new Date(error.timestamp).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant='destructive'>{error.error_type}</Badge>
</TableCell>
<TableCell className='max-w-md truncate'>
{error.message}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.pathname}:{error.lineno}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.request_id || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}
+387
View File
@@ -0,0 +1,387 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ExpandIcon, Minimize2Icon } from 'lucide-react';
import { Area, AreaChart, XAxis, YAxis, CartesianGrid } from 'recharts';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-mobile';
interface UsageMetricsChartProps {
data: Array<Record<string, unknown> & { timestamp: string }>;
title: string;
description?: string;
dataKeys: Array<{
key: string;
name: string;
color: string;
}>;
totals?: Partial<Record<string, number>>;
metricType?: 'currency' | 'count';
currencyUnitLabel?: string;
tabs?: Array<{ id: string; label: string }>;
activeTabId?: string;
onTabChange?: (tabId: string) => void;
}
export function UsageMetricsChart({
data,
title,
description,
dataKeys,
totals,
metricType = 'count',
currencyUnitLabel = 'sats',
tabs,
activeTabId,
onTabChange,
}: UsageMetricsChartProps) {
const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set());
const [isFullscreen, setIsFullscreen] = useState(false);
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const compactNumber = useMemo(
() =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}),
[]
);
const hasMultipleDays = useMemo(() => {
const daySet = new Set(
data.map((item) => new Date(item.timestamp).toDateString())
);
return daySet.size > 1;
}, [data]);
const formatAxisTick = (timestamp: string): string => {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return '';
}
if (hasMultipleDays) {
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
});
}
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
const formatMetricValue = (value: number): string => {
const formatted = compactNumber.format(value);
return metricType === 'currency'
? `${formatted} ${currencyUnitLabel}`
: formatted;
};
const metricTotals = useMemo(() => {
const fallbackTotals = dataKeys.reduce<Record<string, number>>(
(acc, dataKey) => {
acc[dataKey.key] = 0;
return acc;
},
{}
);
for (const point of data) {
for (const dataKey of dataKeys) {
const rawValue = point?.[dataKey.key];
const value =
typeof rawValue === 'number' ? rawValue : Number(rawValue || 0);
if (Number.isFinite(value)) {
fallbackTotals[dataKey.key] += value;
}
}
}
if (!totals) {
return fallbackTotals;
}
const mergedTotals = { ...fallbackTotals };
for (const dataKey of dataKeys) {
const rawTotal = totals[dataKey.key];
if (typeof rawTotal === 'number' && Number.isFinite(rawTotal)) {
mergedTotals[dataKey.key] = rawTotal;
}
}
return mergedTotals;
}, [data, dataKeys, totals]);
const metricChips = useMemo(
() =>
dataKeys.map((dataKey) => ({
...dataKey,
value: Number.isFinite(metricTotals[dataKey.key])
? metricTotals[dataKey.key]
: 0,
})),
[dataKeys, metricTotals]
);
const visibleDataKeys = dataKeys.filter(
(dataKey) => !hiddenSeries.has(dataKey.key)
);
const toggleSeries = (key: string) => {
setHiddenSeries((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const chartConfig = dataKeys.reduce<ChartConfig>((acc, keyConfig) => {
acc[keyConfig.key] = {
label: keyConfig.name,
color: keyConfig.color,
};
return acc;
}, {});
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(document.fullscreenElement === containerRef.current);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const toggleFullscreen = async () => {
if (!containerRef.current) {
return;
}
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen();
} else {
await containerRef.current.requestFullscreen();
}
} catch (error) {
console.error('Failed to toggle fullscreen analytics chart', error);
}
};
return (
<div ref={containerRef}>
<Card
className={cn(isFullscreen && 'h-full rounded-none border-0 ring-0')}
>
<CardHeader className='space-y-3 sm:space-y-4'>
{tabs && activeTabId && onTabChange ? (
<Tabs
value={activeTabId}
onValueChange={onTabChange}
className='w-full'
>
<TabsList
variant='line'
className='max-w-full overflow-x-auto border-b-0 pb-1 whitespace-nowrap'
>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<CardTitle className='text-base sm:text-lg'>{title}</CardTitle>
{description ? (
<p className='text-muted-foreground mt-1 text-xs sm:text-sm'>
{description}
</p>
) : null}
</div>
<Button
type='button'
variant='outline'
size='icon'
className='hidden h-8 w-8 shrink-0 sm:inline-flex'
onClick={toggleFullscreen}
>
{isFullscreen ? (
<Minimize2Icon className='h-4 w-4' />
) : (
<ExpandIcon className='h-4 w-4' />
)}
<span className='sr-only'>
{isFullscreen
? 'Exit fullscreen chart'
: 'Enter fullscreen chart'}
</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3 sm:space-y-4'>
<div className='flex gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-visible sm:pb-0'>
{metricChips.map((metric) => {
const hidden = hiddenSeries.has(metric.key);
return (
<Button
key={metric.key}
type='button'
size='sm'
variant={hidden ? 'ghost' : 'secondary'}
className={cn(
'h-8 shrink-0 rounded-full px-3 text-xs transition-colors sm:text-sm',
hidden
? 'text-muted-foreground/55 hover:text-muted-foreground/70 hover:bg-muted/25'
: 'text-foreground'
)}
onClick={() => toggleSeries(metric.key)}
>
<span
className={cn(
'size-2 rounded-full',
hidden && 'opacity-35'
)}
style={{ backgroundColor: metric.color }}
/>
<span className='max-w-[9rem] truncate sm:max-w-none'>
{metric.name}
</span>
<span
className={cn(
'text-xs',
hidden
? 'text-muted-foreground/45'
: 'text-muted-foreground'
)}
>
{formatMetricValue(metric.value)}
</span>
</Button>
);
})}
</div>
<ChartContainer
className={cn(
'aspect-auto w-full',
isFullscreen
? 'h-[calc(100vh-220px)] min-h-[340px] sm:h-[calc(100vh-260px)] sm:min-h-[420px]'
: 'h-[260px] sm:h-[340px]'
)}
config={chartConfig}
>
<AreaChart
data={data}
margin={{ top: 8, right: isMobile ? 0 : 12, left: 0, bottom: 0 }}
>
<defs>
{dataKeys.map((dataKey) => (
<linearGradient
key={dataKey.key}
id={`color${dataKey.key}`}
x1='0'
y1='0'
x2='0'
y2='1'
>
<stop
offset='5%'
stopColor={dataKey.color}
stopOpacity={0.3}
/>
<stop
offset='95%'
stopColor={dataKey.color}
stopOpacity={0}
/>
</linearGradient>
))}
</defs>
<CartesianGrid
vertical={false}
strokeDasharray='3 3'
className='stroke-muted/30'
/>
<XAxis
dataKey='timestamp'
className='text-xs'
tick={{ fill: 'var(--muted-foreground)' }}
tickFormatter={formatAxisTick}
tickLine={false}
axisLine={false}
minTickGap={isMobile ? 20 : 32}
/>
<YAxis
className='text-xs'
tick={{ fill: 'var(--muted-foreground)' }}
tickFormatter={(value) =>
compactNumber.format(
typeof value === 'number' ? value : Number(value || 0)
)
}
tickLine={false}
axisLine={false}
width={isMobile ? 40 : 48}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(label) =>
new Date(String(label)).toLocaleString()
}
/>
}
/>
{visibleDataKeys.map((dataKey) => (
<Area
key={dataKey.key}
type='monotone'
dataKey={dataKey.key}
stroke={dataKey.color}
fillOpacity={1}
fill={`url(#color${dataKey.key})`}
name={dataKey.name}
strokeWidth={2}
connectNulls
animationDuration={1000}
/>
))}
</AreaChart>
</ChartContainer>
{visibleDataKeys.length === 0 ? (
<p className='text-muted-foreground text-sm'>
Select at least one metric to display the chart.
</p>
) : null}
</CardContent>
</Card>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageSummary } from '@/lib/api/services/admin';
import {
CheckCircle2,
XCircle,
AlertTriangle,
Activity,
Database,
CreditCard,
TrendingUp,
DollarSign,
TrendingDown,
Coins,
} from 'lucide-react';
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { formatFromMsat } from '@/lib/currency';
interface UsageSummaryCardsProps {
summary: UsageSummary;
}
export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
const { displayUnit } = useCurrencyStore();
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
refetchInterval: 120_000,
staleTime: 60_000,
});
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
const formatAmount = (msat: number) =>
formatFromMsat(msat, displayUnit, usdPerSat);
const hasTokenStats =
typeof summary.total_tokens === 'number' ||
typeof summary.avg_total_tokens_per_completion === 'number';
const cards = [
{
title: 'Total Requests',
value: summary.total_requests.toLocaleString(),
icon: Activity,
iconClassName: 'text-blue-600 dark:text-blue-300',
},
{
title: 'Successful Completions',
value: summary.successful_chat_completions.toLocaleString(),
icon: CheckCircle2,
iconClassName: 'text-emerald-600 dark:text-emerald-300',
},
...(hasTokenStats
? [
{
title: 'Total Tokens',
value: Number(summary.total_tokens ?? 0).toLocaleString(),
icon: Database,
iconClassName: 'text-cyan-600 dark:text-cyan-300',
},
{
title: 'Avg Tokens/Completion',
value: Number(
summary.avg_total_tokens_per_completion ?? 0
).toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
icon: Activity,
iconClassName: 'text-indigo-600 dark:text-indigo-300',
},
]
: []),
{
title: 'Revenue',
value: formatAmount(summary.revenue_msats),
icon: Coins,
iconClassName: 'text-amber-600 dark:text-amber-300',
},
{
title: 'Operational Net',
value: formatAmount(summary.net_revenue_msats),
icon: DollarSign,
iconClassName: 'text-lime-600 dark:text-lime-300',
},
{
title: 'Reverted Holds',
value: formatAmount(summary.refunds_msats),
icon: TrendingDown,
iconClassName: 'text-rose-600 dark:text-rose-300',
},
{
title: 'Avg Revenue/Request',
value: formatAmount(summary.avg_revenue_per_request_msats),
icon: CreditCard,
iconClassName: 'text-violet-600 dark:text-violet-300',
},
{
title: 'Success Rate',
value: `${summary.success_rate.toFixed(1)}%`,
icon: TrendingUp,
iconClassName: 'text-teal-600 dark:text-teal-300',
},
{
title: 'Refund Rate',
value: `${summary.refund_rate.toFixed(1)}%`,
icon: XCircle,
iconClassName: 'text-fuchsia-600 dark:text-fuchsia-300',
},
{
title: 'Failed Requests',
value: summary.failed_requests.toLocaleString(),
icon: XCircle,
iconClassName: 'text-red-600 dark:text-red-300',
},
{
title: 'Errors',
value: summary.total_errors.toLocaleString(),
icon: AlertTriangle,
iconClassName: 'text-orange-600 dark:text-orange-300',
},
{
title: 'Unique Models',
value: summary.unique_models_count.toLocaleString(),
icon: Database,
iconClassName: 'text-cyan-600 dark:text-cyan-300',
},
{
title: 'Upstream Errors',
value: summary.upstream_errors.toLocaleString(),
icon: AlertTriangle,
iconClassName: 'text-pink-600 dark:text-pink-300',
},
];
return (
<div className='grid grid-cols-1 gap-2.5 px-1 min-[380px]:grid-cols-2 sm:gap-4 sm:px-0 xl:grid-cols-4'>
{cards.map((card) => (
<Card key={card.title} size='sm'>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-1'>
<CardTitle className='text-muted-foreground text-[11px] font-medium sm:text-sm'>
{card.title}
</CardTitle>
<span className='inline-flex size-6 items-center justify-center sm:size-7'>
<card.icon
className={`size-3.5 sm:size-4 ${card.iconClassName}`}
/>
</span>
</CardHeader>
<CardContent className='pt-0'>
<div className='text-base font-semibold break-words tabular-nums sm:text-2xl'>
{card.value}
</div>
</CardContent>
</Card>
))}
</div>
);
}
+5 -1
View File
@@ -13,7 +13,11 @@ class ApiClient {
private handleAuthError(error: unknown): void {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response?.status === 401) {
const status = axiosError.response?.status;
const requestUrl = axiosError.config?.url ?? '';
const isAdminRequest = requestUrl.includes('/admin/');
if (status === 401 || (status === 403 && isAdminRequest)) {
ConfigurationService.clearToken();
if (
typeof window !== 'undefined' &&
+114
View File
@@ -833,6 +833,39 @@ export class AdminService {
);
}
static async getUsageMetrics(
interval: number = 15,
hours: number = 24
): Promise<UsageMetrics> {
return await apiClient.get<UsageMetrics>(
`/admin/api/usage/metrics?interval=${interval}&hours=${hours}`
);
}
static async getUsageSummary(hours: number = 24): Promise<UsageSummary> {
return await apiClient.get<UsageSummary>(
`/admin/api/usage/summary?hours=${hours}`
);
}
static async getErrorDetails(
hours: number = 24,
limit: number = 100
): Promise<ErrorDetails> {
return await apiClient.get<ErrorDetails>(
`/admin/api/usage/error-details?hours=${hours}&limit=${limit}`
);
}
static async getRevenueByModel(
hours: number = 24,
limit: number = 20
): Promise<RevenueByModel> {
return await apiClient.get<RevenueByModel>(
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
@@ -897,6 +930,87 @@ export const TemporaryBalanceSchema = z.object({
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
export interface UsageMetricData {
timestamp: string;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
errors: number;
warnings: number;
payment_processed: number;
upstream_errors: number;
revenue_msats: number;
refunds_msats: number;
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
[key: string]: unknown;
}
export interface UsageMetrics {
metrics: UsageMetricData[];
interval_minutes: number;
hours_back: number;
total_buckets: number;
totals?: Partial<Record<string, number>>;
}
export interface UsageSummary {
total_entries: number;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
total_errors: number;
total_warnings: number;
payment_processed: number;
upstream_errors: number;
unique_models_count: number;
unique_models: string[];
error_types: Record<string, number>;
success_rate: number;
revenue_msats: number;
refunds_msats: number;
revenue_sats: number;
refunds_sats: number;
net_revenue_msats: number;
net_revenue_sats: number;
avg_revenue_per_request_msats: number;
refund_rate: number;
total_tokens?: number;
avg_total_tokens_per_completion?: number;
}
export interface ErrorDetail {
timestamp: string;
message: string;
error_type: string;
pathname: string;
lineno: number;
request_id: string;
}
export interface ErrorDetails {
errors: ErrorDetail[];
total_count: number;
}
export interface ModelRevenueData {
model: string;
revenue_sats: number;
refunds_sats: number;
net_revenue_sats: number;
requests: number;
successful: number;
failed: number;
avg_revenue_per_request: number;
}
export interface RevenueByModel {
models: ModelRevenueData[];
total_revenue_sats: number;
total_models: number;
}
export interface LogEntry {
asctime: string;
name: string;