mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bcf7bb948 |
+1
-34
@@ -3,7 +3,7 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
@@ -20,7 +20,6 @@ from ..wallet import (
|
||||
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService, settings
|
||||
from .usage_metrics import UsageMetricsService, list_metric_definitions
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -169,38 +168,6 @@ async def get_balances_api(request: Request) -> list[dict[str, object]]:
|
||||
return [dict(d) for d in balance_details]
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/usage-metrics/definitions", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def get_usage_metric_definitions() -> list[dict[str, str]]:
|
||||
return list_metric_definitions()
|
||||
|
||||
|
||||
@admin_router.get("/api/usage-metrics", dependencies=[Depends(require_admin_api)])
|
||||
async def get_usage_metrics(
|
||||
metrics: str | None = Query(
|
||||
default=None,
|
||||
description="Comma-separated list of usage metric identifiers to include",
|
||||
),
|
||||
bucket_minutes: int = Query(default=15, ge=1, le=24 * 60),
|
||||
hours: int = Query(default=24, ge=1, le=24 * 7),
|
||||
) -> dict[str, object]:
|
||||
metric_list = (
|
||||
[item.strip() for item in metrics.split(",") if item.strip()]
|
||||
if metrics
|
||||
else []
|
||||
)
|
||||
try:
|
||||
result = await UsageMetricsService.collect(
|
||||
metrics=metric_list,
|
||||
bucket_minutes=bucket_minutes,
|
||||
hours=hours,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@admin_router.get("/api/settings", dependencies=[Depends(require_admin_api)])
|
||||
async def get_settings(request: Request) -> dict:
|
||||
data = settings.dict()
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Callable, Literal, cast
|
||||
|
||||
UsageMetricName = Literal["errors", "chat_completions_success"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricDefinition:
|
||||
name: UsageMetricName
|
||||
label: str
|
||||
description: str
|
||||
matcher: Callable[[dict[str, object]], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageMetricPoint:
|
||||
bucket_start: datetime
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageMetricSeries:
|
||||
name: UsageMetricName
|
||||
label: str
|
||||
description: str
|
||||
total: int
|
||||
points: list[UsageMetricPoint]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageMetricsComputation:
|
||||
bucket_minutes: int
|
||||
bucket_count: int
|
||||
start: datetime
|
||||
end: datetime
|
||||
series: list[UsageMetricSeries]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _is_error(record: dict[str, object]) -> bool:
|
||||
level = record.get("levelname")
|
||||
if not isinstance(level, str):
|
||||
return False
|
||||
return level.upper() in {"ERROR", "CRITICAL"}
|
||||
|
||||
|
||||
def _coerce_int(value: object) -> int | None:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _is_successful_chat_completion(record: dict[str, object]) -> bool:
|
||||
message = record.get("message")
|
||||
if not isinstance(message, str) or message != "Received upstream response":
|
||||
return False
|
||||
status_code = _coerce_int(record.get("status_code"))
|
||||
if status_code != 200:
|
||||
return False
|
||||
path = record.get("path")
|
||||
return isinstance(path, str) and path.endswith("chat/completions")
|
||||
|
||||
|
||||
METRIC_DEFINITIONS: dict[UsageMetricName, MetricDefinition] = {
|
||||
"errors": MetricDefinition(
|
||||
name="errors",
|
||||
label="Errors",
|
||||
description="Log entries emitted at ERROR or CRITICAL level",
|
||||
matcher=_is_error,
|
||||
),
|
||||
"chat_completions_success": MetricDefinition(
|
||||
name="chat_completions_success",
|
||||
label="200 chat/completions",
|
||||
description="Successful upstream responses for chat/completions",
|
||||
matcher=_is_successful_chat_completion,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def list_metric_definitions() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"name": definition.name,
|
||||
"label": definition.label,
|
||||
"description": definition.description,
|
||||
}
|
||||
for definition in METRIC_DEFINITIONS.values()
|
||||
]
|
||||
|
||||
|
||||
def default_metric_names() -> list[UsageMetricName]:
|
||||
return list(METRIC_DEFINITIONS.keys())
|
||||
|
||||
|
||||
class UsageMetricsService:
|
||||
@classmethod
|
||||
async def collect(
|
||||
cls,
|
||||
metrics: list[str],
|
||||
bucket_minutes: int,
|
||||
hours: int,
|
||||
log_dir: Path | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> UsageMetricsComputation:
|
||||
return await asyncio.to_thread(
|
||||
cls._collect_sync, metrics, bucket_minutes, hours, log_dir, now
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _collect_sync(
|
||||
cls,
|
||||
metrics: list[str],
|
||||
bucket_minutes: int,
|
||||
hours: int,
|
||||
log_dir: Path | None,
|
||||
now: datetime | None,
|
||||
) -> UsageMetricsComputation:
|
||||
metric_list = cls._sanitize_metrics(metrics)
|
||||
bucket_minutes = cls._clamp(bucket_minutes, minimum=1, maximum=24 * 60)
|
||||
hours = cls._clamp(hours, minimum=1, maximum=24 * 7)
|
||||
now_dt = now or datetime.now()
|
||||
start = now_dt - timedelta(hours=hours)
|
||||
bucket_seconds = bucket_minutes * 60
|
||||
bucket_count = max(1, math.ceil((hours * 3600) / bucket_seconds))
|
||||
bucket_starts = [
|
||||
start + timedelta(seconds=bucket_seconds * index)
|
||||
for index in range(bucket_count)
|
||||
]
|
||||
counts: dict[UsageMetricName, list[int]] = {
|
||||
name: [0] * bucket_count for name in metric_list
|
||||
}
|
||||
|
||||
for log_file in cls._iter_log_files(log_dir):
|
||||
try:
|
||||
with log_file.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
record = cls._parse_record(line)
|
||||
if not record:
|
||||
continue
|
||||
timestamp = cls._parse_timestamp(record.get("asctime"))
|
||||
if timestamp is None or timestamp < start or timestamp > now_dt:
|
||||
continue
|
||||
bucket_index = cls._bucket_index(
|
||||
timestamp, start, bucket_seconds, bucket_count
|
||||
)
|
||||
if bucket_index is None:
|
||||
continue
|
||||
for metric_name in metric_list:
|
||||
definition = METRIC_DEFINITIONS[metric_name]
|
||||
if definition.matcher(record):
|
||||
counts[metric_name][bucket_index] += 1
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
series = [
|
||||
UsageMetricSeries(
|
||||
name=metric_name,
|
||||
label=METRIC_DEFINITIONS[metric_name].label,
|
||||
description=METRIC_DEFINITIONS[metric_name].description,
|
||||
total=sum(counts[metric_name]),
|
||||
points=[
|
||||
UsageMetricPoint(bucket_start=bucket_starts[index], count=count)
|
||||
for index, count in enumerate(counts[metric_name])
|
||||
],
|
||||
)
|
||||
for metric_name in metric_list
|
||||
]
|
||||
|
||||
return UsageMetricsComputation(
|
||||
bucket_minutes=bucket_minutes,
|
||||
bucket_count=bucket_count,
|
||||
start=start,
|
||||
end=now_dt,
|
||||
series=series,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_metrics(metrics: list[str]) -> list[UsageMetricName]:
|
||||
requested = [metric.strip() for metric in metrics if metric.strip()]
|
||||
unique: list[UsageMetricName] = []
|
||||
for normalized in requested:
|
||||
if normalized not in METRIC_DEFINITIONS:
|
||||
continue
|
||||
metric_name = cast(UsageMetricName, normalized)
|
||||
if metric_name not in unique:
|
||||
unique.append(metric_name)
|
||||
if unique:
|
||||
return unique
|
||||
if requested:
|
||||
raise ValueError("No valid usage metrics requested")
|
||||
return default_metric_names()
|
||||
|
||||
@staticmethod
|
||||
def _clamp(value: int, *, minimum: int, maximum: int) -> int:
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
@staticmethod
|
||||
def _iter_log_files(log_dir: Path | None) -> list[Path]:
|
||||
directory = log_dir or Path("logs")
|
||||
if not directory.exists():
|
||||
return []
|
||||
log_files = sorted(
|
||||
directory.glob("*.log"),
|
||||
key=UsageMetricsService._safe_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
return log_files
|
||||
|
||||
@staticmethod
|
||||
def _safe_mtime(path: Path) -> float:
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _parse_record(line: str) -> dict[str, object] | None:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _bucket_index(
|
||||
timestamp: datetime,
|
||||
start: datetime,
|
||||
bucket_seconds: int,
|
||||
bucket_count: int,
|
||||
) -> int | None:
|
||||
delta_seconds = (timestamp - start).total_seconds()
|
||||
if delta_seconds < 0:
|
||||
return None
|
||||
index = int(delta_seconds // bucket_seconds)
|
||||
if index >= bucket_count:
|
||||
index = bucket_count - 1
|
||||
return index
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.usage_metrics import UsageMetricsService
|
||||
|
||||
|
||||
def _write_records(path: Path, records: list[dict[str, object]]) -> None:
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record) + "\n")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_metrics_counts(tmp_path: Path) -> None:
|
||||
log_dir = tmp_path / "logs"
|
||||
log_dir.mkdir()
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
records = [
|
||||
{
|
||||
"asctime": (now - timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"levelname": "ERROR",
|
||||
"message": "Proxy failure",
|
||||
},
|
||||
{
|
||||
"asctime": (now - timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"levelname": "INFO",
|
||||
"message": "Received upstream response",
|
||||
"status_code": 200,
|
||||
"path": "chat/completions",
|
||||
},
|
||||
{
|
||||
"asctime": (now - timedelta(minutes=20)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"levelname": "INFO",
|
||||
"message": "Received upstream response",
|
||||
"status_code": 500,
|
||||
"path": "chat/completions",
|
||||
},
|
||||
]
|
||||
_write_records(log_dir / "app_2025-01-01.log", records)
|
||||
|
||||
result = await UsageMetricsService.collect(
|
||||
metrics=["errors", "chat_completions_success"],
|
||||
bucket_minutes=15,
|
||||
hours=1,
|
||||
log_dir=log_dir,
|
||||
now=now,
|
||||
)
|
||||
|
||||
errors_series = next(series for series in result.series if series.name == "errors")
|
||||
completions_series = next(
|
||||
series
|
||||
for series in result.series
|
||||
if series.name == "chat_completions_success"
|
||||
)
|
||||
|
||||
assert errors_series.total == 1
|
||||
assert sum(point.count for point in errors_series.points) == 1
|
||||
assert completions_series.total == 1
|
||||
assert any(point.count == 1 for point in completions_series.points)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usage_metrics_invalid_metric(tmp_path: Path) -> None:
|
||||
log_dir = tmp_path / "logs"
|
||||
log_dir.mkdir()
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
with pytest.raises(ValueError):
|
||||
await UsageMetricsService.collect(
|
||||
metrics=["unknown"],
|
||||
bucket_minutes=15,
|
||||
hours=1,
|
||||
log_dir=log_dir,
|
||||
now=now,
|
||||
)
|
||||
+15
-19
@@ -10,7 +10,6 @@ import { TemporaryBalances } from '@/components/temporary-balances';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { DisplayUnit } from '@/lib/types/units';
|
||||
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
|
||||
import { UsageTracking } from '@/components/usage-tracking';
|
||||
|
||||
export default function Page() {
|
||||
const [displayUnit, setDisplayUnit] = useState<DisplayUnit>('sat');
|
||||
@@ -66,25 +65,22 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<UsageTracking />
|
||||
</div>
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdminService,
|
||||
UsageMetricDefinition,
|
||||
UsageMetricName,
|
||||
} from '@/lib/api/services/admin';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const bucketOptions = [
|
||||
{ label: '15 minutes', value: 15 },
|
||||
{ label: '1 hour', value: 60 },
|
||||
];
|
||||
|
||||
const rangeOptions = [
|
||||
{ label: 'Last 6 hours', value: 6 },
|
||||
{ label: 'Last 24 hours', value: 24 },
|
||||
{ label: 'Last 72 hours', value: 72 },
|
||||
];
|
||||
|
||||
const palette = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
|
||||
const defaultMetrics: UsageMetricName[] = [
|
||||
'errors',
|
||||
'chat_completions_success',
|
||||
];
|
||||
|
||||
export function UsageTracking() {
|
||||
const [bucketMinutes, setBucketMinutes] = useState(15);
|
||||
const [hours, setHours] = useState(24);
|
||||
const [selectedMetrics, setSelectedMetrics] =
|
||||
useState<UsageMetricName[]>(defaultMetrics);
|
||||
|
||||
const {
|
||||
data: metricDefinitions,
|
||||
isLoading: definitionsLoading,
|
||||
isError: definitionsError,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-metric-definitions'],
|
||||
queryFn: async () => AdminService.getUsageMetricDefinitions(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!metricDefinitions || !metricDefinitions.length) {
|
||||
return;
|
||||
}
|
||||
setSelectedMetrics((current) => {
|
||||
const filtered = current.filter((metric) =>
|
||||
metricDefinitions.some((definition) => definition.name === metric)
|
||||
) as UsageMetricName[];
|
||||
if (filtered.length) {
|
||||
return filtered;
|
||||
}
|
||||
return metricDefinitions.map(
|
||||
(definition) => definition.name as UsageMetricName
|
||||
);
|
||||
});
|
||||
}, [metricDefinitions]);
|
||||
|
||||
const metricsKey = useMemo(
|
||||
() => [...selectedMetrics].sort().join(','),
|
||||
[selectedMetrics]
|
||||
);
|
||||
|
||||
const {
|
||||
data: metricsData,
|
||||
isLoading: metricsLoading,
|
||||
isFetching: metricsFetching,
|
||||
isError: metricsError,
|
||||
error: metricsErrorObject,
|
||||
refetch: refetchMetrics,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-metrics', metricsKey, bucketMinutes, hours],
|
||||
queryFn: async () =>
|
||||
AdminService.getUsageMetrics({
|
||||
metrics: selectedMetrics,
|
||||
bucket_minutes: bucketMinutes,
|
||||
hours,
|
||||
}),
|
||||
enabled: selectedMetrics.length > 0,
|
||||
refetchInterval: 60_000,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const colorMap = useMemo(() => {
|
||||
const map: Partial<Record<UsageMetricName, string>> = {};
|
||||
metricDefinitions?.forEach((definition, index) => {
|
||||
map[definition.name] = palette[index % palette.length];
|
||||
});
|
||||
return map;
|
||||
}, [metricDefinitions]);
|
||||
|
||||
const chartConfig = useMemo<ChartConfig>(() => {
|
||||
const config: ChartConfig = {};
|
||||
metricDefinitions?.forEach((definition) => {
|
||||
const color = colorMap[definition.name];
|
||||
config[definition.name] = {
|
||||
label: definition.label,
|
||||
color,
|
||||
};
|
||||
});
|
||||
return config;
|
||||
}, [metricDefinitions, colorMap]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!metricsData || !metricsData.series.length) {
|
||||
return [];
|
||||
}
|
||||
const referenceSeries = metricsData.series[0];
|
||||
return referenceSeries.points.map((point, index) => {
|
||||
const base: Record<string, string | number> = {
|
||||
bucketStart: point.bucket_start,
|
||||
label: formatBucketLabel(point.bucket_start, hours),
|
||||
};
|
||||
metricsData.series.forEach((series) => {
|
||||
base[series.name] = series.points[index]?.count ?? 0;
|
||||
});
|
||||
return base;
|
||||
});
|
||||
}, [metricsData, hours]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!metricsData) {
|
||||
return [];
|
||||
}
|
||||
return metricsData.series.map((series) => ({
|
||||
name: series.name,
|
||||
label: series.label,
|
||||
total: series.total,
|
||||
latest: series.points.at(-1)?.count ?? 0,
|
||||
averagePerHour: series.total / Math.max(1, hours),
|
||||
}));
|
||||
}, [metricsData, hours]);
|
||||
|
||||
const handleMetricToggle = (metric: UsageMetricName) => {
|
||||
setSelectedMetrics((current) => {
|
||||
if (current.includes(metric)) {
|
||||
if (current.length === 1) {
|
||||
return current;
|
||||
}
|
||||
return current.filter((item) => item !== metric);
|
||||
}
|
||||
return [...current, metric];
|
||||
});
|
||||
};
|
||||
|
||||
const renderSummaryCard = (definition: UsageMetricDefinition) => {
|
||||
const stat = summary.find((item) => item.name === definition.name);
|
||||
const Icon = definition.name === 'errors' ? AlertCircle : Activity;
|
||||
return (
|
||||
<div
|
||||
key={definition.name}
|
||||
className='rounded-lg border bg-muted/20 p-4'
|
||||
style={{
|
||||
borderColor: colorMap[definition.name] ?? 'hsl(var(--border))',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='h-5 w-5' />
|
||||
<span className='text-sm font-medium'>{definition.label}</span>
|
||||
</div>
|
||||
<Badge variant='outline'>
|
||||
{bucketMinutes >= 60
|
||||
? `${bucketMinutes / 60}h buckets`
|
||||
: `${bucketMinutes}m buckets`}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='mt-3 flex items-end justify-between'>
|
||||
<div>
|
||||
<div className='text-3xl font-bold'>
|
||||
{stat ? stat.total.toLocaleString() : '—'}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
total events in range
|
||||
</p>
|
||||
</div>
|
||||
{stat && (
|
||||
<div className='text-right text-xs'>
|
||||
<div className='flex items-center justify-end gap-1 text-muted-foreground'>
|
||||
<TrendingUp className='h-3 w-3' />
|
||||
<span>{stat.latest} latest bucket</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-end gap-1 text-muted-foreground'>
|
||||
<TrendingDown className='h-3 w-3' />
|
||||
<span>{stat.averagePerHour.toFixed(2)} avg/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
definitionsLoading ||
|
||||
metricsLoading ||
|
||||
(selectedMetrics.length > 0 && !metricsData);
|
||||
|
||||
return (
|
||||
<Card className='shadow-sm'>
|
||||
<CardHeader className='pb-4'>
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
||||
<div>
|
||||
<CardTitle className='flex items-center gap-2 text-xl'>
|
||||
<BarChart3 className='h-5 w-5' />
|
||||
Usage Tracking
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Monitor errors and successful upstream traffic over time
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
|
||||
<Select
|
||||
value={hours.toString()}
|
||||
onValueChange={(value) => setHours(Number(value))}
|
||||
>
|
||||
<SelectTrigger className='w-[160px]'>
|
||||
<SelectValue placeholder='Time range' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={bucketMinutes.toString()}
|
||||
onValueChange={(value) => setBucketMinutes(Number(value))}
|
||||
>
|
||||
<SelectTrigger className='w-[140px]'>
|
||||
<SelectValue placeholder='Bucket size' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bucketOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => refetchMetrics()}
|
||||
disabled={isLoading || metricsFetching}
|
||||
className='h-9 w-9'
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
(metricsFetching || metricsLoading) && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span className='sr-only'>Refresh usage metrics</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
{definitionsError ? (
|
||||
<div className='bg-destructive/10 text-destructive flex items-center gap-2 rounded-md p-4 text-sm'>
|
||||
<AlertCircle className='h-5 w-5' />
|
||||
Failed to load metric definitions.
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{metricDefinitions?.map((definition) => (
|
||||
<Button
|
||||
key={definition.name}
|
||||
variant={
|
||||
selectedMetrics.includes(
|
||||
definition.name as UsageMetricName
|
||||
)
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleMetricToggle(definition.name as UsageMetricName)
|
||||
}
|
||||
className='text-xs'
|
||||
>
|
||||
{definition.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metricsError && (
|
||||
<div className='bg-destructive/10 text-destructive flex items-center gap-2 rounded-md p-4 text-sm'>
|
||||
<AlertCircle className='h-5 w-5' />
|
||||
{metricsErrorObject instanceof Error
|
||||
? metricsErrorObject.message
|
||||
: 'Failed to load usage metrics.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-64 w-full' />
|
||||
) : (
|
||||
metricsData &&
|
||||
summary.length > 0 && (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
{metricDefinitions
|
||||
?.filter((definition) =>
|
||||
selectedMetrics.includes(definition.name as UsageMetricName)
|
||||
)
|
||||
.map((definition) => renderSummaryCard(definition))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-72 w-full' />
|
||||
) : chartData.length > 0 ? (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className='h-[360px] w-full text-xs'
|
||||
>
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray='3 3' vertical={false} />
|
||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||
<ChartTooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => value as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{metricsData?.series.map((series) => (
|
||||
<Area
|
||||
key={series.name}
|
||||
type='monotone'
|
||||
dataKey={series.name}
|
||||
stroke={`var(--color-${series.name})`}
|
||||
fill={`var(--color-${series.name})`}
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex flex-col items-center gap-2 rounded-md border border-dashed p-10 text-sm'>
|
||||
<AlertCircle className='h-6 w-6' />
|
||||
<p>No data available for the selected window.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBucketLabel(value: string | undefined, rangeHours: number) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
if (rangeHours <= 24) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -802,30 +802,6 @@ export class AdminService {
|
||||
'/admin/api/temporary-balances'
|
||||
);
|
||||
}
|
||||
|
||||
static async getUsageMetricDefinitions(): Promise<UsageMetricDefinition[]> {
|
||||
return await apiClient.get<UsageMetricDefinition[]>(
|
||||
'/admin/api/usage-metrics/definitions'
|
||||
);
|
||||
}
|
||||
|
||||
static async getUsageMetrics(params: {
|
||||
metrics: UsageMetricName[];
|
||||
bucket_minutes: number;
|
||||
hours: number;
|
||||
}): Promise<UsageMetricsResponse> {
|
||||
const query: Record<string, string | number> = {
|
||||
bucket_minutes: params.bucket_minutes,
|
||||
hours: params.hours,
|
||||
};
|
||||
if (params.metrics.length > 0) {
|
||||
query.metrics = params.metrics.join(',');
|
||||
}
|
||||
return await apiClient.get<UsageMetricsResponse>(
|
||||
'/admin/api/usage-metrics',
|
||||
query
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const TemporaryBalanceSchema = z.object({
|
||||
@@ -838,32 +814,3 @@ export const TemporaryBalanceSchema = z.object({
|
||||
});
|
||||
|
||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||
|
||||
export type UsageMetricName = 'errors' | 'chat_completions_success';
|
||||
|
||||
export interface UsageMetricDefinition {
|
||||
name: UsageMetricName;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UsageMetricPoint {
|
||||
bucket_start: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface UsageMetricSeries {
|
||||
name: UsageMetricName;
|
||||
label: string;
|
||||
description: string;
|
||||
total: number;
|
||||
points: UsageMetricPoint[];
|
||||
}
|
||||
|
||||
export interface UsageMetricsResponse {
|
||||
bucket_minutes: number;
|
||||
bucket_count: number;
|
||||
start: string;
|
||||
end: string;
|
||||
series: UsageMetricSeries[];
|
||||
}
|
||||
|
||||
Vendored
-1
Submodule vendor/cdk deleted from 52d796e9fe
Vendored
-1
Submodule vendor/cdk-python deleted from 915c6966b0
Reference in New Issue
Block a user