Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
ceb843ef15 feat: Add admin logs and usage dashboards
This commit introduces new admin dashboard pages for viewing system logs and usage statistics. It includes backend API endpoints for fetching log data, usage metrics, error details, and revenue by model. The frontend has been updated with new UI components and pages to display this information. Additionally, important log messages used for analytics have been marked to prevent accidental modification.

Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-22 02:18:39 +00:00
21 changed files with 2454 additions and 1 deletions

View File

@@ -370,6 +370,7 @@ async def pay_for_request(
await session.refresh(key)
# IMPORTANT: Do not modify this log string; usage analytics relies on it.
logger.info(
"Payment processed successfully",
extra={
@@ -399,6 +400,7 @@ async def revert_pay_for_request(
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
# IMPORTANT: Do not modify this log string; analytics rely on it to flag refund failures.
logger.error(
"Failed to revert payment - insufficient reserved balance",
extra={

View File

@@ -3,11 +3,20 @@ import secrets
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from sqlmodel import select
from ..logs import (
LogSearchFilters,
collect_error_details,
get_available_log_dates,
revenue_by_model as aggregate_revenue_by_model,
search_logs,
summarize_usage,
usage_metrics,
)
from ..payment.models import _row_to_model, list_models
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..wallet import (
@@ -908,6 +917,96 @@ async def view_logs(request: Request, request_id: str) -> str:
)
@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)])
async def get_logs_api(
request: Request,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict[str, object]:
logs_dir = Path("logs")
limit = max(1, min(limit, 1000))
filters = LogSearchFilters(
date=None if not date or date.lower() == "all" else date,
level=None if not level or level.lower() == "all" else level,
request_id=request_id or None,
search_text=search or None,
)
log_entries = search_logs(logs_dir, filters, limit)
return {
"logs": log_entries,
"total": len(log_entries),
"date": date,
"level": level,
"request_id": request_id,
"search": search,
"limit": limit,
}
@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)])
async def get_log_dates_api(request: Request) -> dict[str, list[str]]:
logs_dir = Path("logs")
dates = get_available_log_dates(logs_dir)
return {"dates": dates}
@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
async def get_usage_metrics_api(
request: Request,
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
) -> dict[str, object]:
return usage_metrics(Path("logs"), interval, hours)
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
async def get_usage_summary_api(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
) -> dict[str, object]:
return summarize_usage(Path("logs"), hours)
@admin_router.get(
"/api/usage/error-details", dependencies=[Depends(require_admin_api)]
)
async def get_error_details_api(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
) -> dict[str, object]:
return collect_error_details(Path("logs"), hours, limit)
@admin_router.get(
"/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
)
async def get_revenue_by_model_api(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
) -> dict[str, object]:
return aggregate_revenue_by_model(Path("logs"), hours, limit)
@admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)])
async def withdraw(
request: Request, withdraw_request: WithdrawRequest
@@ -2404,6 +2503,58 @@ def upstream_providers_page() -> str:
)
def logs_page() -> str:
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logs - Admin Dashboard</title>
<script>
window.location.href = '/logs';
</script>
</head>
<body>
<p>Redirecting to logs page...</p>
</body>
</html>
"""
def usage_page() -> str:
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Usage - Admin Dashboard</title>
<script>
window.location.href = '/usage';
</script>
</head>
<body>
<p>Redirecting to usage dashboard...</p>
</body>
</html>
"""
@admin_router.get("/logs", response_class=HTMLResponse)
async def admin_logs(request: Request) -> str:
if is_admin_authenticated(request):
return logs_page()
return admin_auth()
@admin_router.get("/usage", response_class=HTMLResponse)
async def admin_usage(request: Request) -> str:
if is_admin_authenticated(request):
return usage_page()
return admin_auth()
@admin_router.get("/upstream-providers", response_class=HTMLResponse)
async def admin_upstream_providers(request: Request) -> str:
if is_admin_authenticated(request):

View File

@@ -1,3 +1,26 @@
"""
Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The usage analytics pipeline parses specific log messages emitted by the proxy,
auth, and upstream layers. If you need to modify any of the following log
statements, update routstr/logs/service.py accordingly to keep the dashboard
accurate:
1. "Received proxy request" (INFO) - counts total requests per model.
2. "Token adjustment completed for streaming|non-streaming" (INFO) - marks
successful completions and extracts cost_data.total_msats for revenue.
3. "Payment processed successfully" (INFO) - tracks payment lifecycle events.
4. "Upstream request failed, revert payment" (WARNING) and "revert payment"
(any level) - mark failed requests and extract max_cost_for_model for refunds.
5. Any ERROR level log containing the word "upstream" - counted as upstream
provider failures.
Keep the wording of these messages stable or adjust the parsers in the log
service whenever you make changes.
"""
import logging.config
import logging.handlers
import os

View File

@@ -264,6 +264,22 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@app.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
@app.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@app.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
@app.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@app.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")

19
routstr/logs/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
from .service import ( # noqa: F401
LogSearchFilters,
collect_error_details,
get_available_log_dates,
search_logs,
summarize_usage,
usage_metrics,
revenue_by_model,
)
__all__ = [
"LogSearchFilters",
"collect_error_details",
"get_available_log_dates",
"search_logs",
"summarize_usage",
"usage_metrics",
"revenue_by_model",
]

554
routstr/logs/service.py Normal file
View File

@@ -0,0 +1,554 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator
LOG_FILE_PREFIX = "app_"
LOG_FILE_SUFFIX = ".log"
LOG_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
DEFAULT_SEARCH_FILE_LIMIT = 7
LogEntry = dict[str, Any]
@dataclass(slots=True)
class LogSearchFilters:
date: str | None = None
level: str | None = None
request_id: str | None = None
search_text: str | None = None
@dataclass(slots=True)
class EntryInsights:
timestamp: datetime | None
level: str
model: str | None
event_total_request: bool
event_success: bool
event_fail: bool
event_payment_processed: bool
event_upstream_error: bool
revenue_msats: float
refund_msats: float
error_type: str | None
def search_logs(
logs_dir: Path, filters: LogSearchFilters, limit: int = 100
) -> list[LogEntry]:
if limit <= 0 or not logs_dir.exists():
return []
files = _collect_log_files(
logs_dir,
date=filters.date,
limit=None if filters.date else DEFAULT_SEARCH_FILE_LIMIT,
)
search_lower = filters.search_text.lower() if filters.search_text else None
normalized_level = filters.level.upper() if filters.level else None
entries: list[LogEntry] = []
for log_file in files:
for entry in _stream_log_entries(log_file):
if not _matches_filters(
entry=entry,
level=normalized_level,
request_id=filters.request_id,
search_lower=search_lower,
):
continue
entries.append(entry)
if len(entries) >= limit:
break
if len(entries) >= limit:
break
entries.sort(key=lambda e: e.get("asctime", ""), reverse=True)
return entries[:limit]
def get_available_log_dates(logs_dir: Path, limit: int = 30) -> list[str]:
if not logs_dir.exists():
return []
dates: list[str] = []
files = _collect_log_files(logs_dir, limit=limit)
for log_file in files:
date_str = _extract_date_from_filename(log_file)
if date_str:
dates.append(date_str)
return dates
def usage_metrics(
logs_dir: Path, interval_minutes: int, hours: int
) -> dict[str, Any]:
if not logs_dir.exists():
return {
"metrics": [],
"interval_minutes": interval_minutes,
"hours_back": hours,
"total_buckets": 0,
}
entries = _load_entries_since(logs_dir, hours)
if not entries:
return {
"metrics": [],
"interval_minutes": interval_minutes,
"hours_back": hours,
"total_buckets": 0,
}
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
buckets: dict[str, dict[str, float]] = {}
for entry in entries:
insights = _analyze_entry(entry)
timestamp = insights.timestamp
if timestamp is None or timestamp < cutoff:
continue
bucket_time = timestamp.replace(
minute=(timestamp.minute // interval_minutes) * interval_minutes,
second=0,
microsecond=0,
)
bucket_key = bucket_time.isoformat()
bucket = buckets.setdefault(
bucket_key,
{
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"errors": 0,
"warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"revenue_msats": 0.0,
"refunds_msats": 0.0,
},
)
if insights.level == "ERROR":
bucket["errors"] += 1
elif insights.level == "WARNING":
bucket["warnings"] += 1
if insights.event_total_request:
bucket["total_requests"] += 1
if insights.event_success:
bucket["successful_chat_completions"] += 1
bucket["revenue_msats"] += insights.revenue_msats
if insights.event_fail:
bucket["failed_requests"] += 1
bucket["refunds_msats"] += insights.refund_msats
if insights.event_payment_processed:
bucket["payment_processed"] += 1
if insights.event_upstream_error:
bucket["upstream_errors"] += 1
metrics = [
{"timestamp": key, **values}
for key, values in sorted(buckets.items(), key=lambda item: item[0])
]
return {
"metrics": metrics,
"interval_minutes": interval_minutes,
"hours_back": hours,
"total_buckets": len(metrics),
}
def summarize_usage(logs_dir: Path, hours: int) -> dict[str, Any]:
if not logs_dir.exists():
return _empty_summary()
entries = _load_entries_since(logs_dir, hours)
if not entries:
return _empty_summary()
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
total_entries = 0
total_requests = 0
successful = 0
failed = 0
total_errors = 0
total_warnings = 0
payment_processed = 0
upstream_errors = 0
revenue_msats = 0.0
refunds_msats = 0.0
unique_models: set[str] = set()
error_types: dict[str, int] = {}
for entry in entries:
insights = _analyze_entry(entry)
timestamp = insights.timestamp
if timestamp is None or timestamp < cutoff:
continue
total_entries += 1
if insights.level == "ERROR":
total_errors += 1
if insights.error_type:
error_types[insights.error_type] = (
error_types.get(insights.error_type, 0) + 1
)
elif insights.level == "WARNING":
total_warnings += 1
if insights.event_total_request:
total_requests += 1
if insights.event_success:
successful += 1
revenue_msats += insights.revenue_msats
if insights.event_fail:
failed += 1
refunds_msats += insights.refund_msats
if insights.event_payment_processed:
payment_processed += 1
if insights.event_upstream_error:
upstream_errors += 1
if insights.model:
unique_models.add(insights.model)
revenue_sats = revenue_msats / 1000
refunds_sats = refunds_msats / 1000
net_revenue_msats = revenue_msats - refunds_msats
net_revenue_sats = net_revenue_msats / 1000
success_rate = (successful / total_requests * 100) if total_requests else 0
refund_rate = (failed / total_requests * 100) if total_requests else 0
avg_revenue_per_request = (
revenue_msats / successful if successful else 0
)
return {
"total_entries": total_entries,
"total_requests": total_requests,
"successful_chat_completions": successful,
"failed_requests": failed,
"total_errors": total_errors,
"total_warnings": total_warnings,
"payment_processed": payment_processed,
"upstream_errors": upstream_errors,
"unique_models_count": len(unique_models),
"unique_models": sorted(unique_models),
"error_types": error_types,
"success_rate": success_rate,
"revenue_msats": revenue_msats,
"refunds_msats": refunds_msats,
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_msats": net_revenue_msats,
"net_revenue_sats": net_revenue_sats,
"avg_revenue_per_request_msats": avg_revenue_per_request,
"refund_rate": refund_rate,
}
def collect_error_details(
logs_dir: Path, hours: int, limit: int
) -> dict[str, Any]:
if not logs_dir.exists():
return {"errors": [], "total_count": 0}
entries = _load_entries_since(logs_dir, hours)
if not entries:
return {"errors": [], "total_count": 0}
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
errors: list[dict[str, Any]] = []
for entry in entries:
level = str(entry.get("levelname", "")).upper()
if level != "ERROR":
continue
timestamp = _parse_timestamp(entry)
if timestamp is None or timestamp < cutoff:
continue
timestamp_str = str(entry.get("asctime", ""))
message = str(entry.get("message", ""))
error_type = str(entry.get("error_type", "unknown"))
pathname = str(entry.get("pathname", ""))
try:
lineno = int(entry.get("lineno", 0))
except (TypeError, ValueError):
lineno = 0
request_id = str(entry.get("request_id", ""))
errors.append(
{
"timestamp": timestamp_str,
"message": message,
"error_type": error_type,
"pathname": pathname,
"lineno": lineno,
"request_id": request_id,
}
)
errors.sort(key=lambda item: item["timestamp"], reverse=True)
return {"errors": errors[:limit], "total_count": len(errors)}
def revenue_by_model(
logs_dir: Path, hours: int, limit: int
) -> dict[str, Any]:
if not logs_dir.exists():
return {"models": [], "total_revenue_sats": 0, "total_models": 0}
entries = _load_entries_since(logs_dir, hours)
if not entries:
return {"models": [], "total_revenue_sats": 0, "total_models": 0}
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
model_stats: dict[str, dict[str, float | int]] = {}
for entry in entries:
insights = _analyze_entry(entry)
timestamp = insights.timestamp
if timestamp is None or timestamp < cutoff:
continue
model = insights.model or "unknown"
stats = model_stats.setdefault(
model,
{
"requests": 0,
"successful": 0,
"failed": 0,
"revenue_msats": 0.0,
"refunds_msats": 0.0,
},
)
if insights.event_total_request:
stats["requests"] += 1
if insights.event_success:
stats["successful"] += 1
stats["revenue_msats"] += insights.revenue_msats
if insights.event_fail:
stats["failed"] += 1
stats["refunds_msats"] += insights.refund_msats
models: list[dict[str, Any]] = []
total_revenue_sats = 0.0
for model, stats in model_stats.items():
revenue_msats = float(stats["revenue_msats"])
refunds_msats = float(stats["refunds_msats"])
requests = int(stats["requests"])
successful = int(stats["successful"])
failed = int(stats["failed"])
revenue_sats = revenue_msats / 1000
refunds_sats = refunds_msats / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_revenue_sats += net_revenue_sats
avg_revenue = revenue_sats / successful if successful else 0
models.append(
{
"model": model,
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_sats": net_revenue_sats,
"requests": requests,
"successful": successful,
"failed": failed,
"avg_revenue_per_request": avg_revenue,
}
)
models.sort(key=lambda item: item["net_revenue_sats"], reverse=True)
return {
"models": models[:limit],
"total_revenue_sats": total_revenue_sats,
"total_models": len(models),
}
def _collect_log_files(
logs_dir: Path, date: str | None = None, limit: int | None = None
) -> list[Path]:
if date:
candidate = logs_dir / f"{LOG_FILE_PREFIX}{date}{LOG_FILE_SUFFIX}"
return [candidate] if candidate.exists() else []
files = sorted(
logs_dir.glob(f"{LOG_FILE_PREFIX}*{LOG_FILE_SUFFIX}"),
key=lambda path: path.name,
reverse=True,
)
if limit is not None:
return files[:limit]
return files
def _stream_log_entries(log_file: Path) -> Iterator[LogEntry]:
try:
with log_file.open("r") as handle:
for line in handle:
stripped = line.strip()
if not stripped:
continue
try:
data = json.loads(stripped)
if isinstance(data, dict):
yield data
except json.JSONDecodeError:
continue
except Exception:
return
def _matches_filters(
entry: LogEntry,
level: str | None,
request_id: str | None,
search_lower: str | None,
) -> bool:
if level and str(entry.get("levelname", "")).upper() != level:
return False
if request_id and entry.get("request_id") != request_id:
return False
if search_lower:
message = str(entry.get("message", "")).lower()
name = str(entry.get("name", "")).lower()
if search_lower not in message and search_lower not in name:
return False
return True
def _extract_date_from_filename(log_file: Path) -> str | None:
name = log_file.stem
if not name.startswith(LOG_FILE_PREFIX):
return None
return name.replace(LOG_FILE_PREFIX, "", 1)
def _parse_timestamp(entry: LogEntry) -> datetime | None:
raw = entry.get("asctime")
if not raw:
return None
try:
parsed = datetime.strptime(str(raw), LOG_TIME_FORMAT)
return parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def _load_entries_since(logs_dir: Path, hours: int) -> list[LogEntry]:
files = _collect_log_files(logs_dir)
if not files:
return []
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
cutoff_floor = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
entries: list[LogEntry] = []
for log_file in files:
file_date = _extract_date_from_filename(log_file)
if file_date:
try:
parsed_date = datetime.strptime(file_date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
if parsed_date < cutoff_floor:
break
except ValueError:
pass
entries.extend(_stream_log_entries(log_file))
return entries
def _analyze_entry(entry: LogEntry) -> EntryInsights:
message_raw = str(entry.get("message", ""))
message = message_raw.lower()
level = str(entry.get("levelname", "")).upper()
timestamp = _parse_timestamp(entry)
model_value = entry.get("model")
model = model_value if isinstance(model_value, str) else None
error_type = entry.get("error_type")
error_type_str = str(error_type) if error_type else None
event_total_request = "received proxy request" in message
event_success = "token adjustment completed" in message
event_fail = (
"upstream request failed" in message or "revert payment" in message
)
event_payment_processed = "payment processed successfully" in message
event_upstream_error = level == "ERROR" and "upstream" in message
revenue_msats = 0.0
if event_success:
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
total_msats = cost_data.get("total_msats")
if isinstance(total_msats, (int, float)):
revenue_msats = float(total_msats)
refund_msats = 0.0
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model")
if isinstance(max_cost, (int, float)):
refund_msats = float(max_cost)
return EntryInsights(
timestamp=timestamp,
level=level,
model=model,
event_total_request=event_total_request,
event_success=event_success,
event_fail=event_fail,
event_payment_processed=event_payment_processed,
event_upstream_error=event_upstream_error,
revenue_msats=revenue_msats,
refund_msats=refund_msats,
error_type=error_type_str,
)
def _empty_summary() -> dict[str, Any]:
return {
"total_entries": 0,
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"total_errors": 0,
"total_warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"unique_models_count": 0,
"unique_models": [],
"error_types": {},
"success_rate": 0,
"revenue_msats": 0,
"refunds_msats": 0,
"revenue_sats": 0,
"refunds_sats": 0,
"net_revenue_msats": 0,
"net_revenue_sats": 0,
"avg_revenue_per_request_msats": 0,
"refund_rate": 0,
}

View File

@@ -141,6 +141,7 @@ async def proxy(
"unauthorized", "Unauthorized", 401, request=request
)
# IMPORTANT: Do not modify this log string; usage analytics depends on it.
logger.info( # TODO: move to middleware, async
"Received proxy request",
extra={
@@ -222,6 +223,7 @@ async def proxy(
if response.status_code != 200:
await revert_pay_for_request(key, session, max_cost_for_model)
# IMPORTANT: Do not modify this log string; analytics parses it for refunds.
logger.warning(
"Upstream request failed, revert payment",
extra={

View File

@@ -452,6 +452,7 @@ class BaseUpstreamProvider:
)
)
usage_finalized = True
# IMPORTANT: Do not modify this log string; analytics relies on the wording.
logger.info(
"Token adjustment completed for streaming",
extra={
@@ -555,6 +556,7 @@ class BaseUpstreamProvider:
)
response_json["cost"] = cost_data
# IMPORTANT: Do not modify this log string; analytics relies on the wording.
logger.info(
"Token adjustment completed for non-streaming",
extra={

View File

@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { LogEntry } from './types';
interface LogDetailsDialogProps {
log: LogEntry | null;
isOpen: boolean;
onClose: () => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogDetailsDialog({
log,
isOpen,
onClose,
}: LogDetailsDialogProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
if (!log) return null;
const copyToClipboard = (text: string, fieldName?: string) => {
if (typeof navigator === 'undefined') return;
void navigator.clipboard.writeText(text);
if (fieldName) {
setCopiedField(fieldName);
window.setTimeout(() => setCopiedField(null), 2000);
}
};
const standardFields = [
'asctime',
'name',
'levelname',
'message',
'pathname',
'lineno',
'version',
'request_id',
];
const extraFields = Object.keys(log).filter(
(key) => key !== 'key' && !standardFields.includes(key)
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Badge variant='outline' className={getLevelColor(log.levelname || '')}>
{log.levelname}
</Badge>
<span>Log Entry Details</span>
</DialogTitle>
<DialogDescription>
{log.asctime ?? 'unknown time'} {log.name ?? 'unknown'} {' '}
{log.pathname ?? 'unknown'}:{log.lineno ?? '-'}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
<div className='space-y-6'>
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
<pre className='font-mono text-sm whitespace-pre break-all'>
{String(log.message ?? '')}
</pre>
</div>
</div>
<div>
<h4 className='mb-3 text-sm font-medium'>Standard Fields</h4>
<div className='grid grid-cols-1 gap-3'>
{standardFields.map((field) => (
<div key={field} className='flex flex-col space-y-1'>
<div className='flex items-center justify-between gap-2'>
<span className='text-muted-foreground text-xs font-medium uppercase'>
{field}
</span>
{field === 'request_id' && (
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(String(log[field] ?? ''), field)
}
className='h-6 flex-shrink-0 px-2'
>
{copiedField === field ? (
<>
<Check className='mr-1 h-3 w-3' />
Copied
</>
) : (
<>
<Copy className='mr-1 h-3 w-3' />
Copy
</>
)}
</Button>
)}
</div>
<div className='bg-muted max-h-32 overflow-auto rounded p-2'>
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
{String(log[field] ?? 'N/A')}
</pre>
</div>
</div>
))}
</div>
</div>
{extraFields.length > 0 && (
<div>
<h4 className='mb-3 text-sm font-medium'>Additional Fields</h4>
<div className='grid grid-cols-1 gap-3'>
{extraFields.map((field) => (
<div key={field} className='flex flex-col space-y-1'>
<span className='text-muted-foreground truncate text-xs font-medium uppercase'>
{field}
</span>
<div className='bg-muted max-h-48 overflow-auto rounded p-2'>
{typeof log[field] === 'object' ? (
<pre className='font-mono text-xs break-all whitespace-pre-wrap'>
{JSON.stringify(log[field], null, 2)}
</pre>
) : (
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
{String(log[field] ?? 'N/A')}
</pre>
)}
</div>
</div>
))}
</div>
</div>
)}
<div>
<div className='mb-3 flex items-center justify-between'>
<h4 className='text-sm font-medium'>Raw JSON</h4>
<Button
variant='outline'
size='sm'
onClick={() =>
copyToClipboard(JSON.stringify(log, null, 2), 'json')
}
className='h-6 px-2'
>
{copiedField === 'json' ? (
<>
<Check className='mr-1 h-3 w-3' />
Copied
</>
) : (
<>
<Copy className='mr-1 h-3 w-3' />
Copy
</>
)}
</Button>
</div>
<div className='bg-muted max-h-64 overflow-auto rounded-md p-4'>
<pre className='text-xs break-all whitespace-pre-wrap'>
{JSON.stringify(log, null, 2)}
</pre>
</div>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Eye } from 'lucide-react';
import type { LogEntry } from './types';
interface LogEntryCardProps {
entry: LogEntry;
onClick: (entry: LogEntry) => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
const extraFields = Object.keys(entry).filter(
(key) =>
![
'asctime',
'name',
'levelname',
'message',
'pathname',
'lineno',
'version',
'request_id',
].includes(key)
);
return (
<div
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
onClick={() => onClick(entry)}
>
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<Badge
variant='outline'
className={getLevelColor(entry.levelname || '')}
>
{entry.levelname ?? 'UNKNOWN'}
</Badge>
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
{entry.asctime ?? 'unknown'}
</span>
<Badge variant='secondary' className='truncate text-xs'>
{entry.name ?? 'unknown'}
</Badge>
</div>
<div className='flex min-w-0 items-center gap-2'>
<div className='text-muted-foreground truncate text-xs'>
{entry.pathname ?? 'unknown'}:{entry.lineno ?? '-'}
</div>
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
</div>
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
{entry.message}
</div>
{entry.request_id && entry.request_id !== 'no-request-id' && (
<div className='mb-2 min-w-0'>
<div className='inline-block max-w-full'>
<Badge variant='outline' className='text-xs'>
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
Request ID: {entry.request_id}
</span>
</Badge>
</div>
</div>
)}
{extraFields.length > 0 && (
<div className='mt-3 min-w-0 border-t pt-3'>
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
<div className='grid grid-cols-1 gap-2'>
{extraFields.slice(0, 4).map((key) => {
const value = entry[key];
return (
<div key={key} className='min-w-0 overflow-hidden text-xs break-words'>
<span className='font-medium break-all'>{key}:</span>{' '}
<span className='text-muted-foreground break-all'>
{typeof value === 'object'
? JSON.stringify(value)
: String(value ?? '')}
</span>
</div>
);
})}
{extraFields.length > 4 && (
<div className='text-muted-foreground text-xs'>
...and {extraFields.length - 4} more fields
</div>
)}
</div>
</div>
)}
</div>
);
}

298
ui/app/logs/log-filters.tsx Normal file
View File

@@ -0,0 +1,298 @@
'use client';
import { useEffect, useState } from 'react';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { format } from 'date-fns';
import { CalendarIcon, Filter, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { cn } from '@/lib/utils';
interface LogFiltersProps {
selectedDate: string;
selectedLevel: string;
requestId: string;
searchText: string;
limit: number;
onDateChange: (date: string) => void;
onLevelChange: (level: string) => void;
onRequestIdChange: (requestId: string) => void;
onSearchTextChange: (searchText: string) => void;
onLimitChange: (limit: number) => void;
onClearFilters: () => void;
}
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
export function LogFilters({
selectedDate,
selectedLevel,
requestId,
searchText,
limit,
onDateChange,
onLevelChange,
onRequestIdChange,
onSearchTextChange,
onLimitChange,
onClearFilters,
}: LogFiltersProps) {
const isPreset = PRESET_LIMITS.includes(String(limit));
const [customLimit, setCustomLimit] = useState<string>(
isPreset ? '' : String(limit)
);
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
const [date, setDate] = useState<Date | undefined>(
selectedDate && selectedDate !== 'all' ? new Date(selectedDate) : undefined
);
useEffect(() => {
const preset = PRESET_LIMITS.includes(String(limit));
setIsCustom(!preset);
if (!preset) {
setCustomLimit(String(limit));
}
}, [limit]);
useEffect(() => {
if (!selectedDate || selectedDate === 'all') {
setDate(undefined);
return;
}
try {
setDate(new Date(selectedDate));
} catch {
setDate(undefined);
}
}, [selectedDate]);
const handleLimitChange = (value: string) => {
if (value === 'custom') {
setIsCustom(true);
setCustomLimit(String(limit));
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
}
};
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
setCustomLimit(event.target.value);
};
const handleCustomLimitApply = () => {
const parsed = parseInt(customLimit, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
onLimitChange(parsed);
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
}
};
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleCustomLimitApply();
}
};
const handleDateSelect = (selected: Date | undefined) => {
setDate(selected);
if (selected) {
onDateChange(format(selected, 'yyyy-MM-dd'));
} else {
onDateChange('all');
}
};
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Filter className='h-5 w-5' />
Filters
</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, and limit
</CardDescription>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='date'>Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className={cn(
'w-full justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
>
<CalendarIcon className='mr-2 h-4 w-4' />
{date ? format(date, 'PPP') : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={date}
onSelect={handleDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
{date && (
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => handleDateSelect(undefined)}
className='w-full'
>
<X className='mr-2 h-4 w-4' />
Clear date
</Button>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='level'>Log Level</Label>
<Select value={selectedLevel} onValueChange={onLevelChange}>
<SelectTrigger>
<SelectValue placeholder='Select level' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All levels</SelectItem>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
<Input
id='request-id'
type='text'
placeholder='Search by request ID'
value={requestId}
onChange={(event) => onRequestIdChange(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='search-text' className='flex items-center gap-1'>
<span>Text Search</span>
<span className='text-muted-foreground text-xs font-normal'>
(can be slow)
</span>
</Label>
<Input
id='search-text'
type='text'
placeholder='Search in message and logger'
value={searchText}
onChange={(event) => onSearchTextChange(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='limit'>Limit</Label>
{isCustom ? (
<div className='flex gap-2'>
<Input
id='limit'
type='number'
min='1'
placeholder='Enter custom limit'
value={customLimit}
onChange={handleCustomLimitChange}
onKeyDown={handleCustomLimitKeyDown}
onBlur={handleCustomLimitApply}
autoFocus
className='flex-1'
/>
<Button
type='button'
variant='secondary'
size='sm'
onClick={() => {
setIsCustom(false);
setCustomLimit('');
if (!isPreset) {
onLimitChange(100);
}
}}
>
Cancel
</Button>
</div>
) : (
<Select
value={isPreset ? String(limit) : 'custom'}
onValueChange={handleLimitChange}
>
<SelectTrigger>
<SelectValue placeholder='Select limit' />
</SelectTrigger>
<SelectContent>
<SelectItem value='25'>25</SelectItem>
<SelectItem value='50'>50</SelectItem>
<SelectItem value='100'>100</SelectItem>
<SelectItem value='200'>200</SelectItem>
<SelectItem value='500'>500</SelectItem>
<SelectItem value='1000'>1000</SelectItem>
<SelectItem value='custom'>Custom...</SelectItem>
</SelectContent>
</Select>
)}
{!isCustom && !isPreset && (
<p className='text-muted-foreground text-xs'>Custom: {limit}</p>
)}
</div>
<div className='space-y-2'>
<Label aria-hidden='true'>&nbsp;</Label>
<Button
onClick={onClearFilters}
variant='outline'
className='w-full'
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

178
ui/app/logs/page.tsx Normal file
View File

@@ -0,0 +1,178 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { FileText, RefreshCw } from 'lucide-react';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AdminService } from '@/lib/api/services/admin';
import type { LogEntry } from './types';
import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
import { LogDetailsDialog } from './log-details-dialog';
export default function LogsPage() {
const [selectedDate, setSelectedDate] = useState<string>('all');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const [requestId, setRequestId] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [limit, setLimit] = useState<number>(100);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const {
data: logsData,
refetch: refetchLogs,
isLoading,
} = useQuery({
queryKey: [
'logs',
selectedDate,
selectedLevel,
requestId,
searchText,
limit,
],
queryFn: () =>
AdminService.getLogs({
date: selectedDate === 'all' ? undefined : selectedDate,
level: selectedLevel === 'all' ? undefined : selectedLevel,
requestId: requestId || undefined,
search: searchText || undefined,
limit,
}),
refetchInterval: 30_000,
});
const handleClearFilters = () => {
setSelectedDate('all');
setSelectedLevel('all');
setRequestId('');
setSearchText('');
setLimit(100);
};
const handleLogClick = (entry: LogEntry) => {
setSelectedLog(entry);
setIsDialogOpen(true);
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='overflow-x-hidden p-0'>
<SiteHeader />
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
System Logs
</h1>
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
View and filter application logs
</p>
</div>
<Button
onClick={() => refetchLogs()}
variant='outline'
size='sm'
className='self-start'
>
<RefreshCw className='mr-2 h-4 w-4' />
Refresh
</Button>
</div>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
<Card>
<CardHeader>
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-lg sm:text-xl'>Log Entries</span>
{logsData && (
<Badge variant='secondary' className='text-xs sm:text-sm'>
{logsData.logs.length} entries
</Badge>
)}
</CardTitle>
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
</CardDescription>
)}
</CardHeader>
<CardContent className='overflow-hidden p-3 sm:p-6'>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<RefreshCw className='h-6 w-6 animate-spin' />
<span className='ml-2 text-sm sm:text-base'>
Loading logs...
</span>
</div>
) : logsData?.logs && logsData.logs.length > 0 ? (
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
<div className='space-y-2 pr-3'>
{logsData.logs.map((entry, index) => (
<LogEntryCard
key={`${entry.request_id ?? 'no-request'}-${entry.asctime ?? 'unknown'}-${entry.lineno ?? '0'}-${index}`}
entry={entry}
onClick={handleLogClick}
/>
))}
</div>
</ScrollArea>
) : (
<div className='text-muted-foreground py-8 text-center'>
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
<p className='text-sm sm:text-base'>No log entries found</p>
<p className='text-xs sm:text-sm'>
Try adjusting your filters or check back later
</p>
</div>
)}
</CardContent>
</Card>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</SidebarInset>
</SidebarProvider>
);
}

3
ui/app/logs/types.ts Normal file
View File

@@ -0,0 +1,3 @@
import type { LogEntry, LogsResponse } from '@/lib/api/services/admin';
export type { LogEntry, LogsResponse };

267
ui/app/usage/page.tsx Normal file
View File

@@ -0,0 +1,267 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminService } from '@/lib/api/services/admin';
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';
export default function UsagePage() {
const [timeRange, setTimeRange] = useState('24');
const [interval, setInterval] = useState('15');
const {
data: metricsData,
isLoading: metricsLoading,
refetch: refetchMetrics,
} = useQuery({
queryKey: ['usage-metrics', interval, timeRange],
queryFn: () =>
AdminService.getUsageMetrics(parseInt(interval, 10), parseInt(timeRange, 10)),
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: summaryData,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useQuery({
queryKey: ['usage-summary', timeRange],
queryFn: () => AdminService.getUsageSummary(parseInt(timeRange, 10)),
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: errorData,
isLoading: errorLoading,
refetch: refetchErrors,
} = useQuery({
queryKey: ['usage-errors', timeRange],
queryFn: () => AdminService.getErrorDetails(parseInt(timeRange, 10), 100),
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: revenueByModelData,
isLoading: revenueByModelLoading,
refetch: refetchRevenueByModel,
} = useQuery({
queryKey: ['revenue-by-model', timeRange],
queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange, 10), 20),
refetchInterval: 60_000,
staleTime: 30_000,
});
const handleRefresh = () => {
refetchMetrics();
refetchSummary();
refetchErrors();
refetchRevenueByModel();
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='p-0'>
<SiteHeader />
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>Usage Tracking</h1>
<p className='text-muted-foreground mt-2'>
Monitor system usage, requests, and errors over time
</p>
</div>
<div className='flex items-center gap-4'>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select time range' />
</SelectTrigger>
<SelectContent>
<SelectItem value='1'>Last Hour</SelectItem>
<SelectItem value='6'>Last 6 Hours</SelectItem>
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
</SelectContent>
</Select>
<Select value={interval} onValueChange={setInterval}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select interval' />
</SelectTrigger>
<SelectContent>
<SelectItem value='5'>5 Minutes</SelectItem>
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleRefresh} variant='outline' size='icon'>
<RefreshCw className='h-4 w-4' />
</Button>
</div>
</div>
<div className='space-y-6'>
{summaryLoading ? (
<div className='text-center py-8'>Loading summary...</div>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<div className='grid gap-6 lg:grid-cols-2'>
{metricsLoading ? (
<div className='text-center py-8 col-span-2'>Loading metrics...</div>
) : metricsData && metricsData.metrics.length > 0 ? (
<>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Request Volume'
dataKeys={[
{ key: 'total_requests', name: 'Total Requests', color: '#3b82f6' },
{
key: 'successful_chat_completions',
name: 'Successful',
color: '#10b981',
},
{ key: 'failed_requests', name: 'Failed', color: '#ef4444' },
]}
/>
<UsageMetricsChart
data={metricsData.metrics.map((metric) => ({
...metric,
revenue_sats: metric.revenue_msats / 1000,
refunds_sats: metric.refunds_msats / 1000,
net_revenue_sats:
(metric.revenue_msats - metric.refunds_msats) / 1000,
})) as Array<Record<string, unknown> & { timestamp: string }>}
title='Revenue Over Time (sats)'
dataKeys={[
{ key: 'revenue_sats', name: 'Revenue', color: '#10b981' },
{ key: 'refunds_sats', name: 'Refunds', color: '#ef4444' },
{ key: 'net_revenue_sats', name: 'Net Revenue', color: '#059669' },
]}
/>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Error Tracking'
dataKeys={[
{ key: 'errors', name: 'Errors', color: '#f97316' },
{ key: 'warnings', name: 'Warnings', color: '#eab308' },
{ key: 'upstream_errors', name: 'Upstream Errors', color: '#ef4444' },
]}
/>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Payment Activity'
dataKeys={[
{
key: 'payment_processed',
name: 'Payments Processed',
color: '#6366f1',
},
]}
/>
</>
) : (
<Card className='col-span-2'>
<CardHeader>
<CardTitle>No Data Available</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground'>
No metrics data found for the selected time range. This could be
because no requests have been logged yet or the log files are not
available.
</p>
</CardContent>
</Card>
)}
</div>
{revenueByModelLoading ? (
<div className='text-center py-8'>Loading revenue by model...</div>
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
) : null}
{errorLoading ? (
<div className='text-center py-8'>Loading errors...</div>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
{summaryData && summaryData.unique_models.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Active Models</CardTitle>
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{summaryData.unique_models.map((model) => (
<span
key={model}
className='bg-secondary text-secondary-foreground inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold'
>
{model}
</span>
))}
</div>
</CardContent>
</Card>
)}
{summaryData &&
summaryData.error_types &&
Object.keys(summaryData.error_types).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Error Types Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-2'>
{Object.entries(summaryData.error_types)
.sort(([, a], [, b]) => (b as number) - (a as number))
.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>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -2,7 +2,9 @@
import * as React from 'react';
import {
ActivityIcon,
DatabaseIcon,
FileTextIcon,
LayoutDashboardIcon,
ServerIcon,
SettingsIcon,
@@ -39,6 +41,16 @@ const data = {
url: '/model',
icon: DatabaseIcon,
},
{
title: 'Usage',
url: '/usage',
icon: ActivityIcon,
},
{
title: 'Logs',
url: '/logs',
icon: FileTextIcon,
},
{
title: 'Providers',
url: '/providers',

View File

@@ -0,0 +1,78 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { ErrorDetail } from '@/lib/api/services/admin';
interface ErrorDetailsTableProps {
errors: ErrorDetail[];
}
export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
if (errors.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground text-center py-8'>
No errors found in the selected time period
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors ({errors.length})</CardTitle>
</CardHeader>
<CardContent>
<div className='max-h-[400px] overflow-y-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
<TableHead>Location</TableHead>
<TableHead>Request ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{errors.map((error, index) => (
<TableRow key={index}>
<TableCell className='font-mono text-xs'>
{error.timestamp}
</TableCell>
<TableCell>
<Badge variant='destructive'>{error.error_type}</Badge>
</TableCell>
<TableCell className='max-w-md truncate'>
{error.message}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.pathname}:{error.lineno}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.request_id || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ModelRevenueData } from '@/lib/api/services/admin';
interface RevenueByModelTableProps {
models: ModelRevenueData[];
totalRevenue: number;
}
export function RevenueByModelTable({
models,
totalRevenue,
}: RevenueByModelTableProps) {
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className='text-sm text-muted-foreground'>
Total Revenue:{' '}
{totalRevenue.toLocaleString(undefined, {
maximumFractionDigits: 2,
})}{' '}
sats
</p>
</CardHeader>
<CardContent>
<Table>
<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 (sats)</TableHead>
<TableHead className='text-right'>Refunds (sats)</TableHead>
<TableHead className='text-right'>Net Revenue (sats)</TableHead>
<TableHead className='text-right'>Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className='text-center text-muted-foreground'>
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => (
<TableRow key={model.model}>
<TableCell className='font-medium'>{model.model}</TableCell>
<TableCell className='text-right'>{model.requests}</TableCell>
<TableCell className='text-right text-green-600'>
{model.successful}
</TableCell>
<TableCell className='text-right text-red-600'>
{model.failed}
</TableCell>
<TableCell className='text-right'>
{model.revenue_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
})}
</TableCell>
<TableCell className='text-right text-red-500'>
{model.refunds_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
})}
</TableCell>
<TableCell className='text-right font-semibold'>
{model.net_revenue_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
})}
</TableCell>
<TableCell className='text-right text-muted-foreground'>
{model.avg_revenue_per_request.toLocaleString(undefined, {
maximumFractionDigits: 3,
})}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months:
'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
),
month_grid: 'w-full border-collapse space-y-1',
weekdays: 'flex',
weekday:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
week: 'flex w-full mt-2',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_button: 'h-9 w-9 p-0 font-normal',
range_end: 'day-range-end',
selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
today: 'bg-accent text-accent-foreground',
outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
disabled: 'text-muted-foreground opacity-50',
range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
hidden: 'invisible',
...classNames,
}}
components={{
Chevron: ({ orientation }) => {
if (orientation === 'left') {
return <ChevronLeft className='h-4 w-4' />;
}
return <ChevronRight className='h-4 w-4' />;
},
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@@ -0,0 +1,77 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Legend,
CartesianGrid,
} from 'recharts';
interface UsageMetricsChartProps {
data: Array<Record<string, unknown> & { timestamp: string }>;
title: string;
dataKeys: Array<{
key: string;
name: string;
color: string;
}>;
}
export function UsageMetricsChart({
data,
title,
dataKeys,
}: UsageMetricsChartProps) {
const formattedData = data.map((item) => ({
...item,
time: new Date(item.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}));
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width='100%' height={300}>
<LineChart data={formattedData}>
<CartesianGrid strokeDasharray='3 3' className='stroke-muted' />
<XAxis
dataKey='time'
className='text-xs'
tick={{ fill: 'currentColor' }}
/>
<YAxis className='text-xs' tick={{ fill: 'currentColor' }} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Legend />
{dataKeys.map((dataKey) => (
<Line
key={dataKey.key}
type='monotone'
dataKey={dataKey.key}
stroke={dataKey.color}
name={dataKey.name}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageSummary } from '@/lib/api/services/admin';
import {
CheckCircle2,
XCircle,
AlertTriangle,
Activity,
Database,
CreditCard,
TrendingUp,
DollarSign,
TrendingDown,
Coins,
} from 'lucide-react';
interface UsageSummaryCardsProps {
summary: UsageSummary;
}
export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
const cards = [
{
title: 'Total Requests',
value: summary.total_requests.toLocaleString(),
icon: Activity,
color: 'text-blue-500',
},
{
title: 'Successful Completions',
value: summary.successful_chat_completions.toLocaleString(),
icon: CheckCircle2,
color: 'text-green-500',
},
{
title: 'Revenue (sats)',
value: summary.revenue_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
}),
icon: Coins,
color: 'text-green-600',
},
{
title: 'Net Revenue (sats)',
value: summary.net_revenue_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
}),
icon: DollarSign,
color: 'text-emerald-600',
},
{
title: 'Refunds (sats)',
value: summary.refunds_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
}),
icon: TrendingDown,
color: 'text-red-500',
},
{
title: 'Avg Revenue/Request',
value: `${(summary.avg_revenue_per_request_msats / 1000).toLocaleString(undefined, {
maximumFractionDigits: 3,
})} sats`,
icon: CreditCard,
color: 'text-cyan-500',
},
{
title: 'Success Rate',
value: `${summary.success_rate.toFixed(1)}%`,
icon: TrendingUp,
color: 'text-emerald-500',
},
{
title: 'Refund Rate',
value: `${summary.refund_rate.toFixed(1)}%`,
icon: XCircle,
color: 'text-orange-500',
},
{
title: 'Failed Requests',
value: summary.failed_requests.toLocaleString(),
icon: XCircle,
color: 'text-red-400',
},
{
title: 'Errors',
value: summary.total_errors.toLocaleString(),
icon: AlertTriangle,
color: 'text-orange-500',
},
{
title: 'Unique Models',
value: summary.unique_models_count.toLocaleString(),
icon: Database,
color: 'text-purple-500',
},
{
title: 'Upstream Errors',
value: summary.upstream_errors.toLocaleString(),
icon: AlertTriangle,
color: 'text-yellow-500',
},
];
return (
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
<card.icon className={`h-4 w-4 ${card.color}`} />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{card.value}</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -802,6 +802,68 @@ export class AdminService {
'/admin/api/temporary-balances'
);
}
static async getLogs(params: {
date?: string;
level?: string;
requestId?: string;
search?: string;
limit?: number;
}): Promise<LogsResponse> {
const query: Record<string, string | number | undefined> = {
date: params.date,
level: params.level,
request_id: params.requestId,
search: params.search,
limit: params.limit,
};
Object.keys(query).forEach((key) => {
if (query[key] === undefined || query[key] === '') {
delete query[key];
}
});
return await apiClient.get<LogsResponse>('/admin/api/logs', query);
}
static async getLogDates(): Promise<string[]> {
const response = await apiClient.get<{ dates: string[] }>(
'/admin/api/logs/dates'
);
return response.dates;
}
static async getUsageMetrics(
interval: number = 15,
hours: number = 24
): Promise<UsageMetrics> {
return await apiClient.get<UsageMetrics>(
`/admin/api/usage/metrics?interval=${interval}&hours=${hours}`
);
}
static async getUsageSummary(hours: number = 24): Promise<UsageSummary> {
return await apiClient.get<UsageSummary>(
`/admin/api/usage/summary?hours=${hours}`
);
}
static async getErrorDetails(
hours: number = 24,
limit: number = 100
): Promise<ErrorDetails> {
return await apiClient.get<ErrorDetails>(
`/admin/api/usage/error-details?hours=${hours}&limit=${limit}`
);
}
static async getRevenueByModel(
hours: number = 24,
limit: number = 20
): Promise<RevenueByModel> {
return await apiClient.get<RevenueByModel>(
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
);
}
}
export const TemporaryBalanceSchema = z.object({
@@ -814,3 +876,100 @@ export const TemporaryBalanceSchema = z.object({
});
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
export interface LogEntry {
asctime?: string;
name?: string;
levelname?: string;
message?: string;
pathname?: string;
lineno?: number;
version?: string;
request_id?: string;
[key: string]: unknown;
}
export interface LogsResponse {
logs: LogEntry[];
total: number;
date: string | null;
level: string | null;
request_id: string | null;
search: string | null;
limit: number;
}
export interface UsageMetricData {
timestamp: string;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
errors: number;
warnings: number;
payment_processed: number;
upstream_errors: number;
revenue_msats: number;
refunds_msats: number;
[key: string]: unknown;
}
export interface UsageMetrics {
metrics: UsageMetricData[];
interval_minutes: number;
hours_back: number;
total_buckets: number;
}
export interface UsageSummary {
total_entries: number;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
total_errors: number;
total_warnings: number;
payment_processed: number;
upstream_errors: number;
unique_models_count: number;
unique_models: string[];
error_types: Record<string, number>;
success_rate: number;
revenue_msats: number;
refunds_msats: number;
revenue_sats: number;
refunds_sats: number;
net_revenue_msats: number;
net_revenue_sats: number;
avg_revenue_per_request_msats: number;
refund_rate: number;
}
export interface ErrorDetail {
timestamp: string;
message: string;
error_type: string;
pathname: string;
lineno: number;
request_id: string;
}
export interface ErrorDetails {
errors: ErrorDetail[];
total_count: number;
}
export interface ModelRevenueData {
model: string;
revenue_sats: number;
refunds_sats: number;
net_revenue_sats: number;
requests: number;
successful: number;
failed: number;
avg_revenue_per_request: number;
}
export interface RevenueByModel {
models: ModelRevenueData[];
total_revenue_sats: number;
total_models: number;
}