mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Restore analytics dashboard and polish mobile shell
This commit is contained in:
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+782
-207
File diff suppressed because it is too large
Load Diff
+13
-11
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export function DashboardBalanceSummary({
|
||||
queryFn: async () => {
|
||||
return WalletService.getDetailedBalances();
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchInterval: 900000,
|
||||
staleTime: 300000,
|
||||
});
|
||||
|
||||
const calculateTotals = (balances: BalanceDetail[]) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user