diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 611a9df2..dd3ad5a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "20" cache: "pnpm" cache-dependency-path: ui/pnpm-lock.yaml diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 181df6de..c3160c9e 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1081,9 +1081,7 @@ async def get_usage_metrics( interval: int = Query( default=15, ge=1, le=1440, description="Time interval in minutes" ), - hours: int = Query( - default=24, ge=1, le=168, description="Hours of history to analyze" - ), + hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), ) -> dict: """Get usage metrics aggregated by time interval.""" return log_manager.get_usage_metrics(interval=interval, hours=hours) @@ -1092,9 +1090,7 @@ async def get_usage_metrics( @admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)]) async def get_usage_summary( request: Request, - hours: int = Query( - default=24, ge=1, le=168, description="Hours of history to analyze" - ), + hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), ) -> dict: """Get summary statistics for the specified time period.""" return log_manager.get_usage_summary(hours=hours) @@ -1103,9 +1099,7 @@ async def get_usage_summary( @admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)]) async def get_error_details( request: Request, - hours: int = Query( - default=24, ge=1, le=168, description="Hours of history to analyze" - ), + hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), limit: int = Query( default=100, ge=1, le=1000, description="Maximum number of errors to return" ), @@ -1119,9 +1113,7 @@ async def get_error_details( ) async def get_revenue_by_model( request: Request, - hours: int = Query( - default=24, ge=1, le=168, description="Hours of history to analyze" - ), + hours: int = Query(default=24, ge=1, description="Hours of history to analyze"), limit: int = Query( default=20, ge=1, le=100, description="Maximum number of models to return" ), diff --git a/routstr/core/log_manager.py b/routstr/core/log_manager.py index 3498404e..39ac8e7c 100644 --- a/routstr/core/log_manager.py +++ b/routstr/core/log_manager.py @@ -36,6 +36,7 @@ class LogManager: log_files = [] cutoff_date = None + cutoff_timestamp_str: str | None = None if specific_date: log_file = self.logs_dir / f"app_{specific_date}.log" @@ -79,6 +80,7 @@ class LogManager: # If we only care about hours back, we can optimize file selection if hours_back is not None: cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back) + cutoff_timestamp_str = cutoff_date.strftime("%Y-%m-%d %H:%M:%S") filtered_files = [] for log_path in log_files: try: @@ -101,27 +103,20 @@ class LogManager: for log_file in log_files: try: with open(log_file, "r") as f: - # For reverse search, we might want to read lines in reverse? - # But usually logs are append-only. - # If reverse_files is True, we iterate files newest to oldest. - # But lines within file are still oldest to newest unless we reverse them. - lines = f.readlines() - if reverse_files: - lines.reverse() + lines_iter = reversed(f.readlines()) if reverse_files else f - for line in lines: + for line in lines_iter: try: entry = json.loads(line.strip()) - if cutoff_date: + if cutoff_timestamp_str: timestamp_str = entry.get("asctime", "") - if not timestamp_str: + if ( + not isinstance(timestamp_str, str) + or len(timestamp_str) != 19 + ): continue - log_time = datetime.strptime( - timestamp_str, "%Y-%m-%d %H:%M:%S" - ) - log_time = log_time.replace(tzinfo=timezone.utc) - if log_time < cutoff_date: + if timestamp_str < cutoff_timestamp_str: continue yield entry @@ -198,7 +193,7 @@ class LogManager: methods: list[str] | None = None, endpoints: list[str] | None = None, ) -> bool: - if level and log_data.get("levelname", "").upper() != level.upper(): + if level and str(log_data.get("levelname", "")).upper() != level.upper(): return False if request_id and log_data.get("request_id") != request_id: @@ -248,6 +243,65 @@ class LogManager: return True + def _bucket_key_for_timestamp( + self, timestamp_str: str, interval_minutes: int + ) -> str | None: + if len(timestamp_str) != 19: + return None + if timestamp_str[10] != " ": + return None + + try: + hour = int(timestamp_str[11:13]) + minute = int(timestamp_str[14:16]) + except (TypeError, ValueError): + return None + + total_minutes = hour * 60 + minute + rounded_minutes = (total_minutes // interval_minutes) * interval_minutes + rounded_hour = rounded_minutes // 60 + rounded_minute = rounded_minutes % 60 + return f"{timestamp_str[:10]} {rounded_hour:02d}:{rounded_minute:02d}:00" + + def _extract_success_metrics( + self, entry: dict[str, Any], message: str + ) -> tuple[bool, float, int, int]: + # Use auth settlement logs as the canonical successful request signal. + logger_name = str(entry.get("name", "")) + if not logger_name.startswith("routstr.auth"): + return False, 0.0, 0, 0 + + input_tokens = self._parse_token_count(entry.get("input_tokens", 0)) + output_tokens = self._parse_token_count(entry.get("output_tokens", 0)) + + if "calculated token-based cost" in message: + token_cost = entry.get("token_cost", 0) + if isinstance(token_cost, (int, float)) and token_cost > 0: + return True, float(token_cost), input_tokens, output_tokens + return True, 0.0, input_tokens, output_tokens + + if "max cost payment finalized" in message: + charged_amount = entry.get("charged_amount", 0) + if isinstance(charged_amount, (int, float)) and charged_amount > 0: + return True, float(charged_amount), input_tokens, output_tokens + return True, 0.0, input_tokens, output_tokens + + return False, 0.0, 0, 0 + + def _parse_token_count(self, value: Any) -> int: + if isinstance(value, bool): + return 0 + if isinstance(value, int): + return max(0, value) + if isinstance(value, float): + return max(0, int(value)) + if isinstance(value, str): + try: + return max(0, int(float(value))) + except ValueError: + return 0 + return 0 + def get_usage_summary(self, hours: int = 24) -> dict: entries = list( self._yield_log_entries( @@ -265,37 +319,24 @@ class LogManager: return self._aggregate_metrics_by_time(entries, interval, hours) def get_error_details(self, hours: int = 24, limit: int = 100) -> dict: - errors: list[dict] = [] - # Iterate newest to oldest for errors? - # yield_log_entries sorts files by name (date) ascending by default. - # usage stats logic usually expects ascending time for aggregation (though dictionaries don't care). - # For error details "last N errors", we probably want newest first. + errors: list[dict[str, Any]] = [] - # Using list() loads everything into memory, which is what PR 229 did. - # For optimization, we could use reverse iterator. + for entry in self._yield_log_entries(hours_back=hours): + if str(entry.get("levelname", "")).upper() != "ERROR": + continue - # Let's just stick to PR 229 logic which filters 'ERROR' level. + errors.append( + { + "timestamp": entry.get("asctime", ""), + "message": entry.get("message", ""), + "error_type": entry.get("error_type", "unknown"), + "pathname": entry.get("pathname", ""), + "lineno": entry.get("lineno", 0), + "request_id": entry.get("request_id", ""), + } + ) - entries = self._yield_log_entries( - hours_back=hours, window_center=datetime.now(timezone.utc) - ) # oldest to newest - - for entry in entries: - if entry.get("levelname", "").upper() == "ERROR": - timestamp_str = entry.get("asctime", "") - errors.append( - { - "timestamp": timestamp_str, - "message": entry.get("message", ""), - "error_type": entry.get("error_type", "unknown"), - "pathname": entry.get("pathname", ""), - "lineno": entry.get("lineno", 0), - "request_id": entry.get("request_id", ""), - } - ) - - # Sort reverse time - errors.sort(key=lambda x: x["timestamp"], reverse=True) + errors.sort(key=lambda x: str(x["timestamp"]), reverse=True) return {"errors": errors[:limit], "total_count": len(errors)} def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict: @@ -321,23 +362,22 @@ class LogManager: if not isinstance(model, str): model = "unknown" - message = entry.get("message", "").lower() + message = str(entry.get("message", "")).lower() - if "received proxy request" in message: + completed, revenue_msats, _, _ = self._extract_success_metrics( + entry, message + ) + if completed: model_stats[model]["requests"] += 1 - - if ( - "completed for streaming" in message - or "completed for non-streaming" in message - ): model_stats[model]["successful"] += 1 - cost_data = entry.get("cost_data") - if isinstance(cost_data, dict): - actual_cost = cost_data.get("total_msats", 0) - if isinstance(actual_cost, (int, float)) and actual_cost > 0: - model_stats[model]["revenue_msats"] += actual_cost + if revenue_msats > 0: + model_stats[model]["revenue_msats"] += revenue_msats - if "revert payment" in message or "upstream request failed" in message: + failed = ( + "revert payment" in message or "upstream request failed" in message + ) + if failed: + model_stats[model]["requests"] += 1 model_stats[model]["failed"] += 1 if "revert payment" in message: max_cost = entry.get("max_cost_for_model", 0) @@ -386,83 +426,16 @@ class LogManager: "total_models": len(models), } - def _calculate_summary_stats(self, entries: list[dict]) -> dict: - stats: dict[str, Any] = { - "total_entries": 0, - "total_requests": 0, - "successful_chat_completions": 0, - "failed_requests": 0, - "total_errors": 0, - "total_warnings": 0, - "payment_processed": 0, - "upstream_errors": 0, - "unique_models": set(), - "error_types": defaultdict(int), - "revenue_msats": 0.0, - "refunds_msats": 0.0, - } - - for entry in entries: - try: - stats["total_entries"] += 1 - - message = entry.get("message", "").lower() - level = entry.get("levelname", "").upper() - - if level == "ERROR": - stats["total_errors"] += 1 - if "error_type" in entry: - stats["error_types"][str(entry["error_type"])] += 1 - elif level == "WARNING": - stats["total_warnings"] += 1 - - if "received proxy request" in message: - stats["total_requests"] += 1 - - if ( - "completed for streaming" in message - or "completed for non-streaming" in message - ): - stats["successful_chat_completions"] += 1 - - if "upstream request failed" in message or "revert payment" in message: - stats["failed_requests"] += 1 - - if "payment processed successfully" in message: - stats["payment_processed"] += 1 - - if "upstream" in message and level == "ERROR": - stats["upstream_errors"] += 1 - - if "model" in entry: - model = entry["model"] - if isinstance(model, str) and model != "unknown": - stats["unique_models"].add(model) - - if ( - "completed for streaming" in message - or "completed for non-streaming" in message - ): - cost_data = entry.get("cost_data") - if isinstance(cost_data, dict): - actual_cost = cost_data.get("total_msats", 0) - if isinstance(actual_cost, (int, float)) and actual_cost > 0: - stats["revenue_msats"] += float(actual_cost) - - if "revert payment" in message: - max_cost = entry.get("max_cost_for_model", 0) - if isinstance(max_cost, (int, float)) and max_cost > 0: - stats["refunds_msats"] += float(max_cost) - - except Exception: - continue - + def _build_summary_response(self, stats: dict[str, Any]) -> dict[str, Any]: revenue_sats = stats["revenue_msats"] / 1000 refunds_sats = stats["refunds_msats"] / 1000 net_revenue_sats = revenue_sats - refunds_sats total_requests = stats["total_requests"] successful = stats["successful_chat_completions"] + input_tokens = stats["input_tokens"] + output_tokens = stats["output_tokens"] + total_tokens = stats["total_tokens"] return { "total_entries": stats["total_entries"], @@ -476,6 +449,18 @@ class LogManager: "unique_models_count": len(stats["unique_models"]), "unique_models": sorted(list(stats["unique_models"])), "error_types": dict(stats["error_types"]), + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "avg_input_tokens_per_completion": ( + input_tokens / successful if successful > 0 else 0 + ), + "avg_output_tokens_per_completion": ( + output_tokens / successful if successful > 0 else 0 + ), + "avg_total_tokens_per_completion": ( + total_tokens / successful if successful > 0 else 0 + ), "success_rate": (successful / total_requests * 100) if total_requests > 0 else 0, @@ -495,62 +480,198 @@ class LogManager: ), } + def _calculate_summary_stats(self, entries: list[dict]) -> dict: + stats: dict[str, Any] = { + "total_entries": 0, + "total_requests": 0, + "successful_chat_completions": 0, + "failed_requests": 0, + "total_errors": 0, + "total_warnings": 0, + "payment_processed": 0, + "upstream_errors": 0, + "unique_models": set(), + "error_types": defaultdict(int), + "revenue_msats": 0.0, + "refunds_msats": 0.0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + } + + for entry in entries: + try: + stats["total_entries"] += 1 + + message = str(entry.get("message", "")).lower() + level = str(entry.get("levelname", "")).upper() + + if level == "ERROR": + stats["total_errors"] += 1 + if "error_type" in entry: + stats["error_types"][str(entry["error_type"])] += 1 + elif level == "WARNING": + stats["total_warnings"] += 1 + + completed, revenue_msats, input_tokens, output_tokens = ( + self._extract_success_metrics(entry, message) + ) + if completed: + stats["total_requests"] += 1 + stats["successful_chat_completions"] += 1 + stats["input_tokens"] += input_tokens + stats["output_tokens"] += output_tokens + stats["total_tokens"] += input_tokens + output_tokens + + failed = ( + "upstream request failed" in message + or "revert payment" in message + ) + if failed: + stats["total_requests"] += 1 + stats["failed_requests"] += 1 + + if "payment processed successfully" in message: + stats["payment_processed"] += 1 + + if "upstream" in message and level == "ERROR": + stats["upstream_errors"] += 1 + + if "model" in entry: + model = entry["model"] + if isinstance(model, str) and model != "unknown": + stats["unique_models"].add(model) + + if completed and revenue_msats > 0: + stats["revenue_msats"] += revenue_msats + + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if isinstance(max_cost, (int, float)) and max_cost > 0: + stats["refunds_msats"] += float(max_cost) + + except Exception: + continue + + return self._build_summary_response(stats) + def _aggregate_metrics_by_time( self, entries: list[dict], interval_minutes: int, hours_back: int ) -> dict: time_buckets: dict[str, dict[str, Any]] = defaultdict( - lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0} + lambda: { + "total_requests": 0, + "successful_chat_completions": 0, + "failed_requests": 0, + "errors": 0, + "warnings": 0, + "payment_processed": 0, + "upstream_errors": 0, + "revenue_msats": 0.0, + "refunds_msats": 0.0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + } ) for entry in entries: try: timestamp_str = entry.get("asctime", "") - if not timestamp_str: + if not isinstance(timestamp_str, str): continue - - log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") - log_time = log_time.replace(tzinfo=timezone.utc) - - # Round down to nearest interval - minutes = log_time.minute - rounded_minutes = (minutes // interval_minutes) * interval_minutes - bucket_time = log_time.replace( - minute=rounded_minutes, second=0, microsecond=0 + bucket_key = self._bucket_key_for_timestamp( + timestamp_str, interval_minutes ) - bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S") + if not bucket_key: + continue bucket = time_buckets[bucket_key] - message = entry.get("message", "").lower() - level = entry.get("levelname", "").upper() + message = str(entry.get("message", "")).lower() + level = str(entry.get("levelname", "")).upper() - if "received proxy request" in message: - bucket["requests"] += 1 + completed, revenue_msats, input_tokens, output_tokens = ( + self._extract_success_metrics(entry, message) + ) + if completed: + bucket["total_requests"] += 1 + bucket["successful_chat_completions"] += 1 + bucket["input_tokens"] += input_tokens + bucket["output_tokens"] += output_tokens + bucket["total_tokens"] += input_tokens + output_tokens if level == "ERROR": bucket["errors"] += 1 + if "upstream" in message: + bucket["upstream_errors"] += 1 + elif level == "WARNING": + bucket["warnings"] += 1 - if ( - "completed for streaming" in message - or "completed for non-streaming" in message - ): - cost_data = entry.get("cost_data") - if isinstance(cost_data, dict): - actual_cost = cost_data.get("total_msats", 0) - if isinstance(actual_cost, (int, float)) and actual_cost > 0: - bucket["revenue_msats"] += float(actual_cost) + failed = ( + "upstream request failed" in message + or "revert payment" in message + ) + if failed: + bucket["total_requests"] += 1 + bucket["failed_requests"] += 1 + + if "payment processed successfully" in message: + bucket["payment_processed"] += 1 + + if completed and revenue_msats > 0: + bucket["revenue_msats"] += revenue_msats + + if "revert payment" in message: + max_cost = entry.get("max_cost_for_model", 0) + if isinstance(max_cost, (int, float)) and max_cost > 0: + bucket["refunds_msats"] += float(max_cost) except Exception: continue result = [] for bucket_key in sorted(time_buckets.keys()): - result.append({"timestamp": bucket_key, **time_buckets[bucket_key]}) + bucket = dict(time_buckets[bucket_key]) + # Backward-compatible alias for any callers still reading "requests". + bucket["requests"] = bucket["total_requests"] + result.append({"timestamp": bucket_key, **bucket}) + + totals = { + "total_requests": 0, + "successful_chat_completions": 0, + "failed_requests": 0, + "errors": 0, + "warnings": 0, + "payment_processed": 0, + "upstream_errors": 0, + "revenue_msats": 0.0, + "refunds_msats": 0.0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + } + for bucket in result: + totals["total_requests"] += int(bucket["total_requests"]) + totals["successful_chat_completions"] += int( + bucket["successful_chat_completions"] + ) + totals["failed_requests"] += int(bucket["failed_requests"]) + totals["errors"] += int(bucket["errors"]) + totals["warnings"] += int(bucket["warnings"]) + totals["payment_processed"] += int(bucket["payment_processed"]) + totals["upstream_errors"] += int(bucket["upstream_errors"]) + totals["revenue_msats"] += float(bucket["revenue_msats"]) + totals["refunds_msats"] += float(bucket["refunds_msats"]) + totals["input_tokens"] += int(bucket["input_tokens"]) + totals["output_tokens"] += int(bucket["output_tokens"]) + totals["total_tokens"] += int(bucket["total_tokens"]) return { "metrics": result, "interval_minutes": interval_minutes, "hours_back": hours_back, "total_buckets": len(result), + "totals": totals, } diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json deleted file mode 100644 index 2988de07..00000000 --- a/ui/.eslintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": [ - "next", - "next/core-web-vitals", - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], - "plugins": ["react", "@typescript-eslint"] -} diff --git a/ui/.gitignore b/ui/.gitignore index fcc7df91..ba72a214 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -42,3 +42,4 @@ next-env.d.ts # favicon conflicts /app/favicon.ico +.pnpm-store/ diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 00000000..50268ec1 --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1,5 @@ +pnpm-lock.yaml +.next +node_modules +out +next-env.d.ts diff --git a/ui/app/balances/page.tsx b/ui/app/balances/page.tsx index 89e44f3a..4e962d0c 100644 --- a/ui/app/balances/page.tsx +++ b/ui/app/balances/page.tsx @@ -2,12 +2,11 @@ import { useCurrencyStore } from '@/lib/stores/currency'; import { useQuery } from '@tanstack/react-query'; -import { AppSidebar } from '@/components/app-sidebar'; -import { SiteHeader } from '@/components/site-header'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { DetailedWalletBalance } from '@/components/detailed-wallet-balance'; import { TemporaryBalances } from '@/components/temporary-balances'; import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; +import { AppPageShell } from '@/components/app-page-shell'; +import { PageHeader } from '@/components/page-header'; export default function BalancesPage() { const { displayUnit } = useCurrencyStore(); @@ -22,39 +21,26 @@ export default function BalancesPage() { const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; return ( - - - - -
-
-
-

Balances

-

- Monitor and manage wallet balances -

-
- {/* Global currency toggle is now in SiteHeader */} -
+ +
+ -
-
- -
-
- -
-
+
+ +
- - +
+
); } diff --git a/ui/app/globals.css b/ui/app/globals.css index 37756b40..ec301380 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -1,13 +1,133 @@ @import 'tailwindcss'; @import 'tw-animate-css'; -@custom-variant dark (&:is(.dark *)); +@custom-variant dark (&:is(.dark *, .red *)); + +:root { + --radius: 0.625rem; + --font-geist-sans: + ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, + Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; + --font-geist-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --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 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --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.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +.red { + --background: oklch(0.14 0.03 24); + --foreground: oklch(0.9 0.05 28); + --card: oklch(0.17 0.04 24); + --card-foreground: oklch(0.9 0.05 28); + --popover: oklch(0.17 0.04 24); + --popover-foreground: oklch(0.9 0.05 28); + --primary: oklch(0.78 0.14 25); + --primary-foreground: oklch(0.14 0.03 24); + --secondary: oklch(0.22 0.05 24); + --secondary-foreground: oklch(0.9 0.05 28); + --muted: oklch(0.22 0.05 24); + --muted-foreground: oklch(0.72 0.04 26); + --accent: oklch(0.25 0.08 25); + --accent-foreground: oklch(0.92 0.05 28); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(0.72 0.11 25 / 24%); + --input: oklch(0.75 0.12 25 / 28%); + --ring: oklch(0.62 0.12 24); +} + +html.red { + color-scheme: dark; +} @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: Geist, sans-serif; - --font-mono: Geist Mono, monospace; + --color-muted: var(--muted); + --color-accent: var(--accent); + --color-border: var(--border); + --color-card: var(--card); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-ring: var(--ring); + --color-input: var(--input); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -21,194 +141,34 @@ --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --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); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); } -:root { - --radius: 0.5rem; - --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.99 0 0); - --popover-foreground: oklch(0 0 0); - --primary: oklch(0 0 0); - --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.94 0 0); - --secondary-foreground: oklch(0 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.44 0 0); - --accent: oklch(0.94 0 0); - --accent-foreground: oklch(0 0 0); - --destructive: oklch(0.63 0.19 23.03); - --border: oklch(0.92 0 0); - --input: oklch(0.94 0 0); - --ring: oklch(0 0 0); - --chart-1: oklch(0.81 0.17 75.35); - --chart-2: oklch(0.55 0.22 264.53); - --chart-3: oklch(0.72 0 0); - --chart-4: oklch(0.92 0 0); - --chart-5: oklch(0.56 0 0); - --sidebar: oklch(0.99 0 0); - --sidebar-foreground: oklch(0 0 0); - --sidebar-primary: oklch(0 0 0); - --sidebar-primary-foreground: oklch(1 0 0); - --sidebar-accent: oklch(0.94 0 0); - --sidebar-accent-foreground: oklch(0 0 0); - --sidebar-border: oklch(0.94 0 0); - --sidebar-ring: oklch(0 0 0); - --destructive-foreground: oklch(1 0 0); - --font-sans: Geist, sans-serif; - --font-serif: Georgia, serif; - --font-mono: Geist Mono, monospace; - --shadow-color: hsl(0 0% 0%); - --shadow-opacity: 0.18; - --shadow-blur: 2px; - --shadow-spread: 0px; - --shadow-offset-x: 0px; - --shadow-offset-y: 1px; - --letter-spacing: 0em; - --spacing: 0.25rem; - --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); - --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); - --shadow-sm: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); - --shadow: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); - --shadow-md: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); - --shadow-lg: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); - --shadow-xl: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); - --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); - --tracking-normal: 0em; +body { + font-family: var(--font-sans); } -.dark { - --background: oklch(0 0 0); - --foreground: oklch(1 0 0); - --card: oklch(0.14 0 0); - --card-foreground: oklch(1 0 0); - --popover: oklch(0.18 0 0); - --popover-foreground: oklch(1 0 0); - --primary: oklch(1 0 0); - --primary-foreground: oklch(0 0 0); - --secondary: oklch(0.25 0 0); - --secondary-foreground: oklch(1 0 0); - --muted: oklch(0.23 0 0); - --muted-foreground: oklch(0.72 0 0); - --accent: oklch(0.32 0 0); - --accent-foreground: oklch(1 0 0); - --destructive: oklch(0.69 0.2 23.91); - --border: oklch(0.26 0 0); - --input: oklch(0.32 0 0); - --ring: oklch(0.72 0 0); - --chart-1: oklch(0.81 0.17 75.35); - --chart-2: oklch(0.58 0.21 260.84); - --chart-3: oklch(0.56 0 0); - --chart-4: oklch(0.44 0 0); - --chart-5: oklch(0.92 0 0); - --sidebar: oklch(0.18 0 0); - --sidebar-foreground: oklch(1 0 0); - --sidebar-primary: oklch(1 0 0); - --sidebar-primary-foreground: oklch(0 0 0); - --sidebar-accent: oklch(0.32 0 0); - --sidebar-accent-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.32 0 0); - --sidebar-ring: oklch(0.72 0 0); - --destructive-foreground: oklch(0 0 0); - --radius: 0.5rem; - --font-sans: Geist, sans-serif; - --font-serif: Georgia, serif; - --font-mono: Geist Mono, monospace; - --shadow-color: hsl(0 0% 0%); - --shadow-opacity: 0.18; - --shadow-blur: 2px; - --shadow-spread: 0px; - --shadow-offset-x: 0px; - --shadow-offset-y: 1px; - --letter-spacing: 0em; - --spacing: 0.25rem; - --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); - --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); - --shadow-sm: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); - --shadow: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); - --shadow-md: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); - --shadow-lg: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); - --shadow-xl: - 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); - --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); +html, +body { + max-width: 100%; } @layer base { * { @apply border-border outline-ring/50; } + body { @apply bg-background text-foreground; - letter-spacing: var(--tracking-normal); } -} -/* Custom animations */ -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); + button, + [type='button'], + [type='submit'], + [type='reset'], + [role='button'] { + cursor: pointer; } } - -.animate-shimmer { - animation: shimmer 2s infinite; -} diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx index 6c087695..92c51b56 100644 --- a/ui/app/layout.tsx +++ b/ui/app/layout.tsx @@ -1,23 +1,10 @@ import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; +import { GeistMono } from 'geist/font/mono'; +import { GeistSans } from 'geist/font/sans'; import './globals.css'; import { Providers } from './providers'; import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning'; -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], - preload: false, - display: 'swap', -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], - preload: false, - display: 'swap', -}); - export const metadata: Metadata = { title: 'Routstr', description: 'Routstr model management', @@ -34,7 +21,7 @@ export default function RootLayout({ return ( {children} diff --git a/ui/app/login/page.tsx b/ui/app/login/page.tsx index ba47a120..9144c262 100644 --- a/ui/app/login/page.tsx +++ b/ui/app/login/page.tsx @@ -5,16 +5,10 @@ import type { ChangeEvent, FormEvent, ReactElement } from 'react'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; import { adminLogin } from '@/lib/api/services/auth'; import { ConfigurationService } from '@/lib/api/services/configuration'; import { toast } from 'sonner'; +import { AuthPageShell } from '@/components/auth-page-shell'; export default function AdminLoginPage(): ReactElement { const router = useRouter(); @@ -78,51 +72,42 @@ export default function AdminLoginPage(): ReactElement { }; return ( -
- - - - Admin Login - - - Enter your admin password to access the dashboard - - - -
- {allowCustomBaseUrl && ( -
- ) => - setBaseUrl(event.target.value) - } - disabled={isLoading} - required - /> -
- )} -
- ) => - setPassword(event.target.value) - } - disabled={isLoading} - autoFocus - required - /> -
- -
-
-
-
+ +
+ {allowCustomBaseUrl && ( +
+ ) => + setBaseUrl(event.target.value) + } + disabled={isLoading} + required + /> +
+ )} +
+ ) => + setPassword(event.target.value) + } + disabled={isLoading} + autoFocus + required + /> +
+ +
+
); } diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx index 5c0193ce..627d8c04 100644 --- a/ui/app/logs/log-details-dialog.tsx +++ b/ui/app/logs/log-details-dialog.tsx @@ -10,18 +10,8 @@ import { } from '@/components/ui/dialog'; import { Copy, Check } from 'lucide-react'; import { useState } from 'react'; - -interface LogEntry { - asctime: string; - name: string; - levelname: string; - message: string; - pathname: string; - lineno: number; - version: string; - request_id: string; - [key: string]: string | number | object | undefined; -} +import { getLogLevelBadgeVariant } from '@/lib/utils/log-level'; +import type { LogEntry } from './types'; interface LogDetailsDialogProps { log: LogEntry | null; @@ -29,24 +19,6 @@ interface LogDetailsDialogProps { 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, @@ -79,10 +51,13 @@ export function LogDetailsDialog({ return ( - + - + {log.levelname} Log Entry Details @@ -92,7 +67,7 @@ export function LogDetailsDialog({ - +

Message

diff --git a/ui/app/logs/log-entry-card.tsx b/ui/app/logs/log-entry-card.tsx index 20133908..ae20a77b 100644 --- a/ui/app/logs/log-entry-card.tsx +++ b/ui/app/logs/log-entry-card.tsx @@ -1,41 +1,13 @@ 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; -} +import { ChevronRight } from 'lucide-react'; +import { getLogLevelBadgeVariant } from '@/lib/utils/log-level'; +import type { LogEntry } from './types'; interface LogEntryCardProps { entry: LogEntry; onClick: (entry: LogEntry) => void; } -const getLevelColor = (level: string): string => { - switch (level.toUpperCase()) { - case 'TRACE': - case 'DEBUG': - return 'bg-gray-100 text-gray-800 border-gray-200'; - case 'INFO': - return 'bg-blue-100 text-blue-800 border-blue-200'; - case 'WARNING': - return 'bg-yellow-100 text-yellow-800 border-yellow-200'; - case 'ERROR': - return 'bg-red-100 text-red-800 border-red-200'; - case 'CRITICAL': - return 'bg-purple-100 text-purple-800 border-purple-200'; - default: - return 'bg-gray-100 text-gray-800 border-gray-200'; - } -}; - export function LogEntryCard({ entry, onClick }: LogEntryCardProps) { const extraFields = Object.keys(entry).filter( (key) => @@ -50,73 +22,58 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) { 'request_id', ].includes(key) ); + const hasRequestId = + Boolean(entry.request_id) && entry.request_id !== 'no-request-id'; + const shortPath = entry.pathname.split('/').pop() || entry.pathname; 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} - +
+
+
+ + {entry.levelname} + + + {entry.asctime} + + + {entry.name}
-
- )} - {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 -
- )} +

+ {entry.message} +

+ +
+ {hasRequestId ? ( + + {entry.request_id} + + ) : null} + + {shortPath}:{entry.lineno} + + {extraFields.length > 0 ? ( + {extraFields.length} extra + ) : null}
- )} + +
+ +
+
); } diff --git a/ui/app/logs/log-filters.tsx b/ui/app/logs/log-filters.tsx index b72cd73a..e9ce78d6 100644 --- a/ui/app/logs/log-filters.tsx +++ b/ui/app/logs/log-filters.tsx @@ -1,3 +1,11 @@ +import { + useEffect, + useState, + type ChangeEvent, + type KeyboardEvent, +} from 'react'; +import { format } from 'date-fns'; +import { CalendarIcon, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, @@ -21,20 +29,8 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Calendar } from '@/components/ui/calendar'; -import { Badge } from '@/components/ui/badge'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command'; -import { Checkbox } from '@/components/ui/checkbox'; -import { CalendarIcon, Filter, X, Plus } from 'lucide-react'; -import { useState, useEffect } from 'react'; -import { format } from 'date-fns'; import { cn } from '@/lib/utils'; +import { MultiSelectCommandFilter } from './multi-select-command-filter'; interface LogFiltersProps { selectedDate: string; @@ -97,32 +93,12 @@ const ENDPOINT_OPTIONS = [ '/embeddings/models', ]; -interface FilterBadgeProps { - value: string; - onRemove: (value: string) => void; -} - -function FilterBadge({ value, onRemove }: FilterBadgeProps) { - return ( - - {value} - - - ); -} +const STATUS_4XX_CODES = STATUS_CODE_OPTIONS.filter((code) => + code.startsWith('4') +); +const STATUS_5XX_CODES = STATUS_CODE_OPTIONS.filter((code) => + code.startsWith('5') +); export function LogFilters({ selectedDate, @@ -151,7 +127,7 @@ export function LogFilters({ const [isCustom, setIsCustom] = useState(!isPreset); const [date, setDate] = useState( selectedDate && selectedDate !== 'all' - ? new Date(selectedDate + 'T00:00:00') + ? new Date(`${selectedDate}T00:00:00`) : undefined ); @@ -162,6 +138,7 @@ export function LogFilters({ useEffect(() => { const currentIsPreset = PRESET_LIMITS.includes(limit.toString()); setIsCustom(!currentIsPreset); + if (!currentIsPreset) { setCustomLimit(limit.toString()); } @@ -170,88 +147,79 @@ export function LogFilters({ useEffect(() => { if (selectedDate === 'all' || !selectedDate) { setDate(undefined); - } else { - const d = new Date(selectedDate + 'T00:00:00'); - setDate(isNaN(d.getTime()) ? undefined : d); + return; } + + const parsedDate = new Date(`${selectedDate}T00:00:00`); + setDate(Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate); }, [selectedDate]); const handleLimitChange = (value: string) => { if (value === 'custom') { setIsCustom(true); setCustomLimit(limit.toString()); - } else { - setIsCustom(false); - setCustomLimit(''); - onLimitChange(Number(value)); + return; } + + setIsCustom(false); + setCustomLimit(''); + onLimitChange(Number(value)); }; - const handleCustomLimitChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setCustomLimit(value); + const handleCustomLimitChange = (event: ChangeEvent) => { + setCustomLimit(event.target.value); }; const handleCustomLimitApply = () => { - const numValue = parseInt(customLimit); - if (!isNaN(numValue) && numValue > 0) { - onLimitChange(numValue); - } else { - setIsCustom(false); - setCustomLimit(''); - onLimitChange(100); + const numericValue = Number.parseInt(customLimit, 10); + + if (!Number.isNaN(numericValue) && numericValue > 0) { + onLimitChange(numericValue); + return; } + + setIsCustom(false); + setCustomLimit(''); + onLimitChange(100); }; - const handleCustomLimitKeyDown = ( - e: React.KeyboardEvent - ) => { - if (e.key === 'Enter') { + const handleCustomLimitKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { handleCustomLimitApply(); } }; - const handleDateSelect = (selectedDate: Date | undefined) => { - setDate(selectedDate); - if (selectedDate) { - onDateChange(format(selectedDate, 'yyyy-MM-dd')); - } else { - onDateChange('all'); - } - }; + const handleDateSelect = (nextDate: Date | undefined) => { + setDate(nextDate); - const toggleSelection = ( - current: string[], - value: string, - onChange: (val: string[]) => void - ) => { - if (current.includes(value)) { - onChange(current.filter((v) => v !== value)); - } else { - onChange([...current, value]); + if (nextDate) { + onDateChange(format(nextDate, 'yyyy-MM-dd')); + return; } + + onDateChange('all'); }; const handleQuickStatusCode = (range: '4xx' | '5xx') => { - const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0])); - const newSelection = new Set([...selectedStatusCodes]); - const allIncluded = codes.every((c) => selectedStatusCodes.includes(c)); + const rangeCodes = range === '4xx' ? STATUS_4XX_CODES : STATUS_5XX_CODES; + const nextSelection = new Set(selectedStatusCodes); + const allSelected = rangeCodes.every((code) => + selectedStatusCodes.includes(code) + ); - if (allIncluded) { - codes.forEach((c) => newSelection.delete(c)); + if (allSelected) { + rangeCodes.forEach((code) => nextSelection.delete(code)); } else { - codes.forEach((c) => newSelection.add(c)); + rangeCodes.forEach((code) => nextSelection.add(code)); } - onStatusCodesChange(Array.from(newSelection)); + + onStatusCodesChange(Array.from(nextSelection)); }; return ( - - - Filters - + Filters Filter logs by date, level, request ID, text search, status code, method, endpoint and limit @@ -314,336 +282,62 @@ export function LogFilters({
-
- - - - - - - - - - {selectedStatusCodes.length > 0 && ( - - {selectedStatusCodes.map((code) => ( - - toggleSelection( - selectedStatusCodes, - code, - onStatusCodesChange - ) - } - > - - {code} - - ))} - - )} - {statusSearch && - !STATUS_CODE_OPTIONS.includes(statusSearch) && - !selectedStatusCodes.includes(statusSearch) && ( - - { - if (/^\d+$/.test(statusSearch)) { - toggleSelection( - selectedStatusCodes, - statusSearch, - onStatusCodesChange - ); - setStatusSearch(''); - } - }} - > - - Add "{statusSearch}" - - - )} - No results found. - - handleQuickStatusCode('4xx')} - > - - c.startsWith('4') - ).every((c) => selectedStatusCodes.includes(c))} - className='mr-2' - /> - 4xx Errors - - handleQuickStatusCode('5xx')} - > - - c.startsWith('5') - ).every((c) => selectedStatusCodes.includes(c))} - className='mr-2' - /> - 5xx Errors - - - - {STATUS_CODE_OPTIONS.filter( - (code) => !selectedStatusCodes.includes(code) - ).map((code) => ( - - toggleSelection( - selectedStatusCodes, - code, - onStatusCodesChange - ) - } - > - - {code} - - ))} - - - - - -
+ /^\d+$/.test(value)} + quickFilters={[ + { + label: '4xx Errors', + checked: STATUS_4XX_CODES.every((code) => + selectedStatusCodes.includes(code) + ), + onSelect: () => handleQuickStatusCode('4xx'), + }, + { + label: '5xx Errors', + checked: STATUS_5XX_CODES.every((code) => + selectedStatusCodes.includes(code) + ), + onSelect: () => handleQuickStatusCode('5xx'), + }, + ]} + /> -
- - - - - - - - - - {selectedMethods.length > 0 && ( - - {selectedMethods.map((method) => ( - - toggleSelection( - selectedMethods, - method, - onMethodsChange - ) - } - > - - {method} - - ))} - - )} - {methodSearch && - !METHOD_OPTIONS.includes(methodSearch.toUpperCase()) && - !selectedMethods.includes(methodSearch.toUpperCase()) && ( - - { - toggleSelection( - selectedMethods, - methodSearch.toUpperCase(), - onMethodsChange - ); - setMethodSearch(''); - }} - > - - Add "{methodSearch.toUpperCase()}" - - - )} - No results found. - - {METHOD_OPTIONS.filter( - (method) => !selectedMethods.includes(method) - ).map((method) => ( - - toggleSelection( - selectedMethods, - method, - onMethodsChange - ) - } - > - - {method} - - ))} - - - - - -
+ value.toUpperCase()} + /> -
- - - - - - - - - - {selectedEndpoints.length > 0 && ( - - {selectedEndpoints.map((endpoint) => ( - - toggleSelection( - selectedEndpoints, - endpoint, - onEndpointsChange - ) - } - > - - {endpoint} - - ))} - - )} - {endpointSearch && - !ENDPOINT_OPTIONS.includes(endpointSearch) && - !selectedEndpoints.includes(endpointSearch) && ( - - { - toggleSelection( - selectedEndpoints, - endpointSearch, - onEndpointsChange - ); - setEndpointSearch(''); - }} - > - - Add "{endpointSearch}" - - - )} - No results found. - - {ENDPOINT_OPTIONS.filter( - (endpoint) => !selectedEndpoints.includes(endpoint) - ).map((endpoint) => ( - - toggleSelection( - selectedEndpoints, - endpoint, - onEndpointsChange - ) - } - > - - {endpoint} - - ))} - - - - - -
+
@@ -652,7 +346,7 @@ export function LogFilters({ type='text' placeholder='Search by request ID' value={requestId} - onChange={(e) => onRequestIdChange(e.target.value)} + onChange={(event) => onRequestIdChange(event.target.value)} />
@@ -668,14 +362,14 @@ export function LogFilters({ type='text' placeholder='Search in message and name' value={searchText} - onChange={(e) => onSearchTextChange(e.target.value)} + onChange={(event) => onSearchTextChange(event.target.value)} />
{isCustom ? ( -
+
-
- +
+ + + + + + {selectedValues.length > 0 && ( + + {selectedValues.map((value) => ( + toggleSelection(value)} + > + + {value} + + ))} + + )} + + {canShowCustomAction && ( + + { + toggleSelection(normalizedSearch); + onSearchValueChange(''); + }} + > + + Add "{normalizedSearch}" + + + )} + + No results found. + + {quickFilters.length > 0 && ( + + {quickFilters.map((filter) => ( + + + {filter.label} + + ))} + + )} + + + {options + .filter((option) => !selectedValues.includes(option)) + .map((option) => ( + toggleSelection(option)} + > + + {option} + + ))} + + + + + +
+ ); +} diff --git a/ui/app/logs/page.tsx b/ui/app/logs/page.tsx index 877a5de5..64d16c77 100644 --- a/ui/app/logs/page.tsx +++ b/ui/app/logs/page.tsx @@ -2,8 +2,6 @@ import { useState, useEffect } 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, @@ -14,9 +12,18 @@ import { } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; +import { Skeleton } from '@/components/ui/skeleton'; import { FileText, RefreshCw } from 'lucide-react'; import { apiClient } from '@/lib/api/client'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { AppPageShell } from '@/components/app-page-shell'; +import { PageHeader } from '@/components/page-header'; import { LogEntry, LogsResponse } from './types'; import { LogFilters } from './log-filters'; import { LogEntryCard } from './log-entry-card'; @@ -135,126 +142,125 @@ export default function LogsPage() { setIsDialogOpen(true); }; + const hasActiveFilters = + selectedDate !== 'all' || + selectedLevel !== 'all' || + Boolean(requestId) || + Boolean(searchText) || + selectedStatusCodes.length > 0 || + selectedMethods.length > 0 || + selectedEndpoints.length > 0; + + const activeFilterDescription = [ + selectedDate !== 'all' ? `date ${selectedDate}` : null, + selectedLevel !== 'all' ? `level ${selectedLevel}` : null, + requestId ? `request ID ${requestId}` : null, + searchText ? `text "${searchText}"` : null, + selectedStatusCodes.length > 0 + ? `status ${selectedStatusCodes.join(', ')}` + : null, + selectedMethods.length > 0 ? `method ${selectedMethods.join(', ')}` : null, + selectedEndpoints.length > 0 + ? `endpoint ${selectedEndpoints.join(', ')}` + : null, + ] + .filter(Boolean) + .join(' • '); + return ( - - - - -
-
-
-

- - System Logs -

-

- View and filter application logs -

-
- -
+ } + /> - + - - - - Log Entries - {logsData && ( - - {logsData.logs.length} entries - - )} - - {(selectedDate !== 'all' || - selectedLevel !== 'all' || - requestId || - searchText || - selectedStatusCodes.length > 0 || - selectedMethods.length > 0 || - selectedEndpoints.length > 0) && ( - - Showing logs - {selectedDate !== 'all' && ` for ${selectedDate}`} - {selectedLevel !== 'all' && ` with level ${selectedLevel}`} - {requestId && ` with request ID ${requestId}`} - {searchText && ` matching "${searchText}"`} - {selectedStatusCodes.length > 0 && - ` with status ${selectedStatusCodes.join(', ')}`} - {selectedMethods.length > 0 && - ` with method ${selectedMethods.join(', ')}`} - {selectedEndpoints.length > 0 && - ` with endpoint ${selectedEndpoints.join(', ')}`} - + + +
+ Log Entries + {logsData && ( + + {logsData.logs.length} entries + )} - - - {isLoading ? ( -
- - - Loading logs... - +
+ {hasActiveFilters && ( + + Showing logs filtered by {activeFilterDescription} + + )} + + + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ) : logsData?.logs && logsData.logs.length > 0 ? ( + +
+ {logsData.logs.map((entry, index) => ( + + ))}
- ) : logsData?.logs && logsData.logs.length > 0 ? ( - <> - -
- {logsData.logs.map((entry, index) => ( - - ))} -
-
- - ) : ( -
- -

No log entries found

-

- Try adjusting your filters or check back later -

-
- )} -
- + + ) : ( + + + + + + No log entries found + + Try adjusting your filters or check back later. + + + + )} +
+ - setIsDialogOpen(false)} - /> -
- - + setIsDialogOpen(false)} + /> +
+ ); } diff --git a/ui/app/model/page.tsx b/ui/app/model/page.tsx deleted file mode 100644 index 6f523cf7..00000000 --- a/ui/app/model/page.tsx +++ /dev/null @@ -1,329 +0,0 @@ -'use client'; - -import { ModelSelector } from '@/components/ModelSelector'; -import { ModelTester } from '@/components/ModelTester'; -import { ApiEndpointTester } from '@/components/ApiEndpointTester'; -import { ModelSearchFilter } from '@/components/ModelSearchFilter'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { AppSidebar } from '@/components/app-sidebar'; -import { SiteHeader } from '@/components/site-header'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useQuery } from '@tanstack/react-query'; -import { AdminService } from '@/lib/api/services/admin'; -import { Skeleton } from '@/components/ui/skeleton'; -import { AlertCircle, Users, Globe } from 'lucide-react'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; -import { useMemo, useState } from 'react'; -import type { Model } from '@/lib/api/schemas/models'; -import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort'; - -export default function ModelsPage() { - const [filteredModels, setFilteredModels] = useState([]); - - const { - data: modelsData, - isLoading: isLoadingModels, - error: modelsError, - } = useQuery({ - queryKey: ['admin-models-with-providers'], - queryFn: () => AdminService.getModelsWithProviders(), - refetchOnWindowFocus: false, - }); - - const { models = [], groups = [] } = modelsData || {}; - - const groupedModels = useMemo(() => { - return groupAndSortModelsByProvider(models); - }, [models]); - - const groupDataMap = useMemo(() => { - return new Map(groups.map((group) => [group.provider, group])); - }, [groups]); - - const providerInfo = useMemo(() => { - const allProviders = new Set([ - ...Object.keys(groupedModels), - ...groups.map((g) => g.provider), - ]); - console.log(allProviders); - - return Array.from(allProviders).map((provider) => { - const providerModels = groupedModels[provider] || []; - const groupData = groupDataMap.get(provider); - const activeModels = providerModels.filter( - (m) => m.isEnabled && !m.soft_deleted - ).length; - const totalModels = providerModels.length; - - return { - provider, - activeModels, - totalModels, - groupData, - hasGroupUrl: !!groupData?.group_url, - hasGroupApiKey: !!groupData?.group_api_key, - }; - }); - }, [groupedModels, groupDataMap, groups]); - - return ( - - - - -
-
-
-

- Model Management & API Testing -

-
- - - - Manage Models - {/*Basic Testing - API Endpoints */} - - - -
- Manage your AI models organized by provider groups. Configure - API keys, and organize models by provider groups. -
- - {isLoadingModels ? ( -
- - -
- ) : modelsError ? ( - - - - Failed to load models. Please try refreshing the page. - - - ) : ( - -
- {/* Provider Tabs Navigation */} -
- - - - All Models - All - - {models.length} - - - {providerInfo.map( - ({ provider, activeModels, totalModels }) => ( - - - - {provider} - -
- - {activeModels}/{totalModels} - -
-
- ) - )} -
-
- - {/* All Models Tab */} - -
-
- Overview of all models across all provider groups. -
- - -
-
- - {providerInfo.map( - ({ provider, totalModels, groupData }) => { - const providerModels = groupedModels[provider] || []; - - return ( - -
-
-
-

- - {provider} -

-
- {providerModels.filter( - (m) => m.soft_deleted - ).length > 0 && ( - - { - providerModels.filter( - (m) => m.soft_deleted - ).length - }{' '} - disabled - - )} - {groupData?.group_url && ( - - - {groupData.group_url} - - )} - {totalModels === 0 && ( - - No models configured - - )} -
-
-
- {totalModels === 0 && ( - - - -
-

- No models found for this provider -

-
-

- Common issues: -

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

- Current endpoint:{' '} - - {groupData.group_url} - -

- )} -
-
-
-
- )} - -
-
- ); - } - )} -
-
- )} -
- - -
- Test model credentials and connectivity with basic chat - completion requests through the secure proxy (resolves CORS - and Docker network issues). Models can be tested even without - API keys configured (useful for free models or when - authentication is handled elsewhere). -
- - {isLoadingModels ? ( -
- - -
- ) : modelsError ? ( - - - - Failed to load models for testing. Please try refreshing - the page. - - - ) : ( - - )} -
- - -
- Comprehensive testing of all OpenAI API endpoints including - chat completions, embeddings, image generation, audio - synthesis, and model listing through the secure proxy - (resolves CORS and Docker network issues). Models can be - tested with or without API keys configured. -
- - {isLoadingModels ? ( -
- - -
- ) : modelsError ? ( - - - - Failed to load models for API testing. Please try - refreshing the page. - - - ) : ( - - )} -
-
-
-
-
-
- ); -} diff --git a/ui/app/models/page.tsx b/ui/app/models/page.tsx new file mode 100644 index 00000000..4b448165 --- /dev/null +++ b/ui/app/models/page.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { AlertCircle } from 'lucide-react'; +import type { Model } from '@/lib/api/schemas/models'; +import { AdminService } from '@/lib/api/services/admin'; +import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort'; +import { AppPageShell } from '@/components/app-page-shell'; +import { PageHeader } from '@/components/page-header'; +import { ModelSelector } from '@/components/model-selector'; +import { ModelTester } from '@/components/model-tester'; +import { ApiEndpointTester } from '@/components/api-endpoint-tester'; +import { ModelSearchFilter } from '@/components/model-search-filter'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +export default function ModelsPage() { + const [filteredModels, setFilteredModels] = useState( + undefined + ); + const [selectedProviderScope, setSelectedProviderScope] = + useState('all'); + + const { + data: modelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useQuery({ + queryKey: ['admin-models-with-providers'], + queryFn: () => AdminService.getModelsWithProviders(), + refetchOnWindowFocus: false, + }); + + const { models = [], groups = [] } = modelsData || {}; + + const groupedModels = useMemo( + () => groupAndSortModelsByProvider(models), + [models] + ); + + const groupDataMap = useMemo( + () => new Map(groups.map((group) => [group.provider, group])), + [groups] + ); + + const providerInfo = useMemo(() => { + const allProviders = new Set([ + ...Object.keys(groupedModels), + ...groups.map((group) => group.provider), + ]); + + return Array.from(allProviders) + .map((provider) => { + const providerModels = groupedModels[provider] || []; + const groupData = groupDataMap.get(provider); + + return { + provider, + totalModels: providerModels.length, + disabledModels: providerModels.filter((model) => model.soft_deleted) + .length, + groupData, + }; + }) + .sort((a, b) => a.provider.localeCompare(b.provider)); + }, [groupDataMap, groupedModels, groups]); + + const activeProviderScope = useMemo(() => { + if (selectedProviderScope === 'all') { + return 'all'; + } + + const providerExists = providerInfo.some( + (provider) => provider.provider === selectedProviderScope + ); + + return providerExists ? selectedProviderScope : 'all'; + }, [providerInfo, selectedProviderScope]); + + const selectedProviderGroup = + activeProviderScope === 'all' + ? undefined + : groupDataMap.get(activeProviderScope); + + const scopedModels = useMemo(() => { + if (activeProviderScope === 'all') { + return models; + } + + return models.filter((model) => model.provider === activeProviderScope); + }, [activeProviderScope, models]); + + return ( + +
+ + + + + + Manage Models + + + Basic Testing + + + API Endpoints + + + + + {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models. Please try refreshing the page. + + + ) : ( +
+
+ + + +
+ + +
+ )} +
+ + +
+

+ Basic Credential Testing +

+

+ Run chat-completion checks through the secure proxy to validate + model credentials and endpoint connectivity. +

+
+ {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models for testing. Please try refreshing the + page. + + + ) : ( + + )} +
+ + +
+

+ OpenAI Endpoint Testing +

+

+ Validate chat, embeddings, image, audio, and model-listing + endpoints through the secure proxy. +

+
+ {isLoadingModels ? ( +
+ + +
+ ) : modelsError ? ( + + + + Failed to load models for API testing. Please try refreshing + the page. + + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/ui/app/page.tsx b/ui/app/page.tsx index a3686a94..380f465e 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -1,17 +1,41 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { format } from 'date-fns'; 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 { CalendarIcon, RefreshCw } from 'lucide-react'; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import type { DateRange } from 'react-day-picker'; import { UsageMetricsChart } from '@/components/usage-metrics-chart'; import { UsageSummaryCards } from '@/components/usage-summary-cards'; import { ErrorDetailsTable } from '@/components/error-details-table'; -import { RevenueByModelTable } from '@/components/revenue-by-model-table'; import { DashboardBalanceSummary } from '@/components/dashboard-balance-summary'; -import { AdminService } from '@/lib/api/services/admin'; +import { + AdminService, + type UsageMetricData, + type UsageSummary, +} from '@/lib/api/services/admin'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Calendar } from '@/components/ui/calendar'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig as UiChartConfig, +} from '@/components/ui/chart'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { Select, SelectContent, @@ -19,23 +43,481 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { RefreshCw } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; import { useCurrencyStore } from '@/lib/stores/currency'; import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; import { CheatSheet } from '@/components/landing/cheat-sheet'; import { ConfigurationService } from '@/lib/api/services/configuration'; +import { AppPageShell } from '@/components/app-page-shell'; +import { useIsMobile } from '@/hooks/use-mobile'; +import type { DisplayUnit } from '@/lib/types/units'; +import { cn } from '@/lib/utils'; + +type ChartDatum = Record & { timestamp: string }; + +type ChartKeyConfig = { + key: string; + name: string; + color: string; +}; + +type ChartConfig = { + id: string; + title: string; + mobileTitle?: string; + description: string; + data: ChartDatum[]; + dataKeys: ChartKeyConfig[]; + totals?: Partial>; + metricType: 'currency' | 'count'; +}; + +const TIME_RANGE_PRESETS = [ + { value: '24h', label: 'Last 24 Hours', hours: 24 }, + { value: '7d', label: 'Last 7 Days', hours: 7 * 24 }, + { value: '30d', label: 'Last 30 Days', hours: 30 * 24 }, + { value: '3m', label: 'Last 3 Months', hours: 90 * 24 }, + { value: '12m', label: 'Last 12 Months', hours: 365 * 24 }, +] as const; + +type TimeRangePresetValue = (typeof TIME_RANGE_PRESETS)[number]['value']; + +const DEFAULT_TIME_RANGE_PRESET = + TIME_RANGE_PRESETS.find((option) => option.value === '7d') ?? + TIME_RANGE_PRESETS[0]; + +function normalizeDateRange(range: DateRange): DateRange { + if (!range.from || !range.to) { + return range; + } + + if (range.from.getTime() <= range.to.getTime()) { + return range; + } + + return { + from: range.to, + to: range.from, + }; +} + +function getRangeHours(range?: DateRange): number | null { + if (!range?.from || !range.to) { + return null; + } + + const normalized = normalizeDateRange(range); + const fromTime = normalized.from?.getTime(); + const toTime = normalized.to?.getTime(); + + if (fromTime === undefined || toTime === undefined) { + return null; + } + + const diffMs = toTime - fromTime; + const diffHours = Math.ceil(diffMs / (1000 * 60 * 60)); + + return Math.max(1, diffHours); +} + +function formatCompactDateRangeLabel(range?: DateRange): string { + if (!range?.from || !range.to) { + return 'Custom range'; + } + + const normalized = normalizeDateRange(range); + const from = normalized.from; + const to = normalized.to; + + if (!from || !to) { + return 'Custom range'; + } + + const sameMonth = format(from, 'yyyy-MM') === format(to, 'yyyy-MM'); + if (sameMonth) { + return `${format(from, 'MMM d')} - ${format(to, 'd')}`; + } + + const sameYear = format(from, 'yyyy') === format(to, 'yyyy'); + if (sameYear) { + return `${format(from, 'MMM d')} - ${format(to, 'MMM d')}`; + } + + return `${format(from, 'MMM d, yyyy')} - ${format(to, 'MMM d, yyyy')}`; +} + +function getAutoIntervalMinutes(hours: number): number { + const totalMinutes = Math.max(60, Math.ceil(hours * 60)); + const targetPoints = 96; + const idealInterval = Math.ceil(totalMinutes / targetPoints); + const allowedIntervals = [5, 15, 30, 60, 120, 180, 240, 360, 480, 720, 1440]; + + return ( + allowedIntervals.find( + (intervalMinutes) => intervalMinutes >= idealInterval + ) ?? allowedIntervals[allowedIntervals.length - 1] + ); +} + +function getQueryErrorMessage(error: unknown): string { + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' && + error.message.trim().length > 0 + ) { + return error.message; + } + + return 'The analytics request failed. Refresh and try again.'; +} + +function SectionLoading({ label }: { label: string }) { + if (label === 'summary') { + return ( +
+ {Array.from({ length: 14 }).map((_, index) => ( + + + + + + + + + + + ))} +
+ ); + } + + if (label === 'metrics') { + return ( + + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+
+ + +
+ +
+
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ + + +
+ ))} +
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+
+
+
+ ); + } + + if (label === 'revenue by model') { + return ( + + + + + + +
+
+
+ {Array.from({ length: 9 }).map((_, index) => ( + 0 && index < 5 && 'w-14 justify-self-end', + index === 5 && 'w-24', + index > 5 && 'w-16 justify-self-end' + )} + /> + ))} +
+ {Array.from({ length: 5 }).map((_, rowIndex) => ( +
+ + + + + +
+ + +
+ + + +
+ ))} +
+
+
+
+ ); + } + + if (label === 'errors') { + return ( + + + + + + +
+
+
+ + + + + +
+ {Array.from({ length: 6 }).map((_, rowIndex) => ( +
+ + + + + +
+ ))} +
+
+
+
+ ); + } + + return ( + + + + + + + ); +} + +function DashboardInsights({ + summary, + isMobile, +}: { + summary?: UsageSummary; + isMobile: boolean; +}) { + if (!summary) { + return null; + } + + const errorTypes = Object.entries(summary.error_types || {}) + .map(([type, count]) => ({ + type, + count: typeof count === 'number' ? count : Number(count || 0), + })) + .filter((entry) => Number.isFinite(entry.count) && entry.count > 0) + .sort((a, b) => b.count - a.count); + + const hasErrorTypes = errorTypes.length > 0; + + if (!hasErrorTypes) { + return null; + } + + const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }); + const truncateErrorType = (value: string): string => { + const maxLength = isMobile ? 16 : 26; + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 1)}…`; + }; + const chartData = errorTypes.slice(0, 10).map(({ type, count }) => ({ + type, + label: truncateErrorType(type), + count, + })); + const totalErrors = errorTypes.reduce((sum, entry) => sum + entry.count, 0); + const chartConfig: UiChartConfig = { + count: { + label: 'Errors', + color: 'var(--chart-4)', + }, + }; + + return ( +
+ {hasErrorTypes && ( + + + Error Types Distribution +

+ Top {chartData.length} categories in this range. +

+
+ + + + + + compactNumber.format( + typeof value === 'number' ? value : Number(value || 0) + ) + } + /> + + + String(payload?.[0]?.payload?.type ?? '') + } + formatter={(value, name) => { + const numericValue = + typeof value === 'number' + ? value + : Number(value || 0); + return ( +
+ + {name} + + + {Number.isFinite(numericValue) + ? numericValue.toLocaleString() + : '-'} + +
+ ); + }} + /> + } + /> + +
+
+

+ Total errors: {totalErrors.toLocaleString()} +

+
+
+ )} +
+ ); +} export default function DashboardPage() { - const [timeRange, setTimeRange] = useState('24'); - const [interval, setInterval] = useState('15'); + const [selectedPreset, setSelectedPreset] = useState( + DEFAULT_TIME_RANGE_PRESET.value + ); + const [customRange, setCustomRange] = useState(); + const [pendingCustomRange, setPendingCustomRange] = useState(); + const [isCustomRangeActive, setIsCustomRangeActive] = useState(false); + const [isCustomRangePickerOpen, setIsCustomRangePickerOpen] = useState(false); + const [isManualRefreshing, setIsManualRefreshing] = useState(false); + const [activeChartId, setActiveChartId] = useState('revenue'); + const isMobile = useIsMobile(); const { displayUnit } = useCurrencyStore(); - const [isAuthenticated, setIsAuthenticated] = useState(() => { - if (typeof window === 'undefined') { - return false; - } - return ConfigurationService.isTokenValid(); - }); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isAuthResolved, setIsAuthResolved] = useState(false); useEffect(() => { if (typeof window === 'undefined') { @@ -44,6 +526,7 @@ export default function DashboardPage() { const syncAuthState = (): void => { setIsAuthenticated(ConfigurationService.isTokenValid()); + setIsAuthResolved(true); }; syncAuthState(); @@ -63,324 +546,552 @@ export default function DashboardPage() { }); const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; + const activePreset = + TIME_RANGE_PRESETS.find((option) => option.value === selectedPreset) ?? + DEFAULT_TIME_RANGE_PRESET; + const customRangeHours = getRangeHours(customRange); + const queryHours = + isCustomRangeActive && customRangeHours + ? customRangeHours + : activePreset.hours; + const autoInterval = getAutoIntervalMinutes(queryHours); + const usageRefetchIntervalMs = useMemo(() => { + if (queryHours > 90 * 24) { + return 4 * 60 * 60_000; + } + if (queryHours > 30 * 24) { + return 2 * 60 * 60_000; + } + if (queryHours > 7 * 24) { + return 30 * 60_000; + } + return 60_000; + }, [queryHours]); + const revenueDisplayUnit: DisplayUnit = useMemo(() => { + if (displayUnit === 'usd' && usdPerSat === null) { + // Keep revenue charts meaningful while the USD rate is unavailable. + return 'sat'; + } + return displayUnit; + }, [displayUnit, usdPerSat]); + const revenueUnitLabel = + revenueDisplayUnit === 'usd' + ? 'USD' + : revenueDisplayUnit === 'sat' + ? 'sats' + : revenueDisplayUnit === 'msat' + ? 'msats' + : revenueDisplayUnit; const { data: metricsData, isLoading: metricsLoading, + error: metricsError, refetch: refetchMetrics, } = useQuery({ - queryKey: ['usage-metrics', interval, timeRange], - queryFn: () => - AdminService.getUsageMetrics(parseInt(interval), parseInt(timeRange)), + queryKey: ['usage-metrics', autoInterval, queryHours], + queryFn: () => AdminService.getUsageMetrics(autoInterval, queryHours), enabled: isAuthenticated, - refetchInterval: 60_000, + refetchInterval: usageRefetchIntervalMs, staleTime: 30_000, }); const { data: summaryData, isLoading: summaryLoading, + error: summaryError, refetch: refetchSummary, } = useQuery({ - queryKey: ['usage-summary', timeRange], - queryFn: () => AdminService.getUsageSummary(parseInt(timeRange)), + queryKey: ['usage-summary', queryHours], + queryFn: () => AdminService.getUsageSummary(queryHours), enabled: isAuthenticated, - refetchInterval: 60_000, + refetchInterval: usageRefetchIntervalMs, staleTime: 30_000, }); const { data: errorData, isLoading: errorLoading, + error: errorDetailsError, refetch: refetchErrors, } = useQuery({ - queryKey: ['usage-errors', timeRange], - queryFn: () => AdminService.getErrorDetails(parseInt(timeRange), 100), + queryKey: ['usage-errors', queryHours], + queryFn: () => AdminService.getErrorDetails(queryHours, 100), enabled: isAuthenticated, - refetchInterval: 60_000, + refetchInterval: usageRefetchIntervalMs, staleTime: 30_000, }); - const { - data: revenueByModelData, - isLoading: revenueByModelLoading, - refetch: refetchRevenueByModel, - } = useQuery({ - queryKey: ['revenue-by-model', timeRange], - queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange), 20), - enabled: isAuthenticated, - refetchInterval: 60_000, - staleTime: 30_000, - }); + const chartConfigs = useMemo(() => { + if (!metricsData || metricsData.metrics.length === 0) { + return []; + } + + const metricPoints = metricsData.metrics as ChartDatum[]; + const convertRevenueMsats = (amountMsats: number): number => { + if (revenueDisplayUnit === 'msat') { + return amountMsats; + } + + const sats = amountMsats / 1000; + if (revenueDisplayUnit === 'usd') { + return sats * (usdPerSat ?? 0); + } + + return sats; + }; + const revenuePoints = metricsData.metrics.map( + (metric: UsageMetricData) => ({ + ...metric, + revenue_display: convertRevenueMsats(metric.revenue_msats), + }) + ) as ChartDatum[]; + + const hasTokenMetrics = metricPoints.some((metric) => + ['input_tokens', 'output_tokens', 'total_tokens'].some( + (key) => typeof metric[key] === 'number' + ) + ); + + return [ + { + id: 'revenue', + title: 'Revenue Over Time', + mobileTitle: 'Revenue', + description: 'Track collected revenue trends over time.', + data: revenuePoints, + metricType: 'currency', + dataKeys: [ + { + key: 'revenue_display', + name: 'Revenue', + color: 'var(--chart-1)', + }, + ], + }, + { + id: 'requests', + title: 'Request Volume', + mobileTitle: 'Requests', + description: 'Understand traffic and completion reliability over time.', + data: metricPoints, + metricType: 'count', + dataKeys: [ + { + key: 'total_requests', + name: 'Total Requests', + color: 'var(--chart-1)', + }, + { + key: 'successful_chat_completions', + name: 'Successful', + color: 'var(--chart-2)', + }, + { + key: 'failed_requests', + name: 'Failed', + color: 'var(--chart-5)', + }, + ], + }, + { + id: 'errors', + title: 'Error Tracking', + mobileTitle: 'Errors', + description: 'Monitor warnings, handled errors, and upstream failures.', + data: metricPoints, + metricType: 'count', + dataKeys: [ + { + key: 'errors', + name: 'Errors', + color: 'var(--chart-4)', + }, + { + key: 'warnings', + name: 'Warnings', + color: 'var(--chart-3)', + }, + { + key: 'upstream_errors', + name: 'Upstream Errors', + color: 'var(--chart-5)', + }, + ], + }, + { + id: 'payments', + title: 'Payment Activity', + mobileTitle: 'Payments', + description: 'Follow payment processing activity by interval.', + data: metricPoints, + metricType: 'count', + dataKeys: [ + { + key: 'payment_processed', + name: 'Payments Processed', + color: 'var(--chart-2)', + }, + ], + }, + ...(hasTokenMetrics + ? [ + { + id: 'tokens', + title: 'Token Usage', + mobileTitle: 'Tokens', + description: + 'Track input, output, and total token throughput over time.', + data: metricPoints, + metricType: 'count' as const, + dataKeys: [ + { + key: 'total_tokens', + name: 'Total Tokens', + color: 'var(--chart-1)', + }, + { + key: 'input_tokens', + name: 'Input Tokens', + color: 'var(--chart-2)', + }, + { + key: 'output_tokens', + name: 'Output Tokens', + color: 'var(--chart-3)', + }, + ], + }, + ] + : []), + ]; + }, [metricsData, revenueDisplayUnit, usdPerSat]); + + useEffect(() => { + if (chartConfigs.length === 0) { + return; + } + + if (!chartConfigs.some((config) => config.id === activeChartId)) { + setActiveChartId(chartConfigs[0].id); + } + }, [chartConfigs, activeChartId]); + + if (!isAuthResolved) { + return ( + +
+
+ + +
+ + +
+
+ ); + } if (!isAuthenticated) { return ; } - const handleRefresh = () => { - refetchMetrics(); - refetchSummary(); - refetchErrors(); - refetchRevenueByModel(); + const handleRefresh = async () => { + if (isManualRefreshing) { + return; + } + + setIsManualRefreshing(true); + try { + await Promise.allSettled([ + refetchMetrics(), + refetchSummary(), + refetchErrors(), + ]); + } finally { + setIsManualRefreshing(false); + } }; - return ( - - - - -
-
-

- Dashboard -

- -
+ const openRangePicker = () => { + // Force a fresh selection so the range is only applied + // after the user explicitly chooses both start and end. + setPendingCustomRange(undefined); + setIsCustomRangePickerOpen(true); + }; -
-
-

+ const handleCustomRangePickerChange = (open: boolean) => { + if (open) { + openRangePicker(); + return; + } + + setIsCustomRangePickerOpen(false); + }; + + const handleRangeSelectChange = (value: string) => { + if (value === 'custom') { + // Always require an explicit fresh range selection. + openRangePicker(); + return; + } + + const preset = TIME_RANGE_PRESETS.find((option) => option.value === value); + + if (!preset) { + return; + } + + setSelectedPreset(preset.value); + setIsCustomRangeActive(false); + }; + + const handleCustomRangeSelect = (nextRange: DateRange | undefined) => { + if (!nextRange?.from) { + setPendingCustomRange(undefined); + return; + } + + const normalized = normalizeDateRange(nextRange); + const from = normalized.from; + const to = normalized.to; + const hasPreviousStart = Boolean(pendingCustomRange?.from); + + if (!from) { + setPendingCustomRange(undefined); + return; + } + + // DayPicker may emit from===to on the first click in range mode. + // Keep waiting until the user explicitly picks a second (end) date. + const isSameDay = to ? from.getTime() === to.getTime() : false; + if (!hasPreviousStart || !to || isSameDay) { + setPendingCustomRange({ from, to: undefined }); + return; + } + + setPendingCustomRange(normalized); + setCustomRange(normalized); + setIsCustomRangeActive(true); + setIsCustomRangePickerOpen(false); + }; + + const activeChartConfig = + chartConfigs.find((config) => config.id === activeChartId) ?? + chartConfigs[0]; + const selectedRangeValue = + isCustomRangeActive && customRange?.from && customRange?.to + ? 'custom' + : selectedPreset; + const compactCustomRangeLabel = formatCompactDateRangeLabel(customRange); + + return ( + +
+
+

+ Dashboard +

+

+ Node balances, request health, and revenue trends. +

+
+ + + +
+
+
+

Usage Analytics

-

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

-
-
- - -
+

+ All cards and charts in this section update from the selected + range. +

-
- {summaryLoading ? ( -
Loading summary...
- ) : summaryData ? ( - - ) : null} - -
- {metricsLoading ? ( -
- Loading metrics... -
- ) : metricsData && metricsData.metrics.length > 0 ? ( - <> -
- ({ - ...m, - revenue_sats: m.revenue_msats / 1000, - refunds_sats: m.refunds_msats / 1000, - net_revenue_sats: - (m.revenue_msats - m.refunds_msats) / 1000, - })) as Array< - Record & { timestamp: string } - > - } - title='Revenue Over Time (sats)' - dataKeys={[ - { - key: 'revenue_sats', - name: 'Revenue', - color: '#10b981', - }, - { - key: 'net_revenue_sats', - name: 'Net Revenue', - color: '#059669', - }, - { - key: 'refunds_sats', - name: 'Refunds', - color: '#ef4444', - }, - ]} +
+
+
+ + + + + + -
- & { timestamp: string } - > - } - title='Request Volume' - dataKeys={[ - { - key: 'total_requests', - name: 'Total Requests', - color: '#3b82f6', - }, - { - key: 'successful_chat_completions', - name: 'Successful', - color: '#22c55e', - }, - { - key: 'failed_requests', - name: 'Failed', - color: '#f43f5e', - }, - ]} - /> - & { timestamp: string } - > - } - title='Error Tracking' - dataKeys={[ - { - key: 'errors', - name: 'Errors', - color: '#f97316', - }, - { - key: 'warnings', - name: 'Warnings', - color: '#eab308', - }, - { - key: 'upstream_errors', - name: 'Upstream Errors', - color: '#dc2626', - }, - ]} - /> - & { timestamp: string } - > - } - title='Payment Activity' - dataKeys={[ - { - key: 'payment_processed', - name: 'Payments Processed', - color: '#8b5cf6', - }, - ]} - /> -
- {summaryData && summaryData.unique_models.length > 0 && ( - - - Active Models - - -
- {summaryData.unique_models.map((model) => ( - - {model} - - ))} -
-
-
+ + + +
+ + +
- {revenueByModelLoading ? ( -
- Loading revenue by model... -
- ) : revenueByModelData && revenueByModelData.models.length > 0 ? ( - + - ) : null} - - {errorLoading ? ( -
Loading errors...
- ) : errorData ? ( - - ) : null} + + {isManualRefreshing ? 'Refreshing...' : 'Refresh'} + +
-
- - + + {metricsLoading ? ( + + ) : metricsError ? ( + + + + + + + + Unable to load analytics + + {getQueryErrorMessage(metricsError)} + + + + + + ) : activeChartConfig ? ( + ({ + id: config.id, + label: isMobile + ? (config.mobileTitle ?? config.title) + : config.title, + }))} + activeTabId={activeChartId} + onTabChange={setActiveChartId} + /> + ) : ( + + + + + + + + No data available + + No metrics data exists for this range yet. Try a broader + range. + + + + + + )} + + {summaryLoading ? ( + + ) : summaryError ? ( + + + + + Usage summary unavailable + + {getQueryErrorMessage(summaryError)} + + + + + + ) : summaryData ? ( + + ) : null} + + + + {errorLoading ? ( + + ) : errorDetailsError ? ( + + + + + Error details unavailable + + {getQueryErrorMessage(errorDetailsError)} + + + + + + ) : errorData ? ( + + ) : null} +
+
+
); } diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx index db29ca68..3ab0fc06 100644 --- a/ui/app/providers.tsx +++ b/ui/app/providers.tsx @@ -3,10 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useState, type ReactNode } from 'react'; -import { Toaster } from 'sonner'; -import { AuthProvider } from '@/lib/auth/AuthContext'; -import { ProtectedRoute } from '@/lib/auth/ProtectedRoute'; +import { AuthProvider } from '@/lib/auth/auth-context'; +import { ProtectedRoute } from '@/lib/auth/protected-route'; import { ThemeProvider } from '@/components/theme-provider'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { Toaster } from '@/components/ui/sonner'; interface ProvidersProps { children: ReactNode; @@ -34,12 +35,14 @@ export function Providers({ children }: ProvidersProps) { enableSystem disableTransitionOnChange > - - - {children} - - - + + + + {children} + + + + diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx index fcc4c686..4a7046a8 100644 --- a/ui/app/providers/page.tsx +++ b/ui/app/providers/page.tsx @@ -1,65 +1,49 @@ 'use client'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -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 { Card, CardContent } from '@/components/ui/card'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { AdminService, + ProviderModels, + ProviderType, UpstreamProvider, CreateUpstreamProvider, UpdateUpstreamProvider, AdminModel, } from '@/lib/api/services/admin'; -import { AddProviderModelDialog } from '@/components/AddProviderModelDialog'; -import { BatchOverrideDialog } from '@/components/BatchOverrideDialog'; +import { AddProviderModelDialog } from '@/components/add-provider-model-dialog'; +import { BatchOverrideDialog } from '@/components/batch-override-dialog'; +import { ProviderCard } from '@/components/provider-card'; +import { ProviderFormDialogContent } from '@/components/provider-form-dialog-content'; import { Skeleton } from '@/components/ui/skeleton'; -import { - AlertCircle, - Plus, - Pencil, - Trash2, - Server, - Database, - ChevronDown, - ChevronUp, -} from 'lucide-react'; +import { AlertCircle, Plus, Server } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Dialog, DialogTrigger } from '@/components/ui/dialog'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { RoutstrProviderCard } from '@/components/providers/RoutstrProviderCard'; -import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection'; -import { RoutstrNodeSettings } from '@/components/providers/RoutstrNodeSettings'; -import { ProviderBalance } from '@/components/providers/ProviderBalance'; -import { useState } from 'react'; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { AppPageShell } from '@/components/app-page-shell'; +import { PageHeader } from '@/components/page-header'; + +const apiKeyDocsLinkClassName = + 'text-primary text-xs underline-offset-4 hover:underline'; export default function ProvidersPage() { const queryClient = useQueryClient(); @@ -86,6 +70,12 @@ export default function ProvidersPage() { const [batchOverrideProviderId, setBatchOverrideProviderId] = useState< number | null >(null); + const [providerDeleteTarget, setProviderDeleteTarget] = + useState(null); + const [modelDeleteTarget, setModelDeleteTarget] = useState<{ + providerId: number; + modelId: string; + } | null>(null); const [formData, setFormData] = useState({ provider_type: 'openrouter', @@ -107,6 +97,11 @@ export default function ProvidersPage() { refetchOnWindowFocus: false, }); + const providerTypeById = useMemo( + () => new Map(providerTypes.map((pt) => [pt.id, pt])), + [providerTypes] + ); + const { data: globalSettings } = useQuery({ queryKey: ['settings'], queryFn: () => AdminService.getSettings(), @@ -123,15 +118,16 @@ export default function ProvidersPage() { refetchOnWindowFocus: false, }); - const { data: providerModels, isLoading: isLoadingModels } = useQuery({ - queryKey: ['provider-models', viewingModels], - queryFn: () => - viewingModels - ? AdminService.getProviderModels(viewingModels) - : Promise.resolve(null), - enabled: !!viewingModels, - refetchOnWindowFocus: false, - }); + const { data: providerModels, isLoading: isLoadingModels } = + useQuery({ + queryKey: ['provider-models', viewingModels], + queryFn: () => + viewingModels + ? AdminService.getProviderModels(viewingModels) + : Promise.resolve(null), + enabled: !!viewingModels, + refetchOnWindowFocus: false, + }); const createMutation = useMutation({ mutationFn: (data: CreateUpstreamProvider) => @@ -224,6 +220,8 @@ export default function ProvidersPage() { api_key: '', api_version: null, enabled: true, + provider_fee: 1.06, + provider_settings: {}, }); }; @@ -265,40 +263,36 @@ export default function ProvidersPage() { updateMutation.mutate({ id: editingProvider.id, data: updateData }); }; - const handleDelete = (id: number) => { - if (confirm('Are you sure you want to delete this provider?')) { - deleteMutation.mutate(id); + const confirmDeleteProvider = () => { + if (!providerDeleteTarget) { + return; } + + deleteMutation.mutate(providerDeleteTarget.id); + setProviderDeleteTarget(null); }; - const handleDeleteModel = (providerId: number, modelId: string) => { - if (confirm('Are you sure you want to delete this model?')) { - deleteModelMutation.mutate({ providerId, modelId }); + const confirmDeleteModel = () => { + if (!modelDeleteTarget) { + return; } - }; - const getDefaultBaseUrl = (type: string) => { - const providerType = providerTypes.find((pt) => pt.id === type); - return providerType?.default_base_url || ''; - }; - - const hasFixedBaseUrl = (type: string) => { - const providerType = providerTypes.find((pt) => pt.id === type); - return providerType?.fixed_base_url || false; + deleteModelMutation.mutate(modelDeleteTarget); + setModelDeleteTarget(null); }; const getPlatformUrl = (type: string) => { - const providerType = providerTypes.find((pt) => pt.id === type); + const providerType = providerTypeById.get(type); return providerType?.platform_url || null; }; const canCreateAccount = (type: string) => { - const providerType = providerTypes.find((pt) => pt.id === type); + const providerType = providerTypeById.get(type); return providerType?.can_create_account || false; }; const canShowBalance = (type: string) => { - const providerType = providerTypes.find((pt) => pt.id === type); + const providerType = providerTypeById.get(type); return providerType?.can_show_balance || false; }; @@ -351,747 +345,223 @@ export default function ProvidersPage() { const availableMints = (globalSettings?.cashu_mints as string[]) || []; return ( - - - - -
-
-
-
-

- Upstream Providers -

-

- Manage your AI provider connections and credentials -

-
- - - - - - - Add Upstream Provider - - Configure a new AI provider connection - - -
-
- - -
- {formData.provider_type === 'routstr' && ( - - setFormData((prev) => ({ - ...prev, - provider_settings: settings, - })) - } - availableMints={availableMints} - /> - )} -
- - - setFormData({ ...formData, base_url: e.target.value }) - } - placeholder='https://api.example.com/v1' - disabled={hasFixedBaseUrl(formData.provider_type)} - className={ - hasFixedBaseUrl(formData.provider_type) - ? 'cursor-not-allowed opacity-60' - : '' - } - /> -
- {formData.provider_type !== 'routstr' && ( -
-
- - {canCreateAccount(formData.provider_type) ? ( - - ) : ( - getPlatformUrl(formData.provider_type) && ( - - Get Your API Key Here → - - ) - )} -
- - setFormData({ - ...formData, - api_key: e.target.value, - }) - } - placeholder='sk-...' - /> -
- )} - {formData.provider_type === 'azure' && ( -
- - - setFormData({ - ...formData, - api_version: e.target.value || null, - }) - } - placeholder='2024-02-15-preview' - /> -
- )} -
- - setFormData({ ...formData, enabled: checked }) - } - /> - -
-
- - - setFormData({ - ...formData, - provider_fee: e.target.value - ? parseFloat(e.target.value) - : undefined, - }) - } - placeholder={getProviderFeePlaceholder( - formData.provider_type - )} - /> -

- 1.01 means +1% e.g. currency exchange, card fees, etc. -

-
- {formData.provider_type === 'routstr' && ( - { - setFormData((prev) => ({ - ...prev, - api_key: newApiKey, - })); - }} - /> - )} -
- - - - -
-
-
- - {isLoading ? ( -
- - -
- ) : error ? ( - - - - Failed to load providers. Please try refreshing the page. - - - ) : providers.length === 0 ? ( - - - -

- No providers configured -

-

- Get started by adding your first upstream provider -

- -
-
- ) : ( -
- {providers.map((provider) => { - const modelsContent = expandedProviders.has(provider.id) && ( -
- {isLoadingModels && viewingModels === provider.id ? ( -
- - -
- ) : providerModels && viewingModels === provider.id ? ( - 0 - ? 'provided' - : 'custom' - } - className='w-full' - > - - - - Provided Models - - Provided - - {providerModels.remote_models.length} - - - - - Custom Models - - Custom - - {providerModels.db_models.length} - - - - -
- {providerModels.db_models.length > 0 && ( -
- Custom models override or extend the - provider's catalog. -
- )} -
- - -
-
- {providerModels.db_models.length === 0 ? ( -
- No custom models configured -
- ) : ( -
- {providerModels.db_models.map((model) => ( -
-
-
- - {model.id} - - - {model.enabled - ? 'Enabled' - : 'Disabled'} - -
-
- {model.description || model.name} -
-
-
-
- {model.context_length?.toLocaleString()}{' '} - tokens -
- - -
-
- ))} -
- )} -
- -
- These models are provided directly by the upstream - service. -
-
- {providerModels.remote_models.map((model) => ( -
-
-
- - {model.id} - -
-
- {model.name} -
-
-
-
- {model.context_length?.toLocaleString()}{' '} - tokens -
- -
-
- ))} -
-
-
- ) : null} -
- ); - - if (provider.provider_type === 'routstr') { - return ( - - toggleProviderExpansion(provider.id) - } - onEdit={() => handleEdit(provider)} - onDelete={() => handleDelete(provider.id)} - balanceComponent={ - - } - > - -
-
- {provider.api_version && ( -
- - API Version: - - - {provider.api_version} - -
- )} -
- {modelsContent} -
-
-
- ); - } - - return ( - - -
-
-
- - {provider.provider_type} - - - {provider.enabled ? 'Enabled' : 'Disabled'} - -
- - {provider.base_url} - -
-
- {canShowBalance(provider.provider_type) && - provider.api_key && ( -
- -
- )} - - - -
-
-
- -
-
- {provider.api_version && ( -
- - API Version: - - - {provider.api_version} - -
- )} -
- {modelsContent} -
-
-
- ); - })} -
+ +
+ + + + + } + /> + -
- - - - - Edit Upstream Provider - - Update provider configuration - - -
-
- - -
- {formData.provider_type === 'routstr' && ( - - setFormData((prev) => ({ - ...prev, - provider_settings: settings, - })) - } - availableMints={availableMints} - idPrefix='edit' - /> - )} -
- - - setFormData({ ...formData, base_url: e.target.value }) - } - placeholder='https://api.example.com/v1' - disabled={hasFixedBaseUrl(formData.provider_type)} - className={ - hasFixedBaseUrl(formData.provider_type) - ? 'cursor-not-allowed opacity-60' - : '' - } - /> -
- {formData.provider_type === 'azure' && ( -
- - - setFormData({ - ...formData, - api_version: e.target.value || null, - }) - } - placeholder='2024-02-15-preview' - /> -
- )} -
- - setFormData({ ...formData, enabled: checked }) - } - /> - -
-
- - - setFormData({ - ...formData, - provider_fee: e.target.value - ? parseFloat(e.target.value) - : undefined, - }) - } - placeholder={getProviderFeePlaceholder( - formData.provider_type - )} - /> -

- 1.01 means +1% e.g. currency exchange, card fees, etc. -

-
-
- - - - -
+ docsLinkClassName={apiKeyDocsLinkClassName} + canCreateAccount={canCreateAccount(formData.provider_type)} + isCreatingAccount={isCreatingAccount} + onCreateAccount={handleCreateAccount} + onCancel={() => setIsCreateDialogOpen(false)} + onSubmit={handleCreate} + isSubmitting={createMutation.isPending} + availableMints={availableMints} + />
- {modelDialogState.providerId && ( - - setModelDialogState((prev) => ({ ...prev, isOpen: false })) - } - onSuccess={() => { - queryClient.invalidateQueries({ - queryKey: ['provider-models', modelDialogState.providerId], - }); - }} - initialData={modelDialogState.initialData} - mode={modelDialogState.mode} - /> + {isLoading ? ( +
+ + +
+ ) : error ? ( + + + + Failed to load providers. Please try refreshing the page. + + + ) : providers.length === 0 ? ( + + + + + + + + No providers configured + + Get started by adding your first upstream provider. + + + + + + + ) : ( +
+ {providers.map((provider) => ( + toggleProviderExpansion(provider.id)} + onEditProvider={() => handleEdit(provider)} + onDeleteProvider={() => setProviderDeleteTarget(provider)} + onBatchOverride={() => handleBatchOverride(provider.id)} + onAddModel={() => handleAddModel(provider.id)} + onEditModel={(model) => handleEditModel(provider.id, model)} + onDeleteModel={(modelId) => + setModelDeleteTarget({ providerId: provider.id, modelId }) + } + onOverrideModel={(model) => + handleOverrideModel(provider.id, model) + } + onUpdateApiKey={(newKey) => { + updateMutation.mutate({ + id: provider.id, + data: { api_key: newKey }, + }); + }} + availableMints={availableMints} + /> + ))} +
)} +
- {batchOverrideProviderId && ( - setBatchOverrideProviderId(null)} - onSuccess={() => { - queryClient.invalidateQueries({ - queryKey: ['provider-models', batchOverrideProviderId], - }); - }} - /> - )} - - + + setIsEditDialogOpen(false)} + onSubmit={handleUpdate} + isSubmitting={updateMutation.isPending} + availableMints={availableMints} + /> + + + !open && setProviderDeleteTarget(null)} + > + + + Delete Provider? + + This will permanently delete provider{' '} + + {providerDeleteTarget?.provider_type} + {' '} + and remove its associated configuration. + + + + Cancel + + Delete + + + + + + !open && setModelDeleteTarget(null)} + > + + + Delete Model Override? + + This removes the override for model{' '} + {modelDeleteTarget?.modelId}. + + + + Cancel + + Delete + + + + + + {modelDialogState.providerId && ( + + setModelDialogState((prev) => ({ ...prev, isOpen: false })) + } + onSuccess={() => { + queryClient.invalidateQueries({ + queryKey: ['provider-models', modelDialogState.providerId], + }); + }} + initialData={modelDialogState.initialData} + mode={modelDialogState.mode} + /> + )} + + {batchOverrideProviderId && ( + setBatchOverrideProviderId(null)} + onSuccess={() => { + queryClient.invalidateQueries({ + queryKey: ['provider-models', batchOverrideProviderId], + }); + }} + /> + )} + ); } diff --git a/ui/app/settings/page.tsx b/ui/app/settings/page.tsx index 373245f7..2e999307 100644 --- a/ui/app/settings/page.tsx +++ b/ui/app/settings/page.tsx @@ -4,37 +4,30 @@ import * as React from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ServerConfigSettings } from '@/components/settings/server-config-settings'; import { AdminSettings } from '@/components/settings/admin-settings'; -import { SiteHeader } from '@/components/site-header'; -import { AppSidebar } from '@/components/app-sidebar'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { Toaster } from 'sonner'; +import { AppPageShell } from '@/components/app-page-shell'; +import { PageHeader } from '@/components/page-header'; export default function SettingsPage() { return ( - - - - -
-
-
-

Settings

-
- - - Admin Settings - - - - - - - - -
-
-
- -
+ +
+ + + + Admin Settings + Server Config + + + + + + + + +
+
); } diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx deleted file mode 100644 index 973c30bb..00000000 --- a/ui/app/transactions/page.tsx +++ /dev/null @@ -1,353 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { AppSidebar } from '@/components/app-sidebar'; -import { SiteHeader } from '@/components/site-header'; -import { useQuery } from '@tanstack/react-query'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - AlertCircle, - Copy, - RefreshCw, - ChevronLeft, - ChevronRight, -} from 'lucide-react'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Badge } from '@/components/ui/badge'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { toast } from 'sonner'; -import { apiClient } from '@/lib/api/client'; - -interface Transaction { - id: string; - created_at: string; - token: string; - amount: string; -} - -interface PaginatedTransactionsResponse { - transactions: Transaction[]; - total: number; - page: number; - per_page: number; - total_pages: number; -} - -const TransactionService = { - getAllTransactions: async (): Promise => { - try { - const response = await apiClient.get('/api/transactions'); - return response || []; - } catch (error) { - console.error('Failed to fetch transactions:', error); - throw new Error('Failed to fetch transactions'); - } - }, - - getPaginatedTransactions: async ( - page: number, - perPage: number - ): Promise => { - try { - const response = await apiClient.get( - `/api/transactions/paginated/${page}/${perPage}` - ); - return response; - } catch (error) { - console.error('Failed to fetch paginated transactions:', error); - throw new Error('Failed to fetch paginated transactions'); - } - }, - - getRecentTransactions: async (limit: number): Promise => { - try { - const response = await apiClient.get( - `/api/transactions/recent/${limit}` - ); - return response || []; - } catch (error) { - console.error('Failed to fetch recent transactions:', error); - throw new Error('Failed to fetch recent transactions'); - } - }, -}; - -export default function TransactionsPage() { - const [currentPage, setCurrentPage] = useState(1); - const perPage = 20; - - // Fetch paginated transactions data - const { - data: paginationData, - isLoading, - error, - refetch, - } = useQuery({ - queryKey: ['transactions', currentPage, perPage], - queryFn: () => - TransactionService.getPaginatedTransactions(currentPage, perPage), - refetchOnWindowFocus: false, - retry: 1, - staleTime: 30000, // 30 seconds - }); - - const transactions = paginationData?.transactions || []; - const totalPages = paginationData?.total_pages || 0; - const total = paginationData?.total || 0; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString(); - }; - - const formatAmount = (amount: string) => { - return `${parseInt(amount).toLocaleString()} msats`; - }; - - const truncateToken = (token: string) => { - if (token.length <= 20) return token; - return `${token.slice(0, 10)}...${token.slice(-10)}`; - }; - - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - toast.success('Token copied to clipboard!'); - } catch (error) { - console.error('Failed to copy to clipboard:', error); - toast.error('Failed to copy token'); - } - }; - - const goToPage = (page: number) => { - if (page >= 1 && page <= totalPages) { - setCurrentPage(page); - } - }; - - const goToPrevious = () => { - if (currentPage > 1) { - setCurrentPage(currentPage - 1); - } - }; - - const goToNext = () => { - if (currentPage < totalPages) { - setCurrentPage(currentPage + 1); - } - }; - - return ( - - - - - -
-
-
-
-

- Transaction History -

-

- View all Cashu token transactions processed by the system -

-
- -
- - {isLoading ? ( -
- {[...Array(5)].map((_, i) => ( - - ))} -
- ) : error ? ( - - - - Failed to load transactions.{' '} - {error instanceof Error - ? error.message - : 'Please check if the server is running and try refreshing the page.'} - - - ) : transactions.length === 0 ? ( -
-

- No transactions found. -

-
- ) : ( -
-
-
- Showing {(currentPage - 1) * perPage + 1} to{' '} - {Math.min(currentPage * perPage, total)} of {total}{' '} - transactions -
-
- Page {currentPage} of {totalPages} -
-
- -
- - - - ID - Date & Time - Amount - - Cashu Token - - Actions - - - - {transactions.map((transaction) => ( - - - {transaction.id.slice(0, 8)} - - -
- {formatDate(transaction.created_at)} -
-
- - - {formatAmount(transaction.amount)} - - - -
- - -

- {truncateToken(transaction.token)} -

-
- -

- {transaction.token} -

-
-
-
-
- - - -
- ))} -
-
-
- - {/* Pagination Controls */} - {totalPages > 1 && ( -
-
- Page {currentPage} of {totalPages} -
-
- - - {/* Page Numbers */} -
- {Array.from( - { length: Math.min(5, totalPages) }, - (_, i) => { - const pageNumber = - currentPage <= 3 - ? i + 1 - : currentPage >= totalPages - 2 - ? totalPages - 4 + i - : currentPage - 2 + i; - - if (pageNumber < 1 || pageNumber > totalPages) - return null; - - return ( - - ); - } - )} -
- - -
-
- )} -
- )} -
-
-
-
-
- ); -} diff --git a/ui/app/unauthorized/page.tsx b/ui/app/unauthorized/page.tsx index 2fe8f4bc..48a0e159 100644 --- a/ui/app/unauthorized/page.tsx +++ b/ui/app/unauthorized/page.tsx @@ -3,30 +3,34 @@ import { Button } from '@/components/ui/button'; import { useRouter } from 'next/navigation'; import { ShieldAlertIcon } from 'lucide-react'; +import { AuthPageShell } from '@/components/auth-page-shell'; export default function UnauthorizedPage() { const router = useRouter(); return ( -
-
- - -

Access Denied

- -

- You don't have permission to access this page. Please contact - your administrator if you believe this is an error. + +

+ +

+ Contact your administrator if you believe this is an error.

- -
- - - +
-
+ ); } diff --git a/ui/components.json b/ui/components.json index 3d296c4f..14caef54 100644 --- a/ui/components.json +++ b/ui/components.json @@ -1,15 +1,17 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "radix-nova", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "app/globals.css", - "baseColor": "stone", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", + "rtl": false, "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +19,5 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "registries": {} } diff --git a/ui/components/ApiEndpointTester.tsx b/ui/components/ApiEndpointTester.tsx deleted file mode 100644 index 1a0cf81e..00000000 --- a/ui/components/ApiEndpointTester.tsx +++ /dev/null @@ -1,1675 +0,0 @@ -'use client'; - -import React, { useState, useRef } from 'react'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import { type Model } from '@/lib/api/schemas/models'; -import { ModelService } from '@/lib/api/services/models'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { Label } from '@/components/ui/label'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { - Loader2, - Send, - CheckCircle, - XCircle, - Info, - Key, - Globe, - FileText, - Image as ImageIcon, - Mic, - List, - MicOff, - Eye, - Volume2, -} from 'lucide-react'; -import { toast } from 'sonner'; -import { Input } from '@/components/ui/input'; -import Image from 'next/image'; - -interface ApiEndpointTesterProps { - models: Model[]; -} - -// API Endpoint Types -const API_ENDPOINTS = { - 'chat-completions': { - name: 'Chat Completions', - path: '/chat/completions', - icon: FileText, - description: 'Test conversational AI with chat completion requests', - }, - 'vision-chat': { - name: 'Vision Chat (Image + Text)', - path: '/chat/completions', - icon: Eye, - description: 'Analyze images with text prompts using vision models', - }, - embeddings: { - name: 'Embeddings', - path: '/embeddings', - icon: List, - description: 'Generate embeddings for text input', - }, - images: { - name: 'Image Generation', - path: '/images/generations', - icon: ImageIcon, - description: 'Generate images from text prompts', - }, - 'audio-speech': { - name: 'Text-to-Speech', - path: '/audio/speech', - icon: Mic, - description: 'Convert text to speech audio', - }, - 'audio-transcription': { - name: 'Audio Transcription', - path: '/audio/transcriptions', - icon: Volume2, - description: 'Transcribe audio files to text', - }, - models: { - name: 'List Models', - path: '/model', - icon: List, - description: 'List all available models from the provider', - }, -} as const; - -type EndpointType = keyof typeof API_ENDPOINTS; - -// Request/Response interfaces for different endpoints -interface ChatCompletionRequest { - model: string; - messages: { - role: 'system' | 'user' | 'assistant'; - content: - | string - | Array<{ - type: 'text' | 'image_url'; - text?: string; - image_url?: { - url: string; - detail?: 'low' | 'high' | 'auto'; - }; - }>; - }[]; - max_tokens?: number; - temperature?: number; -} - -interface EmbeddingRequest { - model: string; - input: string | string[]; - encoding_format?: 'float' | 'base64'; -} - -interface ImageGenerationRequest { - model?: string; - prompt: string; - n?: number; - size?: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'; - quality?: 'standard' | 'hd'; - style?: 'vivid' | 'natural'; -} - -interface AudioSpeechRequest { - model: string; - input: string; - voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'; - response_format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'; - speed?: number; -} - -interface AudioTranscriptionRequest { - model: string; - file: File; - prompt?: string; - response_format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'; - temperature?: number; - language?: string; -} - -// Response types -interface ChatCompletionResponse { - id: string; - object: string; - created: number; - model: string; - choices: { - index: number; - message: { - role: string; - content: string; - }; - finish_reason: string; - }[]; - usage?: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; -} - -interface EmbeddingResponse { - object: string; - data: { - object: string; - index: number; - embedding: number[]; - }[]; - model: string; - usage: { - prompt_tokens: number; - total_tokens: number; - }; -} - -interface ImageGenerationResponse { - created: number; - data: { - url: string; - revised_prompt?: string; - }[]; -} - -interface AudioResponse { - type: 'audio'; - url: string; - size: number; -} - -interface AudioTranscriptionResponse { - text: string; -} - -interface ModelsListResponse { - object: string; - data: { - id: string; - object?: string; - created?: number; - }[]; -} - -type ApiResponse = - | ChatCompletionResponse - | EmbeddingResponse - | ImageGenerationResponse - | AudioResponse - | AudioTranscriptionResponse - | ModelsListResponse; - -const DEFAULT_REQUESTS = { - 'chat-completions': { - systemMessage: 'You are a helpful assistant. Please respond concisely.', - userMessage: - 'Hello! Can you tell me what model you are and confirm that you are working correctly?', - maxTokens: 150, - temperature: 0.7, - }, - 'vision-chat': { - systemMessage: - 'You are a helpful assistant that can analyze images. Please describe what you see.', - userMessage: - 'What do you see in this image? Please provide a detailed description.', - maxTokens: 300, - temperature: 0.7, - imageDetail: 'auto' as const, - }, - embeddings: { - input: 'The quick brown fox jumps over the lazy dog.', - encoding_format: 'float' as const, - }, - images: { - prompt: 'A beautiful sunset over a mountain landscape', - n: 1, - size: '1024x1024' as const, - quality: 'standard' as const, - style: 'vivid' as const, - }, - 'audio-speech': { - input: 'Hello, this is a test of the text-to-speech functionality.', - voice: 'alloy' as const, - response_format: 'mp3' as const, - speed: 1.0, - }, - 'audio-transcription': { - prompt: 'This is a test transcription.', - response_format: 'json' as const, - temperature: 0.0, - language: '', - }, -}; - -export function ApiEndpointTester({ models }: ApiEndpointTesterProps) { - const [selectedModelId, setSelectedModelId] = useState(''); - const [selectedEndpoint, setSelectedEndpoint] = - useState('chat-completions'); - const [response, setResponse] = useState(null); - const [error, setError] = useState(null); - - // Chat Completions state - const [systemMessage, setSystemMessage] = useState( - DEFAULT_REQUESTS['chat-completions'].systemMessage - ); - const [userMessage, setUserMessage] = useState( - DEFAULT_REQUESTS['chat-completions'].userMessage - ); - const [maxTokens, setMaxTokens] = useState( - DEFAULT_REQUESTS['chat-completions'].maxTokens - ); - const [temperature, setTemperature] = useState( - DEFAULT_REQUESTS['chat-completions'].temperature - ); - - // Vision Chat state - const [visionSystemMessage, setVisionSystemMessage] = useState( - DEFAULT_REQUESTS['vision-chat'].systemMessage - ); - const [visionUserMessage, setVisionUserMessage] = useState( - DEFAULT_REQUESTS['vision-chat'].userMessage - ); - const [visionMaxTokens, setVisionMaxTokens] = useState( - DEFAULT_REQUESTS['vision-chat'].maxTokens - ); - const [visionTemperature, setVisionTemperature] = useState( - DEFAULT_REQUESTS['vision-chat'].temperature - ); - const [imageDetail, setImageDetail] = useState<'low' | 'high' | 'auto'>( - DEFAULT_REQUESTS['vision-chat'].imageDetail - ); - const [selectedImage, setSelectedImage] = useState(null); - const [imagePreviewUrl, setImagePreviewUrl] = useState(null); - - // Voice Recording state - const [isRecording, setIsRecording] = useState(false); - const [recordedAudio, setRecordedAudio] = useState(null); - const [recordingUrl, setRecordingUrl] = useState(null); - const [mediaRecorder, setMediaRecorder] = useState( - null - ); - const [audioTranscriptionPrompt, setAudioTranscriptionPrompt] = useState( - DEFAULT_REQUESTS['audio-transcription'].prompt - ); - const [audioResponseFormat, setAudioResponseFormat] = useState< - 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt' - >(DEFAULT_REQUESTS['audio-transcription'].response_format); - const [audioTemperature, setAudioTemperature] = useState( - DEFAULT_REQUESTS['audio-transcription'].temperature - ); - const [audioLanguage, setAudioLanguage] = useState( - DEFAULT_REQUESTS['audio-transcription'].language - ); - - // File upload refs - const imageInputRef = useRef(null); - const audioInputRef = useRef(null); - - // Embeddings state - const [embeddingInput, setEmbeddingInput] = useState( - DEFAULT_REQUESTS.embeddings.input - ); - const [encodingFormat, setEncodingFormat] = useState<'float' | 'base64'>( - DEFAULT_REQUESTS.embeddings.encoding_format - ); - - // Image Generation state - const [imagePrompt, setImagePrompt] = useState( - DEFAULT_REQUESTS.images.prompt - ); - const [imageCount, setImageCount] = useState(DEFAULT_REQUESTS.images.n); - const [imageSize, setImageSize] = useState< - '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792' - >(DEFAULT_REQUESTS.images.size); - const [imageQuality, setImageQuality] = useState<'standard' | 'hd'>( - DEFAULT_REQUESTS.images.quality - ); - const [imageStyle, setImageStyle] = useState<'vivid' | 'natural'>( - DEFAULT_REQUESTS.images.style - ); - - // Audio Speech state - const [speechInput, setSpeechInput] = useState( - DEFAULT_REQUESTS['audio-speech'].input - ); - const [speechVoice, setSpeechVoice] = useState< - 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer' - >(DEFAULT_REQUESTS['audio-speech'].voice); - const [speechFormat, setSpeechFormat] = useState< - 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm' - >(DEFAULT_REQUESTS['audio-speech'].response_format); - const [speechSpeed, setSpeechSpeed] = useState( - DEFAULT_REQUESTS['audio-speech'].speed - ); - - // Fetch model groups for API key resolution - const { data: groups = [] } = useQuery({ - queryKey: ['model-groups'], - queryFn: () => ModelService.getModelGroups(), - refetchOnWindowFocus: false, - }); - - const selectedModel = models.find((model) => model.id === selectedModelId); - - // Image upload handler - const handleImageUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - if (file.type.startsWith('image/')) { - setSelectedImage(file); - const previewUrl = URL.createObjectURL(file); - setImagePreviewUrl(previewUrl); - } else { - toast.error('Please select a valid image file'); - } - } - }; - - // Audio file upload handler - const handleAudioUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - if (file.type.startsWith('audio/')) { - setRecordedAudio(file); - const audioUrl = URL.createObjectURL(file); - setRecordingUrl(audioUrl); - } else { - toast.error('Please select a valid audio file'); - } - } - }; - - // Voice recording functions - const startRecording = async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const recorder = new MediaRecorder(stream); - const chunks: BlobPart[] = []; - - recorder.ondataavailable = (event) => { - if (event.data.size > 0) { - chunks.push(event.data); - } - }; - - recorder.onstop = () => { - const blob = new Blob(chunks, { type: 'audio/webm' }); - const file = new File([blob], 'recording.webm', { type: 'audio/webm' }); - setRecordedAudio(file); - const audioUrl = URL.createObjectURL(blob); - setRecordingUrl(audioUrl); - - // Stop all tracks to release microphone - stream.getTracks().forEach((track) => track.stop()); - }; - - recorder.start(); - setMediaRecorder(recorder); - setIsRecording(true); - toast.success('Recording started'); - } catch (error) { - console.error('Error starting recording:', error); - toast.error( - 'Failed to start recording. Please check microphone permissions.' - ); - } - }; - - const stopRecording = () => { - if (mediaRecorder) { - mediaRecorder.stop(); - setMediaRecorder(null); - setIsRecording(false); - toast.success('Recording stopped'); - } - }; - - // Convert file to base64 for vision API - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result as string); - reader.onerror = (error) => reject(error); - }); - }; - - // Get effective API key and endpoint URL for the selected model - const getModelCredentials = (model: Model) => { - const group = groups.find((g) => g.provider === model.provider); - - // Determine API key (individual takes precedence over group) - const apiKey = model.api_key || group?.group_api_key; - - // Determine base endpoint URL - let baseUrl = model.url; - - // If model URL is relative and group has a base URL, combine them - if (model.url.startsWith('/') && group?.group_url) { - baseUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`; - } - - // Remove any existing endpoint path to get base URL - baseUrl = baseUrl.replace(/\/v1\/.*$/, '').replace(/\/$/, ''); - - return { - apiKey, - baseUrl, - group, - }; - }; - - const buildEndpointUrl = (baseUrl: string, endpointPath: string) => { - return `${baseUrl}${endpointPath}`; - }; - - const buildRequest = async (): Promise< - | ChatCompletionRequest - | EmbeddingRequest - | ImageGenerationRequest - | AudioSpeechRequest - | AudioTranscriptionRequest - | null - > => { - if (!selectedModel) return null; - - switch (selectedEndpoint) { - case 'chat-completions': - const messages = []; - if (systemMessage.trim()) { - messages.push({ - role: 'system' as const, - content: systemMessage.trim(), - }); - } - messages.push({ role: 'user' as const, content: userMessage.trim() }); - - return { - model: selectedModel.name, - messages, - max_tokens: maxTokens, - temperature: temperature, - } as ChatCompletionRequest; - - case 'vision-chat': - if (!selectedImage) { - throw new Error('Please select an image for vision analysis'); - } - - const imageBase64 = await fileToBase64(selectedImage); - const visionMessages = []; - - if (visionSystemMessage.trim()) { - visionMessages.push({ - role: 'system' as const, - content: visionSystemMessage.trim(), - }); - } - - visionMessages.push({ - role: 'user' as const, - content: [ - { - type: 'text' as const, - text: visionUserMessage.trim(), - }, - { - type: 'image_url' as const, - image_url: { - url: imageBase64, - detail: imageDetail, - }, - }, - ], - }); - - return { - model: selectedModel.name, - messages: visionMessages, - max_tokens: visionMaxTokens, - temperature: visionTemperature, - } as ChatCompletionRequest; - - case 'embeddings': - return { - model: selectedModel.name, - input: embeddingInput, - encoding_format: encodingFormat, - } as EmbeddingRequest; - - case 'images': - return { - model: selectedModel.name, - prompt: imagePrompt, - n: imageCount, - size: imageSize, - quality: imageQuality, - style: imageStyle, - } as ImageGenerationRequest; - - case 'audio-speech': - return { - model: selectedModel.name, - input: speechInput, - voice: speechVoice, - response_format: speechFormat, - speed: speechSpeed, - } as AudioSpeechRequest; - - case 'audio-transcription': - if (!recordedAudio) { - throw new Error( - 'Please record or upload an audio file for transcription' - ); - } - - return { - model: selectedModel.name, - file: recordedAudio, - prompt: audioTranscriptionPrompt.trim() || undefined, - response_format: audioResponseFormat, - temperature: audioTemperature, - language: audioLanguage.trim() || undefined, - } as AudioTranscriptionRequest; - - case 'models': - return null; // No request body needed for models endpoint - - default: - return null; - } - }; - - const testEndpointMutation = useMutation({ - mutationFn: async ( - requestData: - | ChatCompletionRequest - | EmbeddingRequest - | ImageGenerationRequest - | AudioSpeechRequest - | AudioTranscriptionRequest - | null - ) => { - if (!selectedModel) { - throw new Error('No model selected'); - } - - setError(null); - setResponse(null); - - try { - console.log(`Testing endpoint via proxy: ${selectedEndpoint}`); - console.log('Request payload:', requestData); - - const response = await ModelService.testModel( - selectedModel.id, - selectedEndpoint, - requestData as unknown as Record - ); - - if (!response.success) { - throw new Error(response.error || 'Test failed'); - } - - return response.data as ApiResponse; - } catch (err: unknown) { - console.error('API endpoint test error via proxy:', err); - - const errorMessage = - err instanceof Error - ? err.message - : 'Failed to test endpoint via proxy'; - throw new Error(errorMessage); - } - }, - onSuccess: (data) => { - setResponse(data); - toast.success( - `${API_ENDPOINTS[selectedEndpoint].name} test completed successfully!` - ); - }, - onError: (err: Error) => { - const errorMessage = err?.message || 'Unknown error occurred'; - setError(errorMessage); - toast.error( - `${API_ENDPOINTS[selectedEndpoint].name} test failed: ${errorMessage}` - ); - }, - }); - - const handleTest = async () => { - if (!selectedModel) { - toast.error('Please select a model to test'); - return; - } - - // Validate required fields based on endpoint - if (selectedEndpoint === 'chat-completions' && !userMessage.trim()) { - toast.error('Please enter a test message'); - return; - } - - if (selectedEndpoint === 'vision-chat') { - if (!visionUserMessage.trim()) { - toast.error('Please enter a test message'); - return; - } - if (!selectedImage) { - toast.error('Please select an image for vision analysis'); - return; - } - } - - if (selectedEndpoint === 'embeddings' && !embeddingInput.trim()) { - toast.error('Please enter text for embedding'); - return; - } - - if (selectedEndpoint === 'images' && !imagePrompt.trim()) { - toast.error('Please enter an image prompt'); - return; - } - - if (selectedEndpoint === 'audio-speech' && !speechInput.trim()) { - toast.error('Please enter text for speech synthesis'); - return; - } - - if (selectedEndpoint === 'audio-transcription' && !recordedAudio) { - toast.error('Please record or upload an audio file for transcription'); - return; - } - - const requestData = await buildRequest(); - testEndpointMutation.mutate(requestData); - }; - - const enabledModels = models.filter((model) => model.isEnabled); - const credentials = selectedModel ? getModelCredentials(selectedModel) : null; - const endpointUrl = credentials - ? buildEndpointUrl( - credentials.baseUrl, - API_ENDPOINTS[selectedEndpoint].path - ) - : ''; - - const renderEndpointForm = () => { - switch (selectedEndpoint) { - case 'chat-completions': - return ( -
-
-
- - - setMaxTokens(parseInt(e.target.value) || 150) - } - /> -
-
- - - setTemperature(parseFloat(e.target.value) || 0.7) - } - /> -
-
- -
- -