mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
feat: Add admin logs and usage dashboards
This commit introduces new admin dashboard pages for viewing system logs and usage statistics. It includes backend API endpoints for fetching log data, usage metrics, error details, and revenue by model. The frontend has been updated with new UI components and pages to display this information. Additionally, important log messages used for analytics have been marked to prevent accidental modification. Co-authored-by: db2002dominic <db2002dominic@gmail.com>
This commit is contained in:
co-authored by
db2002dominic
parent
14ae4ecce3
commit
ceb843ef15
@@ -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={
|
||||
|
||||
+152
-1
@@ -3,11 +3,20 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..logs import (
|
||||
LogSearchFilters,
|
||||
collect_error_details,
|
||||
get_available_log_dates,
|
||||
revenue_by_model as aggregate_revenue_by_model,
|
||||
search_logs,
|
||||
summarize_usage,
|
||||
usage_metrics,
|
||||
)
|
||||
from ..payment.models import _row_to_model, list_models
|
||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||
from ..wallet import (
|
||||
@@ -908,6 +917,96 @@ async def view_logs(request: Request, request_id: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)])
|
||||
async def get_logs_api(
|
||||
request: Request,
|
||||
date: str | None = None,
|
||||
level: str | None = None,
|
||||
request_id: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, object]:
|
||||
logs_dir = Path("logs")
|
||||
limit = max(1, min(limit, 1000))
|
||||
filters = LogSearchFilters(
|
||||
date=None if not date or date.lower() == "all" else date,
|
||||
level=None if not level or level.lower() == "all" else level,
|
||||
request_id=request_id or None,
|
||||
search_text=search or None,
|
||||
)
|
||||
log_entries = search_logs(logs_dir, filters, limit)
|
||||
|
||||
return {
|
||||
"logs": log_entries,
|
||||
"total": len(log_entries),
|
||||
"date": date,
|
||||
"level": level,
|
||||
"request_id": request_id,
|
||||
"search": search,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)])
|
||||
async def get_log_dates_api(request: Request) -> dict[str, list[str]]:
|
||||
logs_dir = Path("logs")
|
||||
dates = get_available_log_dates(logs_dir)
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
|
||||
async def get_usage_metrics_api(
|
||||
request: Request,
|
||||
interval: int = Query(
|
||||
default=15, ge=1, le=1440, description="Time interval in minutes"
|
||||
),
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
return usage_metrics(Path("logs"), interval, hours)
|
||||
|
||||
|
||||
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
|
||||
async def get_usage_summary_api(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
return summarize_usage(Path("logs"), hours)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/usage/error-details", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def get_error_details_api(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100, ge=1, le=1000, description="Maximum number of errors to return"
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
return collect_error_details(Path("logs"), hours, limit)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
|
||||
)
|
||||
async def get_revenue_by_model_api(
|
||||
request: Request,
|
||||
hours: int = Query(
|
||||
default=24, ge=1, le=168, description="Hours of history to analyze"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=20, ge=1, le=100, description="Maximum number of models to return"
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
return aggregate_revenue_by_model(Path("logs"), hours, limit)
|
||||
|
||||
|
||||
@admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)])
|
||||
async def withdraw(
|
||||
request: Request, withdraw_request: WithdrawRequest
|
||||
@@ -2404,6 +2503,58 @@ def upstream_providers_page() -> str:
|
||||
)
|
||||
|
||||
|
||||
def logs_page() -> str:
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Logs - Admin Dashboard</title>
|
||||
<script>
|
||||
window.location.href = '/logs';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to logs page...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def usage_page() -> str:
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Usage - Admin Dashboard</title>
|
||||
<script>
|
||||
window.location.href = '/usage';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to usage dashboard...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@admin_router.get("/logs", response_class=HTMLResponse)
|
||||
async def admin_logs(request: Request) -> str:
|
||||
if is_admin_authenticated(request):
|
||||
return logs_page()
|
||||
return admin_auth()
|
||||
|
||||
|
||||
@admin_router.get("/usage", response_class=HTMLResponse)
|
||||
async def admin_usage(request: Request) -> str:
|
||||
if is_admin_authenticated(request):
|
||||
return usage_page()
|
||||
return admin_auth()
|
||||
|
||||
|
||||
@admin_router.get("/upstream-providers", response_class=HTMLResponse)
|
||||
async def admin_upstream_providers(request: Request) -> str:
|
||||
if is_admin_authenticated(request):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogDetailsDialogProps {
|
||||
log: LogEntry | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogDetailsDialog({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LogDetailsDialogProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
|
||||
if (!log) return null;
|
||||
|
||||
const copyToClipboard = (text: string, fieldName?: string) => {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
void navigator.clipboard.writeText(text);
|
||||
if (fieldName) {
|
||||
setCopiedField(fieldName);
|
||||
window.setTimeout(() => setCopiedField(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const standardFields = [
|
||||
'asctime',
|
||||
'name',
|
||||
'levelname',
|
||||
'message',
|
||||
'pathname',
|
||||
'lineno',
|
||||
'version',
|
||||
'request_id',
|
||||
];
|
||||
|
||||
const extraFields = Object.keys(log).filter(
|
||||
(key) => key !== 'key' && !standardFields.includes(key)
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(log.levelname || '')}>
|
||||
{log.levelname}
|
||||
</Badge>
|
||||
<span>Log Entry Details</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{log.asctime ?? 'unknown time'} • {log.name ?? 'unknown'} •{' '}
|
||||
{log.pathname ?? 'unknown'}:{log.lineno ?? '-'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm whitespace-pre break-all'>
|
||||
{String(log.message ?? '')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className='mb-3 text-sm font-medium'>Standard Fields</h4>
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
{standardFields.map((field) => (
|
||||
<div key={field} className='flex flex-col space-y-1'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='text-muted-foreground text-xs font-medium uppercase'>
|
||||
{field}
|
||||
</span>
|
||||
{field === 'request_id' && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
copyToClipboard(String(log[field] ?? ''), field)
|
||||
}
|
||||
className='h-6 flex-shrink-0 px-2'
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<>
|
||||
<Check className='mr-1 h-3 w-3' />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className='mr-1 h-3 w-3' />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='bg-muted max-h-32 overflow-auto rounded p-2'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
{String(log[field] ?? 'N/A')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div>
|
||||
<h4 className='mb-3 text-sm font-medium'>Additional Fields</h4>
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
{extraFields.map((field) => (
|
||||
<div key={field} className='flex flex-col space-y-1'>
|
||||
<span className='text-muted-foreground truncate text-xs font-medium uppercase'>
|
||||
{field}
|
||||
</span>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded p-2'>
|
||||
{typeof log[field] === 'object' ? (
|
||||
<pre className='font-mono text-xs break-all whitespace-pre-wrap'>
|
||||
{JSON.stringify(log[field], null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
{String(log[field] ?? 'N/A')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium'>Raw JSON</h4>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
copyToClipboard(JSON.stringify(log, null, 2), 'json')
|
||||
}
|
||||
className='h-6 px-2'
|
||||
>
|
||||
{copiedField === 'json' ? (
|
||||
<>
|
||||
<Check className='mr-1 h-3 w-3' />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className='mr-1 h-3 w-3' />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='bg-muted max-h-64 overflow-auto rounded-md p-4'>
|
||||
<pre className='text-xs break-all whitespace-pre-wrap'>
|
||||
{JSON.stringify(log, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogEntryCardProps {
|
||||
entry: LogEntry;
|
||||
onClick: (entry: LogEntry) => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
const extraFields = Object.keys(entry).filter(
|
||||
(key) =>
|
||||
![
|
||||
'asctime',
|
||||
'name',
|
||||
'levelname',
|
||||
'message',
|
||||
'pathname',
|
||||
'lineno',
|
||||
'version',
|
||||
'request_id',
|
||||
].includes(key)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
|
||||
onClick={() => onClick(entry)}
|
||||
>
|
||||
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={getLevelColor(entry.levelname || '')}
|
||||
>
|
||||
{entry.levelname ?? 'UNKNOWN'}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
|
||||
{entry.asctime ?? 'unknown'}
|
||||
</span>
|
||||
<Badge variant='secondary' className='truncate text-xs'>
|
||||
{entry.name ?? 'unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<div className='text-muted-foreground truncate text-xs'>
|
||||
{entry.pathname ?? 'unknown'}:{entry.lineno ?? '-'}
|
||||
</div>
|
||||
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</div>
|
||||
|
||||
{entry.request_id && entry.request_id !== 'no-request-id' && (
|
||||
<div className='mb-2 min-w-0'>
|
||||
<div className='inline-block max-w-full'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
|
||||
Request ID: {entry.request_id}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div className='mt-3 min-w-0 border-t pt-3'>
|
||||
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
{extraFields.slice(0, 4).map((key) => {
|
||||
const value = entry[key];
|
||||
return (
|
||||
<div key={key} className='min-w-0 overflow-hidden text-xs break-words'>
|
||||
<span className='font-medium break-all'>{key}:</span>{' '}
|
||||
<span className='text-muted-foreground break-all'>
|
||||
{typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{extraFields.length > 4 && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
...and {extraFields.length - 4} more fields
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, Filter, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LogFiltersProps {
|
||||
selectedDate: string;
|
||||
selectedLevel: string;
|
||||
requestId: string;
|
||||
searchText: string;
|
||||
limit: number;
|
||||
onDateChange: (date: string) => void;
|
||||
onLevelChange: (level: string) => void;
|
||||
onRequestIdChange: (requestId: string) => void;
|
||||
onSearchTextChange: (searchText: string) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
|
||||
|
||||
export function LogFilters({
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
limit,
|
||||
onDateChange,
|
||||
onLevelChange,
|
||||
onRequestIdChange,
|
||||
onSearchTextChange,
|
||||
onLimitChange,
|
||||
onClearFilters,
|
||||
}: LogFiltersProps) {
|
||||
const isPreset = PRESET_LIMITS.includes(String(limit));
|
||||
|
||||
const [customLimit, setCustomLimit] = useState<string>(
|
||||
isPreset ? '' : String(limit)
|
||||
);
|
||||
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
|
||||
const [date, setDate] = useState<Date | undefined>(
|
||||
selectedDate && selectedDate !== 'all' ? new Date(selectedDate) : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const preset = PRESET_LIMITS.includes(String(limit));
|
||||
setIsCustom(!preset);
|
||||
if (!preset) {
|
||||
setCustomLimit(String(limit));
|
||||
}
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate || selectedDate === 'all') {
|
||||
setDate(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setDate(new Date(selectedDate));
|
||||
} catch {
|
||||
setDate(undefined);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const handleLimitChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustom(true);
|
||||
setCustomLimit(String(limit));
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomLimit(event.target.value);
|
||||
};
|
||||
|
||||
const handleCustomLimitApply = () => {
|
||||
const parsed = parseInt(customLimit, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
onLimitChange(parsed);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleCustomLimitApply();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (selected: Date | undefined) => {
|
||||
setDate(selected);
|
||||
if (selected) {
|
||||
onDateChange(format(selected, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
onDateChange('all');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Filter className='h-5 w-5' />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Filter logs by date, level, request ID, text search, and limit
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='date'>Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className='mr-2 h-4 w-4' />
|
||||
{date ? format(date, 'PPP') : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{date && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDateSelect(undefined)}
|
||||
className='w-full'
|
||||
>
|
||||
<X className='mr-2 h-4 w-4' />
|
||||
Clear date
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='level'>Log Level</Label>
|
||||
<Select value={selectedLevel} onValueChange={onLevelChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select level' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All levels</SelectItem>
|
||||
{LOG_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='request-id'>Request ID</Label>
|
||||
<Input
|
||||
id='request-id'
|
||||
type='text'
|
||||
placeholder='Search by request ID'
|
||||
value={requestId}
|
||||
onChange={(event) => onRequestIdChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='search-text' className='flex items-center gap-1'>
|
||||
<span>Text Search</span>
|
||||
<span className='text-muted-foreground text-xs font-normal'>
|
||||
(can be slow)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id='search-text'
|
||||
type='text'
|
||||
placeholder='Search in message and logger'
|
||||
value={searchText}
|
||||
onChange={(event) => onSearchTextChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='limit'>Limit</Label>
|
||||
{isCustom ? (
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='limit'
|
||||
type='number'
|
||||
min='1'
|
||||
placeholder='Enter custom limit'
|
||||
value={customLimit}
|
||||
onChange={handleCustomLimitChange}
|
||||
onKeyDown={handleCustomLimitKeyDown}
|
||||
onBlur={handleCustomLimitApply}
|
||||
autoFocus
|
||||
className='flex-1'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
if (!isPreset) {
|
||||
onLimitChange(100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={isPreset ? String(limit) : 'custom'}
|
||||
onValueChange={handleLimitChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select limit' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='25'>25</SelectItem>
|
||||
<SelectItem value='50'>50</SelectItem>
|
||||
<SelectItem value='100'>100</SelectItem>
|
||||
<SelectItem value='200'>200</SelectItem>
|
||||
<SelectItem value='500'>500</SelectItem>
|
||||
<SelectItem value='1000'>1000</SelectItem>
|
||||
<SelectItem value='custom'>Custom...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{!isCustom && !isPreset && (
|
||||
<p className='text-muted-foreground text-xs'>Custom: {limit}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label aria-hidden='true'> </Label>
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
|
||||
import type { LogEntry } from './types';
|
||||
import { LogFilters } from './log-filters';
|
||||
import { LogEntryCard } from './log-entry-card';
|
||||
import { LogDetailsDialog } from './log-details-dialog';
|
||||
|
||||
export default function LogsPage() {
|
||||
const [selectedDate, setSelectedDate] = useState<string>('all');
|
||||
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
||||
const [requestId, setRequestId] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
data: logsData,
|
||||
refetch: refetchLogs,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'logs',
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
limit,
|
||||
],
|
||||
queryFn: () =>
|
||||
AdminService.getLogs({
|
||||
date: selectedDate === 'all' ? undefined : selectedDate,
|
||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||
requestId: requestId || undefined,
|
||||
search: searchText || undefined,
|
||||
limit,
|
||||
}),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedDate('all');
|
||||
setSelectedLevel('all');
|
||||
setRequestId('');
|
||||
setSearchText('');
|
||||
setLimit(100);
|
||||
};
|
||||
|
||||
const handleLogClick = (entry: LogEntry) => {
|
||||
setSelectedLog(entry);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='overflow-x-hidden p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
|
||||
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
|
||||
System Logs
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
|
||||
View and filter application logs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetchLogs()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='self-start'
|
||||
>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-lg sm:text-xl'>Log Entries</span>
|
||||
{logsData && (
|
||||
<Badge variant='secondary' className='text-xs sm:text-sm'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{(selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
requestId ||
|
||||
searchText) && (
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
Showing logs
|
||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||
{requestId && ` with request ID ${requestId}`}
|
||||
{searchText && ` matching "${searchText}"`}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden p-3 sm:p-6'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<RefreshCw className='h-6 w-6 animate-spin' />
|
||||
<span className='ml-2 text-sm sm:text-base'>
|
||||
Loading logs...
|
||||
</span>
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id ?? 'no-request'}-${entry.asctime ?? 'unknown'}-${entry.lineno ?? '0'}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
|
||||
<p className='text-sm sm:text-base'>No log entries found</p>
|
||||
<p className='text-xs sm:text-sm'>
|
||||
Try adjusting your filters or check back later
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { LogEntry, LogsResponse } from '@/lib/api/services/admin';
|
||||
|
||||
export type { LogEntry, LogsResponse };
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { UsageMetricsChart } from '@/components/usage-metrics-chart';
|
||||
import { UsageSummaryCards } from '@/components/usage-summary-cards';
|
||||
import { ErrorDetailsTable } from '@/components/error-details-table';
|
||||
import { RevenueByModelTable } from '@/components/revenue-by-model-table';
|
||||
|
||||
export default function UsagePage() {
|
||||
const [timeRange, setTimeRange] = useState('24');
|
||||
const [interval, setInterval] = useState('15');
|
||||
|
||||
const {
|
||||
data: metricsData,
|
||||
isLoading: metricsLoading,
|
||||
refetch: refetchMetrics,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-metrics', interval, timeRange],
|
||||
queryFn: () =>
|
||||
AdminService.getUsageMetrics(parseInt(interval, 10), parseInt(timeRange, 10)),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: summaryData,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-summary', timeRange],
|
||||
queryFn: () => AdminService.getUsageSummary(parseInt(timeRange, 10)),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: errorData,
|
||||
isLoading: errorLoading,
|
||||
refetch: refetchErrors,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-errors', timeRange],
|
||||
queryFn: () => AdminService.getErrorDetails(parseInt(timeRange, 10), 100),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: revenueByModelData,
|
||||
isLoading: revenueByModelLoading,
|
||||
refetch: refetchRevenueByModel,
|
||||
} = useQuery({
|
||||
queryKey: ['revenue-by-model', timeRange],
|
||||
queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange, 10), 20),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchMetrics();
|
||||
refetchSummary();
|
||||
refetchErrors();
|
||||
refetchRevenueByModel();
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>Usage Tracking</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor system usage, requests, and errors over time
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className='w-[180px]'>
|
||||
<SelectValue placeholder='Select time range' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='1'>Last Hour</SelectItem>
|
||||
<SelectItem value='6'>Last 6 Hours</SelectItem>
|
||||
<SelectItem value='24'>Last 24 Hours</SelectItem>
|
||||
<SelectItem value='72'>Last 3 Days</SelectItem>
|
||||
<SelectItem value='168'>Last Week</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={interval} onValueChange={setInterval}>
|
||||
<SelectTrigger className='w-[180px]'>
|
||||
<SelectValue placeholder='Select interval' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='5'>5 Minutes</SelectItem>
|
||||
<SelectItem value='15'>15 Minutes</SelectItem>
|
||||
<SelectItem value='30'>30 Minutes</SelectItem>
|
||||
<SelectItem value='60'>1 Hour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleRefresh} variant='outline' size='icon'>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
{summaryLoading ? (
|
||||
<div className='text-center py-8'>Loading summary...</div>
|
||||
) : summaryData ? (
|
||||
<UsageSummaryCards summary={summaryData} />
|
||||
) : null}
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
{metricsLoading ? (
|
||||
<div className='text-center py-8 col-span-2'>Loading metrics...</div>
|
||||
) : metricsData && metricsData.metrics.length > 0 ? (
|
||||
<>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Request Volume'
|
||||
dataKeys={[
|
||||
{ key: 'total_requests', name: 'Total Requests', color: '#3b82f6' },
|
||||
{
|
||||
key: 'successful_chat_completions',
|
||||
name: 'Successful',
|
||||
color: '#10b981',
|
||||
},
|
||||
{ key: 'failed_requests', name: 'Failed', color: '#ef4444' },
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics.map((metric) => ({
|
||||
...metric,
|
||||
revenue_sats: metric.revenue_msats / 1000,
|
||||
refunds_sats: metric.refunds_msats / 1000,
|
||||
net_revenue_sats:
|
||||
(metric.revenue_msats - metric.refunds_msats) / 1000,
|
||||
})) as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Revenue Over Time (sats)'
|
||||
dataKeys={[
|
||||
{ key: 'revenue_sats', name: 'Revenue', color: '#10b981' },
|
||||
{ key: 'refunds_sats', name: 'Refunds', color: '#ef4444' },
|
||||
{ key: 'net_revenue_sats', name: 'Net Revenue', color: '#059669' },
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Error Tracking'
|
||||
dataKeys={[
|
||||
{ key: 'errors', name: 'Errors', color: '#f97316' },
|
||||
{ key: 'warnings', name: 'Warnings', color: '#eab308' },
|
||||
{ key: 'upstream_errors', name: 'Upstream Errors', color: '#ef4444' },
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Payment Activity'
|
||||
dataKeys={[
|
||||
{
|
||||
key: 'payment_processed',
|
||||
name: 'Payments Processed',
|
||||
color: '#6366f1',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Card className='col-span-2'>
|
||||
<CardHeader>
|
||||
<CardTitle>No Data Available</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground'>
|
||||
No metrics data found for the selected time range. This could be
|
||||
because no requests have been logged yet or the log files are not
|
||||
available.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{revenueByModelLoading ? (
|
||||
<div className='text-center py-8'>Loading revenue by model...</div>
|
||||
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
|
||||
<RevenueByModelTable
|
||||
models={revenueByModelData.models}
|
||||
totalRevenue={revenueByModelData.total_revenue_sats}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorLoading ? (
|
||||
<div className='text-center py-8'>Loading errors...</div>
|
||||
) : errorData ? (
|
||||
<ErrorDetailsTable errors={errorData.errors} />
|
||||
) : null}
|
||||
|
||||
{summaryData && summaryData.unique_models.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{summaryData.unique_models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className='bg-secondary text-secondary-foreground inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold'
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{summaryData &&
|
||||
summaryData.error_types &&
|
||||
Object.keys(summaryData.error_types).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Error Types Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(summaryData.error_types)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||
.map(([type, count]) => (
|
||||
<div
|
||||
key={type}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<span className='text-sm font-medium'>{type}</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ErrorDetail } from '@/lib/api/services/admin';
|
||||
|
||||
interface ErrorDetailsTableProps {
|
||||
errors: ErrorDetail[];
|
||||
}
|
||||
|
||||
export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
|
||||
if (errors.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground text-center py-8'>
|
||||
No errors found in the selected time period
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Errors ({errors.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='max-h-[400px] overflow-y-auto'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Location</TableHead>
|
||||
<TableHead>Request ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{errors.map((error, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{error.timestamp}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='destructive'>{error.error_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='max-w-md truncate'>
|
||||
{error.message}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{error.pathname}:{error.lineno}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{error.request_id || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ModelRevenueData } from '@/lib/api/services/admin';
|
||||
|
||||
interface RevenueByModelTableProps {
|
||||
models: ModelRevenueData[];
|
||||
totalRevenue: number;
|
||||
}
|
||||
|
||||
export function RevenueByModelTable({
|
||||
models,
|
||||
totalRevenue,
|
||||
}: RevenueByModelTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Model</CardTitle>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Total Revenue:{' '}
|
||||
{totalRevenue.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
sats
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className='text-right'>Requests</TableHead>
|
||||
<TableHead className='text-right'>Successful</TableHead>
|
||||
<TableHead className='text-right'>Failed</TableHead>
|
||||
<TableHead className='text-right'>Revenue (sats)</TableHead>
|
||||
<TableHead className='text-right'>Refunds (sats)</TableHead>
|
||||
<TableHead className='text-right'>Net Revenue (sats)</TableHead>
|
||||
<TableHead className='text-right'>Avg/Request</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{models.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className='text-center text-muted-foreground'>
|
||||
No model data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
models.map((model) => (
|
||||
<TableRow key={model.model}>
|
||||
<TableCell className='font-medium'>{model.model}</TableCell>
|
||||
<TableCell className='text-right'>{model.requests}</TableCell>
|
||||
<TableCell className='text-right text-green-600'>
|
||||
{model.successful}
|
||||
</TableCell>
|
||||
<TableCell className='text-right text-red-600'>
|
||||
{model.failed}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
{model.revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className='text-right text-red-500'>
|
||||
{model.refunds_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-semibold'>
|
||||
{model.net_revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className='text-right text-muted-foreground'>
|
||||
{model.avg_revenue_per_request.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 3,
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months:
|
||||
'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
|
||||
),
|
||||
month_grid: 'w-full border-collapse space-y-1',
|
||||
weekdays: 'flex',
|
||||
weekday:
|
||||
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
week: 'flex w-full mt-2',
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_button: 'h-9 w-9 p-0 font-normal',
|
||||
range_end: 'day-range-end',
|
||||
selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
today: 'bg-accent text-accent-foreground',
|
||||
outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
disabled: 'text-muted-foreground opacity-50',
|
||||
range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeft className='h-4 w-4' />;
|
||||
}
|
||||
return <ChevronRight className='h-4 w-4' />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { UsageSummary } from '@/lib/api/services/admin';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Database,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
TrendingDown,
|
||||
Coins,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
summary: UsageSummary;
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Requests',
|
||||
value: summary.total_requests.toLocaleString(),
|
||||
icon: Activity,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'Successful Completions',
|
||||
value: summary.successful_chat_completions.toLocaleString(),
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
{
|
||||
title: 'Revenue (sats)',
|
||||
value: summary.revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
icon: Coins,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
title: 'Net Revenue (sats)',
|
||||
value: summary.net_revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
icon: DollarSign,
|
||||
color: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
title: 'Refunds (sats)',
|
||||
value: summary.refunds_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-500',
|
||||
},
|
||||
{
|
||||
title: 'Avg Revenue/Request',
|
||||
value: `${(summary.avg_revenue_per_request_msats / 1000).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 3,
|
||||
})} sats`,
|
||||
icon: CreditCard,
|
||||
color: 'text-cyan-500',
|
||||
},
|
||||
{
|
||||
title: 'Success Rate',
|
||||
value: `${summary.success_rate.toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
color: 'text-emerald-500',
|
||||
},
|
||||
{
|
||||
title: 'Refund Rate',
|
||||
value: `${summary.refund_rate.toFixed(1)}%`,
|
||||
icon: XCircle,
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Failed Requests',
|
||||
value: summary.failed_requests.toLocaleString(),
|
||||
icon: XCircle,
|
||||
color: 'text-red-400',
|
||||
},
|
||||
{
|
||||
title: 'Errors',
|
||||
value: summary.total_errors.toLocaleString(),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Unique Models',
|
||||
value: summary.unique_models_count.toLocaleString(),
|
||||
icon: Database,
|
||||
color: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
title: 'Upstream Errors',
|
||||
value: summary.upstream_errors.toLocaleString(),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{card.value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -802,6 +802,68 @@ export class AdminService {
|
||||
'/admin/api/temporary-balances'
|
||||
);
|
||||
}
|
||||
|
||||
static async getLogs(params: {
|
||||
date?: string;
|
||||
level?: string;
|
||||
requestId?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}): Promise<LogsResponse> {
|
||||
const query: Record<string, string | number | undefined> = {
|
||||
date: params.date,
|
||||
level: params.level,
|
||||
request_id: params.requestId,
|
||||
search: params.search,
|
||||
limit: params.limit,
|
||||
};
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (query[key] === undefined || query[key] === '') {
|
||||
delete query[key];
|
||||
}
|
||||
});
|
||||
return await apiClient.get<LogsResponse>('/admin/api/logs', query);
|
||||
}
|
||||
|
||||
static async getLogDates(): Promise<string[]> {
|
||||
const response = await apiClient.get<{ dates: string[] }>(
|
||||
'/admin/api/logs/dates'
|
||||
);
|
||||
return response.dates;
|
||||
}
|
||||
|
||||
static async getUsageMetrics(
|
||||
interval: number = 15,
|
||||
hours: number = 24
|
||||
): Promise<UsageMetrics> {
|
||||
return await apiClient.get<UsageMetrics>(
|
||||
`/admin/api/usage/metrics?interval=${interval}&hours=${hours}`
|
||||
);
|
||||
}
|
||||
|
||||
static async getUsageSummary(hours: number = 24): Promise<UsageSummary> {
|
||||
return await apiClient.get<UsageSummary>(
|
||||
`/admin/api/usage/summary?hours=${hours}`
|
||||
);
|
||||
}
|
||||
|
||||
static async getErrorDetails(
|
||||
hours: number = 24,
|
||||
limit: number = 100
|
||||
): Promise<ErrorDetails> {
|
||||
return await apiClient.get<ErrorDetails>(
|
||||
`/admin/api/usage/error-details?hours=${hours}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
static async getRevenueByModel(
|
||||
hours: number = 24,
|
||||
limit: number = 20
|
||||
): Promise<RevenueByModel> {
|
||||
return await apiClient.get<RevenueByModel>(
|
||||
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const TemporaryBalanceSchema = z.object({
|
||||
@@ -814,3 +876,100 @@ export const TemporaryBalanceSchema = z.object({
|
||||
});
|
||||
|
||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||
|
||||
export interface LogEntry {
|
||||
asctime?: string;
|
||||
name?: string;
|
||||
levelname?: string;
|
||||
message?: string;
|
||||
pathname?: string;
|
||||
lineno?: number;
|
||||
version?: string;
|
||||
request_id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
logs: LogEntry[];
|
||||
total: number;
|
||||
date: string | null;
|
||||
level: string | null;
|
||||
request_id: string | null;
|
||||
search: string | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UsageMetricData {
|
||||
timestamp: string;
|
||||
total_requests: number;
|
||||
successful_chat_completions: number;
|
||||
failed_requests: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
payment_processed: number;
|
||||
upstream_errors: number;
|
||||
revenue_msats: number;
|
||||
refunds_msats: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UsageMetrics {
|
||||
metrics: UsageMetricData[];
|
||||
interval_minutes: number;
|
||||
hours_back: number;
|
||||
total_buckets: number;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
total_entries: number;
|
||||
total_requests: number;
|
||||
successful_chat_completions: number;
|
||||
failed_requests: number;
|
||||
total_errors: number;
|
||||
total_warnings: number;
|
||||
payment_processed: number;
|
||||
upstream_errors: number;
|
||||
unique_models_count: number;
|
||||
unique_models: string[];
|
||||
error_types: Record<string, number>;
|
||||
success_rate: number;
|
||||
revenue_msats: number;
|
||||
refunds_msats: number;
|
||||
revenue_sats: number;
|
||||
refunds_sats: number;
|
||||
net_revenue_msats: number;
|
||||
net_revenue_sats: number;
|
||||
avg_revenue_per_request_msats: number;
|
||||
refund_rate: number;
|
||||
}
|
||||
|
||||
export interface ErrorDetail {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
error_type: string;
|
||||
pathname: string;
|
||||
lineno: number;
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
export interface ErrorDetails {
|
||||
errors: ErrorDetail[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface ModelRevenueData {
|
||||
model: string;
|
||||
revenue_sats: number;
|
||||
refunds_sats: number;
|
||||
net_revenue_sats: number;
|
||||
requests: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
avg_revenue_per_request: number;
|
||||
}
|
||||
|
||||
export interface RevenueByModel {
|
||||
models: ModelRevenueData[];
|
||||
total_revenue_sats: number;
|
||||
total_models: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user