diff --git a/routstr/auth.py b/routstr/auth.py index b1e16987..ec7274ea 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -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={ diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 3baad78b..dadb7850 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -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 """ + + + + + + Logs - Admin Dashboard + + + +

Redirecting to logs page...

+ + + """ + + +def usage_page() -> str: + return """ + + + + + + Usage - Admin Dashboard + + + +

Redirecting to usage dashboard...

+ + + """ + + +@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): diff --git a/routstr/core/logging.py b/routstr/core/logging.py index d682b944..5e4466b2 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -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 diff --git a/routstr/core/main.py b/routstr/core/main.py index 81212a01..1101a376 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -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") diff --git a/routstr/logs/__init__.py b/routstr/logs/__init__.py new file mode 100644 index 00000000..57c329f8 --- /dev/null +++ b/routstr/logs/__init__.py @@ -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", +] diff --git a/routstr/logs/service.py b/routstr/logs/service.py new file mode 100644 index 00000000..6ead78db --- /dev/null +++ b/routstr/logs/service.py @@ -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, + } diff --git a/routstr/proxy.py b/routstr/proxy.py index ce558fc5..6bcfbb2d 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -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={ diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 7af1be85..14784b5b 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -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={ diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx new file mode 100644 index 00000000..e92394cb --- /dev/null +++ b/ui/app/logs/log-details-dialog.tsx @@ -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(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 ( + + + + + + {log.levelname} + + Log Entry Details + + + {log.asctime ?? 'unknown time'} • {log.name ?? 'unknown'} •{' '} + {log.pathname ?? 'unknown'}:{log.lineno ?? '-'} + + + + +
+
+

Message

+
+
+                  {String(log.message ?? '')}
+                
+
+
+ +
+

Standard Fields

+
+ {standardFields.map((field) => ( +
+
+ + {field} + + {field === 'request_id' && ( + + )} +
+
+
+                        {String(log[field] ?? 'N/A')}
+                      
+
+
+ ))} +
+
+ + {extraFields.length > 0 && ( +
+

Additional Fields

+
+ {extraFields.map((field) => ( +
+ + {field} + +
+ {typeof log[field] === 'object' ? ( +
+                            {JSON.stringify(log[field], null, 2)}
+                          
+ ) : ( +
+                            {String(log[field] ?? 'N/A')}
+                          
+ )} +
+
+ ))} +
+
+ )} + +
+
+

Raw JSON

+ +
+
+
+                  {JSON.stringify(log, null, 2)}
+                
+
+
+
+
+
+
+ ); +} diff --git a/ui/app/logs/log-entry-card.tsx b/ui/app/logs/log-entry-card.tsx new file mode 100644 index 00000000..c52cd4c4 --- /dev/null +++ b/ui/app/logs/log-entry-card.tsx @@ -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 ( +
onClick(entry)} + > +
+
+ + {entry.levelname ?? 'UNKNOWN'} + + + {entry.asctime ?? 'unknown'} + + + {entry.name ?? 'unknown'} + +
+
+
+ {entry.pathname ?? 'unknown'}:{entry.lineno ?? '-'} +
+ +
+
+ +
+ {entry.message} +
+ + {entry.request_id && entry.request_id !== 'no-request-id' && ( +
+
+ + + Request ID: {entry.request_id} + + +
+
+ )} + + {extraFields.length > 0 && ( +
+
Additional Fields:
+
+ {extraFields.slice(0, 4).map((key) => { + const value = entry[key]; + return ( +
+ {key}:{' '} + + {typeof value === 'object' + ? JSON.stringify(value) + : String(value ?? '')} + +
+ ); + })} + {extraFields.length > 4 && ( +
+ ...and {extraFields.length - 4} more fields +
+ )} +
+
+ )} +
+ ); +} diff --git a/ui/app/logs/log-filters.tsx b/ui/app/logs/log-filters.tsx new file mode 100644 index 00000000..cfa3dc72 --- /dev/null +++ b/ui/app/logs/log-filters.tsx @@ -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( + isPreset ? '' : String(limit) + ); + const [isCustom, setIsCustom] = useState(!isPreset); + const [date, setDate] = useState( + 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) => { + 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) => { + if (event.key === 'Enter') { + handleCustomLimitApply(); + } + }; + + const handleDateSelect = (selected: Date | undefined) => { + setDate(selected); + if (selected) { + onDateChange(format(selected, 'yyyy-MM-dd')); + } else { + onDateChange('all'); + } + }; + + return ( + + + + + Filters + + + Filter logs by date, level, request ID, text search, and limit + + + +
+
+ + + + + + + + + + {date && ( + + )} +
+ +
+ + +
+ +
+ + onRequestIdChange(event.target.value)} + /> +
+ +
+ + onSearchTextChange(event.target.value)} + /> +
+ +
+ + {isCustom ? ( +
+ + +
+ ) : ( + + )} + {!isCustom && !isPreset && ( +

Custom: {limit}

+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/app/logs/page.tsx b/ui/app/logs/page.tsx new file mode 100644 index 00000000..0cdee136 --- /dev/null +++ b/ui/app/logs/page.tsx @@ -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('all'); + const [selectedLevel, setSelectedLevel] = useState('all'); + const [requestId, setRequestId] = useState(''); + const [searchText, setSearchText] = useState(''); + const [limit, setLimit] = useState(100); + const [selectedLog, setSelectedLog] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(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 ( + + + + +
+
+
+

+ + System Logs +

+

+ View and filter application logs +

+
+ +
+ + + + + + + Log Entries + {logsData && ( + + {logsData.logs.length} entries + + )} + + {(selectedDate !== 'all' || + selectedLevel !== 'all' || + requestId || + searchText) && ( + + Showing logs + {selectedDate !== 'all' && ` for ${selectedDate}`} + {selectedLevel !== 'all' && ` with level ${selectedLevel}`} + {requestId && ` with request ID ${requestId}`} + {searchText && ` matching "${searchText}"`} + + )} + + + {isLoading ? ( +
+ + + Loading logs... + +
+ ) : logsData?.logs && logsData.logs.length > 0 ? ( + +
+ {logsData.logs.map((entry, index) => ( + + ))} +
+
+ ) : ( +
+ +

No log entries found

+

+ Try adjusting your filters or check back later +

+
+ )} +
+
+ + setIsDialogOpen(false)} + /> +
+
+
+ ); +} diff --git a/ui/app/logs/types.ts b/ui/app/logs/types.ts new file mode 100644 index 00000000..5ea97ae4 --- /dev/null +++ b/ui/app/logs/types.ts @@ -0,0 +1,3 @@ +import type { LogEntry, LogsResponse } from '@/lib/api/services/admin'; + +export type { LogEntry, LogsResponse }; diff --git a/ui/app/usage/page.tsx b/ui/app/usage/page.tsx new file mode 100644 index 00000000..e66123cf --- /dev/null +++ b/ui/app/usage/page.tsx @@ -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 ( + + + + +
+
+
+

Usage Tracking

+

+ Monitor system usage, requests, and errors over time +

+
+
+ + + +
+
+ +
+ {summaryLoading ? ( +
Loading summary...
+ ) : summaryData ? ( + + ) : null} + +
+ {metricsLoading ? ( +
Loading metrics...
+ ) : metricsData && metricsData.metrics.length > 0 ? ( + <> + & { 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' }, + ]} + /> + ({ + ...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 & { 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' }, + ]} + /> + & { 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' }, + ]} + /> + & { timestamp: string }>} + title='Payment Activity' + dataKeys={[ + { + key: 'payment_processed', + name: 'Payments Processed', + color: '#6366f1', + }, + ]} + /> + + ) : ( + + + No Data Available + + +

+ 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. +

+
+
+ )} +
+ + {revenueByModelLoading ? ( +
Loading revenue by model...
+ ) : revenueByModelData && revenueByModelData.models.length > 0 ? ( + + ) : null} + + {errorLoading ? ( +
Loading errors...
+ ) : errorData ? ( + + ) : null} + + {summaryData && summaryData.unique_models.length > 0 && ( + + + Active Models + + +
+ {summaryData.unique_models.map((model) => ( + + {model} + + ))} +
+
+
+ )} + + {summaryData && + summaryData.error_types && + Object.keys(summaryData.error_types).length > 0 && ( + + + Error Types Distribution + + +
+ {Object.entries(summaryData.error_types) + .sort(([, a], [, b]) => (b as number) - (a as number)) + .map(([type, count]) => ( +
+ {type} + + {count} + +
+ ))} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/ui/components/app-sidebar.tsx b/ui/components/app-sidebar.tsx index 5ec96ef5..73c3f6b4 100644 --- a/ui/components/app-sidebar.tsx +++ b/ui/components/app-sidebar.tsx @@ -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', diff --git a/ui/components/error-details-table.tsx b/ui/components/error-details-table.tsx new file mode 100644 index 00000000..5846659e --- /dev/null +++ b/ui/components/error-details-table.tsx @@ -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 ( + + + Recent Errors + + +

+ No errors found in the selected time period +

+
+
+ ); + } + + return ( + + + Recent Errors ({errors.length}) + + +
+ + + + Timestamp + Type + Message + Location + Request ID + + + + {errors.map((error, index) => ( + + + {error.timestamp} + + + {error.error_type} + + + {error.message} + + + {error.pathname}:{error.lineno} + + + {error.request_id || '-'} + + + ))} + +
+
+
+
+ ); +} diff --git a/ui/components/revenue-by-model-table.tsx b/ui/components/revenue-by-model-table.tsx new file mode 100644 index 00000000..c0d0d6d4 --- /dev/null +++ b/ui/components/revenue-by-model-table.tsx @@ -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 ( + + + Revenue by Model +

+ Total Revenue:{' '} + {totalRevenue.toLocaleString(undefined, { + maximumFractionDigits: 2, + })}{' '} + sats +

+
+ + + + + Model + Requests + Successful + Failed + Revenue (sats) + Refunds (sats) + Net Revenue (sats) + Avg/Request + + + + {models.length === 0 ? ( + + + No model data available + + + ) : ( + models.map((model) => ( + + {model.model} + {model.requests} + + {model.successful} + + + {model.failed} + + + {model.revenue_sats.toLocaleString(undefined, { + maximumFractionDigits: 2, + })} + + + {model.refunds_sats.toLocaleString(undefined, { + maximumFractionDigits: 2, + })} + + + {model.net_revenue_sats.toLocaleString(undefined, { + maximumFractionDigits: 2, + })} + + + {model.avg_revenue_per_request.toLocaleString(undefined, { + maximumFractionDigits: 3, + })} + + + )) + )} + +
+
+
+ ); +} diff --git a/ui/components/ui/calendar.tsx b/ui/components/ui/calendar.tsx new file mode 100644 index 00000000..eadcef82 --- /dev/null +++ b/ui/components/ui/calendar.tsx @@ -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; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + { + if (orientation === 'left') { + return ; + } + return ; + }, + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/ui/components/usage-metrics-chart.tsx b/ui/components/usage-metrics-chart.tsx new file mode 100644 index 00000000..294d5e56 --- /dev/null +++ b/ui/components/usage-metrics-chart.tsx @@ -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 & { 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 ( + + + {title} + + + + + + + + + + {dataKeys.map((dataKey) => ( + + ))} + + + + + ); +} diff --git a/ui/components/usage-summary-cards.tsx b/ui/components/usage-summary-cards.tsx new file mode 100644 index 00000000..e395ebed --- /dev/null +++ b/ui/components/usage-summary-cards.tsx @@ -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 ( +
+ {cards.map((card) => ( + + + {card.title} + + + +
{card.value}
+
+
+ ))} +
+ ); +} diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index 9645a6a1..435393cb 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -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 { + const query: Record = { + 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('/admin/api/logs', query); + } + + static async getLogDates(): Promise { + const response = await apiClient.get<{ dates: string[] }>( + '/admin/api/logs/dates' + ); + return response.dates; + } + + static async getUsageMetrics( + interval: number = 15, + hours: number = 24 + ): Promise { + return await apiClient.get( + `/admin/api/usage/metrics?interval=${interval}&hours=${hours}` + ); + } + + static async getUsageSummary(hours: number = 24): Promise { + return await apiClient.get( + `/admin/api/usage/summary?hours=${hours}` + ); + } + + static async getErrorDetails( + hours: number = 24, + limit: number = 100 + ): Promise { + return await apiClient.get( + `/admin/api/usage/error-details?hours=${hours}&limit=${limit}` + ); + } + + static async getRevenueByModel( + hours: number = 24, + limit: number = 20 + ): Promise { + return await apiClient.get( + `/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; + +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; + 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; +}