From ceb843ef15d547370c97c8bff362690a80047888 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Nov 2025 02:18:39 +0000 Subject: [PATCH] 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 --- routstr/auth.py | 2 + routstr/core/admin.py | 153 ++++++- routstr/core/logging.py | 23 + routstr/core/main.py | 16 + routstr/logs/__init__.py | 19 + routstr/logs/service.py | 554 +++++++++++++++++++++++ routstr/proxy.py | 2 + routstr/upstream/base.py | 2 + ui/app/logs/log-details-dialog.tsx | 206 +++++++++ ui/app/logs/log-entry-card.tsx | 117 +++++ ui/app/logs/log-filters.tsx | 298 ++++++++++++ ui/app/logs/page.tsx | 178 ++++++++ ui/app/logs/types.ts | 3 + ui/app/usage/page.tsx | 267 +++++++++++ ui/components/app-sidebar.tsx | 12 + ui/components/error-details-table.tsx | 78 ++++ ui/components/revenue-by-model-table.tsx | 95 ++++ ui/components/ui/calendar.tsx | 73 +++ ui/components/usage-metrics-chart.tsx | 77 ++++ ui/components/usage-summary-cards.tsx | 121 +++++ ui/lib/api/services/admin.ts | 159 +++++++ 21 files changed, 2454 insertions(+), 1 deletion(-) create mode 100644 routstr/logs/__init__.py create mode 100644 routstr/logs/service.py create mode 100644 ui/app/logs/log-details-dialog.tsx create mode 100644 ui/app/logs/log-entry-card.tsx create mode 100644 ui/app/logs/log-filters.tsx create mode 100644 ui/app/logs/page.tsx create mode 100644 ui/app/logs/types.ts create mode 100644 ui/app/usage/page.tsx create mode 100644 ui/components/error-details-table.tsx create mode 100644 ui/components/revenue-by-model-table.tsx create mode 100644 ui/components/ui/calendar.tsx create mode 100644 ui/components/usage-metrics-chart.tsx create mode 100644 ui/components/usage-summary-cards.tsx 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; +}