Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
e85f7660ba feat: Add logs and usage tracking to admin UI
This commit introduces new endpoints and UI components for viewing system logs and usage metrics. It includes:

- **Backend changes:**
    - New API endpoints in `admin.py` for fetching logs, usage metrics, summary statistics, error details, and revenue by model.
    - A new `LogManager` class in `log_manager.py` to handle log file parsing and data aggregation.
    - Updates to `logging.py` to document critical log messages used for usage tracking.
    - Routing for the new UI pages in `main.py`.

- **Frontend changes:**
    - New pages for Logs (`ui/app/logs/page.tsx`) and Usage (`ui/app/usage/page.tsx`).
    - Components for displaying log entries, filters, details dialogs, usage charts, summary cards, error tables, and revenue tables.
    - Updates to `app-sidebar.tsx` to include links to the new pages.
    - Styling adjustments in `globals.css`.

These changes provide administrators with valuable insights into system activity and performance.

Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-22 02:03:18 +00:00
18 changed files with 2471 additions and 66 deletions

View File

@@ -1,9 +1,10 @@
import json
import secrets
from datetime import datetime, timezone
from collections import defaultdict
from datetime import datetime, timedelta, 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
@@ -19,6 +20,7 @@ from ..wallet import (
)
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
from .logging import get_logger
from .log_manager import log_manager
from .settings import SettingsService, settings
logger = get_logger(__name__)
@@ -2865,3 +2867,123 @@ h1 { color: #333; }
.no-logs { text-align: center; color: #666; padding: 40px; }
.request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; }
"""
@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
async def get_usage_metrics(
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:
"""Get usage metrics aggregated by time interval."""
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
async def get_usage_summary(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
) -> dict:
"""Get summary statistics for the specified time period."""
return log_manager.get_usage_summary(hours=hours)
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
async def get_error_details(
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:
"""Get detailed error information."""
return log_manager.get_error_details(hours=hours, limit=limit)
@admin_router.get(
"/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
)
async def get_revenue_by_model(
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:
"""
Get revenue breakdown by model.
"""
return log_manager.get_revenue_by_model(hours=hours, limit=limit)
@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]:
"""
Get filtered log entries.
Args:
date: Filter by specific date (YYYY-MM-DD)
level: Filter by log level
request_id: Filter by request ID
search: Search text in message and name fields (case-insensitive)
limit: Maximum number of entries to return
Returns:
Dict containing logs and filter metadata
"""
log_entries = log_manager.search_logs(
date=date,
level=level,
request_id=request_id,
search_text=search,
limit=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, object]:
logs_dir = Path("logs")
dates = []
if logs_dir.exists():
log_files = sorted(
logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True
)
for log_file in log_files[:30]:
try:
filename = log_file.name
date_str = filename.replace("app_", "").replace(".log", "")
dates.append(date_str)
except Exception:
continue
return {"dates": dates}

434
routstr/core/log_manager.py Normal file
View File

@@ -0,0 +1,434 @@
import json
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Generator, Iterator
from .logging import get_logger
logger = get_logger(__name__)
class LogManager:
def __init__(self, logs_dir: Path = Path("logs")):
self.logs_dir = logs_dir
def _yield_log_entries(
self,
hours_back: int | None = None,
specific_date: str | None = None,
reverse_files: bool = False
) -> Iterator[dict[str, Any]]:
"""
Yields log entries from files.
Args:
hours_back: specific number of hours to look back.
specific_date: specific date string (YYYY-MM-DD) to look at.
reverse_files: if True, process files in reverse order (newest first).
"""
if not self.logs_dir.exists():
return
log_files = []
cutoff_date = None
if specific_date:
log_file = self.logs_dir / f"app_{specific_date}.log"
if log_file.exists():
log_files.append(log_file)
else:
log_files = sorted(self.logs_dir.glob("app_*.log"))
if reverse_files:
log_files.reverse()
# If we only care about hours back, we can optimize file selection
if hours_back is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
filtered_files = []
for f in log_files:
try:
file_date_str = f.stem.split("_")[1]
file_date = datetime.strptime(file_date_str, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
# Include file if it's from the same day or after the cutoff day
if file_date >= cutoff_date.replace(hour=0, minute=0, second=0, microsecond=0):
filtered_files.append(f)
except Exception:
continue
log_files = filtered_files
for log_file in log_files:
try:
with open(log_file, "r") as f:
# For reverse search, we might want to read lines in reverse?
# But usually logs are append-only.
# If reverse_files is True, we iterate files newest to oldest.
# But lines within file are still oldest to newest unless we reverse them.
lines = f.readlines()
if reverse_files:
lines.reverse()
for line in lines:
try:
entry = json.loads(line.strip())
if cutoff_date:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
if log_time < cutoff_date:
continue
yield entry
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Error processing log file {log_file}: {e}")
continue
def search_logs(
self,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
Search through log files and return matching entries.
"""
log_entries: list[dict[str, Any]] = []
# Use reverse=True to get newest logs first by default
# If date is specified, we only look at that file
search_text_lower = search_text.lower() if search_text else None
# We iterate efficiently
iterator = self._yield_log_entries(specific_date=date, reverse_files=True if not date else False)
# If we are searching globally (no date), we might want to limit how far back we go?
# PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files)
# My _yield_log_entries with reverse_files=True does all files.
# Let's rely on limit to stop us.
files_processed = 0
# Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield?
# For now, let's just iterate.
for log_data in iterator:
if not self._matches_filters(log_data, level, request_id, search_text_lower):
continue
log_entries.append(log_data)
if len(log_entries) >= limit:
break
# Sort by time descending (newest first)
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
return log_entries
def _matches_filters(
self,
log_data: dict[str, Any],
level: str | None,
request_id: str | None,
search_text_lower: str | None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
if request_id and log_data.get("request_id") != request_id:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
pathname = str(log_data.get("pathname", "")).lower()
if (
search_text_lower not in message
and search_text_lower not in name
and search_text_lower not in pathname
):
return False
return True
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._calculate_summary_stats(entries)
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
errors: list[dict] = []
# Iterate newest to oldest for errors?
# yield_log_entries sorts files by name (date) ascending by default.
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
# For error details "last N errors", we probably want newest first.
# Using list() loads everything into memory, which is what PR 229 did.
# For optimization, we could use reverse iterator.
# Let's just stick to PR 229 logic which filters 'ERROR' level.
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
timestamp_str = entry.get("asctime", "")
errors.append({
"timestamp": timestamp_str,
"message": entry.get("message", ""),
"error_type": entry.get("error_type", "unknown"),
"pathname": entry.get("pathname", ""),
"lineno": entry.get("lineno", 0),
"request_id": entry.get("request_id", ""),
})
# Sort reverse time
errors.sort(key=lambda x: x["timestamp"], reverse=True)
return {"errors": errors[:limit], "total_count": len(errors)}
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
model_stats: dict[str, dict[str, int | float]] = defaultdict(
lambda: {
"revenue_msats": 0,
"refunds_msats": 0,
"requests": 0,
"successful": 0,
"failed": 0,
}
)
for entry in entries:
try:
model = entry.get("model", "unknown")
if not isinstance(model, str):
model = "unknown"
message = entry.get("message", "").lower()
if "received proxy request" in message:
model_stats[model]["requests"] += 1
if "token adjustment completed" in message:
model_stats[model]["successful"] += 1
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
model_stats[model]["revenue_msats"] += actual_cost
if "revert payment" in message or "upstream request failed" in message:
model_stats[model]["failed"] += 1
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
model_stats[model]["refunds_msats"] += max_cost
except Exception:
continue
models = []
total_revenue = 0.0
for model, stats in model_stats.items():
revenue_msats = float(stats["revenue_msats"])
refunds_msats = float(stats["refunds_msats"])
revenue_sats = revenue_msats / 1000
refunds_sats = refunds_msats / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_revenue += net_revenue_sats
requests = int(stats["requests"])
successful = int(stats["successful"])
models.append({
"model": model,
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_sats": net_revenue_sats,
"requests": requests,
"successful": successful,
"failed": int(stats["failed"]),
"avg_revenue_per_request": (
revenue_sats / successful if successful > 0 else 0
),
})
models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True)
return {
"models": models[:limit],
"total_revenue_sats": total_revenue,
"total_models": len(models),
}
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
stats: dict[str, Any] = {
"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": set(),
"error_types": defaultdict(int),
"revenue_msats": 0.0,
"refunds_msats": 0.0,
}
for entry in entries:
try:
stats["total_entries"] += 1
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if level == "ERROR":
stats["total_errors"] += 1
if "error_type" in entry:
stats["error_types"][str(entry["error_type"])] += 1
elif level == "WARNING":
stats["total_warnings"] += 1
if "received proxy request" in message:
stats["total_requests"] += 1
if "token adjustment completed" in message:
stats["successful_chat_completions"] += 1
if "upstream request failed" in message or "revert payment" in message:
stats["failed_requests"] += 1
if "payment processed successfully" in message:
stats["payment_processed"] += 1
if "upstream" in message and level == "ERROR":
stats["upstream_errors"] += 1
if "model" in entry:
model = entry["model"]
if isinstance(model, str) and model != "unknown":
stats["unique_models"].add(model)
if "token adjustment completed" in message:
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
stats["revenue_msats"] += float(actual_cost)
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
stats["refunds_msats"] += float(max_cost)
except Exception:
continue
revenue_sats = stats["revenue_msats"] / 1000
refunds_sats = stats["refunds_msats"] / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_requests = stats["total_requests"]
successful = stats["successful_chat_completions"]
return {
"total_entries": stats["total_entries"],
"total_requests": total_requests,
"successful_chat_completions": successful,
"failed_requests": stats["failed_requests"],
"total_errors": stats["total_errors"],
"total_warnings": stats["total_warnings"],
"payment_processed": stats["payment_processed"],
"upstream_errors": stats["upstream_errors"],
"unique_models_count": len(stats["unique_models"]),
"unique_models": sorted(list(stats["unique_models"])),
"error_types": dict(stats["error_types"]),
"success_rate": (successful / total_requests * 100) if total_requests > 0 else 0,
"revenue_msats": stats["revenue_msats"],
"refunds_msats": stats["refunds_msats"],
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"],
"net_revenue_sats": net_revenue_sats,
"avg_revenue_per_request_msats": (
stats["revenue_msats"] / successful if successful > 0 else 0
),
"refund_rate": (
(stats["failed_requests"] / total_requests * 100) if total_requests > 0 else 0
),
}
def _aggregate_metrics_by_time(
self, entries: list[dict], interval_minutes: int, hours_back: int
) -> dict:
time_buckets = defaultdict(lambda: {
"requests": 0,
"errors": 0,
"revenue_msats": 0.0
})
for entry in entries:
try:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
# Round down to nearest interval
minutes = log_time.minute
rounded_minutes = (minutes // interval_minutes) * interval_minutes
bucket_time = log_time.replace(minute=rounded_minutes, second=0, microsecond=0)
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
bucket = time_buckets[bucket_key]
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if "received proxy request" in message:
bucket["requests"] += 1
if level == "ERROR":
bucket["errors"] += 1
if "token adjustment completed" in message:
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
bucket["revenue_msats"] += float(actual_cost)
except Exception:
continue
result = []
for bucket_key in sorted(time_buckets.keys()):
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
return {
"metrics": result,
"interval_minutes": interval_minutes,
"hours_back": hours_back,
"total_buckets": len(result),
}
log_manager = LogManager()

View File

@@ -1,3 +1,40 @@
"""
Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
DO NOT modify or remove these messages without updating the usage tracking logic:
1. "Received proxy request" (INFO) - routstr/proxy.py
- Used to count total incoming requests
- Includes model information in context
2. "Token adjustment completed for streaming" (INFO) - routstr/upstream/base.py
"Token adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
- Used to track successful completions and revenue
- The 'cost_data.total_msats' field is extracted for revenue calculation
- Must include 'cost_data' in extra dict
3. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
- Used to track failed requests and refunds
- The 'max_cost_for_model' field is extracted for refund calculation
- Must include 'max_cost_for_model' in extra dict
5. Any ERROR level logs with "upstream" in the message
- Used to count upstream provider errors
- Helps identify service reliability issues
If you need to modify these messages, ensure you also update the parsing logic in:
- routstr/core/admin.py:_aggregate_metrics_by_time()
- routstr/core/admin.py:_get_summary_stats()
- routstr/core/admin.py:get_revenue_by_model()
"""
import logging.config
import logging.handlers
import os

View File

@@ -264,6 +264,24 @@ 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")
# Add explicit route for /logs/index.txt to redirect to /logs
@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")
# Add explicit route for /usage/index.txt to redirect to /usage
@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")

View File

@@ -6,8 +6,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: Geist, sans-serif;
--font-mono: Geist Mono, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -41,75 +41,152 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-serif: Georgia, serif;
--radius: 0.5rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--radius: 0.5rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.44 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.63 0.19 23.03);
--border: oklch(0.92 0 0);
--input: oklch(0.94 0 0);
--ring: oklch(0 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.55 0.22 264.53);
--chart-3: oklch(0.72 0 0);
--chart-4: oklch(0.92 0 0);
--chart-5: oklch(0.56 0 0);
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.94 0 0);
--sidebar-ring: oklch(0 0 0);
--destructive-foreground: oklch(1 0 0);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
--tracking-normal: 0em;
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.14 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.25 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.23 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.69 0.2 23.91);
--border: oklch(0.26 0 0);
--input: oklch(0.32 0 0);
--ring: oklch(0.72 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.58 0.21 260.84);
--chart-3: oklch(0.56 0 0);
--chart-4: oklch(0.44 0 0);
--chart-5: oklch(0.92 0 0);
--sidebar: oklch(0.18 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.32 0 0);
--sidebar-ring: oklch(0.72 0 0);
--destructive-foreground: oklch(0 0 0);
--radius: 0.5rem;
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
}
@layer base {
@@ -118,6 +195,7 @@
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

View File

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

View File

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

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

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

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

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

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

@@ -0,0 +1,25 @@
export interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname: string;
lineno: number;
version: string;
request_id: string;
[key: string]: string | number | object | undefined;
}
export interface LogsResponse {
logs: LogEntry[];
total: number;
date: string | null;
level: string | null;
request_id: string | null;
search: string | null;
limit: number;
}
export interface DatesResponse {
dates: string[];
}

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

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

View File

@@ -2,6 +2,8 @@
import * as React from 'react';
import {
ActivityIcon,
FileTextIcon,
DatabaseIcon,
LayoutDashboardIcon,
ServerIcon,
@@ -34,6 +36,16 @@ const data = {
url: '/',
icon: LayoutDashboardIcon,
},
{
title: 'Usage',
url: '/usage',
icon: ActivityIcon,
},
{
title: 'Logs',
url: '/logs',
icon: FileTextIcon,
},
{
title: 'Models',
url: '/model',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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