mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-10 03:07:06 +00:00
Restore dashboard analytics data path
This commit is contained in:
+4
-12
@@ -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"
|
||||
),
|
||||
|
||||
+813
-204
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1021
-7
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user