Restore analytics dashboard and polish mobile shell

This commit is contained in:
Evan Yang
2026-03-07 18:52:17 +08:00
parent 88f7ee734f
commit 20b4c3a641
17 changed files with 4191 additions and 803 deletions
+4
View File
@@ -776,6 +776,8 @@ async def adjust_payment_for_tokens(
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"new_balance": billing_key.balance,
"model": model,
},
@@ -799,6 +801,8 @@ async def adjust_payment_for_tokens(
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
},
)
+37
View File
@@ -28,6 +28,8 @@ admin_router = APIRouter(prefix="/admin", include_in_schema=False)
admin_sessions: dict[str, int] = {}
ADMIN_SESSION_DURATION = 12 * 60 * 60
# Usage analytics remain queryable up to 12 months.
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
def _current_timestamp() -> int:
@@ -971,6 +973,7 @@ async def get_usage_metrics(
hours: int = Query(
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
) -> dict:
@@ -978,12 +981,44 @@ async def get_usage_metrics(
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@admin_router.get("/api/usage/dashboard", dependencies=[Depends(require_admin_api)])
async def get_usage_dashboard(
request: Request,
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
error_limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
model_limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
) -> dict:
"""
Get all dashboard analytics in one request.
This runs one combined aggregation pass and avoids repeated scans.
"""
return log_manager.get_usage_dashboard(
interval=interval,
hours=hours,
error_limit=error_limit,
model_limit=model_limit,
)
@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=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
) -> dict:
@@ -997,6 +1032,7 @@ async def get_error_details(
hours: int = Query(
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
limit: int = Query(
@@ -1015,6 +1051,7 @@ async def get_revenue_by_model(
hours: int = Query(
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
limit: int = Query(
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -3,36 +3,38 @@ Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
The following log messages are parsed by the usage tracking system
(routstr/core/usage_analytics_store.py and routstr/core/log_manager.py).
DO NOT modify or remove these messages without updating the usage tracking logic:
1. "Received proxy request" (INFO) - routstr/proxy.py
- Used to count total incoming requests
- Includes model information in context
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
2. "Calculated token-based cost" (INFO) - routstr/auth.py
- Used to track successful completions and revenue
- The 'cost_data.total_msats' field is extracted for revenue calculation
- Must include 'cost_data' in extra dict
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
3. "Payment processed successfully" (INFO) - routstr/auth.py
3. "Max cost payment finalized" (INFO) - routstr/auth.py
- Used as the successful completion fallback when token usage is unavailable
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
4. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
5. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
- Used to track failed requests and refunds
- The 'max_cost_for_model' field is extracted for refund calculation
- Must include 'max_cost_for_model' in extra dict
5. Any ERROR level logs with "upstream" in the message
6. Any ERROR level logs with "upstream" in the message
- Used to count upstream provider errors
- Helps identify service reliability issues
If you need to modify these messages, ensure you also update the parsing logic in:
- routstr/core/admin.py:_aggregate_metrics_by_time()
- routstr/core/admin.py:_get_summary_stats()
- routstr/core/admin.py:get_revenue_by_model()
- routstr/core/usage_analytics_store.py
- routstr/core/log_manager.py
"""
import logging.config
File diff suppressed because it is too large Load Diff
+47 -23
View File
@@ -16,6 +16,8 @@ class CostData(BaseModel):
output_msats: int
total_msats: int
total_usd: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
class MaxCostData(CostData):
@@ -63,10 +65,49 @@ async def calculate_cost( # todo: can be sync
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
)
usage_data = response_data["usage"]
def parse_token_count(value: object) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(usage_data.get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(usage_data.get("output_tokens", 0))
)
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
)
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
@@ -104,6 +145,8 @@ async def calculate_cost( # todo: can be sync
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as e:
logger.warning(
@@ -184,31 +227,10 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=max_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
@@ -234,4 +256,6 @@ async def calculate_cost( # todo: can be sync
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
+74 -35
View File
@@ -1,5 +1,6 @@
import asyncio
import math
import time
from typing import TypedDict
from cashu.core.base import Proof, Token
@@ -158,6 +159,14 @@ async def credit_balance(
_wallets: dict[str, Wallet] = {}
_balances_cache_ttl_seconds = 300.0
_balances_cache: dict[
tuple[str, ...], tuple[float, tuple[list["BalanceDetail"], int, int, int]]
] = {}
_balances_refresh_tasks: dict[
tuple[str, ...], asyncio.Task[tuple[list["BalanceDetail"], int, int, int]]
] = {}
_balances_cache_lock = asyncio.Lock()
async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
@@ -226,6 +235,12 @@ async def fetch_all_balances(
"""
if units is None:
units = ["sat", "msat"]
units_key = tuple(units)
now = time.time()
cached = _balances_cache.get(units_key)
if cached and cached[0] > now:
return cached[1]
async def fetch_balance(
session: db.AsyncSession, mint_url: str, unit: str
@@ -261,47 +276,71 @@ async def fetch_all_balances(
}
return error_result
# Create tasks for all mint/unit combinations
async with db.create_session() as session:
tasks = [
fetch_balance(session, mint_url, unit)
for mint_url in settings.cashu_mints
for unit in units
]
async def compute_balances() -> tuple[list[BalanceDetail], int, int, int]:
# Create tasks for all mint/unit combinations
async with db.create_session() as session:
tasks = [
fetch_balance(session, mint_url, unit)
for mint_url in settings.cashu_mints
for unit in units
]
# Run all tasks concurrently
balance_details = list(await asyncio.gather(*tasks))
# Run all tasks concurrently
balance_details = list(await asyncio.gather(*tasks))
# Calculate totals
total_wallet_balance_sats = 0
total_user_balance_sats = 0
# Calculate totals
total_wallet_balance_sats = 0
total_user_balance_sats = 0
for detail in balance_details:
if not detail.get("error"):
# Convert to sats for total calculation
unit = detail["unit"]
proofs_balance_sats = (
detail["wallet_balance"]
if unit == "sat"
else detail["wallet_balance"] // 1000
)
user_balance_sats = (
detail["user_balance"]
if unit == "sat"
else detail["user_balance"] // 1000
)
for detail in balance_details:
if not detail.get("error"):
# Convert to sats for total calculation
unit = detail["unit"]
proofs_balance_sats = (
detail["wallet_balance"]
if unit == "sat"
else detail["wallet_balance"] // 1000
)
user_balance_sats = (
detail["user_balance"]
if unit == "sat"
else detail["user_balance"] // 1000
)
total_wallet_balance_sats += proofs_balance_sats
total_user_balance_sats += user_balance_sats
total_wallet_balance_sats += proofs_balance_sats
total_user_balance_sats += user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
return (
balance_details,
total_wallet_balance_sats,
total_user_balance_sats,
owner_balance,
)
return (
balance_details,
total_wallet_balance_sats,
total_user_balance_sats,
owner_balance,
)
async with _balances_cache_lock:
now = time.time()
cached = _balances_cache.get(units_key)
if cached and cached[0] > now:
return cached[1]
refresh_task = _balances_refresh_tasks.get(units_key)
if refresh_task is None or refresh_task.done():
refresh_task = asyncio.create_task(compute_balances())
_balances_refresh_tasks[units_key] = refresh_task
result = await refresh_task
async with _balances_cache_lock:
_balances_cache[units_key] = (
time.time() + _balances_cache_ttl_seconds,
result,
)
current_task = _balances_refresh_tasks.get(units_key)
if current_task is refresh_task and refresh_task.done():
_balances_refresh_tasks.pop(units_key, None)
return result
async def periodic_payout() -> None:
+416 -288
View File
@@ -4,11 +4,12 @@ import { useEffect, useMemo, useState } from 'react';
import { format } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { CalendarIcon, RefreshCw } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import type { DateRange } from 'react-day-picker';
import { UsageMetricsChart } from '@/components/usage-metrics-chart';
import { UsageSummaryCards } from '@/components/usage-summary-cards';
import { ErrorDetailsTable } from '@/components/error-details-table';
import { RevenueByModelTable } from '@/components/revenue-by-model-table';
import { TopModelsUsageChart } from '@/components/top-models-usage-chart';
import { DashboardBalanceSummary } from '@/components/dashboard-balance-summary';
import {
AdminService,
@@ -18,7 +19,12 @@ import {
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig as UiChartConfig,
} from '@/components/ui/chart';
import {
Empty,
EmptyDescription,
@@ -45,6 +51,7 @@ import { CheatSheet } from '@/components/landing/cheat-sheet';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { AppPageShell } from '@/components/app-page-shell';
import { useIsMobile } from '@/hooks/use-mobile';
import type { DisplayUnit } from '@/lib/types/units';
import { cn } from '@/lib/utils';
type ChartDatum = Record<string, unknown> & { timestamp: string };
@@ -62,6 +69,7 @@ type ChartConfig = {
description: string;
data: ChartDatum[];
dataKeys: ChartKeyConfig[];
totals?: Partial<Record<string, number>>;
metricType: 'currency' | 'count';
};
@@ -72,10 +80,13 @@ const TIME_RANGE_PRESETS = [
{ value: '3m', label: 'Last 3 Months', hours: 90 * 24 },
{ value: '12m', label: 'Last 12 Months', hours: 365 * 24 },
] as const;
const MAX_USAGE_RANGE_HOURS = 365 * 24;
type TimeRangePresetValue = (typeof TIME_RANGE_PRESETS)[number]['value'];
const DEFAULT_TIME_RANGE_PRESET = TIME_RANGE_PRESETS[0];
const DEFAULT_TIME_RANGE_PRESET =
TIME_RANGE_PRESETS.find((option) => option.value === '7d') ??
TIME_RANGE_PRESETS[0];
function normalizeDateRange(range: DateRange): DateRange {
if (!range.from || !range.to) {
@@ -111,36 +122,6 @@ function getRangeHours(range?: DateRange): number | null {
return Math.max(1, diffHours);
}
function formatDateRangeLabel(range?: DateRange): string {
if (!range?.from && !range?.to) {
return 'Custom range';
}
if (range.from && !range.to) {
return `${format(range.from, 'MMM d, yyyy')} - ...`;
}
if (!range.from || !range.to) {
return 'Custom range';
}
const normalized = normalizeDateRange(range);
const from = normalized.from;
const to = normalized.to;
if (!from || !to) {
return 'Custom range';
}
const sameDay = format(from, 'yyyy-MM-dd') === format(to, 'yyyy-MM-dd');
if (sameDay) {
return format(from, 'MMM d, yyyy');
}
return `${format(from, 'MMM d')} - ${format(to, 'MMM d, yyyy')}`;
}
function formatCompactDateRangeLabel(range?: DateRange): string {
if (!range?.from || !range.to) {
return 'Custom range';
@@ -184,7 +165,7 @@ function SectionLoading({ label }: { label: string }) {
if (label === 'summary') {
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'>
{Array.from({ length: 12 }).map((_, index) => (
{Array.from({ length: 14 }).map((_, index) => (
<Card key={`summary-skeleton-${index}`}>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-1 sm:pb-2'>
<Skeleton
@@ -374,52 +355,136 @@ function SectionLoading({ label }: { label: string }) {
);
}
function DashboardInsights({ summary }: { summary?: UsageSummary }) {
function DashboardInsights({
summary,
isMobile,
}: {
summary?: UsageSummary;
isMobile: boolean;
}) {
if (!summary) {
return null;
}
const errorTypes = Object.entries(summary.error_types || {}).sort(
([, a], [, b]) => b - a
);
const errorTypes = Object.entries(summary.error_types || {})
.map(([type, count]) => ({
type,
count: typeof count === 'number' ? count : Number(count || 0),
}))
.filter((entry) => Number.isFinite(entry.count) && entry.count > 0)
.sort((a, b) => b.count - a.count);
const hasModels = summary.unique_models.length > 0;
const hasErrorTypes = errorTypes.length > 0;
if (!hasModels && !hasErrorTypes) {
if (!hasErrorTypes) {
return null;
}
const compactNumber = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const truncateErrorType = (value: string): string => {
const maxLength = isMobile ? 16 : 26;
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 1)}…`;
};
const chartData = errorTypes.slice(0, 10).map(({ type, count }) => ({
type,
label: truncateErrorType(type),
count,
}));
const totalErrors = errorTypes.reduce((sum, entry) => sum + entry.count, 0);
const chartConfig: UiChartConfig = {
count: {
label: 'Errors',
color: 'var(--chart-4)',
},
};
return (
<div className='grid gap-6 lg:grid-cols-2'>
{hasModels && (
<Card>
<CardHeader>
<CardTitle>Active Models</CardTitle>
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{summary.unique_models.map((model) => (
<Badge key={model} variant='secondary'>
{model}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
<div className='grid gap-6'>
{hasErrorTypes && (
<Card>
<CardHeader>
<CardHeader className='space-y-1'>
<CardTitle>Error Types Distribution</CardTitle>
<p className='text-muted-foreground text-xs sm:text-sm'>
Top {chartData.length} categories in this range.
</p>
</CardHeader>
<CardContent className='space-y-2'>
{errorTypes.map(([type, count]) => (
<div key={type} className='flex items-center justify-between'>
<span className='text-sm font-medium'>{type}</span>
<span className='text-muted-foreground text-sm'>{count}</span>
</div>
))}
<CardContent>
<ChartContainer config={chartConfig} className='h-[300px] w-full'>
<BarChart
data={chartData}
layout='vertical'
margin={{
top: 6,
right: isMobile ? 8 : 20,
left: isMobile ? 2 : 10,
bottom: 6,
}}
>
<CartesianGrid horizontal={false} className='stroke-muted/25' />
<XAxis
type='number'
allowDecimals={false}
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
compactNumber.format(
typeof value === 'number' ? value : Number(value || 0)
)
}
/>
<YAxis
type='category'
dataKey='label'
tickLine={false}
axisLine={false}
width={isMobile ? 120 : 200}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) =>
String(payload?.[0]?.payload?.type ?? '')
}
formatter={(value, name) => {
const numericValue =
typeof value === 'number'
? value
: Number(value || 0);
return (
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto] gap-x-4'>
<span className='text-muted-foreground truncate pr-1'>
{name}
</span>
<span className='text-foreground text-right font-mono font-medium tabular-nums'>
{Number.isFinite(numericValue)
? numericValue.toLocaleString()
: '-'}
</span>
</div>
);
}}
/>
}
/>
<Bar
dataKey='count'
name='Errors'
fill='var(--color-count)'
radius={[0, 6, 6, 0]}
maxBarSize={24}
/>
</BarChart>
</ChartContainer>
<p className='text-muted-foreground mt-2 text-xs'>
Total errors: {totalErrors.toLocaleString()}
</p>
</CardContent>
</Card>
)}
@@ -428,8 +493,9 @@ function DashboardInsights({ summary }: { summary?: UsageSummary }) {
}
export default function DashboardPage() {
const [selectedPreset, setSelectedPreset] =
useState<TimeRangePresetValue>('24h');
const [selectedPreset, setSelectedPreset] = useState<TimeRangePresetValue>(
DEFAULT_TIME_RANGE_PRESET.value
);
const [customRange, setCustomRange] = useState<DateRange>();
const [pendingCustomRange, setPendingCustomRange] = useState<DateRange>();
const [isCustomRangeActive, setIsCustomRangeActive] = useState(false);
@@ -476,55 +542,62 @@ export default function DashboardPage() {
isCustomRangeActive && customRangeHours
? customRangeHours
: activePreset.hours;
const autoInterval = getAutoIntervalMinutes(queryHours);
const safeQueryHours = Math.min(queryHours, MAX_USAGE_RANGE_HOURS);
const isUsageRangeCapped = safeQueryHours < queryHours;
const autoInterval = getAutoIntervalMinutes(safeQueryHours);
const usageRefetchIntervalMs = useMemo(() => {
if (safeQueryHours > 90 * 24) {
return 4 * 60 * 60_000;
}
if (safeQueryHours > 30 * 24) {
return 2 * 60 * 60_000;
}
if (safeQueryHours > 7 * 24) {
return 30 * 60_000;
}
return 60_000;
}, [safeQueryHours]);
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
// Keep revenue charts meaningful while the USD rate is unavailable.
return 'sat';
}
return displayUnit;
}, [displayUnit, usdPerSat]);
const revenueUnitLabel =
revenueDisplayUnit === 'usd'
? 'USD'
: revenueDisplayUnit === 'sat'
? 'sats'
: revenueDisplayUnit === 'msat'
? 'msats'
: revenueDisplayUnit;
const {
data: metricsData,
isLoading: metricsLoading,
refetch: refetchMetrics,
data: usageDashboardData,
isLoading: usageDashboardLoading,
refetch: refetchUsageDashboard,
} = useQuery({
queryKey: ['usage-metrics', autoInterval, queryHours],
queryFn: () => AdminService.getUsageMetrics(autoInterval, queryHours),
queryKey: ['usage-dashboard', autoInterval, safeQueryHours],
queryFn: () =>
AdminService.getUsageDashboard(safeQueryHours, autoInterval, 100, 20),
enabled: isAuthenticated,
refetchInterval: 60_000,
refetchInterval: usageRefetchIntervalMs,
staleTime: 30_000,
});
const {
data: summaryData,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useQuery({
queryKey: ['usage-summary', queryHours],
queryFn: () => AdminService.getUsageSummary(queryHours),
enabled: isAuthenticated,
refetchInterval: 60_000,
staleTime: 30_000,
});
const metricsData = usageDashboardData?.metrics;
const summaryData = usageDashboardData?.summary;
const errorData = usageDashboardData?.error_details;
const modelUsageMixData = usageDashboardData?.model_usage_mix;
const hasModelUsageMixMetrics =
Array.isArray(modelUsageMixData?.metrics) &&
modelUsageMixData.metrics.length > 0;
const {
data: errorData,
isLoading: errorLoading,
refetch: refetchErrors,
} = useQuery({
queryKey: ['usage-errors', queryHours],
queryFn: () => AdminService.getErrorDetails(queryHours, 100),
enabled: isAuthenticated,
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: revenueByModelData,
isLoading: revenueByModelLoading,
refetch: refetchRevenueByModel,
} = useQuery({
queryKey: ['revenue-by-model', queryHours],
queryFn: () => AdminService.getRevenueByModel(queryHours, 20),
enabled: isAuthenticated,
refetchInterval: 60_000,
staleTime: 30_000,
});
const metricsLoading = usageDashboardLoading;
const summaryLoading = usageDashboardLoading;
const errorLoading = usageDashboardLoading;
const metricsTotals = metricsData?.totals;
const chartConfigs = useMemo<ChartConfig[]>(() => {
if (!metricsData || metricsData.metrics.length === 0) {
@@ -532,39 +605,44 @@ export default function DashboardPage() {
}
const metricPoints = metricsData.metrics as ChartDatum[];
const convertRevenueMsats = (amountMsats: number): number => {
if (revenueDisplayUnit === 'msat') {
return amountMsats;
}
const sats = amountMsats / 1000;
if (revenueDisplayUnit === 'usd') {
return sats * (usdPerSat ?? 0);
}
return sats;
};
const revenuePoints = metricsData.metrics.map(
(metric: UsageMetricData) => ({
...metric,
revenue_sats: metric.revenue_msats / 1000,
refunds_sats: metric.refunds_msats / 1000,
net_revenue_sats: (metric.revenue_msats - metric.refunds_msats) / 1000,
revenue_display: convertRevenueMsats(metric.revenue_msats),
})
) as ChartDatum[];
return [
{
id: 'revenue',
title: 'Revenue Over Time (sats)',
title: 'Revenue Over Time',
mobileTitle: 'Revenue',
description: 'Track gross revenue, refunds, and net revenue trends.',
description: 'Track collected revenue trends over time.',
data: revenuePoints,
metricType: 'currency',
totals: metricsTotals
? {
revenue_display: convertRevenueMsats(metricsTotals.revenue_msats),
}
: undefined,
dataKeys: [
{
key: 'revenue_sats',
key: 'revenue_display',
name: 'Revenue',
color: 'var(--chart-1)',
},
{
key: 'net_revenue_sats',
name: 'Net Revenue',
color: 'var(--chart-2)',
},
{
key: 'refunds_sats',
name: 'Refunds',
color: 'var(--chart-5)',
},
],
},
{
@@ -574,6 +652,14 @@ export default function DashboardPage() {
description: 'Understand traffic and completion reliability over time.',
data: metricPoints,
metricType: 'count',
totals: metricsTotals
? {
total_requests: metricsTotals.total_requests,
successful_chat_completions:
metricsTotals.successful_chat_completions,
failed_requests: metricsTotals.failed_requests,
}
: undefined,
dataKeys: [
{
key: 'total_requests',
@@ -599,6 +685,13 @@ export default function DashboardPage() {
description: 'Monitor warnings, handled errors, and upstream failures.',
data: metricPoints,
metricType: 'count',
totals: metricsTotals
? {
errors: metricsTotals.errors,
warnings: metricsTotals.warnings,
upstream_errors: metricsTotals.upstream_errors,
}
: undefined,
dataKeys: [
{
key: 'errors',
@@ -624,6 +717,11 @@ export default function DashboardPage() {
description: 'Follow payment processing activity by interval.',
data: metricPoints,
metricType: 'count',
totals: metricsTotals
? {
payment_processed: metricsTotals.payment_processed,
}
: undefined,
dataKeys: [
{
key: 'payment_processed',
@@ -632,8 +730,41 @@ export default function DashboardPage() {
},
],
},
{
id: 'tokens',
title: 'Token Usage',
mobileTitle: 'Tokens',
description:
'Track input, output, and total token throughput over time.',
data: metricPoints,
metricType: 'count',
totals: metricsTotals
? {
input_tokens: metricsTotals.input_tokens,
output_tokens: metricsTotals.output_tokens,
total_tokens: metricsTotals.total_tokens,
}
: undefined,
dataKeys: [
{
key: 'total_tokens',
name: 'Total Tokens',
color: 'var(--chart-1)',
},
{
key: 'input_tokens',
name: 'Input Tokens',
color: 'var(--chart-2)',
},
{
key: 'output_tokens',
name: 'Output Tokens',
color: 'var(--chart-3)',
},
],
},
];
}, [metricsData]);
}, [metricsData, metricsTotals, revenueDisplayUnit, usdPerSat]);
useEffect(() => {
if (chartConfigs.length === 0) {
@@ -659,6 +790,7 @@ export default function DashboardPage() {
</AppPageShell>
);
}
if (!isAuthenticated) {
return <CheatSheet />;
}
@@ -669,13 +801,11 @@ export default function DashboardPage() {
}
setIsManualRefreshing(true);
await Promise.allSettled([
refetchMetrics(),
refetchSummary(),
refetchErrors(),
refetchRevenueByModel(),
]);
setIsManualRefreshing(false);
try {
await refetchUsageDashboard();
} finally {
setIsManualRefreshing(false);
}
};
const openRangePicker = () => {
@@ -748,10 +878,6 @@ export default function DashboardPage() {
isCustomRangeActive && customRange?.from && customRange?.to
? 'custom'
: selectedPreset;
const activeRangeLabel =
selectedRangeValue === 'custom'
? formatDateRangeLabel(customRange)
: activePreset.label;
const compactCustomRangeLabel = formatCompactDateRangeLabel(customRange);
return (
@@ -771,165 +897,167 @@ export default function DashboardPage() {
usdPerSat={usdPerSat}
/>
<section className='border-border/60 bg-card/35 space-y-3 rounded-xl border p-3 sm:p-4'>
<div className='space-y-1'>
<div className='min-w-0'>
<section className='border-border/60 bg-card/35 space-y-4 rounded-xl border p-3 sm:p-4'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<h2 className='text-base leading-snug font-semibold tracking-tight sm:text-lg'>
Usage Analytics
</h2>
<p className='text-muted-foreground text-xs sm:text-sm'>
Select a preset or custom date range to analyze traffic and
revenue.
</p>
<p className='text-muted-foreground text-[11px] sm:text-xs'>
Showing {activeRangeLabel}.
</p>
</div>
</div>
<div className='space-y-1.5'>
<p className='text-muted-foreground text-[11px] font-normal tracking-tight sm:text-xs'>
Range
<p className='text-muted-foreground text-xs sm:text-sm'>
All cards and charts in this section update from the selected
range.
</p>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<div className='w-full max-w-[20rem] sm:max-w-[22rem]'>
<div className='border-input bg-card/30 dark:bg-input/30 flex h-8 w-full min-w-0 items-stretch overflow-hidden rounded-lg border sm:h-9'>
<Popover
open={isCustomRangePickerOpen}
onOpenChange={handleCustomRangePickerChange}
>
<PopoverTrigger asChild>
<Button
type='button'
variant='ghost'
size='icon'
id='dashboard-date-range'
className='text-muted-foreground hover:bg-muted/50 hover:text-foreground dark:hover:bg-input/50 h-full w-8 rounded-none border-0 bg-transparent p-0 sm:w-9'
aria-label='Open custom date range'
>
<CalendarIcon className='h-3.5 w-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent
align='start'
className='w-auto overflow-hidden p-0'
>
<Calendar
mode='range'
selected={pendingCustomRange}
onSelect={handleCustomRangeSelect}
defaultMonth={pendingCustomRange?.from}
numberOfMonths={isMobile ? 1 : 2}
className='p-3'
/>
</PopoverContent>
</Popover>
<div className='bg-border/70 w-px' />
<Select
value={selectedRangeValue}
onValueChange={handleRangeSelectChange}
>
<SelectTrigger className='h-full min-h-0 w-full rounded-none border-0 !bg-transparent px-3 py-0 text-sm font-normal shadow-none ring-0 hover:!bg-transparent focus-visible:border-transparent focus-visible:ring-0 data-[state=open]:!bg-transparent dark:!bg-transparent dark:hover:!bg-transparent'>
<SelectValue
placeholder='Select range'
className='text-left'
/>
</SelectTrigger>
<SelectContent align='start'>
{customRange?.from && customRange?.to && (
<SelectItem value='custom'>
{compactCustomRangeLabel}
</SelectItem>
)}
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={handleRefresh}
variant='outline'
disabled={isManualRefreshing}
className='h-8 w-full px-2.5 text-xs sm:ml-auto sm:w-auto'
>
<RefreshCw
className={cn(
'mr-1 h-3 w-3',
isManualRefreshing && 'animate-spin'
)}
/>
{isManualRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
{isUsageRangeCapped ? (
<p className='text-muted-foreground text-[11px] sm:text-xs'>
Usage analytics are capped to the last{' '}
{MAX_USAGE_RANGE_HOURS / 24} days for server safety.
</p>
) : null}
</div>
<div className='flex items-center gap-2'>
<div className='min-w-0 flex-1 sm:max-w-[22rem]'>
<div className='border-input bg-card/30 dark:bg-input/30 flex h-8 w-full min-w-0 items-stretch overflow-hidden rounded-lg border sm:h-9'>
<Popover
open={isCustomRangePickerOpen}
onOpenChange={handleCustomRangePickerChange}
>
<PopoverTrigger asChild>
<Button
type='button'
variant='ghost'
size='icon'
id='dashboard-date-range'
className='text-muted-foreground hover:bg-muted/50 hover:text-foreground dark:hover:bg-input/50 h-full w-8 rounded-none border-0 bg-transparent p-0 sm:w-9'
aria-label='Open custom date range'
>
<CalendarIcon className='h-3.5 w-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent
align='start'
className='w-auto overflow-hidden p-0'
>
<Calendar
mode='range'
selected={pendingCustomRange}
onSelect={handleCustomRangeSelect}
defaultMonth={pendingCustomRange?.from}
numberOfMonths={isMobile ? 1 : 2}
className='p-3'
/>
</PopoverContent>
</Popover>
<div className='bg-border/70 w-px' />
<Select
value={selectedRangeValue}
onValueChange={handleRangeSelectChange}
>
<SelectTrigger className='h-full min-h-0 w-full rounded-none border-0 !bg-transparent px-3 py-0 text-sm font-normal shadow-none ring-0 hover:!bg-transparent focus-visible:border-transparent focus-visible:ring-0 data-[state=open]:!bg-transparent dark:!bg-transparent dark:hover:!bg-transparent'>
<SelectValue
placeholder='Select range'
className='text-left'
/>
</SelectTrigger>
<SelectContent align='start'>
{customRange?.from && customRange?.to && (
<SelectItem value='custom'>
{compactCustomRangeLabel}
</SelectItem>
)}
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={handleRefresh}
variant='outline'
disabled={isManualRefreshing}
aria-label='Refresh analytics'
className='h-8 w-8 shrink-0 rounded-lg p-0 sm:h-9 sm:w-auto sm:px-3'
>
<RefreshCw
className={cn(
'h-3.5 w-3.5 sm:mr-1',
isManualRefreshing && 'animate-spin'
)}
/>
<span className='hidden sm:inline'>
{isManualRefreshing ? 'Refreshing...' : 'Refresh'}
</span>
</Button>
</div>
{metricsLoading ? (
<SectionLoading label='metrics' />
) : activeChartConfig ? (
<UsageMetricsChart
data={activeChartConfig.data}
title={activeChartConfig.title}
description={activeChartConfig.description}
dataKeys={activeChartConfig.dataKeys}
totals={activeChartConfig.totals}
metricType={activeChartConfig.metricType}
currencyUnitLabel={revenueUnitLabel}
tabs={chartConfigs.map((config) => ({
id: config.id,
label: isMobile
? (config.mobileTitle ?? config.title)
: config.title,
}))}
activeTabId={activeChartId}
onTabChange={setActiveChartId}
/>
) : (
<Card>
<CardContent>
<Empty className='border-none py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<RefreshCw className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No data available</EmptyTitle>
<EmptyDescription>
No metrics data exists for this range yet. Try a broader
range.
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
)}
{!metricsLoading && modelUsageMixData && hasModelUsageMixMetrics ? (
<TopModelsUsageChart
mix={modelUsageMixData}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
) : null}
{summaryLoading ? (
<SectionLoading label='summary' />
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<DashboardInsights summary={summaryData} isMobile={isMobile} />
{errorLoading ? (
<SectionLoading label='errors' />
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
</section>
{metricsLoading ? (
<SectionLoading label='metrics' />
) : activeChartConfig ? (
<UsageMetricsChart
data={activeChartConfig.data}
title={activeChartConfig.title}
description={activeChartConfig.description}
dataKeys={activeChartConfig.dataKeys}
metricType={activeChartConfig.metricType}
tabs={chartConfigs.map((config) => ({
id: config.id,
label: isMobile
? (config.mobileTitle ?? config.title)
: config.title,
}))}
activeTabId={activeChartId}
onTabChange={setActiveChartId}
/>
) : (
<Card>
<CardContent>
<Empty className='border-none py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<RefreshCw className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No data available</EmptyTitle>
<EmptyDescription>
No metrics data exists for this range yet. Try a broader
range.
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
)}
{summaryLoading ? (
<SectionLoading label='summary' />
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<DashboardInsights summary={summaryData} />
{revenueByModelLoading ? (
<SectionLoading label='revenue by model' />
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
) : null}
{errorLoading ? (
<SectionLoading label='errors' />
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
</div>
</AppPageShell>
);
+145 -125
View File
@@ -8,7 +8,6 @@ import {
FileTextIcon,
LayoutDashboardIcon,
LogOutIcon,
MoreHorizontalIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
ServerIcon,
@@ -22,12 +21,12 @@ import { Button } from '@/components/ui/button';
import { CurrencyToggle } from '@/components/currency-toggle';
import { ThemeToggle } from '@/components/theme-toggle';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
interface AppPageShellProps {
@@ -45,9 +44,6 @@ const NAV_ITEMS = [
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
] as const;
const MOBILE_NAV_TAB_WIDTH_CLASS = 'auto-cols-[22%]';
const MOBILE_NAV_TAB_WIDTH_CLASS_XS = 'max-[359px]:auto-cols-[31%]';
function isActivePath(pathname: string, itemUrl: string): boolean {
if (itemUrl === '/') {
return pathname === '/';
@@ -64,7 +60,7 @@ export function AppPageShell({
const pathname = usePathname();
const router = useRouter();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileMoreOpen, setIsMobileMoreOpen] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const handleLogout = async (): Promise<void> => {
try {
@@ -82,36 +78,43 @@ export function AppPageShell({
<div className='flex min-h-dvh w-full min-w-0 overflow-x-clip md:h-full'>
<aside
className={cn(
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-200 md:flex md:h-full md:flex-col md:overflow-y-auto',
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-300 ease-in-out md:flex md:h-full md:flex-col md:overflow-y-auto',
isSidebarCollapsed ? 'w-16 px-2' : 'w-60 px-4'
)}
>
<div className={cn('px-1', isSidebarCollapsed && 'px-0')}>
<div
className={cn(
'flex items-center',
isSidebarCollapsed ? 'justify-center' : 'justify-start gap-2'
)}
>
{isSidebarCollapsed ? null : (
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded-sm'
/>
<h1 className='text-lg font-semibold tracking-tight'>
<div
className={cn(
'px-1 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed && 'px-0'
)}
>
<div className='flex items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='shrink-0 rounded-sm'
/>
<div
className={cn(
'min-w-0 overflow-hidden transition-[max-width,opacity,transform] duration-300 ease-in-out',
isSidebarCollapsed
? 'max-w-0 -translate-x-1 opacity-0'
: 'max-w-[11rem] translate-x-0 opacity-100'
)}
>
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
Routstr Node
</h1>
</div>
)}
</div>
<Button
variant='ghost'
size='icon'
className={cn(
'text-muted-foreground hover:text-foreground h-8 w-8',
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
)}
onClick={() => setIsSidebarCollapsed((current) => !current)}
@@ -146,19 +149,24 @@ export function AppPageShell({
asChild
variant={active ? 'outline' : 'ghost'}
className={cn(
'h-10 rounded-lg',
'h-10 rounded-lg transition-[width,padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'mx-auto w-10 justify-center px-0'
: 'w-full justify-start'
)}
>
<Link href={item.url}>
<Icon className='h-4 w-4' />
{isSidebarCollapsed ? (
<span className='sr-only'>{item.title}</span>
) : (
item.title
)}
<Icon className='h-4 w-4 shrink-0' />
<span
className={cn(
'overflow-hidden whitespace-nowrap transition-[max-width,opacity,margin] duration-300 ease-in-out',
isSidebarCollapsed
? 'ml-0 max-w-0 opacity-0'
: 'ml-0.5 max-w-[9rem] opacity-100'
)}
>
{item.title}
</span>
</Link>
</Button>
);
@@ -167,7 +175,7 @@ export function AppPageShell({
<div
className={cn(
'mt-auto pt-3',
'mt-auto pt-3 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'flex flex-col items-center space-y-1.5'
: 'space-y-2'
@@ -224,9 +232,21 @@ export function AppPageShell({
</aside>
<section className='relative flex w-full min-w-0 flex-1 flex-col overflow-x-clip md:h-full md:min-h-0'>
<div className='bg-background/80 supports-[backdrop-filter]:bg-background/72 sticky top-0 z-30 flex items-center gap-2 px-3 py-2 backdrop-blur-xl md:hidden'>
<Button
type='button'
variant='outline'
size='sm'
className='h-9 gap-2 rounded-lg px-3'
onClick={() => setIsMobileSidebarOpen(true)}
>
<PanelLeftOpenIcon className='h-4 w-4' />
Menu
</Button>
</div>
<main
className={cn(
'pb-mobile-nav w-full min-w-0 flex-1 overflow-x-clip p-3 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
'w-full min-w-0 flex-1 overflow-x-clip p-3 pb-4 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
contentClassName,
className
)}
@@ -236,96 +256,96 @@ export function AppPageShell({
</section>
</div>
<div className='pointer-events-none fixed inset-x-0 bottom-0 z-40 px-4 pb-[calc(0.35rem+env(safe-area-inset-bottom))] md:hidden'>
<nav className='border-border/65 bg-background/80 supports-[backdrop-filter]:bg-background/72 pointer-events-auto mx-auto w-full max-w-[34rem] overflow-x-auto overscroll-x-contain rounded-[1.5rem] border shadow-[0_-16px_36px_-22px_rgba(0,0,0,0.9)] backdrop-blur-2xl [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
<div
className={cn(
'grid min-w-full snap-x snap-mandatory grid-flow-col gap-2 p-2.5',
MOBILE_NAV_TAB_WIDTH_CLASS,
MOBILE_NAV_TAB_WIDTH_CLASS_XS
)}
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
return (
<Button
key={`mobile-bottom-${item.url}`}
asChild
size='sm'
variant={active ? 'outline' : 'ghost'}
className='h-12 w-full snap-start flex-col gap-0.5 rounded-2xl px-2 text-[10px] leading-none'
>
<Link href={item.url}>
<Icon className='h-4 w-4' />
<span className='truncate'>{item.title}</span>
</Link>
</Button>
);
})}
<Button
type='button'
size='sm'
variant={isMobileMoreOpen ? 'outline' : 'ghost'}
className='h-12 w-full snap-start flex-col gap-0.5 rounded-2xl px-2 text-[10px] leading-none'
onClick={() => setIsMobileMoreOpen(true)}
>
<MoreHorizontalIcon className='h-4 w-4' />
<span className='truncate'>More</span>
</Button>
</div>
</nav>
</div>
<Drawer open={isMobileMoreOpen} onOpenChange={setIsMobileMoreOpen}>
<DrawerContent className='md:hidden'>
<DrawerHeader className='px-4 pb-2 text-left'>
<DrawerTitle>More</DrawerTitle>
<DrawerDescription>
Account and appearance settings.
</DrawerDescription>
</DrawerHeader>
<div className='space-y-4 px-4 pb-4'>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs font-semibold tracking-[0.08em] uppercase'>
Preferences
</p>
<div className='grid grid-cols-2 gap-2'>
<CurrencyToggle
menuSide='top'
menuAlign='start'
className='h-11 w-full justify-between rounded-lg px-3'
/>
<ThemeToggle
menuSide='top'
menuAlign='end'
className='h-11 w-full justify-between rounded-lg px-3'
/>
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
<SheetContent
side='left'
showCloseButton={false}
className='w-[min(88vw,18rem)] p-0 md:hidden'
>
<SheetTitle className='sr-only'>Navigation sidebar</SheetTitle>
<SheetDescription className='sr-only'>
Browse admin pages and access sidebar controls.
</SheetDescription>
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border/60 px-4 pt-4 pb-3'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-2'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded-sm'
/>
<p className='truncate text-base font-medium tracking-tight'>
Routstr Node
</p>
</div>
<SheetClose asChild>
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground h-8 w-8 shrink-0'
>
<PanelLeftCloseIcon className='h-4 w-4' />
<span className='sr-only'>Close sidebar</span>
</Button>
</SheetClose>
</div>
</div>
<div className='flex h-full min-h-0 flex-col px-3 pb-3'>
<nav className='space-y-1.5 py-3'>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
<div className='space-y-2'>
<p className='text-muted-foreground text-xs font-semibold tracking-[0.08em] uppercase'>
Account
</p>
<Button
variant='outline'
size='sm'
onClick={async () => {
setIsMobileMoreOpen(false);
await handleLogout();
}}
className='h-11 w-full justify-start gap-2 rounded-lg px-3'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
return (
<Button
key={`mobile-sidebar-${item.url}`}
asChild
variant={active ? 'outline' : 'ghost'}
className='h-10 w-full justify-start rounded-lg'
>
<Link
href={item.url}
onClick={() => setIsMobileSidebarOpen(false)}
>
<Icon className='h-4 w-4' />
{item.title}
</Link>
</Button>
);
})}
</nav>
<div className='border-border/60 bg-card/30 mt-auto space-y-1 rounded-lg border p-1'>
<CurrencyToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<ThemeToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<Button
variant='ghost'
size='sm'
onClick={async () => {
setIsMobileSidebarOpen(false);
await handleLogout();
}}
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-9 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</SheetContent>
</Sheet>
</div>
);
}
+2 -1
View File
@@ -21,7 +21,8 @@ export function DashboardBalanceSummary({
queryFn: async () => {
return WalletService.getDetailedBalances();
},
refetchInterval: 30000,
refetchInterval: 900000,
staleTime: 300000,
});
const calculateTotals = (balances: BalanceDetail[]) => {
+26 -4
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import type { AdminModelGroup } from '@/lib/api/services/admin';
import type { DisplayUnit } from '@/lib/types/units';
@@ -80,13 +81,34 @@ export function ModelProviderSection({
const someProviderSelected = providerModels.some((model) =>
selectedModels.has(model.id)
);
const keyedProviderModels = useMemo(() => {
const seenKeys = new Map<string, number>();
return providerModels.map((model) => {
const baseKey = [
provider,
model.id,
model.provider_id ?? '',
model.full_name,
model.api_key_type,
].join('::');
const seenCount = seenKeys.get(baseKey) ?? 0;
seenKeys.set(baseKey, seenCount + 1);
return {
model,
renderKey:
seenCount === 0 ? baseKey : `${baseKey}::duplicate-${seenCount}`,
};
});
}, [provider, providerModels]);
if (filterProvider) {
return (
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{providerModels.map((model) => (
{keyedProviderModels.map(({ model, renderKey }) => (
<ModelItemCard
key={model.id}
key={renderKey}
model={model}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
@@ -195,9 +217,9 @@ export function ModelProviderSection({
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{providerModels.map((model) => (
{keyedProviderModels.map(({ model, renderKey }) => (
<ModelItemCard
key={model.id}
key={renderKey}
model={model}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
+171 -92
View File
@@ -1,122 +1,201 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useCallback, useMemo } from 'react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { ModelRevenueData } from '@/lib/api/services/admin';
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { formatFromMsat, convertToMsat } from '@/lib/currency';
import { convertToMsat, formatFromMsat } from '@/lib/currency';
import { useIsMobile } from '@/hooks/use-mobile';
import type { DisplayUnit } from '@/lib/types/units';
interface RevenueByModelTableProps {
models: ModelRevenueData[];
totalRevenue: number;
displayUnit: DisplayUnit;
usdPerSat: number | null;
}
function truncateModelName(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 1)}…`;
}
export function RevenueByModelTable({
models,
totalRevenue,
displayUnit,
usdPerSat,
}: RevenueByModelTableProps) {
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 isMobile = useIsMobile();
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
return 'sat';
}
return displayUnit;
}, [displayUnit, usdPerSat]);
const unitLabel = revenueDisplayUnit === 'usd' ? 'USD' : revenueDisplayUnit;
const compactNumber = useMemo(
() =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}),
[]
);
const convertSatsToDisplay = useCallback(
(sats: number): number => {
if (revenueDisplayUnit === 'msat') {
return sats * 1000;
}
if (revenueDisplayUnit === 'usd') {
return sats * (usdPerSat ?? 0);
}
return sats;
},
[revenueDisplayUnit, usdPerSat]
);
const formatAmount = (sats: number) =>
formatFromMsat(convertToMsat(sats, 'sat'), displayUnit, usdPerSat);
formatFromMsat(convertToMsat(sats, 'sat'), revenueDisplayUnit, usdPerSat);
const formatCompactAmount = (value: number): string => {
const compact = compactNumber.format(value);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${unitLabel}`;
};
const totalCollectedRevenue = models.reduce(
(sum, model) => sum + model.revenue_sats,
0
);
const totalOperationalNet = models.reduce(
(sum, model) => sum + model.net_revenue_sats,
0
);
const chartData = useMemo(
() =>
[...models]
.sort((a, b) => b.revenue_sats - a.revenue_sats)
.slice(0, 12)
.map((model) => ({
model: model.model,
modelLabel: truncateModelName(model.model, isMobile ? 16 : 28),
revenueDisplay: convertSatsToDisplay(model.revenue_sats),
})),
[models, isMobile, convertSatsToDisplay]
);
const chartConfig: ChartConfig = {
revenueDisplay: {
label: 'Revenue',
color: 'var(--chart-1)',
},
};
if (chartData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
</CardHeader>
<CardContent className='text-muted-foreground text-sm'>
No model data available
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className='text-muted-foreground text-sm'>
Total Revenue:{' '}
Total Collected Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalRevenue)}
{formatAmount(totalCollectedRevenue)}
</span>
</p>
<p className='text-muted-foreground text-xs'>
Operational Net:{' '}
<span className='text-foreground font-mono'>
{formatAmount(totalOperationalNet)}
</span>
</p>
</CardHeader>
<CardContent>
<Table className='min-w-[680px] sm:min-w-[860px]'>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className='text-right'>Requests</TableHead>
<TableHead className='text-right'>Successful</TableHead>
<TableHead className='text-right'>Failed</TableHead>
<TableHead className='text-right'>Revenue</TableHead>
<TableHead className='w-[140px]'>Share</TableHead>
<TableHead className='text-right'>Refunds</TableHead>
<TableHead className='text-right'>Net Revenue</TableHead>
<TableHead className='text-right'>Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className='text-muted-foreground text-center'
>
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => {
const share =
totalRevenue > 0
? (model.revenue_sats / totalRevenue) * 100
: 0;
return (
<TableRow key={model.model}>
<TableCell className='font-medium'>{model.model}</TableCell>
<TableCell className='text-right font-mono'>
{model.requests}
</TableCell>
<TableCell className='text-right font-mono tabular-nums'>
{model.successful}
</TableCell>
<TableCell className='text-destructive text-right font-mono tabular-nums'>
{model.failed}
</TableCell>
<TableCell className='text-right font-mono'>
{formatAmount(model.revenue_sats)}
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Progress value={share} className='h-2' />
<span className='text-muted-foreground w-8 text-right text-xs'>
{share.toFixed(0)}%
<ChartContainer className='h-[360px] w-full' config={chartConfig}>
<BarChart
data={chartData}
layout='vertical'
margin={{
top: 8,
right: isMobile ? 8 : 28,
left: isMobile ? 8 : 28,
bottom: 8,
}}
>
<CartesianGrid horizontal={false} className='stroke-muted/30' />
<XAxis
type='number'
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
compactNumber.format(
typeof value === 'number' ? value : Number(value || 0)
)
}
/>
<YAxis
type='category'
dataKey='modelLabel'
tickLine={false}
axisLine={false}
width={isMobile ? 110 : 220}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(label) => String(label)}
formatter={(value, name) => {
const numericValue =
typeof value === 'number' ? value : Number(value || 0);
return (
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto] gap-x-4'>
<span className='text-muted-foreground truncate pr-1'>
{name}
</span>
<span className='text-foreground text-right font-mono font-medium tabular-nums'>
{Number.isFinite(numericValue)
? formatCompactAmount(numericValue)
: '-'}
</span>
</div>
</TableCell>
<TableCell className='text-destructive text-right font-mono tabular-nums'>
{formatAmount(model.refunds_sats)}
</TableCell>
<TableCell className='text-right font-mono font-semibold tabular-nums'>
{formatAmount(model.net_revenue_sats)}
</TableCell>
<TableCell className='text-muted-foreground text-right font-mono tabular-nums'>
{formatAmount(model.avg_revenue_per_request)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
);
}}
/>
}
/>
<Bar
dataKey='revenueDisplay'
name={`Revenue (${unitLabel})`}
fill='var(--color-revenueDisplay)'
radius={[0, 6, 6, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
+952
View File
@@ -0,0 +1,952 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ExpandIcon, Minimize2Icon } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
} from '@/components/ui/chart';
import { useIsMobile } from '@/hooks/use-mobile';
import { type ModelUsageMix } from '@/lib/api/services/admin';
import type { DisplayUnit } from '@/lib/types/units';
import { cn } from '@/lib/utils';
interface TopModelsUsageChartProps {
mix: ModelUsageMix;
displayUnit: DisplayUnit;
usdPerSat: number | null;
}
type ChartMode = 'requests' | 'revenue' | 'tokens';
interface TooltipRow {
color: string;
dataKey: string;
label: string;
value: number;
}
type LeaderboardTrend = 'up' | 'down' | 'flat' | 'new';
interface LeaderboardRow {
chartDataKey: string | null;
displayName: string;
model: string;
provider: string;
rank: number;
totalRaw: number;
trend: LeaderboardTrend;
trendPercent: number | null;
}
function parseBucketDate(value: string): Date | null {
const normalized = value.includes('T')
? value
: `${value.replace(' ', 'T')}Z`;
const parsed = new Date(normalized);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
const fallback = new Date(value);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
function hueFromString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 360;
}
function getSeriesColor(model: string, index: number): string {
const palette = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'#f59e0b',
'#06b6d4',
'#8b5cf6',
'#f97316',
'#34d399',
];
if (index < palette.length) {
return palette[index];
}
const hue = (hueFromString(model) + index * 23) % 360;
return `hsl(${hue} 70% 56%)`;
}
function formatTooltipTimestamp(
label: string,
intervalMinutes: number,
hoursBack: number
): string {
const date = parseBucketDate(label);
if (!date) {
return label;
}
const shouldShowTime = intervalMinutes <= 6 * 60 || hoursBack <= 48;
if (shouldShowTime) {
return date.toLocaleString([], {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleString([], {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
function formatAxisTimestamp(
timestamp: string,
hasMultipleDays: boolean,
intervalMinutes: number,
hoursBack: number
): string {
const date = parseBucketDate(timestamp);
if (!date) {
return '';
}
const shouldShowTime = intervalMinutes <= 6 * 60 || hoursBack <= 48;
if (shouldShowTime && hasMultipleDays) {
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
if (shouldShowTime) {
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
if (intervalMinutes >= 24 * 60 && hoursBack >= 24 * 180) {
return date.toLocaleDateString([], {
month: 'short',
year: '2-digit',
});
}
if (hasMultipleDays) {
return date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
});
}
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function convertRevenueMsats(
amountMsats: number,
displayUnit: DisplayUnit,
usdPerSat: number | null
): number {
if (displayUnit === 'msat') {
return amountMsats;
}
const sats = amountMsats / 1000;
if (displayUnit === 'usd') {
return sats * (usdPerSat ?? 0);
}
return sats;
}
function prettifyProvider(provider: string): string {
const normalized = provider.trim().toLowerCase();
const aliasMap: Record<string, string> = {
'x ai': 'x-ai',
xai: 'x-ai',
'z ai': 'z-ai',
zai: 'z-ai',
open_ai: 'openai',
openai: 'openai',
};
if (aliasMap[normalized]) {
return aliasMap[normalized];
}
return normalized.replace(/[_-]+/g, ' ');
}
function detectProviderFromModel(model: string): string {
const value = model.toLowerCase();
if (value.includes('claude')) return 'anthropic';
if (value.includes('gpt') || value.includes('openai')) return 'openai';
if (value.includes('gemini')) return 'google';
if (
value.includes('grok') ||
value.includes('x-ai') ||
value.includes('xai')
) {
return 'x-ai';
}
if (value.includes('deepseek')) return 'deepseek';
if (value.includes('minimax')) return 'minimax';
if (value.includes('kimi') || value.includes('moonshot')) return 'moonshot';
if (value.includes('mistral')) return 'mistral';
if (value.includes('qwen') || value.includes('alibaba')) return 'alibaba';
if (
value.includes('glm') ||
value.includes('z-ai') ||
value.includes('z ai')
) {
return 'z-ai';
}
return 'unknown';
}
function getModelPresentation(model: string): {
displayName: string;
provider: string;
} {
const trimmed = model.trim();
const slashIndex = trimmed.indexOf('/');
if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
const provider = prettifyProvider(trimmed.slice(0, slashIndex));
const displayName = trimmed.slice(slashIndex + 1);
return { displayName, provider };
}
return {
displayName: trimmed,
provider: detectProviderFromModel(trimmed),
};
}
export function TopModelsUsageChart({
mix,
displayUnit,
usdPerSat,
}: TopModelsUsageChartProps) {
const [mode, setMode] = useState<ChartMode>('requests');
const [hoveredSeriesKey, setHoveredSeriesKey] = useState<string | null>(null);
const [isChartPointerInside, setIsChartPointerInside] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showAllMobileRows, setShowAllMobileRows] = useState(false);
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const compactNumber = useMemo(
() =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 2,
}),
[]
);
const mixTopModels = useMemo(
() => (Array.isArray(mix.top_models) ? mix.top_models : []),
[mix.top_models]
);
const mixMetrics = useMemo(
() => (Array.isArray(mix.metrics) ? mix.metrics : []),
[mix.metrics]
);
const chartModels = useMemo(() => mixTopModels.slice(0, 20), [mixTopModels]);
const leaderboardModels = useMemo(
() => mixTopModels.slice(0, 20),
[mixTopModels]
);
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
return 'sat';
}
return displayUnit;
}, [displayUnit, usdPerSat]);
const revenueUnitLabel =
revenueDisplayUnit === 'usd'
? 'USD'
: revenueDisplayUnit === 'sat'
? 'sats'
: revenueDisplayUnit === 'msat'
? 'msats'
: revenueDisplayUnit;
const series = useMemo(
() =>
chartModels.map((model, index) => ({
requestsKey: `model_req_${index}`,
revenueKey: `model_rev_${index}`,
tokensKey: `model_tok_${index}`,
label: model,
color: getSeriesColor(model, index),
})),
[chartModels]
);
const chartData = useMemo(
() =>
mixMetrics.map((metric) => {
const modelCounts = metric.model_counts ?? {};
const modelRevenue = metric.model_revenue_msats ?? {};
const modelTokens = metric.model_tokens ?? {};
const point: Record<string, number | string> = {
timestamp: metric.timestamp,
total_successful: metric.total_successful,
total_revenue_msats: metric.total_revenue_msats,
total_tokens: metric.total_tokens,
others_requests: metric.others,
others_revenue_msats: metric.others_revenue_msats,
others_tokens: metric.others_tokens,
};
for (const item of series) {
point[item.requestsKey] = modelCounts[item.label] ?? 0;
point[item.revenueKey] = modelRevenue[item.label] ?? 0;
point[item.tokensKey] = modelTokens[item.label] ?? 0;
}
return point;
}),
[mixMetrics, series]
);
const hasMultipleDays = useMemo(() => {
const daySet = new Set(
chartData.map((item) =>
parseBucketDate(String(item.timestamp))?.toDateString()
)
);
return daySet.size > 1;
}, [chartData]);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
for (const item of series) {
config[item.requestsKey] = {
label: item.label,
color: item.color,
};
config[item.revenueKey] = {
label: item.label,
color: item.color,
};
config[item.tokensKey] = {
label: item.label,
color: item.color,
};
}
config.others_requests = {
label: 'Others',
color: '#6b7280',
};
config.others_revenue_msats = {
label: 'Others',
color: '#6b7280',
};
config.others_tokens = {
label: 'Others',
color: '#6b7280',
};
return config;
}, [series]);
useEffect(() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}, [mode]);
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 top models chart fullscreen', error);
}
};
const formatValue = (rawValue: number): string => {
if (mode === 'requests') {
return compactNumber.format(rawValue);
}
if (mode === 'tokens') {
return compactNumber.format(rawValue);
}
const converted = convertRevenueMsats(
rawValue,
revenueDisplayUnit,
usdPerSat
);
const compact = compactNumber.format(converted);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${revenueUnitLabel}`;
};
const activeSeries = series.map((item) => ({
dataKey:
mode === 'requests'
? item.requestsKey
: mode === 'revenue'
? item.revenueKey
: item.tokensKey,
name: item.label,
color: item.color,
}));
const othersKey = (
mode === 'requests'
? 'others_requests'
: mode === 'revenue'
? 'others_revenue_msats'
: 'others_tokens'
) as 'others_requests' | 'others_revenue_msats' | 'others_tokens';
const activeSeriesKeys = [
...activeSeries.map((item) => item.dataKey),
othersKey,
];
const activeHoverSeriesKey =
hoveredSeriesKey && activeSeriesKeys.includes(hoveredSeriesKey)
? hoveredSeriesKey
: null;
const getSeriesOpacity = (dataKey: string): number =>
activeHoverSeriesKey && activeHoverSeriesKey !== dataKey ? 0.18 : 1;
const formatLeaderboardTotal = (rawValue: number): string => {
if (mode === 'requests') {
return `${compactNumber.format(rawValue)} requests`;
}
if (mode === 'tokens') {
return `${compactNumber.format(rawValue)} tokens`;
}
const converted = convertRevenueMsats(
rawValue,
revenueDisplayUnit,
usdPerSat
);
const compact = compactNumber.format(converted);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${revenueUnitLabel}`;
};
const formatTrendPercent = (value: number): string => {
const abs = Math.abs(value);
const rounded = abs >= 10 ? abs.toFixed(0) : abs.toFixed(1);
return rounded.replace(/\.0$/, '');
};
const leaderboardRows = useMemo<LeaderboardRow[]>(() => {
if (leaderboardModels.length === 0 || mixMetrics.length === 0) {
return [];
}
const windowSize = Math.floor(mixMetrics.length / 2);
const previousMetrics =
windowSize > 0 ? mixMetrics.slice(-windowSize * 2, -windowSize) : [];
const currentMetrics =
windowSize > 0 ? mixMetrics.slice(-windowSize) : mixMetrics;
const rows = leaderboardModels
.map((model) => {
const readMetric = (metric: (typeof mixMetrics)[number]): number =>
mode === 'requests'
? ((metric.model_counts ?? {})[model] ?? 0)
: mode === 'revenue'
? ((metric.model_revenue_msats ?? {})[model] ?? 0)
: ((metric.model_tokens ?? {})[model] ?? 0);
const totalRaw = mixMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const previousRaw = previousMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const currentRaw = currentMetrics.reduce(
(sum, metric) => sum + readMetric(metric),
0
);
const trendPercent =
previousRaw > 0
? ((currentRaw - previousRaw) / previousRaw) * 100
: null;
let trend: LeaderboardTrend = 'flat';
if (previousRaw <= 0 && currentRaw > 0) {
trend = 'new';
} else if (trendPercent !== null && trendPercent > 0.5) {
trend = 'up';
} else if (trendPercent !== null && trendPercent < -0.5) {
trend = 'down';
}
const presentation = getModelPresentation(model);
const matchingSeries = series.find((item) => item.label === model);
const chartDataKey = matchingSeries
? mode === 'requests'
? matchingSeries.requestsKey
: mode === 'revenue'
? matchingSeries.revenueKey
: matchingSeries.tokensKey
: null;
return {
chartDataKey,
displayName: presentation.displayName,
model,
provider: presentation.provider,
rank: 0,
totalRaw,
trend,
trendPercent,
} satisfies LeaderboardRow;
})
.filter((row) => row.totalRaw > 0)
.sort((a, b) => b.totalRaw - a.totalRaw)
.slice(0, 20)
.map((row, index) => ({
...row,
rank: index + 1,
}));
return rows;
}, [leaderboardModels, mixMetrics, mode, series]);
const visibleLeaderboardRows = useMemo(
() =>
isMobile && !showAllMobileRows
? leaderboardRows.slice(0, 10)
: leaderboardRows,
[isMobile, leaderboardRows, showAllMobileRows]
);
const hiddenLeaderboardCount = Math.max(
0,
leaderboardRows.length - visibleLeaderboardRows.length
);
useEffect(() => {
setShowAllMobileRows(false);
}, [isMobile, mode, mixMetrics.length]);
if (chartData.length === 0) {
return null;
}
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'>
<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'>
Model Usage
</CardTitle>
<p className='text-muted-foreground mt-1 text-xs sm:text-sm'>
Stacked requests, revenue, or tokens by model (
{mix.interval_minutes}m buckets).
</p>
</div>
<div className='flex items-center gap-2 sm:shrink-0'>
<div className='bg-muted/25 border-border/60 flex items-center gap-1 rounded-full border p-1'>
<Button
type='button'
size='sm'
variant={mode === 'requests' ? 'secondary' : 'ghost'}
onClick={() => setMode('requests')}
className='h-7 rounded-full px-2.5 text-xs'
>
Requests
</Button>
<Button
type='button'
size='sm'
variant={mode === 'revenue' ? 'secondary' : 'ghost'}
onClick={() => setMode('revenue')}
className='h-7 rounded-full px-2.5 text-xs'
>
Revenue
</Button>
<Button
type='button'
size='sm'
variant={mode === 'tokens' ? 'secondary' : 'ghost'}
onClick={() => setMode('tokens')}
className='h-7 rounded-full px-2.5 text-xs'
>
Tokens
</Button>
</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>
</div>
</CardHeader>
<CardContent className='space-y-3 sm:space-y-4'>
<ChartContainer
config={chartConfig}
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]'
)}
onMouseLeave={() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}}
>
<BarChart
data={chartData}
onMouseEnter={() => setIsChartPointerInside(true)}
onMouseMove={() => setIsChartPointerInside(true)}
onMouseLeave={() => {
setHoveredSeriesKey(null);
setIsChartPointerInside(false);
}}
margin={{
top: 12,
right: isMobile ? 8 : 18,
left: isMobile ? 0 : 8,
bottom: 0,
}}
>
<CartesianGrid vertical={false} className='stroke-muted/30' />
<XAxis
dataKey='timestamp'
tickLine={false}
axisLine={false}
minTickGap={isMobile ? 14 : 24}
tickFormatter={(value) =>
formatAxisTimestamp(
String(value),
hasMultipleDays,
mix.interval_minutes,
mix.hours_back
)
}
/>
<YAxis
tickLine={false}
axisLine={false}
width={isMobile ? 40 : 56}
tickFormatter={(value) =>
formatValue(
typeof value === 'number' ? value : Number(value || 0)
)
}
/>
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
if (!isChartPointerInside || !active || !payload?.length) {
return null;
}
const rows = payload
.map((entry) => {
const value =
typeof entry.value === 'number'
? entry.value
: Number(entry.value || 0);
return {
color: String(entry.color || '#6b7280'),
dataKey: String(entry.dataKey || ''),
label: String(entry.name || ''),
value,
} satisfies TooltipRow;
})
.filter(
(row) => Number.isFinite(row.value) && row.value > 0
)
.sort((a, b) => b.value - a.value);
const total = rows.reduce((sum, row) => sum + row.value, 0);
if (rows.length === 0) {
return null;
}
return (
<div className='border-border/50 bg-background min-w-[220px] rounded-lg border px-2.5 py-2 text-xs shadow-xl'>
<p className='text-foreground mb-2 text-sm font-medium'>
{formatTooltipTimestamp(
String(label || ''),
mix.interval_minutes,
mix.hours_back
)}
</p>
<div className='space-y-1.5'>
{rows.map((row) => (
<div
key={row.label}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3',
activeHoverSeriesKey &&
row.dataKey !== activeHoverSeriesKey &&
'opacity-45'
)}
>
<span className='text-muted-foreground flex min-w-0 items-center gap-2'>
<span
className='h-2.5 w-1.5 shrink-0 rounded-sm'
style={{ backgroundColor: row.color }}
/>
<span className='truncate'>{row.label}</span>
</span>
<span className='text-foreground font-mono tabular-nums'>
{formatValue(row.value)}
</span>
</div>
))}
</div>
<div className='border-border/60 mt-2 border-t pt-2'>
<div className='grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3'>
<span className='text-muted-foreground'>Total</span>
<span className='text-foreground font-mono font-semibold tabular-nums'>
{formatValue(total)}
</span>
</div>
</div>
</div>
);
}}
/>
{activeSeries.map((item) => (
<Bar
key={item.dataKey}
dataKey={item.dataKey}
name={item.name}
stackId='models'
fill={item.color}
fillOpacity={getSeriesOpacity(item.dataKey)}
maxBarSize={44}
onMouseEnter={() => setHoveredSeriesKey(item.dataKey)}
onMouseLeave={() => setHoveredSeriesKey(null)}
/>
))}
<Bar
dataKey={othersKey}
name='Others'
stackId='models'
fill='#6b7280'
fillOpacity={getSeriesOpacity(othersKey)}
maxBarSize={44}
onMouseEnter={() => setHoveredSeriesKey(othersKey)}
onMouseLeave={() => setHoveredSeriesKey(null)}
/>
</BarChart>
</ChartContainer>
<div className='border-border/60 space-y-2 border-t pt-3 sm:pt-4'>
<div className='flex items-center justify-between gap-3'>
<p className='text-muted-foreground text-xs font-medium'>
Top models
</p>
<p className='text-muted-foreground hidden text-xs sm:block'>
Change vs prior period
</p>
</div>
{leaderboardRows.length > 0 ? (
<div className='divide-border/40 divide-y'>
{visibleLeaderboardRows.map((row) => {
const rowIsLinked = Boolean(row.chartDataKey);
const rowIsActive =
row.chartDataKey !== null &&
activeHoverSeriesKey === row.chartDataKey;
const rowIsDimmed =
Boolean(activeHoverSeriesKey) &&
row.chartDataKey !== null &&
row.chartDataKey !== activeHoverSeriesKey;
let trendLabel = '0%';
let trendClass = 'text-muted-foreground';
if (row.trend === 'new') {
trendLabel = 'new';
trendClass = 'text-blue-500';
} else if (row.trend === 'up' && row.trendPercent !== null) {
trendLabel = `↑${formatTrendPercent(row.trendPercent)}%`;
trendClass = 'text-emerald-500';
} else if (
row.trend === 'down' &&
row.trendPercent !== null
) {
trendLabel = `↓${formatTrendPercent(row.trendPercent)}%`;
trendClass = 'text-red-500';
} else if (row.trendPercent !== null) {
trendLabel = `${formatTrendPercent(row.trendPercent)}%`;
}
return (
<div
key={row.model}
className={cn(
'grid rounded-md px-2 py-2 text-xs',
isMobile
? 'grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2.5'
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center',
rowIsLinked &&
'hover:bg-muted/25 cursor-pointer transition',
rowIsActive && 'bg-muted/30',
rowIsDimmed && 'opacity-45'
)}
title={row.model}
onMouseEnter={() => {
if (row.chartDataKey) {
setHoveredSeriesKey(row.chartDataKey);
}
}}
onMouseLeave={() => {
if (row.chartDataKey) {
setHoveredSeriesKey(null);
}
}}
>
<span
className={cn(
'text-muted-foreground w-5 text-right font-mono tabular-nums',
isMobile && 'pt-0.5 text-[11px]'
)}
>
{row.rank}.
</span>
{isMobile ? (
<>
<div className='min-w-0 pt-0.5'>
<p className='leading-snug break-words'>
<span className='font-medium'>
{row.displayName}
</span>{' '}
<span className='text-muted-foreground'>
by {row.provider}
</span>
</p>
</div>
<div className='flex min-w-[5.75rem] flex-col items-end gap-1 text-right'>
<span className='text-foreground font-mono tabular-nums'>
{formatLeaderboardTotal(row.totalRaw)}
</span>
<span
className={cn(
'shrink-0 text-[11px] font-medium',
trendClass
)}
>
{trendLabel}
</span>
</div>
</>
) : (
<>
<div className='min-w-0'>
<span className='truncate font-medium'>
{row.displayName}
</span>{' '}
<span className='text-muted-foreground truncate'>
by {row.provider}
</span>
</div>
<span className='text-foreground font-mono tabular-nums'>
{formatLeaderboardTotal(row.totalRaw)}
</span>
<span
className={cn('text-right font-medium', trendClass)}
>
{trendLabel}
</span>
</>
)}
</div>
);
})}
</div>
) : (
<p className='text-muted-foreground text-xs'>
No model totals available for this range.
</p>
)}
{isMobile && hiddenLeaderboardCount > 0 ? (
<Button
type='button'
variant='ghost'
size='sm'
className='text-muted-foreground hover:text-foreground h-8 px-0 text-xs font-medium'
onClick={() => setShowAllMobileRows(true)}
>
Show {hiddenLeaderboardCount} more
</Button>
) : null}
{isMobile && showAllMobileRows && leaderboardRows.length > 10 ? (
<Button
type='button'
variant='ghost'
size='sm'
className='text-muted-foreground hover:text-foreground h-8 px-0 text-xs font-medium'
onClick={() => setShowAllMobileRows(false)}
>
Show less
</Button>
) : null}
</div>
</CardContent>
</Card>
</div>
);
}
+2 -2
View File
@@ -37,7 +37,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot='sheet-overlay'
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/10 duration-200 supports-backdrop-filter:backdrop-blur-xs',
className
)}
{...props}
@@ -62,7 +62,7 @@ function SheetContent({
data-slot='sheet-content'
data-side={side}
className={cn(
'bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[side=right]:data-[state=closed]:slide-out-to-right-10 data-[side=right]:data-[state=open]:slide-in-from-right-10 data-[side=left]:data-[state=closed]:slide-out-to-left-10 data-[side=left]:data-[state=open]:slide-in-from-left-10 data-[side=top]:data-[state=closed]:slide-out-to-top-10 data-[side=top]:data-[state=open]:slide-in-from-top-10 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[side=bottom]:data-[state=closed]:slide-out-to-bottom-10 data-[side=bottom]:data-[state=open]:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
className
)}
{...props}
+51 -13
View File
@@ -24,7 +24,9 @@ interface UsageMetricsChartProps {
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;
@@ -35,7 +37,9 @@ export function UsageMetricsChart({
title,
description,
dataKeys,
totals,
metricType = 'count',
currencyUnitLabel = 'sats',
tabs,
activeTabId,
onTabChange,
@@ -83,22 +87,56 @@ export function UsageMetricsChart({
const formatMetricValue = (value: number): string => {
const formatted = compactNumber.format(value);
return metricType === 'currency' ? `${formatted} sats` : formatted;
return metricType === 'currency'
? `${formatted} ${currencyUnitLabel}`
: formatted;
};
const metricChips = dataKeys.map((dataKey) => {
const numericValue = data.reduce((sum, point) => {
const rawValue = point?.[dataKey.key];
const value =
typeof rawValue === 'number' ? rawValue : Number(rawValue || 0);
return Number.isFinite(value) ? sum + value : sum;
}, 0);
const metricTotals = useMemo(() => {
const fallbackTotals = dataKeys.reduce<Record<string, number>>(
(acc, dataKey) => {
acc[dataKey.key] = 0;
return acc;
},
{}
);
return {
...dataKey,
value: Number.isFinite(numericValue) ? numericValue : 0,
};
});
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)
+20 -2
View File
@@ -35,6 +35,10 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
const formatAmount = (msat: number) =>
formatFromMsat(msat, displayUnit, usdPerSat);
const totalTokens = Number(summary.total_tokens ?? 0);
const avgTotalTokensPerCompletion = Number(
summary.avg_total_tokens_per_completion ?? 0
);
const cards = [
{
@@ -49,6 +53,20 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
icon: CheckCircle2,
iconClassName: 'text-emerald-600 dark:text-emerald-300',
},
{
title: 'Total Tokens',
value: totalTokens.toLocaleString(),
icon: Database,
iconClassName: 'text-cyan-600 dark:text-cyan-300',
},
{
title: 'Avg Tokens/Completion',
value: avgTotalTokensPerCompletion.toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
icon: Activity,
iconClassName: 'text-indigo-600 dark:text-indigo-300',
},
{
title: 'Revenue',
value: formatAmount(summary.revenue_msats),
@@ -56,13 +74,13 @@ export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
iconClassName: 'text-amber-600 dark:text-amber-300',
},
{
title: 'Net Revenue',
title: 'Operational Net',
value: formatAmount(summary.net_revenue_msats),
icon: DollarSign,
iconClassName: 'text-lime-600 dark:text-lime-300',
},
{
title: 'Refunds',
title: 'Reverted Holds',
value: formatAmount(summary.refunds_msats),
icon: TrendingDown,
iconClassName: 'text-rose-600 dark:text-rose-300',
+69
View File
@@ -842,6 +842,23 @@ export class AdminService {
);
}
static async getUsageDashboard(
hours: number = 24,
interval: number = 15,
errorLimit: number = 100,
modelLimit: number = 20
): Promise<UsageDashboardResponse> {
const params = new URLSearchParams();
params.set('interval', String(interval));
params.set('hours', String(hours));
params.set('error_limit', String(errorLimit));
params.set('model_limit', String(modelLimit));
return await apiClient.get<UsageDashboardResponse>(
`/admin/api/usage/dashboard?${params.toString()}`
);
}
static async getUsageSummary(hours: number = 24): Promise<UsageSummary> {
return await apiClient.get<UsageSummary>(
`/admin/api/usage/summary?hours=${hours}`
@@ -941,6 +958,9 @@ export interface UsageMetricData {
upstream_errors: number;
revenue_msats: number;
refunds_msats: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
[key: string]: unknown;
}
@@ -949,6 +969,20 @@ export interface UsageMetrics {
interval_minutes: number;
hours_back: number;
total_buckets: number;
totals?: {
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;
};
}
export interface UsageSummary {
@@ -963,6 +997,12 @@ export interface UsageSummary {
unique_models_count: number;
unique_models: string[];
error_types: Record<string, number>;
input_tokens: number;
output_tokens: number;
total_tokens: number;
avg_input_tokens_per_completion: number;
avg_output_tokens_per_completion: number;
avg_total_tokens_per_completion: number;
success_rate: number;
revenue_msats: number;
refunds_msats: number;
@@ -1005,6 +1045,35 @@ export interface RevenueByModel {
total_models: number;
}
export interface ModelUsageMixMetric {
timestamp: string;
total_successful: number;
total_revenue_msats: number;
total_tokens: number;
others: number;
others_revenue_msats: number;
others_tokens: number;
model_counts: Record<string, number>;
model_revenue_msats: Record<string, number>;
model_tokens: Record<string, number>;
}
export interface ModelUsageMix {
top_models: string[];
metrics: ModelUsageMixMetric[];
interval_minutes: number;
hours_back: number;
total_buckets: number;
}
export interface UsageDashboardResponse {
metrics: UsageMetrics;
summary: UsageSummary;
error_details: ErrorDetails;
revenue_by_model: RevenueByModel;
model_usage_mix?: ModelUsageMix;
}
export interface LogEntry {
asctime: string;
name: string;