From d693b559c01a7f14f5ce1e84c72784e3e408b8a4 Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:10:25 +0000 Subject: [PATCH 01/37] Add configurable excluded model IDs setting - Add excluded_model_ids field to Settings class - Support comma-separated list via EXCLUDED_MODEL_IDS env var - Include default exclusions for openrouter/auto and google/gemini-2.5-pro-exp-03-25 --- routstr/core/settings.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 35560a4c..314f46fd 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -16,7 +16,7 @@ class Settings(BaseSettings): @classmethod def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override] - if field_name in {"cashu_mints", "cors_origins", "relays"}: + if field_name in {"cashu_mints", "cors_origins", "relays", "excluded_model_ids"}: v = str(raw_value).strip() if v == "": return [] @@ -54,6 +54,16 @@ class Settings(BaseSettings): # Minimum per-request charge in millisatoshis when model pricing is free/zero min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT") + # Model filtering + excluded_model_ids: list[str] = Field( + default_factory=lambda: [ + "openrouter/auto", + "google/gemini-2.5-pro-exp-03-25", + "opengvlab/internvl3-78b" + ], + env="EXCLUDED_MODEL_IDS" + ) + # Network cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS") tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL") @@ -304,4 +314,4 @@ class SettingsService: for k, v in data.items(): setattr(settings, k, v) cls._current = settings - return settings + return settings \ No newline at end of file From fa0b2834c504cb13ce13afb27c00e38e6c77b1db Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:10:30 +0000 Subject: [PATCH 02/37] Remove models variable from base URL output - Remove the deprecated models field from /v1/info endpoint - The models field was kept for back-compatibility but is now removed - Users should use the dedicated /v1/models endpoint instead Fixes #184 --- routstr/core/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/routstr/core/main.py b/routstr/core/main.py index b73b21b5..a89e49e6 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -149,7 +149,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 } @@ -163,4 +162,4 @@ app.include_router(admin_router) app.include_router(balance_router) app.include_router(deprecated_wallet_router) app.include_router(providers_router) -app.include_router(proxy_router) +app.include_router(proxy_router) \ No newline at end of file From 80a7f5d2ebb0e65e7135e9368967116505899de4 Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:11:46 +0000 Subject: [PATCH 03/37] Replace hardcoded model exclusions with configurable setting - Remove hardcoded model ID checks for openrouter/auto and google/gemini-2.5-pro-exp-03-25 - Use settings.excluded_model_ids list for model filtering - Maintain backward compatibility with existing exclusion logic - Add proper error handling for settings access --- routstr/payment/models.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/routstr/payment/models.py b/routstr/payment/models.py index f21da6bf..a652e474 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -79,10 +79,15 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: model["id"] = model_id[len(source_prefix) :] model_id = model["id"] + # Check if model should be excluded based on configuration + try: + excluded_ids = getattr(settings, "excluded_model_ids", []) + except Exception: + excluded_ids = [] + 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 in excluded_ids ): continue @@ -448,4 +453,4 @@ async def refresh_models_periodically() -> None: @models_router.get("/models", include_in_schema=False) async def models(session: AsyncSession = Depends(get_session)) -> dict: items = await list_models(session) - return {"data": items} + return {"data": items} \ No newline at end of file From 859b31e2bc7988548e8e649e7e4ad5d59657104e Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:11:48 +0000 Subject: [PATCH 04/37] Update tests to reflect removal of models field from base URL output - Remove models field from required fields list in root endpoint test - Add explicit test to ensure models field is not present - Update test comments to reference issue #184 Related to #184 --- tests/integration/test_general_info_endpoints.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_general_info_endpoints.py b/tests/integration/test_general_info_endpoints.py index f9d52c25..f83cd64c 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() @@ -457,4 +450,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 From fe2b1846f7d9f9a266b46e20f7948852251d419f Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:12:05 +0000 Subject: [PATCH 05/37] Add EXCLUDED_MODEL_IDS configuration to .env.example - Document the new EXCLUDED_MODEL_IDS environment variable - Show example with default excluded model IDs - Maintain existing configuration structure --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index b5d86e6e..829c55e3 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,4 @@ UPSTREAM_API_KEY=your-upstream-api-key # BASE_URL=https://openrouter.ai/api/v1 # MODELS_PATH=models.json # SOURCE= +# EXCLUDED_MODEL_IDS="openrouter/auto,google/gemini-2.5-pro-exp-03-25,opengvlab/internvl3-78b" \ No newline at end of file From fe66f249f00e562e868530fb4ba02400801eec27 Mon Sep 17 00:00:00 2001 From: GitHappens2Me Date: Tue, 11 Nov 2025 15:42:26 +0100 Subject: [PATCH 06/37] small fixes --- README.md | 2 +- routstr/auth.py | 2 +- routstr/core/settings.py | 2 +- routstr/payment/__init__.py | 2 +- routstr/payment/{cost_caculation.py => cost_calculation.py} | 0 routstr/upstreams/upstream.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename routstr/payment/{cost_caculation.py => cost_calculation.py} (100%) 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/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/settings.py b/routstr/core/settings.py index eb8e59a9..9f213a3f 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/upstreams/upstream.py b/routstr/upstreams/upstream.py index 9d3b1e35..638971dc 100644 --- a/routstr/upstreams/upstream.py +++ b/routstr/upstreams/upstream.py @@ -13,7 +13,7 @@ from fastapi.responses import Response, StreamingResponse from ..auth import adjust_payment_for_tokens from ..core import get_logger from ..core.db import ApiKey, AsyncSession, create_session -from ..payment.cost_caculation import ( +from ..payment.cost_calculation import ( CostData, CostDataError, MaxCostData, From 34cdbfe44697c8ef1de21287aa708f35fbe8a5ca Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Sat, 15 Nov 2025 11:32:18 +0100 Subject: [PATCH 07/37] add logs page --- routstr/core/admin.py | 93 +++++++++++ routstr/search/__init__.py | 3 + routstr/search/log_search.py | 137 ++++++++++++++++ ui/app/globals.css | 198 +++++++++++++++-------- ui/app/logs/log-details-dialog.tsx | 175 ++++++++++++++++++++ ui/app/logs/log-entry-card.tsx | 123 ++++++++++++++ ui/app/logs/log-filters.tsx | 247 +++++++++++++++++++++++++++++ ui/app/logs/page.tsx | 185 +++++++++++++++++++++ ui/app/logs/types.ts | 25 +++ ui/components/app-sidebar.tsx | 6 + 10 files changed, 1127 insertions(+), 65 deletions(-) create mode 100644 routstr/search/__init__.py create mode 100644 routstr/search/log_search.py create mode 100644 ui/app/logs/log-details-dialog.tsx create mode 100644 ui/app/logs/log-entry-card.tsx create mode 100644 ui/app/logs/log-filters.tsx create mode 100644 ui/app/logs/page.tsx create mode 100644 ui/app/logs/types.ts diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 3baad78b..06a8a7ed 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -10,6 +10,7 @@ from sqlmodel import select from ..payment.models import _row_to_model, list_models from ..proxy import refresh_model_maps, reinitialize_upstreams +from ..search import search_logs from ..wallet import ( fetch_all_balances, get_proofs_per_mint_and_unit, @@ -908,6 +909,72 @@ 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]: + """ + 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 + """ + logs_dir = Path("logs") + + # Use the search module for log filtering + log_entries = search_logs( + logs_dir=logs_dir, + 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} + + @admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)]) async def withdraw( request: Request, withdraw_request: WithdrawRequest @@ -2404,6 +2471,32 @@ def upstream_providers_page() -> str: ) +def logs_page() -> str: + return """ + + + + + + Logs - Admin Dashboard + + + +

Redirecting to logs page...

+ + + """ + + +@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("/upstream-providers", response_class=HTMLResponse) async def admin_upstream_providers(request: Request) -> str: if is_admin_authenticated(request): diff --git a/routstr/search/__init__.py b/routstr/search/__init__.py new file mode 100644 index 00000000..963cd1b4 --- /dev/null +++ b/routstr/search/__init__.py @@ -0,0 +1,3 @@ +from .log_search import search_logs + +__all__ = ["search_logs"] diff --git a/routstr/search/log_search.py b/routstr/search/log_search.py new file mode 100644 index 00000000..6740d3a5 --- /dev/null +++ b/routstr/search/log_search.py @@ -0,0 +1,137 @@ +""" +Log search functionality. + +This module contains the search logic for filtering log entries. +It can be replaced with more advanced search mechanisms in the future +(e.g., Elasticsearch, full-text search databases, etc.) +""" + +import json +from pathlib import Path +from typing import Any + + +def search_logs( + logs_dir: Path, + 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. + + This is a simple file-based search implementation. For better performance + with large log volumes, consider using: + - Elasticsearch + - Splunk + - Loki + - Or other log aggregation/search tools + + Args: + logs_dir: Path to the logs directory + date: Filter by specific date (YYYY-MM-DD format) + level: Filter by log level (INFO, WARNING, ERROR, etc.) + request_id: Filter by exact request ID match + search_text: Search in message and name fields (case-insensitive) + limit: Maximum number of entries to return + + Returns: + List of log entries matching the criteria + """ + log_entries = [] + + if not logs_dir.exists(): + return log_entries + + # Determine which log files to search + log_files = [] + if date: + log_file = logs_dir / f"app_{date}.log" + if log_file.exists(): + log_files.append(log_file) + else: + # Search last 7 days of logs + log_files = sorted( + logs_dir.glob("app_*.log"), + key=lambda x: x.stat().st_mtime, + reverse=True, + )[:7] + + # Normalize search text for case-insensitive search + search_text_lower = search_text.lower() if search_text else None + + # Search through log files + for log_file in log_files: + try: + with open(log_file, "r") as f: + for line in f: + try: + log_data = json.loads(line.strip()) + + # Apply filters + if not _matches_filters( + log_data, level, request_id, search_text_lower + ): + continue + + log_entries.append(log_data) + + # Stop if we've reached the limit + if len(log_entries) >= limit: + break + + except json.JSONDecodeError: + # Skip malformed JSON lines + continue + + # Stop searching more files if we've reached the limit + if len(log_entries) >= limit: + break + + except Exception: + # Skip files that can't be read + continue + + # Sort by timestamp (most recent first) + log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True) + + return log_entries + + +def _matches_filters( + log_data: dict[str, Any], + level: str | None, + request_id: str | None, + search_text_lower: str | None, +) -> bool: + """ + Check if a log entry matches the given filters. + + Args: + log_data: The log entry to check + level: Log level filter (if any) + request_id: Request ID filter (if any) + search_text_lower: Lowercase search text (if any) + + Returns: + True if the log entry matches all filters, False otherwise + """ + # Filter by log level + if level and log_data.get("levelname", "").upper() != level.upper(): + return False + + # Filter by request ID (exact match) + if request_id and log_data.get("request_id") != request_id: + return False + + # Filter by search text (case-insensitive search in message and name) + if search_text_lower: + message = str(log_data.get("message", "")).lower() + name = str(log_data.get("name", "")).lower() + + if search_text_lower not in message and search_text_lower not in name: + return False + + return True diff --git a/ui/app/globals.css b/ui/app/globals.css index 86093a5c..9eaa4e84 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,142 @@ --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.9900 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.9900 0 0); + --popover-foreground: oklch(0 0 0); + --primary: oklch(0 0 0); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.9400 0 0); + --secondary-foreground: oklch(0 0 0); + --muted: oklch(0.9700 0 0); + --muted-foreground: oklch(0.4400 0 0); + --accent: oklch(0.9400 0 0); + --accent-foreground: oklch(0 0 0); + --destructive: oklch(0.6300 0.1900 23.0300); + --border: oklch(0.9200 0 0); + --input: oklch(0.9400 0 0); + --ring: oklch(0 0 0); + --chart-1: oklch(0.8100 0.1700 75.3500); + --chart-2: oklch(0.5500 0.2200 264.5300); + --chart-3: oklch(0.7200 0 0); + --chart-4: oklch(0.9200 0 0); + --chart-5: oklch(0.5600 0 0); + --sidebar: oklch(0.9900 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.9400 0 0); + --sidebar-accent-foreground: oklch(0 0 0); + --sidebar-border: oklch(0.9400 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.1400 0 0); + --card-foreground: oklch(1 0 0); + --popover: oklch(0.1800 0 0); + --popover-foreground: oklch(1 0 0); + --primary: oklch(1 0 0); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.2500 0 0); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.2300 0 0); + --muted-foreground: oklch(0.7200 0 0); + --accent: oklch(0.3200 0 0); + --accent-foreground: oklch(1 0 0); + --destructive: oklch(0.6900 0.2000 23.9100); + --border: oklch(0.2600 0 0); + --input: oklch(0.3200 0 0); + --ring: oklch(0.7200 0 0); + --chart-1: oklch(0.8100 0.1700 75.3500); + --chart-2: oklch(0.5800 0.2100 260.8400); + --chart-3: oklch(0.5600 0 0); + --chart-4: oklch(0.4400 0 0); + --chart-5: oklch(0.9200 0 0); + --sidebar: oklch(0.1800 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.3200 0 0); + --sidebar-accent-foreground: oklch(1 0 0); + --sidebar-border: oklch(0.3200 0 0); + --sidebar-ring: oklch(0.7200 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 +185,7 @@ } body { @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); } } @@ -133,4 +201,4 @@ .animate-shimmer { animation: shimmer 2s infinite; -} +} \ No newline at end of file diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx new file mode 100644 index 00000000..842549d7 --- /dev/null +++ b/ui/app/logs/log-details-dialog.tsx @@ -0,0 +1,175 @@ +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 } 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 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) { + if (!log) return null; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + 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} + +
+
+ {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..f60d151d --- /dev/null +++ b/ui/app/logs/log-entry-card.tsx @@ -0,0 +1,123 @@ +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..6257fbd0 --- /dev/null +++ b/ui/app/logs/log-filters.tsx @@ -0,0 +1,247 @@ +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 { Calendar, Filter } from 'lucide-react'; +import { useState, useEffect } from 'react'; + +interface LogFiltersProps { + selectedDate: string; + selectedLevel: string; + requestId: string; + searchText: string; + limit: number; + availableDates: string[]; + 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, + availableDates, + 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); + + useEffect(() => { + const currentIsPreset = PRESET_LIMITS.includes(limit.toString()); + setIsCustom(!currentIsPreset); + if (!currentIsPreset) { + setCustomLimit(limit.toString()); + } + }, [limit]); + + 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(); + } + }; + + return ( + + + + + Filters + + + Filter logs by date, level, request ID, text search, and limit + + + +
+
+ + +
+ +
+ + +
+ +
+ + 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..4a1400ff --- /dev/null +++ b/ui/app/logs/page.tsx @@ -0,0 +1,185 @@ +'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, DatesResponse } 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: datesData } = useQuery({ + queryKey: ['log-dates'], + queryFn: () => apiClient.get('/admin/api/logs/dates'), + refetchInterval: 300000, + }); + + 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) => ( + + ))} +
+
+ + ) : ( +
+ +

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/components/app-sidebar.tsx b/ui/components/app-sidebar.tsx index 5ec96ef5..70771d1f 100644 --- a/ui/components/app-sidebar.tsx +++ b/ui/components/app-sidebar.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { DatabaseIcon, + FileTextIcon, LayoutDashboardIcon, ServerIcon, SettingsIcon, @@ -44,6 +45,11 @@ const data = { url: '/providers', icon: ServerIcon, }, + { + title: 'Logs', + url: '/logs', + icon: FileTextIcon, + }, { title: 'Settings', url: '/settings', From 3e7d4c6e86110cd4c596a3bcd49d4722a9259e68 Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Sat, 15 Nov 2025 11:40:31 +0100 Subject: [PATCH 08/37] add date picker --- ui/app/globals.css | 104 ++++++++++++++++++--------------- ui/app/logs/log-entry-card.tsx | 1 - ui/app/logs/log-filters.tsx | 84 ++++++++++++++++++++------ ui/app/logs/page.tsx | 13 +---- ui/components/ui/calendar.tsx | 72 +++++++++++++++++++++++ 5 files changed, 199 insertions(+), 75 deletions(-) create mode 100644 ui/components/ui/calendar.tsx diff --git a/ui/app/globals.css b/ui/app/globals.css index 9eaa4e84..37756b40 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -70,36 +70,36 @@ :root { --radius: 0.5rem; - --background: oklch(0.9900 0 0); + --background: oklch(0.99 0 0); --foreground: oklch(0 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0 0 0); - --popover: oklch(0.9900 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.9400 0 0); + --secondary: oklch(0.94 0 0); --secondary-foreground: oklch(0 0 0); - --muted: oklch(0.9700 0 0); - --muted-foreground: oklch(0.4400 0 0); - --accent: oklch(0.9400 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.6300 0.1900 23.0300); - --border: oklch(0.9200 0 0); - --input: oklch(0.9400 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.8100 0.1700 75.3500); - --chart-2: oklch(0.5500 0.2200 264.5300); - --chart-3: oklch(0.7200 0 0); - --chart-4: oklch(0.9200 0 0); - --chart-5: oklch(0.5600 0 0); - --sidebar: oklch(0.9900 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.9400 0 0); + --sidebar-accent: oklch(0.94 0 0); --sidebar-accent-foreground: oklch(0 0 0); - --sidebar-border: oklch(0.9400 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; @@ -115,11 +115,16 @@ --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-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; } @@ -127,35 +132,35 @@ .dark { --background: oklch(0 0 0); --foreground: oklch(1 0 0); - --card: oklch(0.1400 0 0); + --card: oklch(0.14 0 0); --card-foreground: oklch(1 0 0); - --popover: oklch(0.1800 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.2500 0 0); + --secondary: oklch(0.25 0 0); --secondary-foreground: oklch(1 0 0); - --muted: oklch(0.2300 0 0); - --muted-foreground: oklch(0.7200 0 0); - --accent: oklch(0.3200 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.6900 0.2000 23.9100); - --border: oklch(0.2600 0 0); - --input: oklch(0.3200 0 0); - --ring: oklch(0.7200 0 0); - --chart-1: oklch(0.8100 0.1700 75.3500); - --chart-2: oklch(0.5800 0.2100 260.8400); - --chart-3: oklch(0.5600 0 0); - --chart-4: oklch(0.4400 0 0); - --chart-5: oklch(0.9200 0 0); - --sidebar: oklch(0.1800 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.3200 0 0); + --sidebar-accent: oklch(0.32 0 0); --sidebar-accent-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.3200 0 0); - --sidebar-ring: oklch(0.7200 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; @@ -171,11 +176,16 @@ --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-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); } @@ -201,4 +211,4 @@ .animate-shimmer { animation: shimmer 2s infinite; -} \ No newline at end of file +} diff --git a/ui/app/logs/log-entry-card.tsx b/ui/app/logs/log-entry-card.tsx index f60d151d..20133908 100644 --- a/ui/app/logs/log-entry-card.tsx +++ b/ui/app/logs/log-entry-card.tsx @@ -53,7 +53,6 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) { return (
onClick(entry)} > diff --git a/ui/app/logs/log-filters.tsx b/ui/app/logs/log-filters.tsx index 6257fbd0..cf85a2ba 100644 --- a/ui/app/logs/log-filters.tsx +++ b/ui/app/logs/log-filters.tsx @@ -15,8 +15,16 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Calendar, Filter } from 'lucide-react'; +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; @@ -24,7 +32,6 @@ interface LogFiltersProps { requestId: string; searchText: string; limit: number; - availableDates: string[]; onDateChange: (date: string) => void; onLevelChange: (level: string) => void; onRequestIdChange: (requestId: string) => void; @@ -42,7 +49,6 @@ export function LogFilters({ requestId, searchText, limit, - availableDates, onDateChange, onLevelChange, onRequestIdChange, @@ -56,6 +62,9 @@ export function LogFilters({ isPreset ? '' : limit.toString() ); const [isCustom, setIsCustom] = useState(!isPreset); + const [date, setDate] = useState( + selectedDate && selectedDate !== 'all' ? new Date(selectedDate) : undefined + ); useEffect(() => { const currentIsPreset = PRESET_LIMITS.includes(limit.toString()); @@ -65,6 +74,18 @@ export function LogFilters({ } }, [limit]); + useEffect(() => { + if (selectedDate === 'all' || !selectedDate) { + setDate(undefined); + } else { + try { + setDate(new Date(selectedDate)); + } catch { + setDate(undefined); + } + } + }, [selectedDate]); + const handleLimitChange = (value: string) => { if (value === 'custom') { setIsCustom(true); @@ -100,6 +121,15 @@ export function LogFilters({ } }; + const handleDateSelect = (selectedDate: Date | undefined) => { + setDate(selectedDate); + if (selectedDate) { + onDateChange(format(selectedDate, 'yyyy-MM-dd')); + } else { + onDateChange('all'); + } + }; + return ( @@ -115,20 +145,40 @@ export function LogFilters({
- + + + + + + + + + {date && ( + + )}
diff --git a/ui/app/logs/page.tsx b/ui/app/logs/page.tsx index 4a1400ff..b3551aad 100644 --- a/ui/app/logs/page.tsx +++ b/ui/app/logs/page.tsx @@ -17,7 +17,7 @@ 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, DatesResponse } from './types'; +import { LogEntry, LogsResponse } from './types'; import { LogFilters } from './log-filters'; import { LogEntryCard } from './log-entry-card'; import { LogDetailsDialog } from './log-details-dialog'; @@ -31,12 +31,6 @@ export default function LogsPage() { const [selectedLog, setSelectedLog] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); - const { data: datesData } = useQuery({ - queryKey: ['log-dates'], - queryFn: () => apiClient.get('/admin/api/logs/dates'), - refetchInterval: 300000, - }); - const { data: logsData, refetch: refetchLogs, @@ -107,7 +101,6 @@ export default function LogsPage() { requestId={requestId} searchText={searchText} limit={limit} - availableDates={datesData?.dates || []} onDateChange={setSelectedDate} onLevelChange={setSelectedLevel} onRequestIdChange={setRequestId} @@ -151,9 +144,9 @@ export default function LogsPage() { <>
- {logsData.logs.map((entry) => ( + {logsData.logs.map((entry, index) => ( diff --git a/ui/components/ui/calendar.tsx b/ui/components/ui/calendar.tsx new file mode 100644 index 00000000..3cdae570 --- /dev/null +++ b/ui/components/ui/calendar.tsx @@ -0,0 +1,72 @@ +'use client'; + +import * as React from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { DayPicker } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + { + if (orientation === 'left') { + return ; + } + return ; + }, + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; From 23ff99d41f3b8d25b90f5a0b8591e32f0c50bf4e Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Sat, 15 Nov 2025 11:53:27 +0100 Subject: [PATCH 09/37] copy button --- routstr/search/log_search.py | 31 +--------- ui/app/logs/log-details-dialog.tsx | 94 +++++++++++++++++------------- 2 files changed, 53 insertions(+), 72 deletions(-) diff --git a/routstr/search/log_search.py b/routstr/search/log_search.py index 6740d3a5..ff69ef3a 100644 --- a/routstr/search/log_search.py +++ b/routstr/search/log_search.py @@ -1,11 +1,3 @@ -""" -Log search functionality. - -This module contains the search logic for filtering log entries. -It can be replaced with more advanced search mechanisms in the future -(e.g., Elasticsearch, full-text search databases, etc.) -""" - import json from pathlib import Path from typing import Any @@ -21,14 +13,6 @@ def search_logs( ) -> list[dict[str, Any]]: """ Search through log files and return matching entries. - - This is a simple file-based search implementation. For better performance - with large log volumes, consider using: - - Elasticsearch - - Splunk - - Loki - - Or other log aggregation/search tools - Args: logs_dir: Path to the logs directory date: Filter by specific date (YYYY-MM-DD format) @@ -40,29 +24,25 @@ def search_logs( Returns: List of log entries matching the criteria """ - log_entries = [] + log_entries: list[dict[str, Any]] = [] if not logs_dir.exists(): return log_entries - # Determine which log files to search log_files = [] if date: log_file = logs_dir / f"app_{date}.log" if log_file.exists(): log_files.append(log_file) else: - # Search last 7 days of logs log_files = sorted( logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True, )[:7] - # Normalize search text for case-insensitive search search_text_lower = search_text.lower() if search_text else None - # Search through log files for log_file in log_files: try: with open(log_file, "r") as f: @@ -70,7 +50,6 @@ def search_logs( try: log_data = json.loads(line.strip()) - # Apply filters if not _matches_filters( log_data, level, request_id, search_text_lower ): @@ -78,23 +57,18 @@ def search_logs( log_entries.append(log_data) - # Stop if we've reached the limit if len(log_entries) >= limit: break except json.JSONDecodeError: - # Skip malformed JSON lines continue - # Stop searching more files if we've reached the limit if len(log_entries) >= limit: break except Exception: - # Skip files that can't be read continue - # Sort by timestamp (most recent first) log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True) return log_entries @@ -118,15 +92,12 @@ def _matches_filters( Returns: True if the log entry matches all filters, False otherwise """ - # Filter by log level if level and log_data.get("levelname", "").upper() != level.upper(): return False - # Filter by request ID (exact match) if request_id and log_data.get("request_id") != request_id: return False - # Filter by search text (case-insensitive search in message and name) if search_text_lower: message = str(log_data.get("message", "")).lower() name = str(log_data.get("name", "")).lower() diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx index 842549d7..5df1e7dd 100644 --- a/ui/app/logs/log-details-dialog.tsx +++ b/ui/app/logs/log-details-dialog.tsx @@ -8,7 +8,8 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Copy } from 'lucide-react'; +import { Copy, Check } from 'lucide-react'; +import { useState } from 'react'; interface LogEntry { asctime: string; @@ -51,10 +52,16 @@ export function LogDetailsDialog({ isOpen, onClose, }: LogDetailsDialogProps) { + const [copiedField, setCopiedField] = useState(null); + if (!log) return null; - const copyToClipboard = (text: string) => { + 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'); @@ -74,22 +81,12 @@ export function LogDetailsDialog({ -
- - - {log.levelname} - - Log Entry Details - - -
+ + + {log.levelname} + + Log Entry Details + {log.asctime} • {log.name} • {log.pathname}:{log.lineno} @@ -99,8 +96,8 @@ export function LogDetailsDialog({

Message

-
-
+              
+
                   {log.message}
                 
@@ -111,13 +108,35 @@ export function LogDetailsDialog({
{standardFields.map((field) => (
- - {field} - -
-
+
+ + {field} + + {field === 'request_id' && ( + + )} +
+
+
                         {String(log[field as keyof LogEntry] || 'N/A')}
-                      
+
))} @@ -130,18 +149,18 @@ export function LogDetailsDialog({
{extraFields.map((field) => (
- + {field} -
+
{typeof log[field] === 'object' ? ( -
+                          
                             {JSON.stringify(log[field], null, 2)}
                           
) : ( -
+
                             {String(log[field] || 'N/A')}
-                          
+
)}
@@ -152,17 +171,8 @@ export function LogDetailsDialog({

Raw JSON

-
- -
+              
+
                   {JSON.stringify(log, null, 2)}
                 
From 26110a68dd9cf32d446f3e6c1f6c0a233c270fca Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Sat, 15 Nov 2025 15:59:08 +0100 Subject: [PATCH 10/37] add logs page --- routstr/core/main.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/routstr/core/main.py b/routstr/core/main.py index 81212a01..b88be3cc 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -264,6 +264,15 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir(): async def redirect_transactions_index_txt() -> RedirectResponse: return RedirectResponse("/transactions") + @app.get("/logs", include_in_schema=False) + async def serve_logs_ui() -> FileResponse: + return FileResponse(UI_DIST_PATH / "logs" / "index.html") + + # Add explicit route for /logs/index.txt to redirect to /logs + @app.get("/logs/index.txt", include_in_schema=False) + async def redirect_logs_index_txt() -> RedirectResponse: + return RedirectResponse("/logs") + @app.get("/unauthorized", include_in_schema=False) async def serve_unauthorized_ui() -> FileResponse: return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html") From 99d98ffb2c3d7b5ba9b1feb23b537f28ee35b52c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Nov 2025 23:49:36 +0000 Subject: [PATCH 11/37] feat: Add usage tracking dashboard and API Co-authored-by: db2002dominic --- routstr/core/admin.py | 35 ++- routstr/core/usage_metrics.py | 264 +++++++++++++++++++ tests/unit/test_usage_metrics.py | 79 ++++++ ui/app/page.tsx | 34 +-- ui/components/usage-tracking.tsx | 417 +++++++++++++++++++++++++++++++ ui/lib/api/services/admin.ts | 53 ++++ 6 files changed, 866 insertions(+), 16 deletions(-) create mode 100644 routstr/core/usage_metrics.py create mode 100644 tests/unit/test_usage_metrics.py create mode 100644 ui/components/usage-tracking.tsx diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 3baad78b..032b33ae 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 @@ -20,6 +20,7 @@ from ..wallet import ( from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session from .logging import get_logger from .settings import SettingsService, settings +from .usage_metrics import UsageMetricsService, list_metric_definitions logger = get_logger(__name__) @@ -168,6 +169,38 @@ async def get_balances_api(request: Request) -> list[dict[str, object]]: return [dict(d) for d in balance_details] +@admin_router.get( + "/api/usage-metrics/definitions", dependencies=[Depends(require_admin_api)] +) +async def get_usage_metric_definitions() -> list[dict[str, str]]: + return list_metric_definitions() + + +@admin_router.get("/api/usage-metrics", dependencies=[Depends(require_admin_api)]) +async def get_usage_metrics( + metrics: str | None = Query( + default=None, + description="Comma-separated list of usage metric identifiers to include", + ), + bucket_minutes: int = Query(default=15, ge=1, le=24 * 60), + hours: int = Query(default=24, ge=1, le=24 * 7), +) -> dict[str, object]: + metric_list = ( + [item.strip() for item in metrics.split(",") if item.strip()] + if metrics + else [] + ) + try: + result = await UsageMetricsService.collect( + metrics=metric_list, + bucket_minutes=bucket_minutes, + hours=hours, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return result.to_dict() + + @admin_router.get("/api/settings", dependencies=[Depends(require_admin_api)]) async def get_settings(request: Request) -> dict: data = settings.dict() diff --git a/routstr/core/usage_metrics.py b/routstr/core/usage_metrics.py new file mode 100644 index 00000000..6ab243b5 --- /dev/null +++ b/routstr/core/usage_metrics.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import asyncio +import json +import math +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Callable, Literal, cast + +UsageMetricName = Literal["errors", "chat_completions_success"] + + +@dataclass(frozen=True) +class MetricDefinition: + name: UsageMetricName + label: str + description: str + matcher: Callable[[dict[str, object]], bool] + + +@dataclass(frozen=True) +class UsageMetricPoint: + bucket_start: datetime + count: int + + +@dataclass(frozen=True) +class UsageMetricSeries: + name: UsageMetricName + label: str + description: str + total: int + points: list[UsageMetricPoint] + + +@dataclass(frozen=True) +class UsageMetricsComputation: + bucket_minutes: int + bucket_count: int + start: datetime + end: datetime + series: list[UsageMetricSeries] + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +def _is_error(record: dict[str, object]) -> bool: + level = record.get("levelname") + if not isinstance(level, str): + return False + return level.upper() in {"ERROR", "CRITICAL"} + + +def _coerce_int(value: object) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return None + return None + + +def _is_successful_chat_completion(record: dict[str, object]) -> bool: + message = record.get("message") + if not isinstance(message, str) or message != "Received upstream response": + return False + status_code = _coerce_int(record.get("status_code")) + if status_code != 200: + return False + path = record.get("path") + return isinstance(path, str) and path.endswith("chat/completions") + + +METRIC_DEFINITIONS: dict[UsageMetricName, MetricDefinition] = { + "errors": MetricDefinition( + name="errors", + label="Errors", + description="Log entries emitted at ERROR or CRITICAL level", + matcher=_is_error, + ), + "chat_completions_success": MetricDefinition( + name="chat_completions_success", + label="200 chat/completions", + description="Successful upstream responses for chat/completions", + matcher=_is_successful_chat_completion, + ), +} + + +def list_metric_definitions() -> list[dict[str, str]]: + return [ + { + "name": definition.name, + "label": definition.label, + "description": definition.description, + } + for definition in METRIC_DEFINITIONS.values() + ] + + +def default_metric_names() -> list[UsageMetricName]: + return list(METRIC_DEFINITIONS.keys()) + + +class UsageMetricsService: + @classmethod + async def collect( + cls, + metrics: list[str], + bucket_minutes: int, + hours: int, + log_dir: Path | None = None, + now: datetime | None = None, + ) -> UsageMetricsComputation: + return await asyncio.to_thread( + cls._collect_sync, metrics, bucket_minutes, hours, log_dir, now + ) + + @classmethod + def _collect_sync( + cls, + metrics: list[str], + bucket_minutes: int, + hours: int, + log_dir: Path | None, + now: datetime | None, + ) -> UsageMetricsComputation: + metric_list = cls._sanitize_metrics(metrics) + bucket_minutes = cls._clamp(bucket_minutes, minimum=1, maximum=24 * 60) + hours = cls._clamp(hours, minimum=1, maximum=24 * 7) + now_dt = now or datetime.now() + start = now_dt - timedelta(hours=hours) + bucket_seconds = bucket_minutes * 60 + bucket_count = max(1, math.ceil((hours * 3600) / bucket_seconds)) + bucket_starts = [ + start + timedelta(seconds=bucket_seconds * index) + for index in range(bucket_count) + ] + counts: dict[UsageMetricName, list[int]] = { + name: [0] * bucket_count for name in metric_list + } + + for log_file in cls._iter_log_files(log_dir): + try: + with log_file.open("r", encoding="utf-8") as handle: + for line in handle: + record = cls._parse_record(line) + if not record: + continue + timestamp = cls._parse_timestamp(record.get("asctime")) + if timestamp is None or timestamp < start or timestamp > now_dt: + continue + bucket_index = cls._bucket_index( + timestamp, start, bucket_seconds, bucket_count + ) + if bucket_index is None: + continue + for metric_name in metric_list: + definition = METRIC_DEFINITIONS[metric_name] + if definition.matcher(record): + counts[metric_name][bucket_index] += 1 + except (OSError, UnicodeDecodeError): + continue + + series = [ + UsageMetricSeries( + name=metric_name, + label=METRIC_DEFINITIONS[metric_name].label, + description=METRIC_DEFINITIONS[metric_name].description, + total=sum(counts[metric_name]), + points=[ + UsageMetricPoint(bucket_start=bucket_starts[index], count=count) + for index, count in enumerate(counts[metric_name]) + ], + ) + for metric_name in metric_list + ] + + return UsageMetricsComputation( + bucket_minutes=bucket_minutes, + bucket_count=bucket_count, + start=start, + end=now_dt, + series=series, + ) + + @staticmethod + def _sanitize_metrics(metrics: list[str]) -> list[UsageMetricName]: + requested = [metric.strip() for metric in metrics if metric.strip()] + unique: list[UsageMetricName] = [] + for normalized in requested: + if normalized not in METRIC_DEFINITIONS: + continue + metric_name = cast(UsageMetricName, normalized) + if metric_name not in unique: + unique.append(metric_name) + if unique: + return unique + if requested: + raise ValueError("No valid usage metrics requested") + return default_metric_names() + + @staticmethod + def _clamp(value: int, *, minimum: int, maximum: int) -> int: + return max(minimum, min(maximum, value)) + + @staticmethod + def _iter_log_files(log_dir: Path | None) -> list[Path]: + directory = log_dir or Path("logs") + if not directory.exists(): + return [] + log_files = sorted( + directory.glob("*.log"), + key=UsageMetricsService._safe_mtime, + reverse=True, + ) + return log_files + + @staticmethod + def _safe_mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0.0 + + @staticmethod + def _parse_record(line: str) -> dict[str, object] | None: + stripped = line.strip() + if not stripped: + return None + try: + data = json.loads(stripped) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + @staticmethod + def _parse_timestamp(value: object) -> datetime | None: + if not isinstance(value, str): + return None + try: + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + @staticmethod + def _bucket_index( + timestamp: datetime, + start: datetime, + bucket_seconds: int, + bucket_count: int, + ) -> int | None: + delta_seconds = (timestamp - start).total_seconds() + if delta_seconds < 0: + return None + index = int(delta_seconds // bucket_seconds) + if index >= bucket_count: + index = bucket_count - 1 + return index + diff --git a/tests/unit/test_usage_metrics.py b/tests/unit/test_usage_metrics.py new file mode 100644 index 00000000..68f6afde --- /dev/null +++ b/tests/unit/test_usage_metrics.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from routstr.core.usage_metrics import UsageMetricsService + + +def _write_records(path: Path, records: list[dict[str, object]]) -> None: + with path.open("w", encoding="utf-8") as handle: + for record in records: + handle.write(json.dumps(record) + "\n") + + +@pytest.mark.asyncio +async def test_usage_metrics_counts(tmp_path: Path) -> None: + log_dir = tmp_path / "logs" + log_dir.mkdir() + now = datetime(2025, 1, 1, 12, 0, 0) + records = [ + { + "asctime": (now - timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S"), + "levelname": "ERROR", + "message": "Proxy failure", + }, + { + "asctime": (now - timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S"), + "levelname": "INFO", + "message": "Received upstream response", + "status_code": 200, + "path": "chat/completions", + }, + { + "asctime": (now - timedelta(minutes=20)).strftime("%Y-%m-%d %H:%M:%S"), + "levelname": "INFO", + "message": "Received upstream response", + "status_code": 500, + "path": "chat/completions", + }, + ] + _write_records(log_dir / "app_2025-01-01.log", records) + + result = await UsageMetricsService.collect( + metrics=["errors", "chat_completions_success"], + bucket_minutes=15, + hours=1, + log_dir=log_dir, + now=now, + ) + + errors_series = next(series for series in result.series if series.name == "errors") + completions_series = next( + series + for series in result.series + if series.name == "chat_completions_success" + ) + + assert errors_series.total == 1 + assert sum(point.count for point in errors_series.points) == 1 + assert completions_series.total == 1 + assert any(point.count == 1 for point in completions_series.points) + + +@pytest.mark.asyncio +async def test_usage_metrics_invalid_metric(tmp_path: Path) -> None: + log_dir = tmp_path / "logs" + log_dir.mkdir() + now = datetime(2025, 1, 1, 12, 0, 0) + with pytest.raises(ValueError): + await UsageMetricsService.collect( + metrics=["unknown"], + bucket_minutes=15, + hours=1, + log_dir=log_dir, + now=now, + ) diff --git a/ui/app/page.tsx b/ui/app/page.tsx index da8ffba3..5e7d6514 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -10,6 +10,7 @@ import { TemporaryBalances } from '@/components/temporary-balances'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import type { DisplayUnit } from '@/lib/types/units'; import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; +import { UsageTracking } from '@/components/usage-tracking'; export default function Page() { const [displayUnit, setDisplayUnit] = useState('sat'); @@ -65,22 +66,25 @@ export default function Page() {
-
-
- +
+
+ +
+
+ +
+
+ +
-
- -
-
diff --git a/ui/components/usage-tracking.tsx b/ui/components/usage-tracking.tsx new file mode 100644 index 00000000..3be3902f --- /dev/null +++ b/ui/components/usage-tracking.tsx @@ -0,0 +1,417 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Activity, + AlertCircle, + BarChart3, + RefreshCw, + TrendingDown, + TrendingUp, +} from 'lucide-react'; +import { + AdminService, + UsageMetricDefinition, + UsageMetricName, +} from '@/lib/api/services/admin'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; + +const bucketOptions = [ + { label: '15 minutes', value: 15 }, + { label: '1 hour', value: 60 }, +]; + +const rangeOptions = [ + { label: 'Last 6 hours', value: 6 }, + { label: 'Last 24 hours', value: 24 }, + { label: 'Last 72 hours', value: 72 }, +]; + +const palette = [ + 'hsl(var(--chart-1))', + 'hsl(var(--chart-2))', + 'hsl(var(--chart-3))', + 'hsl(var(--chart-4))', + 'hsl(var(--chart-5))', +]; + +const defaultMetrics: UsageMetricName[] = [ + 'errors', + 'chat_completions_success', +]; + +export function UsageTracking() { + const [bucketMinutes, setBucketMinutes] = useState(15); + const [hours, setHours] = useState(24); + const [selectedMetrics, setSelectedMetrics] = + useState(defaultMetrics); + + const { + data: metricDefinitions, + isLoading: definitionsLoading, + isError: definitionsError, + } = useQuery({ + queryKey: ['usage-metric-definitions'], + queryFn: async () => AdminService.getUsageMetricDefinitions(), + staleTime: 10 * 60 * 1000, + }); + + useEffect(() => { + if (!metricDefinitions || !metricDefinitions.length) { + return; + } + setSelectedMetrics((current) => { + const filtered = current.filter((metric) => + metricDefinitions.some((definition) => definition.name === metric) + ) as UsageMetricName[]; + if (filtered.length) { + return filtered; + } + return metricDefinitions.map( + (definition) => definition.name as UsageMetricName + ); + }); + }, [metricDefinitions]); + + const metricsKey = useMemo( + () => [...selectedMetrics].sort().join(','), + [selectedMetrics] + ); + + const { + data: metricsData, + isLoading: metricsLoading, + isFetching: metricsFetching, + isError: metricsError, + error: metricsErrorObject, + refetch: refetchMetrics, + } = useQuery({ + queryKey: ['usage-metrics', metricsKey, bucketMinutes, hours], + queryFn: async () => + AdminService.getUsageMetrics({ + metrics: selectedMetrics, + bucket_minutes: bucketMinutes, + hours, + }), + enabled: selectedMetrics.length > 0, + refetchInterval: 60_000, + keepPreviousData: true, + }); + + const colorMap = useMemo(() => { + const map: Partial> = {}; + metricDefinitions?.forEach((definition, index) => { + map[definition.name] = palette[index % palette.length]; + }); + return map; + }, [metricDefinitions]); + + const chartConfig = useMemo(() => { + const config: ChartConfig = {}; + metricDefinitions?.forEach((definition) => { + const color = colorMap[definition.name]; + config[definition.name] = { + label: definition.label, + color, + }; + }); + return config; + }, [metricDefinitions, colorMap]); + + const chartData = useMemo(() => { + if (!metricsData || !metricsData.series.length) { + return []; + } + const referenceSeries = metricsData.series[0]; + return referenceSeries.points.map((point, index) => { + const base: Record = { + bucketStart: point.bucket_start, + label: formatBucketLabel(point.bucket_start, hours), + }; + metricsData.series.forEach((series) => { + base[series.name] = series.points[index]?.count ?? 0; + }); + return base; + }); + }, [metricsData, hours]); + + const summary = useMemo(() => { + if (!metricsData) { + return []; + } + return metricsData.series.map((series) => ({ + name: series.name, + label: series.label, + total: series.total, + latest: series.points.at(-1)?.count ?? 0, + averagePerHour: series.total / Math.max(1, hours), + })); + }, [metricsData, hours]); + + const handleMetricToggle = (metric: UsageMetricName) => { + setSelectedMetrics((current) => { + if (current.includes(metric)) { + if (current.length === 1) { + return current; + } + return current.filter((item) => item !== metric); + } + return [...current, metric]; + }); + }; + + const renderSummaryCard = (definition: UsageMetricDefinition) => { + const stat = summary.find((item) => item.name === definition.name); + const Icon = definition.name === 'errors' ? AlertCircle : Activity; + return ( +
+
+
+ + {definition.label} +
+ + {bucketMinutes >= 60 + ? `${bucketMinutes / 60}h buckets` + : `${bucketMinutes}m buckets`} + +
+
+
+
+ {stat ? stat.total.toLocaleString() : '—'} +
+

+ total events in range +

+
+ {stat && ( +
+
+ + {stat.latest} latest bucket +
+
+ + {stat.averagePerHour.toFixed(2)} avg/hr +
+
+ )} +
+
+ ); + }; + + const isLoading = + definitionsLoading || + metricsLoading || + (selectedMetrics.length > 0 && !metricsData); + + return ( + + +
+
+ + + Usage Tracking + + + Monitor errors and successful upstream traffic over time + +
+
+ + + +
+
+
+ + {definitionsError ? ( +
+ + Failed to load metric definitions. +
+ ) : ( +
+ {metricDefinitions?.map((definition) => ( + + ))} +
+ )} + + {metricsError && ( +
+ + {metricsErrorObject instanceof Error + ? metricsErrorObject.message + : 'Failed to load usage metrics.'} +
+ )} + + {isLoading ? ( + + ) : ( + metricsData && + summary.length > 0 && ( +
+ {metricDefinitions + ?.filter((definition) => + selectedMetrics.includes(definition.name as UsageMetricName) + ) + .map((definition) => renderSummaryCard(definition))} +
+ ) + )} + + {isLoading ? ( + + ) : chartData.length > 0 ? ( + + + + + value as string} + /> + } + /> + {metricsData?.series.map((series) => ( + + ))} + + + ) : ( +
+ +

No data available for the selected window.

+
+ )} +
+
+ ); +} + +function formatBucketLabel(value: string | undefined, rangeHours: number) { + if (!value) { + return ''; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + if (rangeHours <= 24) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + }); +} diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index 9645a6a1..cbf7571f 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -802,6 +802,30 @@ export class AdminService { '/admin/api/temporary-balances' ); } + + static async getUsageMetricDefinitions(): Promise { + return await apiClient.get( + '/admin/api/usage-metrics/definitions' + ); + } + + static async getUsageMetrics(params: { + metrics: UsageMetricName[]; + bucket_minutes: number; + hours: number; + }): Promise { + const query: Record = { + bucket_minutes: params.bucket_minutes, + hours: params.hours, + }; + if (params.metrics.length > 0) { + query.metrics = params.metrics.join(','); + } + return await apiClient.get( + '/admin/api/usage-metrics', + query + ); + } } export const TemporaryBalanceSchema = z.object({ @@ -814,3 +838,32 @@ export const TemporaryBalanceSchema = z.object({ }); export type TemporaryBalance = z.infer; + +export type UsageMetricName = 'errors' | 'chat_completions_success'; + +export interface UsageMetricDefinition { + name: UsageMetricName; + label: string; + description: string; +} + +export interface UsageMetricPoint { + bucket_start: string; + count: number; +} + +export interface UsageMetricSeries { + name: UsageMetricName; + label: string; + description: string; + total: number; + points: UsageMetricPoint[]; +} + +export interface UsageMetricsResponse { + bucket_minutes: number; + bucket_count: number; + start: string; + end: string; + series: UsageMetricSeries[]; +} From 38356d7bb311e820ec23bdcc88b14bbd2a48200e Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Sun, 16 Nov 2025 10:46:10 +0100 Subject: [PATCH 12/37] add copy button & order entries --- routstr/core/admin.py | 4 ++-- ui/app/logs/log-details-dialog.tsx | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 06a8a7ed..91899aaf 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -827,8 +827,8 @@ async def view_logs(request: Request, request_id: str) -> str: except Exception as e: logger.error(f"Error reading log file {log_file}: {e}") - # Sort entries by timestamp if available - log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=False) + # Sort entries by timestamp if available (newest first) + log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True) # Format log entries for display formatted_logs = [] diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx index 5df1e7dd..852aa4a8 100644 --- a/ui/app/logs/log-details-dialog.tsx +++ b/ui/app/logs/log-details-dialog.tsx @@ -170,7 +170,27 @@ export function LogDetailsDialog({ )}
-

Raw JSON

+
+

Raw JSON

+ +
                   {JSON.stringify(log, null, 2)}

From dbffef62e6f881256b6b6b89416ac5ed429706d0 Mon Sep 17 00:00:00 2001
From: GitHappens2Me 
Date: Mon, 17 Nov 2025 19:08:25 +0100
Subject: [PATCH 13/37] fixed streaming responses for unsupported
 content-encoding

---
 routstr/upstream/base.py | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 7af1be85..4e94d991 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -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={
@@ -503,11 +511,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(

From 0bcf7bb948ab76c7b34d755634ca3edc6f21e0b0 Mon Sep 17 00:00:00 2001
From: Shroominic 
Date: Mon, 17 Nov 2025 12:26:00 -0800
Subject: [PATCH 14/37] rm accidental push of cdk-python binaries

---
 vendor/cdk        | 1 -
 vendor/cdk-python | 1 -
 2 files changed, 2 deletions(-)
 delete mode 160000 vendor/cdk
 delete mode 160000 vendor/cdk-python

diff --git a/vendor/cdk b/vendor/cdk
deleted file mode 160000
index 52d796e9..00000000
--- a/vendor/cdk
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 52d796e9fe2c0728621110b20dd07b120b862498
diff --git a/vendor/cdk-python b/vendor/cdk-python
deleted file mode 160000
index 915c6966..00000000
--- a/vendor/cdk-python
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 915c6966b0aae59568a03f0b4aefccf3fb0359f3

From f45ff16674901eb13d95f524c5262bd3e153f76e Mon Sep 17 00:00:00 2001
From: Shroominic 
Date: Thu, 20 Nov 2025 15:44:13 -0800
Subject: [PATCH 15/37] api cheat sheet

---
 ui/app/page.tsx                       |  87 +--
 ui/components/landing/cheat-sheet.tsx | 746 ++++++++++++++++++++++++++
 ui/lib/auth/ProtectedRoute.tsx        |   6 +-
 3 files changed, 752 insertions(+), 87 deletions(-)
 create mode 100644 ui/components/landing/cheat-sheet.tsx

diff --git a/ui/app/page.tsx b/ui/app/page.tsx
index da8ffba3..b9917325 100644
--- a/ui/app/page.tsx
+++ b/ui/app/page.tsx
@@ -1,88 +1,5 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { AppSidebar } from '@/components/app-sidebar';
-import { SiteHeader } from '@/components/site-header';
-import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
-import { 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 { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
+import { CheatSheet } from '@/components/landing/cheat-sheet';
 
 export default function Page() {
-  const [displayUnit, setDisplayUnit] = useState('sat');
-
-  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]);
-
-  return (
-    
-      
-      
-        
-        
-
-
-

- Admin Dashboard -

-

- Monitor and manage wallet balances -

-
-
- { - if (value) { - setDisplayUnit(value as DisplayUnit); - } - }} - variant='outline' - size='sm' - > - mSAT - sat - - USD - - -
-
- -
-
- -
-
- -
-
-
-
-
- ); + return ; } diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx new file mode 100644 index 00000000..838b2afc --- /dev/null +++ b/ui/components/landing/cheat-sheet.tsx @@ -0,0 +1,746 @@ +'use client'; + +import { 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 + )} +
+