diff --git a/.gitignore b/.gitignore index f9db7ffb..3333d1c9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ proof_backups *.todo ui_out +output/ +.pnpm-store/ + +# env files +.env* diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 7776d5f8..21f54f74 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -26,18 +26,53 @@ logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) admin_sessions: dict[str, int] = {} -ADMIN_SESSION_DURATION = 3600 +ADMIN_SESSION_DURATION = 12 * 60 * 60 + + +def _current_timestamp() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def _cleanup_expired_admin_sessions(now_timestamp: int | None = None) -> None: + current_timestamp = ( + now_timestamp if now_timestamp is not None else _current_timestamp() + ) + expired_tokens = [ + token + for token, expiry_timestamp in admin_sessions.items() + if expiry_timestamp <= current_timestamp + ] + for token in expired_tokens: + admin_sessions.pop(token, None) + + +def _raise_unauthorized(detail: str) -> None: + raise HTTPException( + status_code=401, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) def require_admin_api(request: Request) -> None: - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ", 1)[1] - expiry = admin_sessions.get(token) - if expiry and expiry > int(datetime.now(timezone.utc).timestamp()): - return + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + _raise_unauthorized("Missing bearer token") - raise HTTPException(status_code=403, detail="Unauthorized") + token = auth_header.split(" ", 1)[1].strip() + if not token: + _raise_unauthorized("Missing bearer token") + + now_timestamp = _current_timestamp() + expiry_timestamp = admin_sessions.get(token) + if expiry_timestamp is None: + _raise_unauthorized("Invalid session token") + + if expiry_timestamp <= now_timestamp: + admin_sessions.pop(token, None) + _raise_unauthorized("Session expired") + + _cleanup_expired_admin_sessions(now_timestamp) @admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)]) @@ -206,18 +241,10 @@ async def admin_login( raise HTTPException(status_code=401, detail="Invalid password") token = secrets.token_urlsafe(32) - expiry_timestamp = ( - int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION - ) + expiry_timestamp = _current_timestamp() + ADMIN_SESSION_DURATION admin_sessions[token] = expiry_timestamp - expired_tokens = [ - t - for t, exp in admin_sessions.items() - if exp <= int(datetime.now(timezone.utc).timestamp()) - ] - for t in expired_tokens: - del admin_sessions[t] + _cleanup_expired_admin_sessions() return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION} @@ -941,7 +968,9 @@ async def get_usage_metrics( 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" + default=24, + ge=1, + description="Hours of history to analyze", ), ) -> dict: """Get usage metrics aggregated by time interval.""" @@ -952,7 +981,9 @@ async def get_usage_metrics( async def get_usage_summary( request: Request, hours: int = Query( - default=24, ge=1, le=168, description="Hours of history to analyze" + default=24, + ge=1, + description="Hours of history to analyze", ), ) -> dict: """Get summary statistics for the specified time period.""" @@ -963,7 +994,9 @@ async def get_usage_summary( async def get_error_details( request: Request, hours: int = Query( - default=24, ge=1, le=168, description="Hours of history to analyze" + 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" @@ -979,7 +1012,9 @@ 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" + 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 43920605..e3fd52fd 100644 --- a/routstr/core/log_manager.py +++ b/routstr/core/log_manager.py @@ -453,7 +453,17 @@ class LogManager: 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, + } ) for entry in entries: @@ -479,10 +489,26 @@ class LogManager: level = entry.get("levelname", "").upper() if "received proxy request" in message: - bucket["requests"] += 1 + bucket["total_requests"] += 1 + + if ( + "completed for streaming" in message + or "completed for non-streaming" in message + ): + bucket["successful_chat_completions"] += 1 if level == "ERROR": bucket["errors"] += 1 + if "upstream" in message: + bucket["upstream_errors"] += 1 + elif level == "WARNING": + bucket["warnings"] += 1 + + if "upstream request failed" in message or "revert payment" in message: + bucket["failed_requests"] += 1 + + if "payment processed successfully" in message: + bucket["payment_processed"] += 1 if ( "completed for streaming" in message @@ -493,12 +519,20 @@ class LogManager: actual_cost = cost_data.get("total_msats", 0) if isinstance(actual_cost, (int, float)) and actual_cost > 0: bucket["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: + 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}) return { "metrics": result, 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/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..d56b97f0 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -1,13 +1,133 @@ -@import 'tailwindcss'; -@import 'tw-animate-css'; +@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,40 @@ --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 utilities { + .pb-mobile-nav { + padding-bottom: calc(5.75rem + env(safe-area-inset-bottom)); + } } @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..08eecccb 100644 --- a/ui/app/layout.tsx +++ b/ui/app/layout.tsx @@ -7,15 +7,11 @@ import { SuppressHydrationWarning } from '@/components/suppress-hydration-warnin 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 = { @@ -33,9 +29,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..88a2ea54 100644 --- a/ui/app/logs/log-filters.tsx +++ b/ui/app/logs/log-filters.tsx @@ -1,3 +1,6 @@ +import { useEffect, useState, type ChangeEvent, type KeyboardEvent } from 'react'; +import { format } from 'date-fns'; +import { CalendarIcon, Filter, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, @@ -21,20 +24,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 +88,8 @@ 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 +118,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 +129,7 @@ export function LogFilters({ useEffect(() => { const currentIsPreset = PRESET_LIMITS.includes(limit.toString()); setIsCustom(!currentIsPreset); + if (!currentIsPreset) { setCustomLimit(limit.toString()); } @@ -170,79 +138,71 @@ 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 ( @@ -314,336 +274,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 +338,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 +354,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 ? ( -
+
-
- -
diff --git a/ui/app/logs/multi-select-command-filter.tsx b/ui/app/logs/multi-select-command-filter.tsx new file mode 100644 index 00000000..1f141b84 --- /dev/null +++ b/ui/app/logs/multi-select-command-filter.tsx @@ -0,0 +1,173 @@ +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Badge } from '@/components/ui/badge'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Plus, X } from 'lucide-react'; + +interface QuickFilterOption { + label: string; + checked: boolean; + onSelect: () => void; +} + +interface MultiSelectCommandFilterProps { + label: string; + emptyLabel: string; + selectedValues: string[]; + onSelectedValuesChange: (values: string[]) => void; + options: string[]; + searchValue: string; + onSearchValueChange: (value: string) => void; + searchPlaceholder: string; + popoverClassName?: string; + selectedGroupLabel?: string; + customGroupLabel?: string; + quickGroupLabel?: string; + optionsGroupLabel?: string; + quickFilters?: QuickFilterOption[]; + normalizeCustomValue?: (value: string) => string; + canAddCustom?: (value: string) => boolean; +} + +function FilterBadge({ value }: { value: string }) { + return ( + + {value} + + ); +} + +export function MultiSelectCommandFilter({ + label, + emptyLabel, + selectedValues, + onSelectedValuesChange, + options, + searchValue, + onSearchValueChange, + searchPlaceholder, + popoverClassName = 'w-64 p-0', + selectedGroupLabel = 'Selected', + customGroupLabel = 'Custom', + quickGroupLabel = 'Quick Filters', + optionsGroupLabel = 'Options', + quickFilters = [], + normalizeCustomValue, + canAddCustom, +}: MultiSelectCommandFilterProps) { + const toggleSelection = (value: string) => { + if (selectedValues.includes(value)) { + onSelectedValuesChange(selectedValues.filter((item) => item !== value)); + return; + } + + onSelectedValuesChange([...selectedValues, value]); + }; + + const normalizedSearch = normalizeCustomValue + ? normalizeCustomValue(searchValue) + : searchValue; + + const canShowCustomAction = + normalizedSearch.length > 0 && + !options.includes(normalizedSearch) && + !selectedValues.includes(normalizedSearch) && + (canAddCustom ? canAddCustom(normalizedSearch) : true); + + return ( +
+ + + + + + + + + + {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..e375acdf 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,127 @@ 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 -

-
+ +
+ refetchLogs()} variant='outline' size='sm' - className='self-start' + className='w-full sm:w-auto' > Refresh -
+ } + /> - + - - - - 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 index 6f523cf7..4b448165 100644 --- a/ui/app/model/page.tsx +++ b/ui/app/model/page.tsx @@ -1,25 +1,34 @@ '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 { useQuery } from '@tanstack/react-query'; +import { AlertCircle } from 'lucide-react'; import type { Model } from '@/lib/api/schemas/models'; -import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort'; +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([]); + const [filteredModels, setFilteredModels] = useState( + undefined + ); + const [selectedProviderScope, setSelectedProviderScope] = + useState('all'); const { data: modelsData, @@ -33,297 +42,212 @@ export default function ModelsPage() { const { models = [], groups = [] } = modelsData || {}; - const groupedModels = useMemo(() => { - return groupAndSortModelsByProvider(models); - }, [models]); + const groupedModels = useMemo( + () => groupAndSortModelsByProvider(models), + [models] + ); - const groupDataMap = useMemo(() => { - return new Map(groups.map((group) => [group.provider, group])); - }, [groups]); + const groupDataMap = useMemo( + () => new Map(groups.map((group) => [group.provider, group])), + [groups] + ); const providerInfo = useMemo(() => { const allProviders = new Set([ ...Object.keys(groupedModels), - ...groups.map((g) => g.provider), + ...groups.map((group) => group.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 Array.from(allProviders) + .map((provider) => { + const providerModels = groupedModels[provider] || []; + const groupData = groupDataMap.get(provider); - return { - provider, - activeModels, - totalModels, - groupData, - hasGroupUrl: !!groupData?.group_url, - hasGroupApiKey: !!groupData?.group_api_key, - }; - }); - }, [groupedModels, groupDataMap, groups]); + 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 ( - - - - -
-
-
-

- Model Management & API Testing -

+ +
+ + + + + + 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. + + + ) : ( + + )} +
- - - 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. - - - ) : ( - - )} -
-
-
-
- - + +
+

+ 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 ba5dff37..12573d62 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 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 { Badge } from '@/components/ui/badge'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { Select, SelectContent, @@ -19,16 +43,404 @@ 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 { 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[]; + 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[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 formatDateRangeLabel(range?: DateRange): string { + if (!range?.from && !range?.to) { + return 'Custom range'; + } + + if (range.from && !range.to) { + return `${format(range.from, 'MMM d, yyyy')} - ...`; + } + + 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 sameDay = format(from, 'yyyy-MM-dd') === format(to, 'yyyy-MM-dd'); + + if (sameDay) { + return format(from, 'MMM d, yyyy'); + } + + return `${format(from, 'MMM d')} - ${format(to, 'MMM d, yyyy')}`; +} + +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 SectionLoading({ label }: { label: string }) { + if (label === 'summary') { + return ( +
+ {Array.from({ length: 12 }).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 }: { summary?: UsageSummary }) { + if (!summary) { + return null; + } + + const errorTypes = Object.entries(summary.error_types || {}).sort( + ([, a], [, b]) => b - a + ); + + const hasModels = summary.unique_models.length > 0; + const hasErrorTypes = errorTypes.length > 0; + + if (!hasModels && !hasErrorTypes) { + return null; + } + + return ( +
+ {hasModels && ( + + + Active Models + + +
+ {summary.unique_models.map((model) => ( + + {model} + + ))} +
+
+
+ )} + {hasErrorTypes && ( + + + Error Types Distribution + + + {errorTypes.map(([type, count]) => ( +
+ {type} + {count} +
+ ))} +
+
+ )} +
+ ); +} export default function DashboardPage() { - const [timeRange, setTimeRange] = useState('24'); - const [interval, setInterval] = useState('15'); + const [selectedPreset, setSelectedPreset] = + useState('24h'); + 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') { @@ -63,15 +475,21 @@ 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 { data: metricsData, isLoading: metricsLoading, 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, staleTime: 30_000, @@ -82,8 +500,8 @@ export default function DashboardPage() { isLoading: summaryLoading, 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, staleTime: 30_000, @@ -94,8 +512,8 @@ export default function DashboardPage() { isLoading: errorLoading, 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, staleTime: 30_000, @@ -106,277 +524,378 @@ export default function DashboardPage() { isLoading: revenueByModelLoading, refetch: refetchRevenueByModel, } = useQuery({ - queryKey: ['revenue-by-model', timeRange], - queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange), 20), + queryKey: ['revenue-by-model', queryHours], + queryFn: () => AdminService.getRevenueByModel(queryHours, 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 revenuePoints = metricsData.metrics.map((metric: UsageMetricData) => ({ + ...metric, + revenue_sats: metric.revenue_msats / 1000, + refunds_sats: metric.refunds_msats / 1000, + net_revenue_sats: (metric.revenue_msats - metric.refunds_msats) / 1000, + })) as ChartDatum[]; + + return [ + { + id: 'revenue', + title: 'Revenue Over Time (sats)', + mobileTitle: 'Revenue', + description: 'Track gross revenue, refunds, and net revenue trends.', + data: revenuePoints, + metricType: 'currency', + dataKeys: [ + { + key: 'revenue_sats', + name: 'Revenue', + color: 'var(--chart-1)', + }, + { + key: 'net_revenue_sats', + name: 'Net Revenue', + color: 'var(--chart-2)', + }, + { + key: 'refunds_sats', + name: 'Refunds', + color: 'var(--chart-5)', + }, + ], + }, + { + 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)', + }, + ], + }, + ]; + }, [metricsData]); + + useEffect(() => { + if (chartConfigs.length === 0) { + return; + } + + if (!chartConfigs.some((config) => config.id === activeChartId)) { + setActiveChartId(chartConfigs[0].id); + } + }, [chartConfigs, activeChartId]); + if (!isAuthenticated) { return ; } - const handleRefresh = () => { - refetchMetrics(); - refetchSummary(); - refetchErrors(); - refetchRevenueByModel(); + const handleRefresh = async () => { + if (isManualRefreshing) { + return; + } + + setIsManualRefreshing(true); + await Promise.allSettled([ + refetchMetrics(), + refetchSummary(), + refetchErrors(), + refetchRevenueByModel(), + ]); + 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 activeRangeLabel = + selectedRangeValue === 'custom' + ? formatDateRangeLabel(customRange) + : activePreset.label; + 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 +

+ Select a preset or custom date range to analyze traffic and revenue. +

+

+ Showing {activeRangeLabel}.

-
- - -
+ +
+

+ Range +

+
+
+
+ + + + + + + + + +
+ + +
+
+ +
+
-
- {summaryLoading ? ( -
Loading summary...
- ) : summaryData ? ( - - ) : null} + {metricsLoading ? ( + + ) : 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. + + + + + + )} -
- {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} - - ))} -
-
-
- )} - {summaryData && - summaryData.error_types && - Object.keys(summaryData.error_types).length > 0 && ( - - - Error Types Distribution - - -
- {Object.entries(summaryData.error_types) - .sort(([, a], [, b]) => b - a) - .map(([type, count]) => ( -
- - {type} - - - {count} - -
- ))} -
-
-
- )} -
- - ) : ( - - - No Data Available - - -

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

-
-
- )} -
+ {summaryLoading ? ( + + ) : summaryData ? ( + + ) : null} - {revenueByModelLoading ? ( -
- Loading revenue by model... -
- ) : revenueByModelData && revenueByModelData.models.length > 0 ? ( - - ) : null} + - {errorLoading ? ( -
Loading errors...
- ) : errorData ? ( - - ) : null} -
-
- - + {revenueByModelLoading ? ( + + ) : revenueByModelData && revenueByModelData.models.length > 0 ? ( + + ) : null} + + {errorLoading ? ( + + ) : 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 2f377011..8a6536f9 100644 --- a/ui/app/providers/page.tsx +++ b/ui/app/providers/page.tsx @@ -1,388 +1,42 @@ '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'; -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 { useState, useEffect } 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'; -function ProviderBalance({ - providerId, - platformUrl, -}: { - providerId: number; - platformUrl?: string | null; -}) { - const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false); - const [topupAmount, setTopupAmount] = useState(''); - const [topupError, setTopupError] = useState(''); - const [isHovered, setIsHovered] = useState(false); - const [invoiceData, setInvoiceData] = useState<{ - payment_request: string; - invoice_id: string; - } | null>(null); - const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>( - null - ); - const queryClient = useQueryClient(); - - const { - data: balanceData, - isLoading, - error, - } = useQuery({ - queryKey: ['provider-balance', providerId], - queryFn: () => AdminService.getProviderBalance(providerId), - refetchInterval: 30000, - refetchOnWindowFocus: true, - retry: 1, - }); - - const { data: statusData } = useQuery({ - queryKey: ['topup-status', providerId, invoiceData?.invoice_id], - queryFn: () => - AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id), - enabled: !!invoiceData && paymentStatus === 'pending', - refetchInterval: 2000, - }); - - useEffect(() => { - if (statusData?.paid === true) { - setPaymentStatus('paid'); - queryClient.invalidateQueries({ - queryKey: ['provider-balance', providerId], - }); - toast.success('Payment received!', { - description: 'Your balance has been updated.', - }); - } - }, [statusData, queryClient, providerId]); - - const topupMutation = useMutation({ - mutationFn: async (amount: number) => { - console.log('Calling top-up API with:', { providerId, amount }); - try { - const result = await AdminService.initiateProviderTopup( - providerId, - amount - ); - console.log('API returned:', result); - return result; - } catch (err) { - console.error('API call failed:', err); - throw err; - } - }, - onSuccess: (data) => { - console.log('Top-up response:', data); - console.log('Type of data:', typeof data); - console.log('Keys in data:', Object.keys(data || {})); - - if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) { - setInvoiceData({ - payment_request: data.topup_data.payment_request as string, - invoice_id: data.topup_data.invoice_id as string, - }); - setPaymentStatus('pending'); - } else { - console.error('Missing invoice data:', data); - console.error('topup_data:', data?.topup_data); - toast.error('No invoice returned from provider'); - setIsTopupDialogOpen(false); - } - }, - onError: (error: Error) => { - console.error('Top-up mutation error:', error); - toast.error(`Failed to initiate top-up: ${error.message}`); - }, - }); - - const handleTopup = () => { - // If no dialog open logic (which depends on API implementation), - // we check if we should redirect or open dialog based on available info - // But since this function is called inside the dialog, we might want to change - // how the "Top Up" button behaves instead. - const amount = parseFloat(topupAmount); - - if (isNaN(amount)) { - setTopupError('Please enter a valid amount'); - return; - } - - if (amount < 1 || amount > 500) { - setTopupError('Amount must be between $1 and $500'); - return; - } - - topupMutation.mutate(amount); - }; - - const handleTopUpClick = () => { - // Check if the provider supports direct topup (currently only PPQ.AI effectively) - // We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability - // For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented), - // we should redirect. - // However, the prompt asks to redirect if topup is not implemented. - // The backend throws 500/400 if not implemented. - // A better approach is to check if we have a platform URL and maybe redirect there - // if we know it's not supported. - - // BUT, we don't know for sure if it's supported without checking metadata or trying. - // Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance". - - // Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect? - // Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect? - // The user specifically mentioned "like in openrouter". - - if ( - platformUrl && - (platformUrl.includes('openrouter.ai') || - platformUrl.includes('openai.com')) - ) { - window.open(platformUrl, '_blank'); - return; - } - - setIsTopupDialogOpen(true); - }; - - const handleCloseDialog = () => { - setIsTopupDialogOpen(false); - setTopupAmount(''); - setTopupError(''); - setInvoiceData(null); - setPaymentStatus(null); - }; - - if (isLoading) { - return ; - } - - if ( - error || - !balanceData?.ok || - balanceData.balance_data === undefined || - balanceData.balance_data === null - ) { - return null; - } - - const balance = balanceData.balance_data; - let displayValue = 'N/A'; - - if (typeof balance === 'number') { - displayValue = `$${balance.toFixed(2)}`; - } else if (balance && typeof balance === 'object') { - // Legacy support for object response - const b = balance as Record; - if (typeof b.balance === 'number') { - displayValue = `$${b.balance.toFixed(2)}`; - } else if (typeof b.balance === 'string') { - displayValue = b.balance; - } else if (b.amount !== undefined) { - displayValue = `$${Number(b.amount).toFixed(2)}`; - } - } - - return ( - <> - - - - - - - {paymentStatus === 'paid' - ? 'Payment Confirmed!' - : 'Top Up Balance'} - - - {paymentStatus === 'paid' - ? 'Your account balance has been updated.' - : invoiceData - ? 'Scan the QR code or copy the Lightning invoice to pay.' - : 'Enter the amount you want to add to your account balance.'} - - - - {paymentStatus === 'paid' ? ( -
-
- - - -
-

Top-up successful!

-
- ) : invoiceData ? ( -
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - Lightning Invoice QR Code -
-
- -
- - -
-
- {paymentStatus === 'pending' && ( -

- Waiting for payment... -

- )} -
- ) : ( -
-
- - { - setTopupAmount(e.target.value); - setTopupError(''); - }} - min='1' - max='500' - step='0.01' - /> - {topupError && ( -

- {topupError} -

- )} -
-
- )} - - - {paymentStatus === 'paid' ? ( - - ) : invoiceData ? ( - - ) : ( - <> - - - - )} - -
-
- - ); -} +const apiKeyDocsLinkClassName = + 'text-primary text-xs underline-offset-4 hover:underline'; export default function ProvidersPage() { const queryClient = useQueryClient(); @@ -409,6 +63,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', @@ -429,6 +89,11 @@ export default function ProvidersPage() { refetchOnWindowFocus: false, }); + const providerTypeById = useMemo( + () => new Map(providerTypes.map((pt) => [pt.id, pt])), + [providerTypes] + ); + const { data: providers = [], isLoading, @@ -439,7 +104,9 @@ export default function ProvidersPage() { refetchOnWindowFocus: false, }); - const { data: providerModels, isLoading: isLoadingModels } = useQuery({ + const { data: providerModels, isLoading: isLoadingModels } = useQuery< + ProviderModels | null + >({ queryKey: ['provider-models', viewingModels], queryFn: () => viewingModels @@ -579,40 +246,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; }; @@ -663,705 +326,204 @@ export default function ProvidersPage() { }; return ( - - - - -
-
-
-
-

- Upstream Providers -

-

- Manage your AI provider connections and credentials -

-
- - - - - - - Add Upstream Provider - - Configure a new AI provider connection - - -
-
- - -
-
- - - 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' - : '' - } - /> -
-
-
- - {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. -

-
-
- - - - -
-
-
- - {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) => ( - - -
-
-
- - {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} - -
- )} -
- - {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 -
- - -
-
- ))} -
- )} -
- - {providerModels.remote_models.length > 0 ? ( - <> -
- Models automatically discovered from the - provider's catalog. -
-
- {providerModels.remote_models.map( - (model) => ( -
-
-
- {model.id} -
-
- {model.description || - model.name} -
-
-
-
- {model.context_length?.toLocaleString()}{' '} - tokens -
- -
-
- ) - )} -
- - ) : ( -
- No provided models available -
- )} -
-
- ) : null} -
- )} -
-
-
- ))} -
+ +
+ + + + + } + /> + -
- - - - - Edit Upstream Provider - - Update provider configuration - - -
-
- - -
-
- - - 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' - : '' - } - /> -
-
-
- - {getPlatformUrl(formData.provider_type) && ( - - Get Your API Key Here → - - )} -
- - setFormData({ ...formData, api_key: e.target.value }) - } - placeholder='Leave blank to keep current' - /> -
- {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} + />
- {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) + } + /> + ))} +
)} +
- {batchOverrideProviderId && ( - setBatchOverrideProviderId(null)} - onSuccess={() => { - queryClient.invalidateQueries({ - queryKey: ['provider-models', batchOverrideProviderId], - }); - }} - /> - )} - - + + setIsEditDialogOpen(false)} + onSubmit={handleUpdate} + isSubmitting={updateMutation.isPending} + /> + + + !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..99718acb 100644 --- a/ui/app/unauthorized/page.tsx +++ b/ui/app/unauthorized/page.tsx @@ -3,30 +3,30 @@ 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) - } - /> -
-
- -
- -