diff --git a/.dockerignore b/.dockerignore index a1831c97..4018f08a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,6 @@ compose.testing.yml .todo .github .vscode -.DS_Store \ No newline at end of file +.DS_Store +**/node_modules +ui/.next diff --git a/README.md b/README.md index 2d482dc1..a0834551 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Once built, the UI is automatically served by the FastAPI backend: - **Dashboard**: `http://localhost:8000/` - **Login**: `http://localhost:8000/login` -- **Models Management**: `http://localhost:8000/model +- **Models Management**: `http://localhost:8000/model` - **Providers Management**: `http://localhost:8000/providers` - **Settings**: `http://localhost:8000/settings` diff --git a/pyproject.toml b/pyproject.toml index 10918b41..34eb16e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.2.0c" +version = "0.2.1" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" diff --git a/routstr/algorithm.py b/routstr/algorithm.py index efd2a566..ccdc7088 100644 --- a/routstr/algorithm.py +++ b/routstr/algorithm.py @@ -153,9 +153,9 @@ def should_prefer_model( # Log provider changes when candidate wins if should_replace: candidate_provider_name = getattr( - candidate_provider, "upstream_name", "unknown" + candidate_provider, "provider_type", "unknown" ) - current_provider_name = getattr(current_provider, "upstream_name", "unknown") + current_provider_name = getattr(current_provider, "provider_type", "unknown") logger.debug( f"Model selection for alias '{alias}': choosing {candidate_provider_name} " f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} " diff --git a/routstr/auth.py b/routstr/auth.py index b1e16987..d5b8ed87 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -9,7 +9,7 @@ from sqlmodel import col, update from .core import get_logger from .core.db import ApiKey, AsyncSession from .core.settings import settings -from .payment.cost_caculation import ( +from .payment.cost_calculation import ( CostData, CostDataError, MaxCostData, diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 3baad78b..c9357b60 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -3,7 +3,7 @@ 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 @@ -18,6 +18,7 @@ from ..wallet import ( slow_filter_spend_proofs, ) from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session +from .log_manager import log_manager from .logging import get_logger from .settings import SettingsService, settings @@ -2865,3 +2866,122 @@ h1 { color: #333; } .no-logs { text-align: center; color: #666; padding: 40px; } .request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; } """ + + +@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)]) +async def get_usage_metrics( + request: Request, + interval: int = Query( + default=15, ge=1, le=1440, description="Time interval in minutes" + ), + hours: int = Query( + default=24, ge=1, le=168, description="Hours of history to analyze" + ), +) -> dict: + """Get usage metrics aggregated by time interval.""" + return log_manager.get_usage_metrics(interval=interval, hours=hours) + + +@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)]) +async def get_usage_summary( + request: Request, + hours: int = Query( + default=24, ge=1, le=168, description="Hours of history to analyze" + ), +) -> dict: + """Get summary statistics for the specified time period.""" + return log_manager.get_usage_summary(hours=hours) + + +@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)]) +async def get_error_details( + request: Request, + hours: int = Query( + default=24, ge=1, le=168, description="Hours of history to analyze" + ), + limit: int = Query( + default=100, ge=1, le=1000, description="Maximum number of errors to return" + ), +) -> dict: + """Get detailed error information.""" + return log_manager.get_error_details(hours=hours, limit=limit) + + +@admin_router.get( + "/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)] +) +async def get_revenue_by_model( + request: Request, + hours: int = Query( + default=24, ge=1, le=168, description="Hours of history to analyze" + ), + limit: int = Query( + default=20, ge=1, le=100, description="Maximum number of models to return" + ), +) -> dict: + """ + Get revenue breakdown by model. + """ + return log_manager.get_revenue_by_model(hours=hours, limit=limit) + + +@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)]) +async def get_logs_api( + request: Request, + date: str | None = None, + level: str | None = None, + request_id: str | None = None, + search: str | None = None, + limit: int = 100, +) -> dict[str, object]: + """ + Get filtered log entries. + + Args: + date: Filter by specific date (YYYY-MM-DD) + level: Filter by log level + request_id: Filter by request ID + search: Search text in message and name fields (case-insensitive) + limit: Maximum number of entries to return + + Returns: + Dict containing logs and filter metadata + """ + log_entries = log_manager.search_logs( + date=date, + level=level, + request_id=request_id, + search_text=search, + limit=limit, + ) + + return { + "logs": log_entries, + "total": len(log_entries), + "date": date, + "level": level, + "request_id": request_id, + "search": search, + "limit": limit, + } + + +@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)]) +async def get_log_dates_api(request: Request) -> dict[str, object]: + logs_dir = Path("logs") + dates = [] + + if logs_dir.exists(): + log_files = sorted( + logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True + ) + + for log_file in log_files[:30]: + try: + filename = log_file.name + date_str = filename.replace("app_", "").replace(".log", "") + dates.append(date_str) + except Exception: + continue + + return {"dates": dates} diff --git a/routstr/core/log_manager.py b/routstr/core/log_manager.py new file mode 100644 index 00000000..5052f3d2 --- /dev/null +++ b/routstr/core/log_manager.py @@ -0,0 +1,469 @@ +import json +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterator + +from .logging import get_logger + +logger = get_logger(__name__) + + +class LogManager: + def __init__(self, logs_dir: Path = Path("logs")): + self.logs_dir = logs_dir + + def _yield_log_entries( + self, + hours_back: int | None = None, + specific_date: str | None = None, + reverse_files: bool = False, + max_files: int | None = None, + ) -> Iterator[dict[str, Any]]: + """ + Yields log entries from files. + + Args: + hours_back: specific number of hours to look back. + specific_date: specific date string (YYYY-MM-DD) to look at. + reverse_files: if True, process files in reverse order (newest first). + max_files: maximum number of log files to process (most recent if reverse_files is True). + """ + if not self.logs_dir.exists(): + return + + log_files = [] + cutoff_date = None + + if specific_date: + log_file = self.logs_dir / f"app_{specific_date}.log" + if log_file.exists(): + log_files.append(log_file) + else: + log_files = sorted(self.logs_dir.glob("app_*.log")) + if reverse_files: + log_files.reverse() + + # If we only care about hours back, we can optimize file selection + if hours_back is not None: + cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back) + filtered_files = [] + for log_path in log_files: + try: + file_date_str = log_path.stem.split("_")[1] + file_date = datetime.strptime( + file_date_str, "%Y-%m-%d" + ).replace(tzinfo=timezone.utc) + # Include file if it's from the same day or after the cutoff day + if file_date >= cutoff_date.replace( + hour=0, minute=0, second=0, microsecond=0 + ): + filtered_files.append(log_path) + except Exception: + continue + log_files = filtered_files + + if max_files is not None and len(log_files) > max_files: + log_files = log_files[:max_files] + + for log_file in log_files: + try: + with open(log_file, "r") as f: + # For reverse search, we might want to read lines in reverse? + # But usually logs are append-only. + # If reverse_files is True, we iterate files newest to oldest. + # But lines within file are still oldest to newest unless we reverse them. + lines = f.readlines() + if reverse_files: + lines.reverse() + + for line in lines: + try: + entry = json.loads(line.strip()) + + if cutoff_date: + timestamp_str = entry.get("asctime", "") + if not timestamp_str: + continue + log_time = datetime.strptime( + timestamp_str, "%Y-%m-%d %H:%M:%S" + ) + log_time = log_time.replace(tzinfo=timezone.utc) + if log_time < cutoff_date: + continue + + yield entry + except json.JSONDecodeError: + continue + except Exception as e: + logger.error(f"Error processing log file {log_file}: {e}") + continue + + def search_logs( + self, + date: str | None = None, + level: str | None = None, + request_id: str | None = None, + search_text: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + """ + Search through log files and return matching entries. + """ + log_entries: list[dict[str, Any]] = [] + + # Use reverse=True to get newest logs first by default + # If date is specified, we only look at that file + + search_text_lower = search_text.lower() if search_text else None + + # We iterate efficiently + iterator = self._yield_log_entries( + specific_date=date, + reverse_files=True if not date else False, + max_files=7 if not date else None, + ) + + # If we are searching globally (no date), we might want to limit how far back we go? + # PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files) + # My _yield_log_entries with reverse_files=True does all files. + # Let's rely on limit to stop us. + + # Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield? + # For now, let's just iterate. + + for log_data in iterator: + if not self._matches_filters( + log_data, level, request_id, search_text_lower + ): + continue + + log_entries.append(log_data) + + if len(log_entries) >= limit: + break + + # Sort by time descending (newest first) + log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True) + return log_entries + + def _matches_filters( + self, + log_data: dict[str, Any], + level: str | None, + request_id: str | None, + search_text_lower: str | None, + ) -> bool: + if level and log_data.get("levelname", "").upper() != level.upper(): + return False + + if request_id and log_data.get("request_id") != request_id: + return False + + if search_text_lower: + message = str(log_data.get("message", "")).lower() + name = str(log_data.get("name", "")).lower() + pathname = str(log_data.get("pathname", "")).lower() + + if ( + search_text_lower not in message + and search_text_lower not in name + and search_text_lower not in pathname + ): + return False + + return True + + def get_usage_summary(self, hours: int = 24) -> dict: + entries = list(self._yield_log_entries(hours_back=hours)) + return self._calculate_summary_stats(entries) + + def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict: + entries = list(self._yield_log_entries(hours_back=hours)) + return self._aggregate_metrics_by_time(entries, interval, hours) + + def get_error_details(self, hours: int = 24, limit: int = 100) -> dict: + errors: list[dict] = [] + # Iterate newest to oldest for errors? + # yield_log_entries sorts files by name (date) ascending by default. + # usage stats logic usually expects ascending time for aggregation (though dictionaries don't care). + # For error details "last N errors", we probably want newest first. + + # Using list() loads everything into memory, which is what PR 229 did. + # For optimization, we could use reverse iterator. + + # Let's just stick to PR 229 logic which filters 'ERROR' level. + + entries = self._yield_log_entries(hours_back=hours) # oldest to newest + + for entry in entries: + if entry.get("levelname", "").upper() == "ERROR": + timestamp_str = entry.get("asctime", "") + errors.append( + { + "timestamp": timestamp_str, + "message": entry.get("message", ""), + "error_type": entry.get("error_type", "unknown"), + "pathname": entry.get("pathname", ""), + "lineno": entry.get("lineno", 0), + "request_id": entry.get("request_id", ""), + } + ) + + # Sort reverse time + errors.sort(key=lambda x: x["timestamp"], reverse=True) + return {"errors": errors[:limit], "total_count": len(errors)} + + def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict: + entries = list(self._yield_log_entries(hours_back=hours)) + + model_stats: dict[str, dict[str, int | float]] = defaultdict( + lambda: { + "revenue_msats": 0, + "refunds_msats": 0, + "requests": 0, + "successful": 0, + "failed": 0, + } + ) + + for entry in entries: + try: + model = entry.get("model", "unknown") + if not isinstance(model, str): + model = "unknown" + + message = entry.get("message", "").lower() + + if "received proxy request" in message: + model_stats[model]["requests"] += 1 + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + model_stats[model]["successful"] += 1 + cost_data = entry.get("cost_data") + if isinstance(cost_data, dict): + actual_cost = cost_data.get("total_msats", 0) + if isinstance(actual_cost, (int, float)) and actual_cost > 0: + model_stats[model]["revenue_msats"] += actual_cost + + if "revert payment" in message or "upstream request failed" in message: + model_stats[model]["failed"] += 1 + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if isinstance(max_cost, (int, float)) and max_cost > 0: + model_stats[model]["refunds_msats"] += max_cost + + except Exception: + continue + + models: list[dict[str, Any]] = [] + total_revenue = 0.0 + + for model, stats in model_stats.items(): + revenue_msats = float(stats["revenue_msats"]) + refunds_msats = float(stats["refunds_msats"]) + + revenue_sats = revenue_msats / 1000 + refunds_sats = refunds_msats / 1000 + net_revenue_sats = revenue_sats - refunds_sats + + total_revenue += net_revenue_sats + + requests = int(stats["requests"]) + successful = int(stats["successful"]) + + models.append( + { + "model": model, + "revenue_sats": revenue_sats, + "refunds_sats": refunds_sats, + "net_revenue_sats": net_revenue_sats, + "requests": requests, + "successful": successful, + "failed": int(stats["failed"]), + "avg_revenue_per_request": ( + revenue_sats / successful if successful > 0 else 0 + ), + } + ) + + models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True) + + return { + "models": models[:limit], + "total_revenue_sats": total_revenue, + "total_models": len(models), + } + + def _calculate_summary_stats(self, entries: list[dict]) -> dict: + stats: dict[str, Any] = { + "total_entries": 0, + "total_requests": 0, + "successful_chat_completions": 0, + "failed_requests": 0, + "total_errors": 0, + "total_warnings": 0, + "payment_processed": 0, + "upstream_errors": 0, + "unique_models": set(), + "error_types": defaultdict(int), + "revenue_msats": 0.0, + "refunds_msats": 0.0, + } + + for entry in entries: + try: + stats["total_entries"] += 1 + + message = entry.get("message", "").lower() + level = entry.get("levelname", "").upper() + + if level == "ERROR": + stats["total_errors"] += 1 + if "error_type" in entry: + stats["error_types"][str(entry["error_type"])] += 1 + elif level == "WARNING": + stats["total_warnings"] += 1 + + if "received proxy request" in message: + stats["total_requests"] += 1 + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + stats["successful_chat_completions"] += 1 + + if "upstream request failed" in message or "revert payment" in message: + stats["failed_requests"] += 1 + + if "payment processed successfully" in message: + stats["payment_processed"] += 1 + + if "upstream" in message and level == "ERROR": + stats["upstream_errors"] += 1 + + if "model" in entry: + model = entry["model"] + if isinstance(model, str) and model != "unknown": + stats["unique_models"].add(model) + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + cost_data = entry.get("cost_data") + if isinstance(cost_data, dict): + actual_cost = cost_data.get("total_msats", 0) + if isinstance(actual_cost, (int, float)) and actual_cost > 0: + stats["revenue_msats"] += float(actual_cost) + + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if isinstance(max_cost, (int, float)) and max_cost > 0: + stats["refunds_msats"] += float(max_cost) + + except Exception: + continue + + revenue_sats = stats["revenue_msats"] / 1000 + refunds_sats = stats["refunds_msats"] / 1000 + net_revenue_sats = revenue_sats - refunds_sats + + total_requests = stats["total_requests"] + successful = stats["successful_chat_completions"] + + return { + "total_entries": stats["total_entries"], + "total_requests": total_requests, + "successful_chat_completions": successful, + "failed_requests": stats["failed_requests"], + "total_errors": stats["total_errors"], + "total_warnings": stats["total_warnings"], + "payment_processed": stats["payment_processed"], + "upstream_errors": stats["upstream_errors"], + "unique_models_count": len(stats["unique_models"]), + "unique_models": sorted(list(stats["unique_models"])), + "error_types": dict(stats["error_types"]), + "success_rate": (successful / total_requests * 100) + if total_requests > 0 + else 0, + "revenue_msats": stats["revenue_msats"], + "refunds_msats": stats["refunds_msats"], + "revenue_sats": revenue_sats, + "refunds_sats": refunds_sats, + "net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"], + "net_revenue_sats": net_revenue_sats, + "avg_revenue_per_request_msats": ( + stats["revenue_msats"] / successful if successful > 0 else 0 + ), + "refund_rate": ( + (stats["failed_requests"] / total_requests * 100) + if total_requests > 0 + else 0 + ), + } + + def _aggregate_metrics_by_time( + self, entries: list[dict], interval_minutes: int, hours_back: int + ) -> dict: + time_buckets: dict[str, dict[str, Any]] = defaultdict( + lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0} + ) + + for entry in entries: + try: + timestamp_str = entry.get("asctime", "") + if not timestamp_str: + continue + + log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") + log_time = log_time.replace(tzinfo=timezone.utc) + + # Round down to nearest interval + minutes = log_time.minute + rounded_minutes = (minutes // interval_minutes) * interval_minutes + bucket_time = log_time.replace( + minute=rounded_minutes, second=0, microsecond=0 + ) + bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S") + + bucket = time_buckets[bucket_key] + + message = entry.get("message", "").lower() + level = entry.get("levelname", "").upper() + + if "received proxy request" in message: + bucket["requests"] += 1 + + if level == "ERROR": + bucket["errors"] += 1 + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + cost_data = entry.get("cost_data") + if isinstance(cost_data, dict): + actual_cost = cost_data.get("total_msats", 0) + if isinstance(actual_cost, (int, float)) and actual_cost > 0: + bucket["revenue_msats"] += float(actual_cost) + except Exception: + continue + + result = [] + for bucket_key in sorted(time_buckets.keys()): + result.append({"timestamp": bucket_key, **time_buckets[bucket_key]}) + + return { + "metrics": result, + "interval_minutes": interval_minutes, + "hours_back": hours_back, + "total_buckets": len(result), + } + + +log_manager = LogManager() diff --git a/routstr/core/logging.py b/routstr/core/logging.py index 7e4a4851..65b6a5c0 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -1,3 +1,40 @@ +""" +Logging configuration for Routstr. + +CRITICAL LOG MESSAGES FOR USAGE STATISTICS: +=========================================== +The following log messages are parsed by the usage tracking system (routstr/core/admin.py). +DO NOT modify or remove these messages without updating the usage tracking logic: + +1. "Received proxy request" (INFO) - routstr/proxy.py + - Used to count total incoming requests + - Includes model information in context + + 2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py + "Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py + - Used to track successful completions and revenue + - The 'cost_data.total_msats' field is extracted for revenue calculation + - Must include 'cost_data' in extra dict + +3. "Payment processed successfully" (INFO) - routstr/auth.py + - Used to count successful payment processing events + - Tracks payment-related metrics + +4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py + - Used to track failed requests and refunds + - The 'max_cost_for_model' field is extracted for refund calculation + - Must include 'max_cost_for_model' in extra dict + +5. Any ERROR level logs with "upstream" in the message + - Used to count upstream provider errors + - Helps identify service reliability issues + +If you need to modify these messages, ensure you also update the parsing logic in: +- routstr/core/admin.py:_aggregate_metrics_by_time() +- routstr/core/admin.py:_get_summary_stats() +- routstr/core/admin.py:get_revenue_by_model() +""" + import logging.config import logging.handlers import os diff --git a/routstr/core/main.py b/routstr/core/main.py index 81212a01..c3d4e104 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -34,9 +34,9 @@ setup_logging() logger = get_logger(__name__) if os.getenv("VERSION_SUFFIX") is not None: - __version__ = f"0.2.0c-{os.getenv('VERSION_SUFFIX')}" + __version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}" else: - __version__ = "0.2.0c" + __version__ = "0.2.1" @asynccontextmanager @@ -182,7 +182,6 @@ async def info() -> dict: "mints": global_settings.cashu_mints, "http_url": global_settings.http_url, "onion_url": global_settings.onion_url, - "models": [], # kept for back-compat; prefer /v1/models } @@ -264,6 +263,33 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir(): async def redirect_transactions_index_txt() -> RedirectResponse: return RedirectResponse("/transactions") + @app.get("/balances", include_in_schema=False) + async def serve_balances_ui() -> FileResponse: + return FileResponse(UI_DIST_PATH / "balances" / "index.html") + + # Add explicit route for /balances/index.txt to redirect to /balances + @app.get("/balances/index.txt", include_in_schema=False) + async def redirect_balances_index_txt() -> RedirectResponse: + return RedirectResponse("/balances") + + @app.get("/logs", include_in_schema=False) + async def serve_logs_ui() -> FileResponse: + return FileResponse(UI_DIST_PATH / "logs" / "index.html") + + # Add explicit route for /logs/index.txt to redirect to /logs + @app.get("/logs/index.txt", include_in_schema=False) + async def redirect_logs_index_txt() -> RedirectResponse: + return RedirectResponse("/logs") + + @app.get("/usage", include_in_schema=False) + async def serve_usage_ui() -> FileResponse: + return FileResponse(UI_DIST_PATH / "usage" / "index.html") + + # Add explicit route for /usage/index.txt to redirect to /usage + @app.get("/usage/index.txt", include_in_schema=False) + async def redirect_usage_index_txt() -> RedirectResponse: + return RedirectResponse("/usage") + @app.get("/unauthorized", include_in_schema=False) async def serve_unauthorized_ui() -> FileResponse: return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html") diff --git a/routstr/core/settings.py b/routstr/core/settings.py index cd59758c..302df24d 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -114,7 +114,7 @@ def resolve_bootstrap() -> Settings: ) except Exception: pass - # Map COST_PER_1K_* -> CUSTOM_PER_1K_* + # Map COST_PER_1K_* -> FIXED_PER_1K_* if ( "COST_PER_1K_INPUT_TOKENS" in os.environ and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ diff --git a/routstr/payment/__init__.py b/routstr/payment/__init__.py index 55f5a854..0ca1ed03 100644 --- a/routstr/payment/__init__.py +++ b/routstr/payment/__init__.py @@ -1,4 +1,4 @@ -from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost +from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost __all__ = [ "CostData", diff --git a/routstr/payment/cost_caculation.py b/routstr/payment/cost_calculation.py similarity index 100% rename from routstr/payment/cost_caculation.py rename to routstr/payment/cost_calculation.py diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 849cf103..2e3fbd9c 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -2,6 +2,7 @@ import asyncio import json import random from pathlib import Path +from typing import Final from urllib.request import urlopen import httpx @@ -19,6 +20,14 @@ logger = get_logger(__name__) models_router = APIRouter() +DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = { + "openrouter/auto", + "google/gemini-2.5-pro-exp-03-25", + "opengvlab/internvl3-78b", + "openrouter/sonoma-dusk-alpha", + "openrouter/sonoma-sky-alpha", +} + class Architecture(BaseModel): modality: str @@ -89,11 +98,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: if ( "(free)" in model.get("name", "") - or model_id == "openrouter/auto" - or model_id == "google/gemini-2.5-pro-exp-03-25" - or model_id == "opengvlab/internvl3-78b" - or model_id == "openrouter/sonoma-dusk-alpha" - or model_id == "openrouter/sonoma-sky-alpha" + or model_id in DEFAULT_EXCLUDED_MODEL_IDS ): continue @@ -130,11 +135,7 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis if ( "(free)" in model.get("name", "") - or model_id == "openrouter/auto" - or model_id == "google/gemini-2.5-pro-exp-03-25" - or model_id == "opengvlab/internvl3-78b" - or model_id == "openrouter/sonoma-dusk-alpha" - or model_id == "openrouter/sonoma-sky-alpha" + or model_id in DEFAULT_EXCLUDED_MODEL_IDS ): continue diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 7af1be85..576ade2f 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -18,7 +18,7 @@ from ..core.db import ApiKey, AsyncSession, create_session if TYPE_CHECKING: from ..core.db import UpstreamProviderRow -from ..payment.cost_caculation import ( +from ..payment.cost_calculation import ( CostData, CostDataError, MaxCostData, @@ -136,6 +136,14 @@ class BaseUpstreamProvider: if headers.pop(auth_header, None) is not None: removed_headers.append(auth_header) + for header in ["authorization", "accept-encoding"]: + if headers.pop(header, None) is not None: + removed_headers.append(f"{header} (replaced with routstr-safe version)") + + # Explicitly define the list of supported compression encodings + headers["accept-encoding"] = "gzip, deflate, br, identity" + + logger.debug( "Headers prepared for upstream", extra={ @@ -453,11 +461,12 @@ class BaseUpstreamProvider: ) usage_finalized = True logger.info( - "Token adjustment completed for streaming", + "Payment adjustment completed for streaming", extra={ "key_hash": key.hashed_key[:8] + "...", "cost_data": cost_data, + "model": last_model_seen, "balance_after_adjustment": fresh_key.balance, }, ) @@ -503,11 +512,16 @@ class BaseUpstreamProvider: ) await finalize_without_usage() raise + + # Remove inaccurate encoding headers from upstream response + response_headers = dict(response.headers) + response_headers.pop("content-encoding", None) + response_headers.pop("content-length", None) return StreamingResponse( stream_with_cost(max_cost_for_model), status_code=response.status_code, - headers=dict(response.headers), + headers=response_headers, ) async def handle_non_streaming_chat_completion( @@ -556,7 +570,7 @@ class BaseUpstreamProvider: response_json["cost"] = cost_data logger.info( - "Token adjustment completed for non-streaming", + "Payment adjustment completed for non-streaming", extra={ "key_hash": key.hashed_key[:8] + "...", "cost_data": cost_data, diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index cdc8e652..41250f66 100644 --- a/tests/integration/test_general_info_endpoints.py +++ b/tests/integration/test_general_info_endpoints.py @@ -62,7 +62,6 @@ async def test_root_endpoint_structure_and_performance( "mints", "http_url", "onion_url", - "models", ] for field in required_fields: assert field in data, f"Missing required field: {field}" @@ -75,15 +74,9 @@ async def test_root_endpoint_structure_and_performance( assert isinstance(data["mints"], list) assert isinstance(data["http_url"], str) assert isinstance(data["onion_url"], str) - assert isinstance(data["models"], list) - # Validate models structure if any exist - for model in data["models"]: - assert isinstance(model, dict) - # Models should have at least basic fields - model_required_fields = ["id", "name"] - for field in model_required_fields: - assert field in model, f"Model missing required field: {field}" + # Ensure models field is not present (removed as per issue #184) + assert "models" not in data, "Models field should not be present in base URL output" # Verify no database state changes diff = await db_snapshot.diff() @@ -386,4 +379,4 @@ async def test_info_endpoints_response_consistency( # Model IDs should be the same first_ids = {m["id"] for m in first_models} response_ids = {m["id"] for m in models} - assert first_ids == response_ids + assert first_ids == response_ids \ No newline at end of file diff --git a/ui/app/_register/page.tsx b/ui/app/_register/page.tsx deleted file mode 100644 index f88bf940..00000000 --- a/ui/app/_register/page.tsx +++ /dev/null @@ -1,172 +0,0 @@ -'use client'; - -import { useAuth } from '@/lib/auth/AuthContext'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { toast } from 'sonner'; -import Link from 'next/link'; -import { ArrowUpCircleIcon } from 'lucide-react'; -import { registerUser, SchemaRegisterProps } from '@/lib/api/services/auth'; - -export default function NostrRegisterPage() { - const router = useRouter(); - const { connectNostr } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [formData, setFormData] = useState({ - npub: '', - name: '', - }); - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; - - const handleNostrConnect = async () => { - setIsLoading(true); - try { - const publicKey = await connectNostr(); - if (publicKey) { - setFormData((prev) => ({ ...prev, npub: publicKey })); - toast.success( - 'Nostr connected. Please enter your name to complete registration.' - ); - } else { - toast.error( - 'Failed to connect. Please make sure your Nostr extension is installed and enabled.' - ); - } - } catch (error) { - console.error('Nostr connection error:', error); - toast.error('Failed to connect to Nostr. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!formData.npub || formData.npub.length < 10) { - toast.error('Please enter a valid Nostr public key'); - return; - } - - if (!formData.name) { - toast.error('Please enter your name'); - return; - } - - setIsLoading(true); - try { - // Register the user - const result = await registerUser(formData); - console.log('Registration successful:', result); - toast.success('Account created successfully'); - router.push('/login'); - } catch (error) { - console.error('Registration error:', error); - toast.error('Registration failed. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-
-
-
-
- -
- -
- Routstr - -

Create an Account

-
- Already have an account?{' '} - - Sign in - -
-
-
-
- - -
-
- - -
- -
-
- - Or - -
-
- -
-
-
-
- By clicking create account, you agree to our{' '} - Terms of Service and{' '} - Privacy Policy. -
-
-
-
- ); -} diff --git a/ui/app/balances/page.tsx b/ui/app/balances/page.tsx new file mode 100644 index 00000000..b5213287 --- /dev/null +++ b/ui/app/balances/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useCurrencyStore } from '@/lib/stores/currency'; +import { useQuery } from '@tanstack/react-query'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { DetailedWalletBalance } from '@/components/detailed-wallet-balance'; +import { TemporaryBalances } from '@/components/temporary-balances'; +import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; + +export default function BalancesPage() { + const { displayUnit } = useCurrencyStore(); + + const { data: btcUsdPrice } = useQuery({ + queryKey: ['btc-usd-price'], + queryFn: fetchBtcUsdPrice, + refetchInterval: 120_000, + staleTime: 60_000, + }); + + const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; + + return ( + + + + +
+
+
+

+ Balances +

+

+ Monitor and manage wallet balances +

+
+ {/* Global currency toggle is now in SiteHeader */} +
+ +
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/ui/app/globals.css b/ui/app/globals.css index 86093a5c..37756b40 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -6,8 +6,8 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: Geist, sans-serif; + --font-mono: Geist Mono, monospace; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -41,75 +41,152 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --font-serif: Georgia, serif; + --radius: 0.5rem; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xl: var(--shadow-2xl); + --shadow-xl: var(--shadow-xl); + --shadow-lg: var(--shadow-lg); + --shadow-md: var(--shadow-md); + --shadow: var(--shadow); + --shadow-sm: var(--shadow-sm); + --shadow-xs: var(--shadow-xs); + --shadow-2xs: var(--shadow-2xs); + --spacing: var(--spacing); + --letter-spacing: var(--letter-spacing); + --shadow-offset-y: var(--shadow-offset-y); + --shadow-offset-x: var(--shadow-offset-x); + --shadow-spread: var(--shadow-spread); + --shadow-blur: var(--shadow-blur); + --shadow-opacity: var(--shadow-opacity); + --color-shadow-color: var(--shadow-color); + --color-destructive-foreground: var(--destructive-foreground); } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.147 0.004 49.25); + --radius: 0.5rem; + --background: oklch(0.99 0 0); + --foreground: oklch(0 0 0); --card: oklch(1 0 0); - --card-foreground: oklch(0.147 0.004 49.25); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.147 0.004 49.25); - --primary: oklch(0.216 0.006 56.043); - --primary-foreground: oklch(0.985 0.001 106.423); - --secondary: oklch(0.97 0.001 106.424); - --secondary-foreground: oklch(0.216 0.006 56.043); - --muted: oklch(0.97 0.001 106.424); - --muted-foreground: oklch(0.553 0.013 58.071); - --accent: oklch(0.97 0.001 106.424); - --accent-foreground: oklch(0.216 0.006 56.043); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.923 0.003 48.717); - --input: oklch(0.923 0.003 48.717); - --ring: oklch(0.709 0.01 56.259); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0.001 106.423); - --sidebar-foreground: oklch(0.147 0.004 49.25); - --sidebar-primary: oklch(0.216 0.006 56.043); - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); - --sidebar-accent: oklch(0.97 0.001 106.424); - --sidebar-accent-foreground: oklch(0.216 0.006 56.043); - --sidebar-border: oklch(0.923 0.003 48.717); - --sidebar-ring: oklch(0.709 0.01 56.259); + --card-foreground: oklch(0 0 0); + --popover: oklch(0.99 0 0); + --popover-foreground: oklch(0 0 0); + --primary: oklch(0 0 0); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.94 0 0); + --secondary-foreground: oklch(0 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.44 0 0); + --accent: oklch(0.94 0 0); + --accent-foreground: oklch(0 0 0); + --destructive: oklch(0.63 0.19 23.03); + --border: oklch(0.92 0 0); + --input: oklch(0.94 0 0); + --ring: oklch(0 0 0); + --chart-1: oklch(0.81 0.17 75.35); + --chart-2: oklch(0.55 0.22 264.53); + --chart-3: oklch(0.72 0 0); + --chart-4: oklch(0.92 0 0); + --chart-5: oklch(0.56 0 0); + --sidebar: oklch(0.99 0 0); + --sidebar-foreground: oklch(0 0 0); + --sidebar-primary: oklch(0 0 0); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.94 0 0); + --sidebar-accent-foreground: oklch(0 0 0); + --sidebar-border: oklch(0.94 0 0); + --sidebar-ring: oklch(0 0 0); + --destructive-foreground: oklch(1 0 0); + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; + --shadow-color: hsl(0 0% 0%); + --shadow-opacity: 0.18; + --shadow-blur: 2px; + --shadow-spread: 0px; + --shadow-offset-x: 0px; + --shadow-offset-y: 1px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); + --tracking-normal: 0em; } .dark { - --background: oklch(0.147 0.004 49.25); - --foreground: oklch(0.985 0.001 106.423); - --card: oklch(0.216 0.006 56.043); - --card-foreground: oklch(0.985 0.001 106.423); - --popover: oklch(0.216 0.006 56.043); - --popover-foreground: oklch(0.985 0.001 106.423); - --primary: oklch(0.923 0.003 48.717); - --primary-foreground: oklch(0.216 0.006 56.043); - --secondary: oklch(0.268 0.007 34.298); - --secondary-foreground: oklch(0.985 0.001 106.423); - --muted: oklch(0.268 0.007 34.298); - --muted-foreground: oklch(0.709 0.01 56.259); - --accent: oklch(0.268 0.007 34.298); - --accent-foreground: oklch(0.985 0.001 106.423); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.553 0.013 58.071); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.216 0.006 56.043); - --sidebar-foreground: oklch(0.985 0.001 106.423); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); - --sidebar-accent: oklch(0.268 0.007 34.298); - --sidebar-accent-foreground: oklch(0.985 0.001 106.423); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.553 0.013 58.071); + --background: oklch(0 0 0); + --foreground: oklch(1 0 0); + --card: oklch(0.14 0 0); + --card-foreground: oklch(1 0 0); + --popover: oklch(0.18 0 0); + --popover-foreground: oklch(1 0 0); + --primary: oklch(1 0 0); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.25 0 0); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.23 0 0); + --muted-foreground: oklch(0.72 0 0); + --accent: oklch(0.32 0 0); + --accent-foreground: oklch(1 0 0); + --destructive: oklch(0.69 0.2 23.91); + --border: oklch(0.26 0 0); + --input: oklch(0.32 0 0); + --ring: oklch(0.72 0 0); + --chart-1: oklch(0.81 0.17 75.35); + --chart-2: oklch(0.58 0.21 260.84); + --chart-3: oklch(0.56 0 0); + --chart-4: oklch(0.44 0 0); + --chart-5: oklch(0.92 0 0); + --sidebar: oklch(0.18 0 0); + --sidebar-foreground: oklch(1 0 0); + --sidebar-primary: oklch(1 0 0); + --sidebar-primary-foreground: oklch(0 0 0); + --sidebar-accent: oklch(0.32 0 0); + --sidebar-accent-foreground: oklch(1 0 0); + --sidebar-border: oklch(0.32 0 0); + --sidebar-ring: oklch(0.72 0 0); + --destructive-foreground: oklch(0 0 0); + --radius: 0.5rem; + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; + --shadow-color: hsl(0 0% 0%); + --shadow-opacity: 0.18; + --shadow-blur: 2px; + --shadow-spread: 0px; + --shadow-offset-x: 0px; + --shadow-offset-y: 1px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: + 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); } @layer base { @@ -118,6 +195,7 @@ } body { @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); } } diff --git a/ui/app/login/page.tsx b/ui/app/login/page.tsx index 0355f8c2..7df50995 100644 --- a/ui/app/login/page.tsx +++ b/ui/app/login/page.tsx @@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement { }; return ( -
- +
+ Admin Login diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx new file mode 100644 index 00000000..852aa4a8 --- /dev/null +++ b/ui/app/logs/log-details-dialog.tsx @@ -0,0 +1,205 @@ +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Copy, Check } from 'lucide-react'; +import { useState } from 'react'; + +interface LogEntry { + asctime: string; + name: string; + levelname: string; + message: string; + pathname: string; + lineno: number; + version: string; + request_id: string; + [key: string]: string | number | object | undefined; +} + +interface LogDetailsDialogProps { + log: LogEntry | null; + isOpen: boolean; + onClose: () => void; +} + +const getLevelColor = (level: string): string => { + switch (level.toUpperCase()) { + case 'TRACE': + case 'DEBUG': + return 'bg-gray-100 text-gray-800 border-gray-200'; + case 'INFO': + return 'bg-blue-100 text-blue-800 border-blue-200'; + case 'WARNING': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case 'ERROR': + return 'bg-red-100 text-red-800 border-red-200'; + case 'CRITICAL': + return 'bg-purple-100 text-purple-800 border-purple-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } +}; + +export function LogDetailsDialog({ + log, + isOpen, + onClose, +}: LogDetailsDialogProps) { + const [copiedField, setCopiedField] = useState(null); + + if (!log) return null; + + const copyToClipboard = (text: string, fieldName?: string) => { + navigator.clipboard.writeText(text); + if (fieldName) { + setCopiedField(fieldName); + setTimeout(() => setCopiedField(null), 2000); + } + }; + + const allFields = Object.keys(log).filter((key) => key !== 'key'); + const standardFields = [ + 'asctime', + 'name', + 'levelname', + 'message', + 'pathname', + 'lineno', + 'version', + 'request_id', + ]; + const extraFields = allFields.filter((key) => !standardFields.includes(key)); + + return ( + + + + + + {log.levelname} + + Log Entry Details + + + {log.asctime} • {log.name} • {log.pathname}:{log.lineno} + + + + +
+
+

Message

+
+
+                  {log.message}
+                
+
+
+ +
+

Standard Fields

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

Additional Fields

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

Raw JSON

+ +
+
+
+                  {JSON.stringify(log, null, 2)}
+                
+
+
+
+
+
+
+ ); +} diff --git a/ui/app/logs/log-entry-card.tsx b/ui/app/logs/log-entry-card.tsx new file mode 100644 index 00000000..20133908 --- /dev/null +++ b/ui/app/logs/log-entry-card.tsx @@ -0,0 +1,122 @@ +import { Badge } from '@/components/ui/badge'; +import { Eye } from 'lucide-react'; + +interface LogEntry { + asctime: string; + name: string; + levelname: string; + message: string; + pathname: string; + lineno: number; + version: string; + request_id: string; + [key: string]: string | number | object | undefined; +} + +interface LogEntryCardProps { + entry: LogEntry; + onClick: (entry: LogEntry) => void; +} + +const getLevelColor = (level: string): string => { + switch (level.toUpperCase()) { + case 'TRACE': + case 'DEBUG': + return 'bg-gray-100 text-gray-800 border-gray-200'; + case 'INFO': + return 'bg-blue-100 text-blue-800 border-blue-200'; + case 'WARNING': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case 'ERROR': + return 'bg-red-100 text-red-800 border-red-200'; + case 'CRITICAL': + return 'bg-purple-100 text-purple-800 border-purple-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } +}; + +export function LogEntryCard({ entry, onClick }: LogEntryCardProps) { + const extraFields = Object.keys(entry).filter( + (key) => + ![ + 'asctime', + 'name', + 'levelname', + 'message', + 'pathname', + 'lineno', + 'version', + 'request_id', + ].includes(key) + ); + + return ( +
onClick(entry)} + > +
+
+ + {entry.levelname} + + + {entry.asctime} + + + {entry.name} + +
+
+
+ {entry.pathname}:{entry.lineno} +
+ +
+
+ +
+ {entry.message} +
+ + {entry.request_id && entry.request_id !== 'no-request-id' && ( +
+
+ + + Request ID: {entry.request_id} + + +
+
+ )} + + {extraFields.length > 0 && ( +
+
Additional Fields:
+
+ {extraFields.slice(0, 4).map((key) => ( +
+ {key}:{' '} + + {typeof entry[key] === 'object' + ? JSON.stringify(entry[key]) + : String(entry[key])} + +
+ ))} + {extraFields.length > 4 && ( +
+ ...and {extraFields.length - 4} more fields +
+ )} +
+
+ )} +
+ ); +} diff --git a/ui/app/logs/log-filters.tsx b/ui/app/logs/log-filters.tsx new file mode 100644 index 00000000..81b9aff9 --- /dev/null +++ b/ui/app/logs/log-filters.tsx @@ -0,0 +1,296 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +import { CalendarIcon, Filter, X } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; + +interface LogFiltersProps { + selectedDate: string; + selectedLevel: string; + requestId: string; + searchText: string; + limit: number; + onDateChange: (date: string) => void; + onLevelChange: (level: string) => void; + onRequestIdChange: (requestId: string) => void; + onSearchTextChange: (searchText: string) => void; + onLimitChange: (limit: number) => void; + onClearFilters: () => void; +} + +const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']; +const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000']; + +export function LogFilters({ + selectedDate, + selectedLevel, + requestId, + searchText, + limit, + onDateChange, + onLevelChange, + onRequestIdChange, + onSearchTextChange, + onLimitChange, + onClearFilters, +}: LogFiltersProps) { + const isPreset = PRESET_LIMITS.includes(limit.toString()); + + const [customLimit, setCustomLimit] = useState( + isPreset ? '' : limit.toString() + ); + const [isCustom, setIsCustom] = useState(!isPreset); + const [date, setDate] = useState( + selectedDate && selectedDate !== 'all' + ? new Date(selectedDate + 'T00:00:00') + : undefined + ); + + useEffect(() => { + const currentIsPreset = PRESET_LIMITS.includes(limit.toString()); + setIsCustom(!currentIsPreset); + if (!currentIsPreset) { + setCustomLimit(limit.toString()); + } + }, [limit]); + + useEffect(() => { + if (selectedDate === 'all' || !selectedDate) { + setDate(undefined); + } else { + const d = new Date(selectedDate + 'T00:00:00'); + setDate(isNaN(d.getTime()) ? undefined : d); + } + }, [selectedDate]); + + const handleLimitChange = (value: string) => { + if (value === 'custom') { + setIsCustom(true); + setCustomLimit(limit.toString()); + } else { + setIsCustom(false); + setCustomLimit(''); + onLimitChange(Number(value)); + } + }; + + const handleCustomLimitChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setCustomLimit(value); + }; + + const handleCustomLimitApply = () => { + const numValue = parseInt(customLimit); + if (!isNaN(numValue) && numValue > 0) { + onLimitChange(numValue); + } else { + setIsCustom(false); + setCustomLimit(''); + onLimitChange(100); + } + }; + + const handleCustomLimitKeyDown = ( + e: React.KeyboardEvent + ) => { + if (e.key === 'Enter') { + handleCustomLimitApply(); + } + }; + + const handleDateSelect = (selectedDate: Date | undefined) => { + setDate(selectedDate); + if (selectedDate) { + onDateChange(format(selectedDate, 'yyyy-MM-dd')); + } else { + onDateChange('all'); + } + }; + + return ( + + + + + Filters + + + Filter logs by date, level, request ID, text search, and limit + + + +
+
+ + + + + + + + + + {date && ( + + )} +
+ +
+ + +
+ +
+ + onRequestIdChange(e.target.value)} + /> +
+ +
+ + onSearchTextChange(e.target.value)} + /> +
+ +
+ + {isCustom ? ( +
+ + +
+ ) : ( + + )} + {!isCustom && !isPreset && ( +

Custom: {limit}

+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/app/logs/page.tsx b/ui/app/logs/page.tsx new file mode 100644 index 00000000..b3551aad --- /dev/null +++ b/ui/app/logs/page.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { AppSidebar } from '@/components/app-sidebar'; +import { SiteHeader } from '@/components/site-header'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { FileText, RefreshCw } from 'lucide-react'; +import { apiClient } from '@/lib/api/client'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { LogEntry, LogsResponse } from './types'; +import { LogFilters } from './log-filters'; +import { LogEntryCard } from './log-entry-card'; +import { LogDetailsDialog } from './log-details-dialog'; + +export default function LogsPage() { + const [selectedDate, setSelectedDate] = useState('all'); + const [selectedLevel, setSelectedLevel] = useState('all'); + const [requestId, setRequestId] = useState(''); + const [searchText, setSearchText] = useState(''); + const [limit, setLimit] = useState(100); + const [selectedLog, setSelectedLog] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { + data: logsData, + refetch: refetchLogs, + isLoading, + } = useQuery({ + queryKey: [ + 'logs', + selectedDate, + selectedLevel, + requestId, + searchText, + limit, + ], + queryFn: () => + apiClient.get('/admin/api/logs', { + date: selectedDate === 'all' ? undefined : selectedDate, + level: selectedLevel === 'all' ? undefined : selectedLevel, + request_id: requestId || undefined, + search: searchText || undefined, + limit: limit, + }), + refetchInterval: 30000, + }); + + const handleClearFilters = () => { + setSelectedDate('all'); + setSelectedLevel('all'); + setRequestId(''); + setSearchText(''); + setLimit(100); + }; + + const handleLogClick = (entry: LogEntry) => { + setSelectedLog(entry); + setIsDialogOpen(true); + }; + + return ( + + + + +
+
+
+

+ + System Logs +

+

+ View and filter application logs +

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

No log entries found

+

+ Try adjusting your filters or check back later +

+
+ )} +
+
+ + setIsDialogOpen(false)} + /> +
+
+
+ ); +} diff --git a/ui/app/logs/types.ts b/ui/app/logs/types.ts new file mode 100644 index 00000000..8c905827 --- /dev/null +++ b/ui/app/logs/types.ts @@ -0,0 +1,25 @@ +export interface LogEntry { + asctime: string; + name: string; + levelname: string; + message: string; + pathname: string; + lineno: number; + version: string; + request_id: string; + [key: string]: string | number | object | undefined; +} + +export interface LogsResponse { + logs: LogEntry[]; + total: number; + date: string | null; + level: string | null; + request_id: string | null; + search: string | null; + limit: number; +} + +export interface DatesResponse { + dates: string[]; +} diff --git a/ui/app/model/page.tsx b/ui/app/model/page.tsx index edd6b20f..7de7c4c5 100644 --- a/ui/app/model/page.tsx +++ b/ui/app/model/page.tsx @@ -34,7 +34,6 @@ export default function ModelsPage() { const { models = [], groups = [] } = modelsData || {}; const groupedModels = useMemo(() => { - if (!models) return {}; return groupAndSortModelsByProvider(models); }, [models]); @@ -43,7 +42,14 @@ export default function ModelsPage() { }, [groups]); const providerInfo = useMemo(() => { - return Object.entries(groupedModels).map(([provider, providerModels]) => { + const allProviders = new Set([ + ...Object.keys(groupedModels), + ...groups.map((g) => g.provider), + ]); + console.log(allProviders); + + return Array.from(allProviders).map((provider) => { + const providerModels = groupedModels[provider] || []; const groupData = groupDataMap.get(provider); const activeModels = providerModels.filter( (m) => m.isEnabled && !m.soft_deleted @@ -59,7 +65,7 @@ export default function ModelsPage() { hasGroupApiKey: !!groupData?.group_api_key, }; }); - }, [groupedModels, groupDataMap]); + }, [groupedModels, groupDataMap, groups]); return ( @@ -158,9 +164,9 @@ export default function ModelsPage() {
- {Object.entries(groupedModels).map( - ([provider, providerModels]) => { - const groupData = groupDataMap.get(provider); + {providerInfo.map( + ({ provider, totalModels, groupData }) => { + const providerModels = groupedModels[provider] || []; return ( @@ -190,15 +196,55 @@ export default function ModelsPage() { {groupData.group_url} )} + {totalModels === 0 && ( + + No models configured + + )}
- + {totalModels === 0 ? ( + + + +
+

+ No models found for this provider +

+
+

Common issues:

+
    +
  • + API credentials: Check if the API key is correct and has the right permissions +
  • +
  • + Base URL: Verify the base URL is correct for your provider +
  • +
  • + Network access: Ensure the server can reach the provider's API endpoint +
  • +
  • + Provider status: The upstream provider might be temporarily unavailable +
  • +
+ {groupData?.group_url && ( +

+ Current endpoint: {groupData.group_url} +

+ )} +
+
+
+
+ ) : ( + + )} ); diff --git a/ui/app/page.tsx b/ui/app/page.tsx index da8ffba3..041512eb 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -5,81 +5,349 @@ import { useQuery } from '@tanstack/react-query'; import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { DetailedWalletBalance } from '@/components/detailed-wallet-balance'; -import { TemporaryBalances } from '@/components/temporary-balances'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import type { DisplayUnit } from '@/lib/types/units'; +import { UsageMetricsChart } from '@/components/usage-metrics-chart'; +import { UsageSummaryCards } from '@/components/usage-summary-cards'; +import { ErrorDetailsTable } from '@/components/error-details-table'; +import { RevenueByModelTable } from '@/components/revenue-by-model-table'; +import { DashboardBalanceSummary } from '@/components/dashboard-balance-summary'; +import { AdminService } from '@/lib/api/services/admin'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { RefreshCw } from 'lucide-react'; +import { useCurrencyStore } from '@/lib/stores/currency'; import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; +import { CheatSheet } from '@/components/landing/cheat-sheet'; +import { ConfigurationService } from '@/lib/api/services/configuration'; -export default function Page() { - const [displayUnit, setDisplayUnit] = useState('sat'); +export default function DashboardPage() { + const [timeRange, setTimeRange] = useState('24'); + const [interval, setInterval] = useState('15'); + const { displayUnit } = useCurrencyStore(); + const [isAuthenticated, setIsAuthenticated] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + return ConfigurationService.isTokenValid(); + }); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const syncAuthState = (): void => { + setIsAuthenticated(ConfigurationService.isTokenValid()); + }; + + syncAuthState(); + window.addEventListener('storage', syncAuthState); + + return () => { + window.removeEventListener('storage', syncAuthState); + }; + }, []); + const { data: btcUsdPrice } = useQuery({ queryKey: ['btc-usd-price'], queryFn: fetchBtcUsdPrice, + enabled: isAuthenticated, refetchInterval: 120_000, staleTime: 60_000, }); const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; - useEffect(() => { - if (displayUnit === 'usd' && usdPerSat === null) { - setDisplayUnit('sat'); - } - }, [displayUnit, usdPerSat]); + const { + data: metricsData, + isLoading: metricsLoading, + refetch: refetchMetrics, + } = useQuery({ + queryKey: ['usage-metrics', interval, timeRange], + queryFn: () => + AdminService.getUsageMetrics(parseInt(interval), parseInt(timeRange)), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + const { + data: summaryData, + isLoading: summaryLoading, + refetch: refetchSummary, + } = useQuery({ + queryKey: ['usage-summary', timeRange], + queryFn: () => AdminService.getUsageSummary(parseInt(timeRange)), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + const { + data: errorData, + isLoading: errorLoading, + refetch: refetchErrors, + } = useQuery({ + queryKey: ['usage-errors', timeRange], + queryFn: () => AdminService.getErrorDetails(parseInt(timeRange), 100), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + const { + data: revenueByModelData, + isLoading: revenueByModelLoading, + refetch: refetchRevenueByModel, + } = useQuery({ + queryKey: ['revenue-by-model', timeRange], + queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange), 20), + enabled: isAuthenticated, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + if (!isAuthenticated) { + return ; + } + + const handleRefresh = () => { + refetchMetrics(); + refetchSummary(); + refetchErrors(); + refetchRevenueByModel(); + }; return ( -
-
+
+
+

Dashboard

+ +
+ +
-

- Admin Dashboard -

-

- Monitor and manage wallet balances +

+ Usage Analytics +

+

+ Monitor requests, errors, and revenue over the last {timeRange} hours

-
- { - if (value) { - setDisplayUnit(value as DisplayUnit); - } - }} - variant='outline' - size='sm' - > - mSAT - sat - - USD - - +
+ + +
-
-
- +
+ {summaryLoading ? ( +
Loading summary...
+ ) : summaryData ? ( + + ) : null} + +
+ {metricsLoading ? ( +
+ Loading metrics... +
+ ) : metricsData && metricsData.metrics.length > 0 ? ( + <> +
+ ({ + ...m, + revenue_sats: m.revenue_msats / 1000, + refunds_sats: m.refunds_msats / 1000, + net_revenue_sats: + (m.revenue_msats - m.refunds_msats) / 1000, + })) as Array & { timestamp: string }>} + title='Revenue Over Time (sats)' + dataKeys={[ + { + key: 'revenue_sats', + name: 'Revenue', + color: '#10b981', + }, + { + key: 'net_revenue_sats', + name: 'Net Revenue', + color: '#059669', + }, + { + key: 'refunds_sats', + name: 'Refunds', + color: '#ef4444', + }, + ]} + /> +
+ & { timestamp: string }>} + title='Request Volume' + dataKeys={[ + { + key: 'total_requests', + name: 'Total Requests', + color: '#3b82f6', + }, + { + key: 'successful_chat_completions', + name: 'Successful', + color: '#22c55e', + }, + { + key: 'failed_requests', + name: 'Failed', + color: '#f43f5e', + }, + ]} + /> + & { 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: '#dc2626', + }, + ]} + /> + & { timestamp: string }>} + title='Payment Activity' + dataKeys={[ + { + key: 'payment_processed', + name: 'Payments Processed', + color: '#8b5cf6', + }, + ]} + /> +
+ {summaryData && summaryData.unique_models.length > 0 && ( + + + Active Models + + +
+ {summaryData.unique_models.map((model) => ( + + {model} + + ))} +
+
+
+ )} + {summaryData && + summaryData.error_types && + Object.keys(summaryData.error_types).length > 0 && ( + + + Error Types Distribution + + +
+ {Object.entries(summaryData.error_types) + .sort(([, a], [, b]) => b - a) + .map(([type, count]) => ( +
+ {type} + + {count} + +
+ ))} +
+
+
+ )} +
+ + ) : ( + + + No Data Available + + +

+ No metrics data found for the selected time range. This + could be because no requests have been logged yet or the + log files are not available. +

+
+
+ )}
-
- Loading revenue by model...
+ ) : revenueByModelData && revenueByModelData.models.length > 0 ? ( + -
+ ) : null} + + {errorLoading ? ( +
Loading errors...
+ ) : errorData ? ( + + ) : null}
diff --git a/ui/components/app-sidebar.tsx b/ui/components/app-sidebar.tsx index 5ec96ef5..acf5a7b5 100644 --- a/ui/components/app-sidebar.tsx +++ b/ui/components/app-sidebar.tsx @@ -2,10 +2,12 @@ import * as React from 'react'; import { + FileTextIcon, DatabaseIcon, LayoutDashboardIcon, ServerIcon, SettingsIcon, + WalletIcon, } from 'lucide-react'; import Image from 'next/image'; @@ -34,6 +36,16 @@ const data = { url: '/', icon: LayoutDashboardIcon, }, + { + title: 'Balances', + url: '/balances', + icon: WalletIcon, + }, + { + title: 'Logs', + url: '/logs', + icon: FileTextIcon, + }, { title: 'Models', url: '/model', @@ -49,26 +61,6 @@ const data = { url: '/settings', icon: SettingsIcon, }, - // { - // title: 'Transactions', - // url: '/transactions', - // icon: ReceiptIcon, - // }, - // { - // title: 'Credit', - // url: '/credits', - // icon: FolderIcon, - // }, - // { - // title: 'Users', - // url: '/users', - // icon: UsersIcon, - // }, - // { - // title: 'Organizations', - // url: '/organizations', - // icon: FolderIcon, - // }, ], documents: [], }; diff --git a/ui/components/currency-toggle.tsx b/ui/components/currency-toggle.tsx new file mode 100644 index 00000000..14045e5c --- /dev/null +++ b/ui/components/currency-toggle.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useCurrencyStore } from '@/lib/stores/currency'; +import { useQuery } from '@tanstack/react-query'; +import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; +import { useEffect } from 'react'; +import type { DisplayUnit } from '@/lib/types/units'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { Coins } from 'lucide-react'; + +export function CurrencyToggle() { + const { displayUnit, setDisplayUnit } = useCurrencyStore(); + + const { data: btcUsdPrice } = useQuery({ + queryKey: ['btc-usd-price'], + queryFn: fetchBtcUsdPrice, + refetchInterval: 120_000, + staleTime: 60_000, + }); + + const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; + + useEffect(() => { + if (displayUnit === 'usd' && usdPerSat === null) { + setDisplayUnit('sat'); + } + }, [displayUnit, usdPerSat, setDisplayUnit]); + + const getLabel = (unit: DisplayUnit) => { + switch (unit) { + case 'msat': + return 'mSAT'; + case 'sat': + return 'sat'; + case 'usd': + return 'USD'; + default: + return unit; + } + }; + + return ( + + + + + + setDisplayUnit('msat')}> + Millisatoshis (mSAT) + + setDisplayUnit('sat')}> + Satoshis (sat) + + setDisplayUnit('usd')} + disabled={!usdPerSat} + > + US Dollar (USD) + + + + ); +} + diff --git a/ui/components/dashboard-balance-summary.tsx b/ui/components/dashboard-balance-summary.tsx new file mode 100644 index 00000000..2921c891 --- /dev/null +++ b/ui/components/dashboard-balance-summary.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { WalletService, BalanceDetail } from '@/lib/api/services/wallet'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { convertToMsat, formatFromMsat } from '@/lib/currency'; +import { Wallet, User, Coins } from 'lucide-react'; +import type { DisplayUnit } from '@/lib/types/units'; + +interface DashboardBalanceSummaryProps { + displayUnit?: DisplayUnit; + usdPerSat?: number | null; +} + +export function DashboardBalanceSummary({ + displayUnit = 'sat', + usdPerSat = null, +}: DashboardBalanceSummaryProps) { + const { data } = useQuery({ + queryKey: ['detailed-wallet-balance'], + queryFn: async () => { + return WalletService.getDetailedBalances(); + }, + refetchInterval: 30000, + }); + + const calculateTotals = (balances: BalanceDetail[]) => { + let totalWallet = 0; + let totalUser = 0; + let totalOwner = 0; + + balances.forEach((detail) => { + if (!detail.error) { + const walletMsat = convertToMsat( + detail.wallet_balance || 0, + detail.unit + ); + const userMsat = convertToMsat(detail.user_balance || 0, detail.unit); + const ownerMsat = convertToMsat(detail.owner_balance || 0, detail.unit); + + totalWallet += walletMsat; + totalUser += userMsat; + totalOwner += ownerMsat; + } + }); + + return { totalWallet, totalUser, totalOwner }; + }; + + const totals = data + ? calculateTotals(data) + : { totalWallet: 0, totalUser: 0, totalOwner: 0 }; + + const formatAmount = (msatAmount: number): string => + formatFromMsat(msatAmount, displayUnit, usdPerSat); + + const cards = [ + { + title: 'Your Balance', + value: formatAmount(totals.totalOwner), + icon: Coins, + color: 'text-green-600', + bgColor: 'bg-green-100 dark:bg-green-900/20', + }, + { + title: 'Total Wallet', + value: formatAmount(totals.totalWallet), + icon: Wallet, + color: 'text-blue-600', + bgColor: 'bg-blue-100 dark:bg-blue-900/20', + }, + { + title: 'User Balance', + value: formatAmount(totals.totalUser), + icon: User, + color: 'text-purple-600', + bgColor: 'bg-purple-100 dark:bg-purple-900/20', + }, + ]; + + return ( +
+ {cards.map((card) => ( + + + {card.title} +
+ +
+
+ +
{card.value}
+
+
+ ))} +
+ ); +} + diff --git a/ui/components/error-details-table.tsx b/ui/components/error-details-table.tsx new file mode 100644 index 00000000..5e51e810 --- /dev/null +++ b/ui/components/error-details-table.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { ErrorDetail } from '@/lib/api/services/admin'; + +interface ErrorDetailsTableProps { + errors: ErrorDetail[]; +} + +export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) { + if (errors.length === 0) { + return ( + + + Recent Errors + + +

+ No errors found in the selected time period +

+
+
+ ); + } + + return ( + + + Recent Errors ({errors.length}) + + +
+ + + + Timestamp + Type + Message + Location + Request ID + + + + {errors.map((error, index) => ( + + + {new Date(error.timestamp).toLocaleString()} + + + {error.error_type} + + + {error.message} + + + {error.pathname}:{error.lineno} + + + {error.request_id || '-'} + + + ))} + +
+
+
+
+ ); +} diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx new file mode 100644 index 00000000..e66897dc --- /dev/null +++ b/ui/components/landing/cheat-sheet.tsx @@ -0,0 +1,746 @@ +'use client'; + +import { type JSX, useCallback, useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Bolt, + Copy, + KeyRound, + RefreshCcw, + ShieldCheck, + Terminal, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { ConfigurationService } from '@/lib/api/services/configuration'; + +type NodeInfo = { + name: string; + description: string; + version: string; + npub?: string | null; + mints: string[]; + http_url?: string | null; + onion_url?: string | null; +}; + +type WalletSnapshot = { + apiKey: string; + balanceMsats: number; + reservedMsats: number; +}; + +type RefundReceipt = { + token?: string; + recipient?: string; + sats?: string; + msats?: string; +}; + +const DEFAULT_BASE_URL = 'http://127.0.0.1:8000'; + +async function fetchNodeInfo(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/v1/info`, { + cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load node info'); + } + + const payload = (await response.json()) as NodeInfo; + return { + ...payload, + mints: Array.isArray(payload.mints) ? payload.mints : [], + }; +} + +async function fetchWalletInfo( + baseUrl: string, + apiKey: string +): Promise { + const response = await fetch(`${baseUrl}/v1/balance/info`, { + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load wallet info'); + } + + const payload = (await response.json()) as { + api_key: string; + balance: number; + reserved?: number; + }; + + return { + apiKey: payload.api_key || apiKey, + balanceMsats: payload.balance ?? 0, + reservedMsats: payload.reserved ?? 0, + }; +} + +function normalizeBaseUrl(url: string): string { + const trimmed = url.trim(); + if (!trimmed) { + return ''; + } + return trimmed.replace(/\/+$/, ''); +} + +function formatMsats(msats: number): string { + return new Intl.NumberFormat('en-US').format(msats); +} + +function formatSats(msats: number): string { + return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); +} + +export function CheatSheet(): JSX.Element { + const [baseUrl, setBaseUrl] = useState(() => + typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl() + ); + const [initialToken, setInitialToken] = useState(''); + const [topupToken, setTopupToken] = useState(''); + const [apiKeyInput, setApiKeyInput] = useState(''); + const [walletInfo, setWalletInfo] = useState(null); + const [refundReceipt, setRefundReceipt] = useState(null); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [isTopupLoading, setIsTopupLoading] = useState(false); + const [isRefunding, setIsRefunding] = useState(false); + const [isSyncingBalance, setIsSyncingBalance] = useState(false); + const [hasInteractedCreate, setHasInteractedCreate] = useState(false); + const [hasInteractedManage, setHasInteractedManage] = useState(false); + const [hasInteractedTopup, setHasInteractedTopup] = useState(false); + + useEffect(() => { + if (!baseUrl && typeof window !== 'undefined') { + setBaseUrl(ConfigurationService.getLocalBaseUrl()); + } + }, [baseUrl]); + + const normalizedBaseUrl = useMemo( + () => normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL, + [baseUrl] + ); + + const activeApiKey = apiKeyInput.trim(); + + const { + data: nodeInfo, + isLoading: isInfoLoading, + isError: isInfoError, + refetch: refetchNodeInfo, + } = useQuery({ + queryKey: ['node-info', normalizedBaseUrl], + queryFn: () => fetchNodeInfo(normalizedBaseUrl), + enabled: Boolean(normalizedBaseUrl), + refetchInterval: 300_000, + staleTime: 120_000, + }); + + const handleCopy = useCallback(async (value: string): Promise => { + if (!value) { + return; + } + if (typeof navigator === 'undefined' || !navigator.clipboard) { + toast.error('Clipboard API unavailable'); + return; + } + try { + await navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + } catch (error) { + console.error(error); + toast.error('Unable to copy'); + } + }, []); + + const handleCreateKey = useCallback(async (): Promise => { + if (!initialToken.trim()) { + toast.error('Cashu token required'); + return; + } + + setIsCreatingKey(true); + setRefundReceipt(null); + + try { + const params = new URLSearchParams({ + initial_balance_token: initialToken.trim(), + }); + const response = await fetch( + `${normalizedBaseUrl}/v1/balance/create?${params.toString()}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to create API key'); + } + const payload = (await response.json()) as { + api_key: string; + balance: number; + }; + const snapshot: WalletSnapshot = { + apiKey: payload.api_key, + balanceMsats: payload.balance ?? 0, + reservedMsats: 0, + }; + setApiKeyInput(snapshot.apiKey); + setWalletInfo(snapshot); + setInitialToken(''); + toast.success('API key ready'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to create API key' + ); + } finally { + setIsCreatingKey(false); + } + }, [initialToken, normalizedBaseUrl]); + + const handleSyncBalance = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsSyncingBalance(true); + try { + const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey); + setWalletInfo(snapshot); + toast.success('Balance synced'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to sync balance' + ); + } finally { + setIsSyncingBalance(false); + } + }, [activeApiKey, normalizedBaseUrl]); + + const handleTopup = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + if (!topupToken.trim()) { + toast.error('Cashu token required for top-up'); + return; + } + + setIsTopupLoading(true); + setRefundReceipt(null); + try { + const response = await fetch(`${normalizedBaseUrl}/v1/balance/topup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${activeApiKey}`, + }, + body: JSON.stringify({ cashu_token: topupToken.trim() }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to top up'); + } + const payload = (await response.json()) as { msats: number }; + toast.success(`Added ${formatSats(payload.msats)} sats`); + setTopupToken(''); + const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey); + setWalletInfo(snapshot); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Top-up failed'); + } finally { + setIsTopupLoading(false); + } + }, [activeApiKey, normalizedBaseUrl, topupToken]); + + const handleRefund = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsRefunding(true); + try { + const response = await fetch(`${normalizedBaseUrl}/v1/balance/refund`, { + method: 'POST', + headers: { + Authorization: `Bearer ${activeApiKey}`, + }, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Refund failed'); + } + const payload = (await response.json()) as RefundReceipt; + setRefundReceipt(payload); + setWalletInfo(null); + toast.success('Refund requested'); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Refund failed'); + } finally { + setIsRefunding(false); + } + }, [activeApiKey, normalizedBaseUrl]); + + const handleRefreshInfo = useCallback(async (): Promise => { + const result = await refetchNodeInfo(); + if (result.error) { + toast.error('Unable to refresh node info'); + } else { + toast.success('Node info refreshed'); + } + }, [refetchNodeInfo]); + + const curlSnippet = useMemo(() => { + const keyPreview = activeApiKey || 'YOUR_API_KEY'; + return [ + `curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`, + ` -H "Authorization: Bearer ${keyPreview}"`, + ' -H "Content-Type: application/json"', + " -d '{", + ' "model": "openai/gpt-4o-mini",', + ' "messages": [', + ' {"role":"system","content":"You are Routstr."},', + ' {"role":"user","content":"Ping the node"}', + ' ]', + " }'", + ].join('\n'); + }, [activeApiKey, normalizedBaseUrl]); + + const showCreateDetails = + hasInteractedCreate || initialToken.trim().length > 0; + const showManageDetails = hasInteractedManage || Boolean(walletInfo); + const showTopupDetails = + hasInteractedTopup || topupToken.trim().length > 0; + const refundToken = refundReceipt?.token ?? null; + const canTopup = Boolean(activeApiKey); + + return ( +
+
+
+
+ +
+
+ + Routstr cheat sheet +
+

+ Node Identity and Cheat Sheet +

+
+ +
+
+ +
+ + +
+ + + Node identity + +

+ /v1/info snapshot +

+
+ +
+ + {isInfoLoading && ( +

+ Loading node profile… +

+ )} + {isInfoError && !isInfoLoading && ( +

+ Unable to reach /v1/info at {normalizedBaseUrl} +

+ )} + {nodeInfo && ( + <> +
+

{nodeInfo.name}

+

+ {nodeInfo.description} +

+
+
+
+
+ Version +
+
+ {nodeInfo.version} +
+
+
+
+ HTTP +
+
+ {nodeInfo.http_url || normalizedBaseUrl} +
+
+ {nodeInfo.onion_url && ( +
+
+ Onion +
+
+ {nodeInfo.onion_url} +
+
+ )} + {nodeInfo.npub && ( +
+
+ npub +
+
+ {nodeInfo.npub} + +
+
+ )} +
+
+

+ Cashu mints +

+
+ {nodeInfo.mints.length ? ( + nodeInfo.mints.map((mint) => ( + + {mint} + + )) + ) : ( +

+ No mint list published +

+ )} +
+
+ + )} +
+
+ + + + + + Quick docs + + + curl-ready + + + +
+ setBaseUrl(event.target.value)} + className='text-sm' + /> + +
+
+
{curlSnippet}
+
+
+ + +
+
+
+
+ + + + + + API key workflow + +

+ Sections expand as soon as you interact +

+
+ +
+
+ 1 · Create key + {showCreateDetails && ( + Cashu token detected + )} +
+