mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
1 Commits
90da3803c6
...
cursor/uni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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={
|
||||
|
||||
@@ -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")
|
||||
|
||||
19
routstr/logs/__init__.py
Normal file
19
routstr/logs/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .service import ( # noqa: F401
|
||||
LogSearchFilters,
|
||||
collect_error_details,
|
||||
get_available_log_dates,
|
||||
search_logs,
|
||||
summarize_usage,
|
||||
usage_metrics,
|
||||
revenue_by_model,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LogSearchFilters",
|
||||
"collect_error_details",
|
||||
"get_available_log_dates",
|
||||
"search_logs",
|
||||
"summarize_usage",
|
||||
"usage_metrics",
|
||||
"revenue_by_model",
|
||||
]
|
||||
554
routstr/logs/service.py
Normal file
554
routstr/logs/service.py
Normal file
@@ -0,0 +1,554 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
LOG_FILE_PREFIX = "app_"
|
||||
LOG_FILE_SUFFIX = ".log"
|
||||
LOG_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
DEFAULT_SEARCH_FILE_LIMIT = 7
|
||||
|
||||
LogEntry = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LogSearchFilters:
|
||||
date: str | None = None
|
||||
level: str | None = None
|
||||
request_id: str | None = None
|
||||
search_text: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EntryInsights:
|
||||
timestamp: datetime | None
|
||||
level: str
|
||||
model: str | None
|
||||
event_total_request: bool
|
||||
event_success: bool
|
||||
event_fail: bool
|
||||
event_payment_processed: bool
|
||||
event_upstream_error: bool
|
||||
revenue_msats: float
|
||||
refund_msats: float
|
||||
error_type: str | None
|
||||
|
||||
|
||||
def search_logs(
|
||||
logs_dir: Path, filters: LogSearchFilters, limit: int = 100
|
||||
) -> list[LogEntry]:
|
||||
if limit <= 0 or not logs_dir.exists():
|
||||
return []
|
||||
|
||||
files = _collect_log_files(
|
||||
logs_dir,
|
||||
date=filters.date,
|
||||
limit=None if filters.date else DEFAULT_SEARCH_FILE_LIMIT,
|
||||
)
|
||||
|
||||
search_lower = filters.search_text.lower() if filters.search_text else None
|
||||
normalized_level = filters.level.upper() if filters.level else None
|
||||
entries: list[LogEntry] = []
|
||||
|
||||
for log_file in files:
|
||||
for entry in _stream_log_entries(log_file):
|
||||
if not _matches_filters(
|
||||
entry=entry,
|
||||
level=normalized_level,
|
||||
request_id=filters.request_id,
|
||||
search_lower=search_lower,
|
||||
):
|
||||
continue
|
||||
|
||||
entries.append(entry)
|
||||
if len(entries) >= limit:
|
||||
break
|
||||
|
||||
if len(entries) >= limit:
|
||||
break
|
||||
|
||||
entries.sort(key=lambda e: e.get("asctime", ""), reverse=True)
|
||||
return entries[:limit]
|
||||
|
||||
|
||||
def get_available_log_dates(logs_dir: Path, limit: int = 30) -> list[str]:
|
||||
if not logs_dir.exists():
|
||||
return []
|
||||
|
||||
dates: list[str] = []
|
||||
files = _collect_log_files(logs_dir, limit=limit)
|
||||
for log_file in files:
|
||||
date_str = _extract_date_from_filename(log_file)
|
||||
if date_str:
|
||||
dates.append(date_str)
|
||||
|
||||
return dates
|
||||
|
||||
|
||||
def usage_metrics(
|
||||
logs_dir: Path, interval_minutes: int, hours: int
|
||||
) -> dict[str, Any]:
|
||||
if not logs_dir.exists():
|
||||
return {
|
||||
"metrics": [],
|
||||
"interval_minutes": interval_minutes,
|
||||
"hours_back": hours,
|
||||
"total_buckets": 0,
|
||||
}
|
||||
|
||||
entries = _load_entries_since(logs_dir, hours)
|
||||
if not entries:
|
||||
return {
|
||||
"metrics": [],
|
||||
"interval_minutes": interval_minutes,
|
||||
"hours_back": hours,
|
||||
"total_buckets": 0,
|
||||
}
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
buckets: dict[str, dict[str, float]] = {}
|
||||
|
||||
for entry in entries:
|
||||
insights = _analyze_entry(entry)
|
||||
timestamp = insights.timestamp
|
||||
if timestamp is None or timestamp < cutoff:
|
||||
continue
|
||||
|
||||
bucket_time = timestamp.replace(
|
||||
minute=(timestamp.minute // interval_minutes) * interval_minutes,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
bucket_key = bucket_time.isoformat()
|
||||
bucket = buckets.setdefault(
|
||||
bucket_key,
|
||||
{
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
if insights.level == "ERROR":
|
||||
bucket["errors"] += 1
|
||||
elif insights.level == "WARNING":
|
||||
bucket["warnings"] += 1
|
||||
|
||||
if insights.event_total_request:
|
||||
bucket["total_requests"] += 1
|
||||
if insights.event_success:
|
||||
bucket["successful_chat_completions"] += 1
|
||||
bucket["revenue_msats"] += insights.revenue_msats
|
||||
if insights.event_fail:
|
||||
bucket["failed_requests"] += 1
|
||||
bucket["refunds_msats"] += insights.refund_msats
|
||||
if insights.event_payment_processed:
|
||||
bucket["payment_processed"] += 1
|
||||
if insights.event_upstream_error:
|
||||
bucket["upstream_errors"] += 1
|
||||
|
||||
metrics = [
|
||||
{"timestamp": key, **values}
|
||||
for key, values in sorted(buckets.items(), key=lambda item: item[0])
|
||||
]
|
||||
|
||||
return {
|
||||
"metrics": metrics,
|
||||
"interval_minutes": interval_minutes,
|
||||
"hours_back": hours,
|
||||
"total_buckets": len(metrics),
|
||||
}
|
||||
|
||||
|
||||
def summarize_usage(logs_dir: Path, hours: int) -> dict[str, Any]:
|
||||
if not logs_dir.exists():
|
||||
return _empty_summary()
|
||||
|
||||
entries = _load_entries_since(logs_dir, hours)
|
||||
if not entries:
|
||||
return _empty_summary()
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
total_entries = 0
|
||||
total_requests = 0
|
||||
successful = 0
|
||||
failed = 0
|
||||
total_errors = 0
|
||||
total_warnings = 0
|
||||
payment_processed = 0
|
||||
upstream_errors = 0
|
||||
revenue_msats = 0.0
|
||||
refunds_msats = 0.0
|
||||
unique_models: set[str] = set()
|
||||
error_types: dict[str, int] = {}
|
||||
|
||||
for entry in entries:
|
||||
insights = _analyze_entry(entry)
|
||||
timestamp = insights.timestamp
|
||||
if timestamp is None or timestamp < cutoff:
|
||||
continue
|
||||
|
||||
total_entries += 1
|
||||
|
||||
if insights.level == "ERROR":
|
||||
total_errors += 1
|
||||
if insights.error_type:
|
||||
error_types[insights.error_type] = (
|
||||
error_types.get(insights.error_type, 0) + 1
|
||||
)
|
||||
elif insights.level == "WARNING":
|
||||
total_warnings += 1
|
||||
|
||||
if insights.event_total_request:
|
||||
total_requests += 1
|
||||
if insights.event_success:
|
||||
successful += 1
|
||||
revenue_msats += insights.revenue_msats
|
||||
if insights.event_fail:
|
||||
failed += 1
|
||||
refunds_msats += insights.refund_msats
|
||||
if insights.event_payment_processed:
|
||||
payment_processed += 1
|
||||
if insights.event_upstream_error:
|
||||
upstream_errors += 1
|
||||
|
||||
if insights.model:
|
||||
unique_models.add(insights.model)
|
||||
|
||||
revenue_sats = revenue_msats / 1000
|
||||
refunds_sats = refunds_msats / 1000
|
||||
net_revenue_msats = revenue_msats - refunds_msats
|
||||
net_revenue_sats = net_revenue_msats / 1000
|
||||
success_rate = (successful / total_requests * 100) if total_requests else 0
|
||||
refund_rate = (failed / total_requests * 100) if total_requests else 0
|
||||
avg_revenue_per_request = (
|
||||
revenue_msats / successful if successful else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_entries": total_entries,
|
||||
"total_requests": total_requests,
|
||||
"successful_chat_completions": successful,
|
||||
"failed_requests": failed,
|
||||
"total_errors": total_errors,
|
||||
"total_warnings": total_warnings,
|
||||
"payment_processed": payment_processed,
|
||||
"upstream_errors": upstream_errors,
|
||||
"unique_models_count": len(unique_models),
|
||||
"unique_models": sorted(unique_models),
|
||||
"error_types": error_types,
|
||||
"success_rate": success_rate,
|
||||
"revenue_msats": revenue_msats,
|
||||
"refunds_msats": refunds_msats,
|
||||
"revenue_sats": revenue_sats,
|
||||
"refunds_sats": refunds_sats,
|
||||
"net_revenue_msats": net_revenue_msats,
|
||||
"net_revenue_sats": net_revenue_sats,
|
||||
"avg_revenue_per_request_msats": avg_revenue_per_request,
|
||||
"refund_rate": refund_rate,
|
||||
}
|
||||
|
||||
|
||||
def collect_error_details(
|
||||
logs_dir: Path, hours: int, limit: int
|
||||
) -> dict[str, Any]:
|
||||
if not logs_dir.exists():
|
||||
return {"errors": [], "total_count": 0}
|
||||
|
||||
entries = _load_entries_since(logs_dir, hours)
|
||||
if not entries:
|
||||
return {"errors": [], "total_count": 0}
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
for entry in entries:
|
||||
level = str(entry.get("levelname", "")).upper()
|
||||
if level != "ERROR":
|
||||
continue
|
||||
|
||||
timestamp = _parse_timestamp(entry)
|
||||
if timestamp is None or timestamp < cutoff:
|
||||
continue
|
||||
|
||||
timestamp_str = str(entry.get("asctime", ""))
|
||||
message = str(entry.get("message", ""))
|
||||
error_type = str(entry.get("error_type", "unknown"))
|
||||
pathname = str(entry.get("pathname", ""))
|
||||
try:
|
||||
lineno = int(entry.get("lineno", 0))
|
||||
except (TypeError, ValueError):
|
||||
lineno = 0
|
||||
request_id = str(entry.get("request_id", ""))
|
||||
|
||||
errors.append(
|
||||
{
|
||||
"timestamp": timestamp_str,
|
||||
"message": message,
|
||||
"error_type": error_type,
|
||||
"pathname": pathname,
|
||||
"lineno": lineno,
|
||||
"request_id": request_id,
|
||||
}
|
||||
)
|
||||
|
||||
errors.sort(key=lambda item: item["timestamp"], reverse=True)
|
||||
return {"errors": errors[:limit], "total_count": len(errors)}
|
||||
|
||||
|
||||
def revenue_by_model(
|
||||
logs_dir: Path, hours: int, limit: int
|
||||
) -> dict[str, Any]:
|
||||
if not logs_dir.exists():
|
||||
return {"models": [], "total_revenue_sats": 0, "total_models": 0}
|
||||
|
||||
entries = _load_entries_since(logs_dir, hours)
|
||||
if not entries:
|
||||
return {"models": [], "total_revenue_sats": 0, "total_models": 0}
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
model_stats: dict[str, dict[str, float | int]] = {}
|
||||
|
||||
for entry in entries:
|
||||
insights = _analyze_entry(entry)
|
||||
timestamp = insights.timestamp
|
||||
if timestamp is None or timestamp < cutoff:
|
||||
continue
|
||||
|
||||
model = insights.model or "unknown"
|
||||
stats = model_stats.setdefault(
|
||||
model,
|
||||
{
|
||||
"requests": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
if insights.event_total_request:
|
||||
stats["requests"] += 1
|
||||
if insights.event_success:
|
||||
stats["successful"] += 1
|
||||
stats["revenue_msats"] += insights.revenue_msats
|
||||
if insights.event_fail:
|
||||
stats["failed"] += 1
|
||||
stats["refunds_msats"] += insights.refund_msats
|
||||
|
||||
models: list[dict[str, Any]] = []
|
||||
total_revenue_sats = 0.0
|
||||
|
||||
for model, stats in model_stats.items():
|
||||
revenue_msats = float(stats["revenue_msats"])
|
||||
refunds_msats = float(stats["refunds_msats"])
|
||||
requests = int(stats["requests"])
|
||||
successful = int(stats["successful"])
|
||||
failed = int(stats["failed"])
|
||||
|
||||
revenue_sats = revenue_msats / 1000
|
||||
refunds_sats = refunds_msats / 1000
|
||||
net_revenue_sats = revenue_sats - refunds_sats
|
||||
total_revenue_sats += net_revenue_sats
|
||||
|
||||
avg_revenue = revenue_sats / successful if successful else 0
|
||||
models.append(
|
||||
{
|
||||
"model": model,
|
||||
"revenue_sats": revenue_sats,
|
||||
"refunds_sats": refunds_sats,
|
||||
"net_revenue_sats": net_revenue_sats,
|
||||
"requests": requests,
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"avg_revenue_per_request": avg_revenue,
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda item: item["net_revenue_sats"], reverse=True)
|
||||
|
||||
return {
|
||||
"models": models[:limit],
|
||||
"total_revenue_sats": total_revenue_sats,
|
||||
"total_models": len(models),
|
||||
}
|
||||
|
||||
|
||||
def _collect_log_files(
|
||||
logs_dir: Path, date: str | None = None, limit: int | None = None
|
||||
) -> list[Path]:
|
||||
if date:
|
||||
candidate = logs_dir / f"{LOG_FILE_PREFIX}{date}{LOG_FILE_SUFFIX}"
|
||||
return [candidate] if candidate.exists() else []
|
||||
|
||||
files = sorted(
|
||||
logs_dir.glob(f"{LOG_FILE_PREFIX}*{LOG_FILE_SUFFIX}"),
|
||||
key=lambda path: path.name,
|
||||
reverse=True,
|
||||
)
|
||||
if limit is not None:
|
||||
return files[:limit]
|
||||
return files
|
||||
|
||||
|
||||
def _stream_log_entries(log_file: Path) -> Iterator[LogEntry]:
|
||||
try:
|
||||
with log_file.open("r") as handle:
|
||||
for line in handle:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
if isinstance(data, dict):
|
||||
yield data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _matches_filters(
|
||||
entry: LogEntry,
|
||||
level: str | None,
|
||||
request_id: str | None,
|
||||
search_lower: str | None,
|
||||
) -> bool:
|
||||
if level and str(entry.get("levelname", "")).upper() != level:
|
||||
return False
|
||||
|
||||
if request_id and entry.get("request_id") != request_id:
|
||||
return False
|
||||
|
||||
if search_lower:
|
||||
message = str(entry.get("message", "")).lower()
|
||||
name = str(entry.get("name", "")).lower()
|
||||
if search_lower not in message and search_lower not in name:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _extract_date_from_filename(log_file: Path) -> str | None:
|
||||
name = log_file.stem
|
||||
if not name.startswith(LOG_FILE_PREFIX):
|
||||
return None
|
||||
return name.replace(LOG_FILE_PREFIX, "", 1)
|
||||
|
||||
|
||||
def _parse_timestamp(entry: LogEntry) -> datetime | None:
|
||||
raw = entry.get("asctime")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.strptime(str(raw), LOG_TIME_FORMAT)
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _load_entries_since(logs_dir: Path, hours: int) -> list[LogEntry]:
|
||||
files = _collect_log_files(logs_dir)
|
||||
if not files:
|
||||
return []
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
cutoff_floor = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
entries: list[LogEntry] = []
|
||||
|
||||
for log_file in files:
|
||||
file_date = _extract_date_from_filename(log_file)
|
||||
if file_date:
|
||||
try:
|
||||
parsed_date = datetime.strptime(file_date, "%Y-%m-%d").replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
if parsed_date < cutoff_floor:
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
entries.extend(_stream_log_entries(log_file))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _analyze_entry(entry: LogEntry) -> EntryInsights:
|
||||
message_raw = str(entry.get("message", ""))
|
||||
message = message_raw.lower()
|
||||
level = str(entry.get("levelname", "")).upper()
|
||||
timestamp = _parse_timestamp(entry)
|
||||
model_value = entry.get("model")
|
||||
model = model_value if isinstance(model_value, str) else None
|
||||
error_type = entry.get("error_type")
|
||||
error_type_str = str(error_type) if error_type else None
|
||||
|
||||
event_total_request = "received proxy request" in message
|
||||
event_success = "token adjustment completed" in message
|
||||
event_fail = (
|
||||
"upstream request failed" in message or "revert payment" in message
|
||||
)
|
||||
event_payment_processed = "payment processed successfully" in message
|
||||
event_upstream_error = level == "ERROR" and "upstream" in message
|
||||
|
||||
revenue_msats = 0.0
|
||||
if event_success:
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
total_msats = cost_data.get("total_msats")
|
||||
if isinstance(total_msats, (int, float)):
|
||||
revenue_msats = float(total_msats)
|
||||
|
||||
refund_msats = 0.0
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model")
|
||||
if isinstance(max_cost, (int, float)):
|
||||
refund_msats = float(max_cost)
|
||||
|
||||
return EntryInsights(
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
model=model,
|
||||
event_total_request=event_total_request,
|
||||
event_success=event_success,
|
||||
event_fail=event_fail,
|
||||
event_payment_processed=event_payment_processed,
|
||||
event_upstream_error=event_upstream_error,
|
||||
revenue_msats=revenue_msats,
|
||||
refund_msats=refund_msats,
|
||||
error_type=error_type_str,
|
||||
)
|
||||
|
||||
|
||||
def _empty_summary() -> dict[str, Any]:
|
||||
return {
|
||||
"total_entries": 0,
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"total_errors": 0,
|
||||
"total_warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"unique_models_count": 0,
|
||||
"unique_models": [],
|
||||
"error_types": {},
|
||||
"success_rate": 0,
|
||||
"revenue_msats": 0,
|
||||
"refunds_msats": 0,
|
||||
"revenue_sats": 0,
|
||||
"refunds_sats": 0,
|
||||
"net_revenue_msats": 0,
|
||||
"net_revenue_sats": 0,
|
||||
"avg_revenue_per_request_msats": 0,
|
||||
"refund_rate": 0,
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
206
ui/app/logs/log-details-dialog.tsx
Normal file
206
ui/app/logs/log-details-dialog.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogDetailsDialogProps {
|
||||
log: LogEntry | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogDetailsDialog({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LogDetailsDialogProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
|
||||
if (!log) return null;
|
||||
|
||||
const copyToClipboard = (text: string, fieldName?: string) => {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
void navigator.clipboard.writeText(text);
|
||||
if (fieldName) {
|
||||
setCopiedField(fieldName);
|
||||
window.setTimeout(() => setCopiedField(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const standardFields = [
|
||||
'asctime',
|
||||
'name',
|
||||
'levelname',
|
||||
'message',
|
||||
'pathname',
|
||||
'lineno',
|
||||
'version',
|
||||
'request_id',
|
||||
];
|
||||
|
||||
const extraFields = Object.keys(log).filter(
|
||||
(key) => key !== 'key' && !standardFields.includes(key)
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className={getLevelColor(log.levelname || '')}>
|
||||
{log.levelname}
|
||||
</Badge>
|
||||
<span>Log Entry Details</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{log.asctime ?? 'unknown time'} • {log.name ?? 'unknown'} •{' '}
|
||||
{log.pathname ?? 'unknown'}:{log.lineno ?? '-'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm whitespace-pre break-all'>
|
||||
{String(log.message ?? '')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className='mb-3 text-sm font-medium'>Standard Fields</h4>
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
{standardFields.map((field) => (
|
||||
<div key={field} className='flex flex-col space-y-1'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='text-muted-foreground text-xs font-medium uppercase'>
|
||||
{field}
|
||||
</span>
|
||||
{field === 'request_id' && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
copyToClipboard(String(log[field] ?? ''), field)
|
||||
}
|
||||
className='h-6 flex-shrink-0 px-2'
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<>
|
||||
<Check className='mr-1 h-3 w-3' />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className='mr-1 h-3 w-3' />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='bg-muted max-h-32 overflow-auto rounded p-2'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
{String(log[field] ?? 'N/A')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div>
|
||||
<h4 className='mb-3 text-sm font-medium'>Additional Fields</h4>
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
{extraFields.map((field) => (
|
||||
<div key={field} className='flex flex-col space-y-1'>
|
||||
<span className='text-muted-foreground truncate text-xs font-medium uppercase'>
|
||||
{field}
|
||||
</span>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded p-2'>
|
||||
{typeof log[field] === 'object' ? (
|
||||
<pre className='font-mono text-xs break-all whitespace-pre-wrap'>
|
||||
{JSON.stringify(log[field], null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
{String(log[field] ?? 'N/A')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium'>Raw JSON</h4>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
copyToClipboard(JSON.stringify(log, null, 2), 'json')
|
||||
}
|
||||
className='h-6 px-2'
|
||||
>
|
||||
{copiedField === 'json' ? (
|
||||
<>
|
||||
<Check className='mr-1 h-3 w-3' />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className='mr-1 h-3 w-3' />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='bg-muted max-h-64 overflow-auto rounded-md p-4'>
|
||||
<pre className='text-xs break-all whitespace-pre-wrap'>
|
||||
{JSON.stringify(log, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
117
ui/app/logs/log-entry-card.tsx
Normal file
117
ui/app/logs/log-entry-card.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
import type { LogEntry } from './types';
|
||||
|
||||
interface LogEntryCardProps {
|
||||
entry: LogEntry;
|
||||
onClick: (entry: LogEntry) => void;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'TRACE':
|
||||
case 'DEBUG':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'INFO':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'WARNING':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'ERROR':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'CRITICAL':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
|
||||
const extraFields = Object.keys(entry).filter(
|
||||
(key) =>
|
||||
![
|
||||
'asctime',
|
||||
'name',
|
||||
'levelname',
|
||||
'message',
|
||||
'pathname',
|
||||
'lineno',
|
||||
'version',
|
||||
'request_id',
|
||||
].includes(key)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
|
||||
onClick={() => onClick(entry)}
|
||||
>
|
||||
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={getLevelColor(entry.levelname || '')}
|
||||
>
|
||||
{entry.levelname ?? 'UNKNOWN'}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
|
||||
{entry.asctime ?? 'unknown'}
|
||||
</span>
|
||||
<Badge variant='secondary' className='truncate text-xs'>
|
||||
{entry.name ?? 'unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<div className='text-muted-foreground truncate text-xs'>
|
||||
{entry.pathname ?? 'unknown'}:{entry.lineno ?? '-'}
|
||||
</div>
|
||||
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
|
||||
{entry.message}
|
||||
</div>
|
||||
|
||||
{entry.request_id && entry.request_id !== 'no-request-id' && (
|
||||
<div className='mb-2 min-w-0'>
|
||||
<div className='inline-block max-w-full'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
|
||||
Request ID: {entry.request_id}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraFields.length > 0 && (
|
||||
<div className='mt-3 min-w-0 border-t pt-3'>
|
||||
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
{extraFields.slice(0, 4).map((key) => {
|
||||
const value = entry[key];
|
||||
return (
|
||||
<div key={key} className='min-w-0 overflow-hidden text-xs break-words'>
|
||||
<span className='font-medium break-all'>{key}:</span>{' '}
|
||||
<span className='text-muted-foreground break-all'>
|
||||
{typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{extraFields.length > 4 && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
...and {extraFields.length - 4} more fields
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
ui/app/logs/log-filters.tsx
Normal file
298
ui/app/logs/log-filters.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, Filter, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LogFiltersProps {
|
||||
selectedDate: string;
|
||||
selectedLevel: string;
|
||||
requestId: string;
|
||||
searchText: string;
|
||||
limit: number;
|
||||
onDateChange: (date: string) => void;
|
||||
onLevelChange: (level: string) => void;
|
||||
onRequestIdChange: (requestId: string) => void;
|
||||
onSearchTextChange: (searchText: string) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
|
||||
|
||||
export function LogFilters({
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
limit,
|
||||
onDateChange,
|
||||
onLevelChange,
|
||||
onRequestIdChange,
|
||||
onSearchTextChange,
|
||||
onLimitChange,
|
||||
onClearFilters,
|
||||
}: LogFiltersProps) {
|
||||
const isPreset = PRESET_LIMITS.includes(String(limit));
|
||||
|
||||
const [customLimit, setCustomLimit] = useState<string>(
|
||||
isPreset ? '' : String(limit)
|
||||
);
|
||||
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
|
||||
const [date, setDate] = useState<Date | undefined>(
|
||||
selectedDate && selectedDate !== 'all' ? new Date(selectedDate) : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const preset = PRESET_LIMITS.includes(String(limit));
|
||||
setIsCustom(!preset);
|
||||
if (!preset) {
|
||||
setCustomLimit(String(limit));
|
||||
}
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate || selectedDate === 'all') {
|
||||
setDate(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setDate(new Date(selectedDate));
|
||||
} catch {
|
||||
setDate(undefined);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const handleLimitChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustom(true);
|
||||
setCustomLimit(String(limit));
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(Number(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomLimit(event.target.value);
|
||||
};
|
||||
|
||||
const handleCustomLimitApply = () => {
|
||||
const parsed = parseInt(customLimit, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
onLimitChange(parsed);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
onLimitChange(100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleCustomLimitApply();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (selected: Date | undefined) => {
|
||||
setDate(selected);
|
||||
if (selected) {
|
||||
onDateChange(format(selected, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
onDateChange('all');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Filter className='h-5 w-5' />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Filter logs by date, level, request ID, text search, and limit
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='date'>Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className='mr-2 h-4 w-4' />
|
||||
{date ? format(date, 'PPP') : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{date && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDateSelect(undefined)}
|
||||
className='w-full'
|
||||
>
|
||||
<X className='mr-2 h-4 w-4' />
|
||||
Clear date
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='level'>Log Level</Label>
|
||||
<Select value={selectedLevel} onValueChange={onLevelChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select level' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All levels</SelectItem>
|
||||
{LOG_LEVELS.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='request-id'>Request ID</Label>
|
||||
<Input
|
||||
id='request-id'
|
||||
type='text'
|
||||
placeholder='Search by request ID'
|
||||
value={requestId}
|
||||
onChange={(event) => onRequestIdChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='search-text' className='flex items-center gap-1'>
|
||||
<span>Text Search</span>
|
||||
<span className='text-muted-foreground text-xs font-normal'>
|
||||
(can be slow)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id='search-text'
|
||||
type='text'
|
||||
placeholder='Search in message and logger'
|
||||
value={searchText}
|
||||
onChange={(event) => onSearchTextChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='limit'>Limit</Label>
|
||||
{isCustom ? (
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='limit'
|
||||
type='number'
|
||||
min='1'
|
||||
placeholder='Enter custom limit'
|
||||
value={customLimit}
|
||||
onChange={handleCustomLimitChange}
|
||||
onKeyDown={handleCustomLimitKeyDown}
|
||||
onBlur={handleCustomLimitApply}
|
||||
autoFocus
|
||||
className='flex-1'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setCustomLimit('');
|
||||
if (!isPreset) {
|
||||
onLimitChange(100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={isPreset ? String(limit) : 'custom'}
|
||||
onValueChange={handleLimitChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select limit' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='25'>25</SelectItem>
|
||||
<SelectItem value='50'>50</SelectItem>
|
||||
<SelectItem value='100'>100</SelectItem>
|
||||
<SelectItem value='200'>200</SelectItem>
|
||||
<SelectItem value='500'>500</SelectItem>
|
||||
<SelectItem value='1000'>1000</SelectItem>
|
||||
<SelectItem value='custom'>Custom...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{!isCustom && !isPreset && (
|
||||
<p className='text-muted-foreground text-xs'>Custom: {limit}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label aria-hidden='true'> </Label>
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
178
ui/app/logs/page.tsx
Normal file
178
ui/app/logs/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
|
||||
import type { LogEntry } from './types';
|
||||
import { LogFilters } from './log-filters';
|
||||
import { LogEntryCard } from './log-entry-card';
|
||||
import { LogDetailsDialog } from './log-details-dialog';
|
||||
|
||||
export default function LogsPage() {
|
||||
const [selectedDate, setSelectedDate] = useState<string>('all');
|
||||
const [selectedLevel, setSelectedLevel] = useState<string>('all');
|
||||
const [requestId, setRequestId] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
data: logsData,
|
||||
refetch: refetchLogs,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'logs',
|
||||
selectedDate,
|
||||
selectedLevel,
|
||||
requestId,
|
||||
searchText,
|
||||
limit,
|
||||
],
|
||||
queryFn: () =>
|
||||
AdminService.getLogs({
|
||||
date: selectedDate === 'all' ? undefined : selectedDate,
|
||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||
requestId: requestId || undefined,
|
||||
search: searchText || undefined,
|
||||
limit,
|
||||
}),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedDate('all');
|
||||
setSelectedLevel('all');
|
||||
setRequestId('');
|
||||
setSearchText('');
|
||||
setLimit(100);
|
||||
};
|
||||
|
||||
const handleLogClick = (entry: LogEntry) => {
|
||||
setSelectedLog(entry);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='overflow-x-hidden p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
|
||||
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
|
||||
System Logs
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
|
||||
View and filter application logs
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => refetchLogs()}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='self-start'
|
||||
>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<LogFilters
|
||||
selectedDate={selectedDate}
|
||||
selectedLevel={selectedLevel}
|
||||
requestId={requestId}
|
||||
searchText={searchText}
|
||||
limit={limit}
|
||||
onDateChange={setSelectedDate}
|
||||
onLevelChange={setSelectedLevel}
|
||||
onRequestIdChange={setRequestId}
|
||||
onSearchTextChange={setSearchText}
|
||||
onLimitChange={setLimit}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-lg sm:text-xl'>Log Entries</span>
|
||||
{logsData && (
|
||||
<Badge variant='secondary' className='text-xs sm:text-sm'>
|
||||
{logsData.logs.length} entries
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{(selectedDate !== 'all' ||
|
||||
selectedLevel !== 'all' ||
|
||||
requestId ||
|
||||
searchText) && (
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
Showing logs
|
||||
{selectedDate !== 'all' && ` for ${selectedDate}`}
|
||||
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
|
||||
{requestId && ` with request ID ${requestId}`}
|
||||
{searchText && ` matching "${searchText}"`}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden p-3 sm:p-6'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<RefreshCw className='h-6 w-6 animate-spin' />
|
||||
<span className='ml-2 text-sm sm:text-base'>
|
||||
Loading logs...
|
||||
</span>
|
||||
</div>
|
||||
) : logsData?.logs && logsData.logs.length > 0 ? (
|
||||
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
|
||||
<div className='space-y-2 pr-3'>
|
||||
{logsData.logs.map((entry, index) => (
|
||||
<LogEntryCard
|
||||
key={`${entry.request_id ?? 'no-request'}-${entry.asctime ?? 'unknown'}-${entry.lineno ?? '0'}-${index}`}
|
||||
entry={entry}
|
||||
onClick={handleLogClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
|
||||
<p className='text-sm sm:text-base'>No log entries found</p>
|
||||
<p className='text-xs sm:text-sm'>
|
||||
Try adjusting your filters or check back later
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LogDetailsDialog
|
||||
log={selectedLog}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
3
ui/app/logs/types.ts
Normal file
3
ui/app/logs/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { LogEntry, LogsResponse } from '@/lib/api/services/admin';
|
||||
|
||||
export type { LogEntry, LogsResponse };
|
||||
267
ui/app/usage/page.tsx
Normal file
267
ui/app/usage/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { UsageMetricsChart } from '@/components/usage-metrics-chart';
|
||||
import { UsageSummaryCards } from '@/components/usage-summary-cards';
|
||||
import { ErrorDetailsTable } from '@/components/error-details-table';
|
||||
import { RevenueByModelTable } from '@/components/revenue-by-model-table';
|
||||
|
||||
export default function UsagePage() {
|
||||
const [timeRange, setTimeRange] = useState('24');
|
||||
const [interval, setInterval] = useState('15');
|
||||
|
||||
const {
|
||||
data: metricsData,
|
||||
isLoading: metricsLoading,
|
||||
refetch: refetchMetrics,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-metrics', interval, timeRange],
|
||||
queryFn: () =>
|
||||
AdminService.getUsageMetrics(parseInt(interval, 10), parseInt(timeRange, 10)),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: summaryData,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-summary', timeRange],
|
||||
queryFn: () => AdminService.getUsageSummary(parseInt(timeRange, 10)),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: errorData,
|
||||
isLoading: errorLoading,
|
||||
refetch: refetchErrors,
|
||||
} = useQuery({
|
||||
queryKey: ['usage-errors', timeRange],
|
||||
queryFn: () => AdminService.getErrorDetails(parseInt(timeRange, 10), 100),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: revenueByModelData,
|
||||
isLoading: revenueByModelLoading,
|
||||
refetch: refetchRevenueByModel,
|
||||
} = useQuery({
|
||||
queryKey: ['revenue-by-model', timeRange],
|
||||
queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange, 10), 20),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchMetrics();
|
||||
refetchSummary();
|
||||
refetchErrors();
|
||||
refetchRevenueByModel();
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>Usage Tracking</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor system usage, requests, and errors over time
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className='w-[180px]'>
|
||||
<SelectValue placeholder='Select time range' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='1'>Last Hour</SelectItem>
|
||||
<SelectItem value='6'>Last 6 Hours</SelectItem>
|
||||
<SelectItem value='24'>Last 24 Hours</SelectItem>
|
||||
<SelectItem value='72'>Last 3 Days</SelectItem>
|
||||
<SelectItem value='168'>Last Week</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={interval} onValueChange={setInterval}>
|
||||
<SelectTrigger className='w-[180px]'>
|
||||
<SelectValue placeholder='Select interval' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='5'>5 Minutes</SelectItem>
|
||||
<SelectItem value='15'>15 Minutes</SelectItem>
|
||||
<SelectItem value='30'>30 Minutes</SelectItem>
|
||||
<SelectItem value='60'>1 Hour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleRefresh} variant='outline' size='icon'>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
{summaryLoading ? (
|
||||
<div className='text-center py-8'>Loading summary...</div>
|
||||
) : summaryData ? (
|
||||
<UsageSummaryCards summary={summaryData} />
|
||||
) : null}
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
{metricsLoading ? (
|
||||
<div className='text-center py-8 col-span-2'>Loading metrics...</div>
|
||||
) : metricsData && metricsData.metrics.length > 0 ? (
|
||||
<>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Request Volume'
|
||||
dataKeys={[
|
||||
{ key: 'total_requests', name: 'Total Requests', color: '#3b82f6' },
|
||||
{
|
||||
key: 'successful_chat_completions',
|
||||
name: 'Successful',
|
||||
color: '#10b981',
|
||||
},
|
||||
{ key: 'failed_requests', name: 'Failed', color: '#ef4444' },
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics.map((metric) => ({
|
||||
...metric,
|
||||
revenue_sats: metric.revenue_msats / 1000,
|
||||
refunds_sats: metric.refunds_msats / 1000,
|
||||
net_revenue_sats:
|
||||
(metric.revenue_msats - metric.refunds_msats) / 1000,
|
||||
})) as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Revenue Over Time (sats)'
|
||||
dataKeys={[
|
||||
{ key: 'revenue_sats', name: 'Revenue', color: '#10b981' },
|
||||
{ key: 'refunds_sats', name: 'Refunds', color: '#ef4444' },
|
||||
{ key: 'net_revenue_sats', name: 'Net Revenue', color: '#059669' },
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Error Tracking'
|
||||
dataKeys={[
|
||||
{ key: 'errors', name: 'Errors', color: '#f97316' },
|
||||
{ key: 'warnings', name: 'Warnings', color: '#eab308' },
|
||||
{ key: 'upstream_errors', name: 'Upstream Errors', color: '#ef4444' },
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
title='Payment Activity'
|
||||
dataKeys={[
|
||||
{
|
||||
key: 'payment_processed',
|
||||
name: 'Payments Processed',
|
||||
color: '#6366f1',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Card className='col-span-2'>
|
||||
<CardHeader>
|
||||
<CardTitle>No Data Available</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground'>
|
||||
No metrics data found for the selected time range. This could be
|
||||
because no requests have been logged yet or the log files are not
|
||||
available.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{revenueByModelLoading ? (
|
||||
<div className='text-center py-8'>Loading revenue by model...</div>
|
||||
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
|
||||
<RevenueByModelTable
|
||||
models={revenueByModelData.models}
|
||||
totalRevenue={revenueByModelData.total_revenue_sats}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorLoading ? (
|
||||
<div className='text-center py-8'>Loading errors...</div>
|
||||
) : errorData ? (
|
||||
<ErrorDetailsTable errors={errorData.errors} />
|
||||
) : null}
|
||||
|
||||
{summaryData && summaryData.unique_models.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{summaryData.unique_models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className='bg-secondary text-secondary-foreground inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold'
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{summaryData &&
|
||||
summaryData.error_types &&
|
||||
Object.keys(summaryData.error_types).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Error Types Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-2'>
|
||||
{Object.entries(summaryData.error_types)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||
.map(([type, count]) => (
|
||||
<div
|
||||
key={type}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<span className='text-sm font-medium'>{type}</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
78
ui/components/error-details-table.tsx
Normal file
78
ui/components/error-details-table.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ErrorDetail } from '@/lib/api/services/admin';
|
||||
|
||||
interface ErrorDetailsTableProps {
|
||||
errors: ErrorDetail[];
|
||||
}
|
||||
|
||||
export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
|
||||
if (errors.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground text-center py-8'>
|
||||
No errors found in the selected time period
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Errors ({errors.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='max-h-[400px] overflow-y-auto'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Location</TableHead>
|
||||
<TableHead>Request ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{errors.map((error, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{error.timestamp}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='destructive'>{error.error_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='max-w-md truncate'>
|
||||
{error.message}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{error.pathname}:{error.lineno}
|
||||
</TableCell>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{error.request_id || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
95
ui/components/revenue-by-model-table.tsx
Normal file
95
ui/components/revenue-by-model-table.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ModelRevenueData } from '@/lib/api/services/admin';
|
||||
|
||||
interface RevenueByModelTableProps {
|
||||
models: ModelRevenueData[];
|
||||
totalRevenue: number;
|
||||
}
|
||||
|
||||
export function RevenueByModelTable({
|
||||
models,
|
||||
totalRevenue,
|
||||
}: RevenueByModelTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Model</CardTitle>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Total Revenue:{' '}
|
||||
{totalRevenue.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
sats
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className='text-right'>Requests</TableHead>
|
||||
<TableHead className='text-right'>Successful</TableHead>
|
||||
<TableHead className='text-right'>Failed</TableHead>
|
||||
<TableHead className='text-right'>Revenue (sats)</TableHead>
|
||||
<TableHead className='text-right'>Refunds (sats)</TableHead>
|
||||
<TableHead className='text-right'>Net Revenue (sats)</TableHead>
|
||||
<TableHead className='text-right'>Avg/Request</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{models.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className='text-center text-muted-foreground'>
|
||||
No model data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
models.map((model) => (
|
||||
<TableRow key={model.model}>
|
||||
<TableCell className='font-medium'>{model.model}</TableCell>
|
||||
<TableCell className='text-right'>{model.requests}</TableCell>
|
||||
<TableCell className='text-right text-green-600'>
|
||||
{model.successful}
|
||||
</TableCell>
|
||||
<TableCell className='text-right text-red-600'>
|
||||
{model.failed}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
{model.revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className='text-right text-red-500'>
|
||||
{model.refunds_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-semibold'>
|
||||
{model.net_revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className='text-right text-muted-foreground'>
|
||||
{model.avg_revenue_per_request.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 3,
|
||||
})}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
73
ui/components/ui/calendar.tsx
Normal file
73
ui/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months:
|
||||
'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
|
||||
),
|
||||
month_grid: 'w-full border-collapse space-y-1',
|
||||
weekdays: 'flex',
|
||||
weekday:
|
||||
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
week: 'flex w-full mt-2',
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_button: 'h-9 w-9 p-0 font-normal',
|
||||
range_end: 'day-range-end',
|
||||
selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
today: 'bg-accent text-accent-foreground',
|
||||
outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
disabled: 'text-muted-foreground opacity-50',
|
||||
range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeft className='h-4 w-4' />;
|
||||
}
|
||||
return <ChevronRight className='h-4 w-4' />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar };
|
||||
77
ui/components/usage-metrics-chart.tsx
Normal file
77
ui/components/usage-metrics-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
ui/components/usage-summary-cards.tsx
Normal file
121
ui/components/usage-summary-cards.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { UsageSummary } from '@/lib/api/services/admin';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Database,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
TrendingDown,
|
||||
Coins,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
summary: UsageSummary;
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Requests',
|
||||
value: summary.total_requests.toLocaleString(),
|
||||
icon: Activity,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'Successful Completions',
|
||||
value: summary.successful_chat_completions.toLocaleString(),
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
{
|
||||
title: 'Revenue (sats)',
|
||||
value: summary.revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
icon: Coins,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
title: 'Net Revenue (sats)',
|
||||
value: summary.net_revenue_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
icon: DollarSign,
|
||||
color: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
title: 'Refunds (sats)',
|
||||
value: summary.refunds_sats.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-500',
|
||||
},
|
||||
{
|
||||
title: 'Avg Revenue/Request',
|
||||
value: `${(summary.avg_revenue_per_request_msats / 1000).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 3,
|
||||
})} sats`,
|
||||
icon: CreditCard,
|
||||
color: 'text-cyan-500',
|
||||
},
|
||||
{
|
||||
title: 'Success Rate',
|
||||
value: `${summary.success_rate.toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
color: 'text-emerald-500',
|
||||
},
|
||||
{
|
||||
title: 'Refund Rate',
|
||||
value: `${summary.refund_rate.toFixed(1)}%`,
|
||||
icon: XCircle,
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Failed Requests',
|
||||
value: summary.failed_requests.toLocaleString(),
|
||||
icon: XCircle,
|
||||
color: 'text-red-400',
|
||||
},
|
||||
{
|
||||
title: 'Errors',
|
||||
value: summary.total_errors.toLocaleString(),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Unique Models',
|
||||
value: summary.unique_models_count.toLocaleString(),
|
||||
icon: Database,
|
||||
color: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
title: 'Upstream Errors',
|
||||
value: summary.upstream_errors.toLocaleString(),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>{card.value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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