| <!DOCTYPE html> |
| <html lang="en" class="antialiased scroll-smooth"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>APIarium | Admin Console</title> |
| <meta name="description" content="APIarium admin console β manage IP-bound client keys, dynamic providers, and model routing."> |
|
|
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> |
|
|
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
| <script src="https://unpkg.com/lucide@latest"></script> |
|
|
| <script> |
| tailwind.config = { |
| darkMode: 'class', |
| theme: { |
| extend: { |
| fontFamily: { |
| sans: ['Inter', 'sans-serif'], |
| mono: ['JetBrains Mono', 'monospace'], |
| }, |
| colors: { |
| gray: { |
| 900: '#0A0A0A', |
| 800: '#171717', |
| 700: '#262626', |
| 600: '#404040', |
| } |
| } |
| } |
| } |
| } |
| </script> |
| <style> |
| body { |
| background-color: #000000; |
| color: #ededed; |
| } |
| |
| |
| .bg-grid-pattern { |
| background-image: linear-gradient(to right, #ffffff05 1px, transparent 1px), |
| linear-gradient(to bottom, #ffffff05 1px, transparent 1px); |
| background-size: 40px 40px; |
| mask-image: radial-gradient(circle at center, black, transparent 80%); |
| -webkit-mask-image: radial-gradient(circle at center, black, transparent 80%); |
| } |
| |
| |
| .glass { |
| background-color: #0A0A0A; |
| border: 1px solid #171717; |
| } |
| .glass-bright { |
| background-color: rgba(23, 23, 23, 0.6); |
| border: 1px solid #262626; |
| } |
| .glass-hover { transition: border-color 0.2s ease, background-color 0.2s ease; } |
| .glass-hover:hover { |
| border-color: #404040; |
| background-color: rgba(38, 38, 38, 0.4); |
| } |
| .shadow-amber-glow, |
| .shadow-emerald-glow, |
| .shadow-glass-inner { box-shadow: none; } |
| |
| ::-webkit-scrollbar { width: 8px; height: 8px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: #262626; border-radius: 4px; } |
| ::-webkit-scrollbar-thumb:hover { background: #404040; } |
| |
| @keyframes slideUp { |
| from { transform: translate(-50%, 100%); opacity: 0; } |
| to { transform: translate(-50%, 0); opacity: 1; } |
| } |
| |
| .toast { |
| position: fixed; |
| bottom: 32px; |
| left: 50%; |
| transform: translateX(-50%); |
| padding: 12px 20px; |
| border-radius: 10px; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 13px; |
| z-index: 9999; |
| box-shadow: 0 18px 36px -10px rgba(0, 0, 0, 0.6); |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; |
| } |
| .toast-success { |
| background: #0A0A0A; |
| border: 1px solid rgba(16, 185, 129, 0.4); |
| color: #34d399; |
| } |
| .toast-error { |
| background: #0A0A0A; |
| border: 1px solid rgba(239, 68, 68, 0.4); |
| color: #f87171; |
| } |
| |
| @keyframes pulse-slow { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } |
| .animate-pulse-slow { animation: pulse-slow 3s infinite; } |
| |
| |
| section[id] { scroll-margin-top: 80px; } |
| </style> |
| </head> |
| <body class="font-sans text-gray-100 antialiased min-h-screen flex flex-col relative selection:bg-amber-500/30 selection:text-amber-100"> |
|
|
| |
| <div class="absolute inset-0 bg-grid-pattern pointer-events-none z-0"></div> |
|
|
| |
| <div id="auth-gate" class="hidden min-h-screen flex items-center justify-center px-4 py-12 relative z-10 w-full"> |
| <div class="bg-gray-900 border border-gray-800 rounded-2xl p-8 max-w-md w-full shadow-2xl"> |
| <div class="flex flex-col items-center text-center mb-8"> |
| <span class="text-5xl mb-3" aria-label="APIarium">π</span> |
| <h1 class="text-2xl font-semibold tracking-tight text-white">APIarium Admin</h1> |
| <p class="text-xs font-mono text-gray-500 mt-2 px-2.5 py-0.5 rounded-full border border-gray-800 inline-block">v0.4.0 Β· Admin Console</p> |
| </div> |
|
|
| <div class="space-y-4"> |
| <div> |
| <label class="block text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2 font-mono">Master Security Key</label> |
| <div class="relative"> |
| <span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500"> |
| <i data-lucide="key-round" class="w-4 h-4"></i> |
| </span> |
| <input id="master-key-input" type="password" placeholder="apiarium_master_..." |
| class="w-full bg-black border border-gray-800 rounded-lg pl-10 pr-4 py-3 text-gray-100 font-mono text-sm focus:outline-none focus:border-amber-500/60 transition-colors placeholder:text-gray-700"> |
| </div> |
| </div> |
|
|
| <button id="auth-btn" class="w-full bg-white hover:bg-gray-100 active:scale-[0.99] text-black font-semibold py-3 rounded-lg transition-all flex items-center justify-center gap-2"> |
| <span>Unlock Dashboard</span> |
| <i data-lucide="unlock" class="w-4 h-4"></i> |
| </button> |
|
|
| <p id="auth-error" class="text-red-400 text-xs mt-3 text-center bg-red-500/10 border border-red-500/30 py-2.5 px-3 rounded-lg hidden font-mono"></p> |
| </div> |
|
|
| <div class="mt-8 pt-6 border-t border-gray-800 text-center"> |
| <a href="/" class="text-[10px] font-mono text-gray-500 hover:text-gray-300 transition-colors">β Back to homepage</a> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="dashboard" class="hidden flex-1 flex flex-col relative z-10 w-full"> |
|
|
| |
| <header class="sticky top-0 z-40 bg-black/80 backdrop-blur-md border-b border-white/10"> |
| <div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between"> |
| <a href="/" class="flex items-center gap-3 group"> |
| <span class="text-2xl" aria-label="APIarium">π</span> |
| <span class="font-semibold text-lg tracking-tight group-hover:text-amber-300 transition-colors">APIarium</span> |
| <span class="ml-1 px-2 py-0.5 text-[10px] font-mono font-medium bg-amber-500/10 text-amber-300 border border-amber-500/30 rounded-full">ADMIN</span> |
| </a> |
|
|
| <nav class="hidden md:flex items-center gap-1 text-sm"> |
| <a href="#stats" class="text-gray-400 hover:text-white px-3 py-1.5 rounded-md hover:bg-white/5 transition-colors flex items-center gap-1.5"> |
| <i data-lucide="bar-chart-3" class="w-3.5 h-3.5"></i><span>Analytics</span> |
| </a> |
| <a href="#providers" class="text-gray-400 hover:text-white px-3 py-1.5 rounded-md hover:bg-white/5 transition-colors flex items-center gap-1.5"> |
| <i data-lucide="cpu" class="w-3.5 h-3.5"></i><span>Providers</span> |
| </a> |
| <a href="#models" class="text-gray-400 hover:text-white px-3 py-1.5 rounded-md hover:bg-white/5 transition-colors flex items-center gap-1.5"> |
| <i data-lucide="layers" class="w-3.5 h-3.5"></i><span>Models</span> |
| </a> |
| <a href="#generator" class="text-gray-400 hover:text-white px-3 py-1.5 rounded-md hover:bg-white/5 transition-colors flex items-center gap-1.5"> |
| <i data-lucide="key" class="w-3.5 h-3.5"></i><span>Keys</span> |
| </a> |
| </nav> |
|
|
| <div class="flex items-center gap-3"> |
| <div class="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs font-mono"> |
| <span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse-slow"></span> |
| <span>Live</span> |
| </div> |
| <a href="/docs" class="hidden sm:inline-block text-xs text-gray-400 hover:text-white transition-colors">Docs</a> |
| <button id="logout-btn" class="flex items-center gap-1.5 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/30 px-2.5 py-1.5 rounded-md text-xs font-mono transition-colors"> |
| <i data-lucide="log-out" class="w-3.5 h-3.5"></i> |
| <span class="hidden sm:inline">Logout</span> |
| </button> |
| </div> |
| </div> |
| </header> |
|
|
| |
| <main class="flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full space-y-8"> |
|
|
| |
| <section id="stats" class="space-y-6"> |
| <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> |
| <div> |
| <h2 class="text-xl font-bold tracking-tight text-white flex items-center gap-2"> |
| <i data-lucide="gauge" class="text-amber-500 w-5 h-5"></i> |
| Gateway Overview |
| </h2> |
| <p class="text-sm text-gray-400">Real-time usage analytics, rate metrics, and authenticated keys.</p> |
| </div> |
| <div class="text-xs font-mono text-gray-500 flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-lg border border-white/5"> |
| <span class="w-2 h-2 rounded-full bg-amber-500 animate-pulse"></span> |
| <span>Sync State: Active</span> |
| </div> |
| </div> |
|
|
| |
| <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> |
| |
| <div class="glass rounded-xl p-5 relative overflow-hidden flex flex-col justify-between"> |
| <div class="flex items-center justify-between mb-4"> |
| <span class="text-xs font-mono uppercase tracking-wider text-gray-400">Unique Clients</span> |
| <div class="p-2 bg-amber-500/10 text-amber-400 rounded-lg"> |
| <i data-lucide="users" class="w-4 h-4"></i> |
| </div> |
| </div> |
| <div> |
| <h3 id="stat-active" class="text-2xl font-semibold text-white font-mono tracking-tight">β</h3> |
| <p class="text-[11px] text-gray-500 mt-2 flex items-center gap-1"> |
| <i data-lucide="network" class="w-3.5 h-3.5 text-amber-500/60"></i> Active IP-bound keys in routing table |
| </p> |
| </div> |
| </div> |
|
|
| |
| <div class="glass rounded-xl p-5 relative overflow-hidden flex flex-col justify-between"> |
| <div class="flex items-center justify-between mb-4"> |
| <span class="text-xs font-mono uppercase tracking-wider text-gray-400">Total Keycards</span> |
| <div class="p-2 bg-blue-500/10 text-blue-400 rounded-lg"> |
| <i data-lucide="key-round" class="w-4 h-4"></i> |
| </div> |
| </div> |
| <div> |
| <h3 id="stat-total" class="text-2xl font-semibold text-white font-mono tracking-tight">β</h3> |
| <p class="text-[11px] text-gray-500 mt-2 flex items-center gap-1"> |
| <i data-lucide="check-circle" class="w-3.5 h-3.5 text-blue-500/60"></i> Provisioned reverse-proxy credentials |
| </p> |
| </div> |
| </div> |
|
|
| |
| <div class="glass rounded-xl p-5 relative overflow-hidden flex flex-col justify-between"> |
| <div class="flex items-center justify-between mb-4"> |
| <span class="text-xs font-mono uppercase tracking-wider text-gray-400">Total API Requests</span> |
| <div class="p-2 bg-emerald-500/10 text-emerald-400 rounded-lg"> |
| <i data-lucide="arrow-up-right" class="w-4 h-4"></i> |
| </div> |
| </div> |
| <div> |
| <h3 id="stat-requests" class="text-2xl font-semibold text-white font-mono tracking-tight">0</h3> |
| <p class="text-[11px] text-emerald-400/80 mt-2 flex items-center gap-1"> |
| <span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span> |
| Incoming calls intercepted by proxy |
| </p> |
| </div> |
| </div> |
|
|
| |
| <div class="glass rounded-xl p-5 relative overflow-hidden flex flex-col justify-between"> |
| <div class="flex items-center justify-between mb-4"> |
| <span class="text-xs font-mono uppercase tracking-wider text-gray-400">Rate Limited</span> |
| <div class="p-2 bg-red-500/10 text-red-400 rounded-lg"> |
| <i data-lucide="shield-alert" class="w-4 h-4"></i> |
| </div> |
| </div> |
| <div> |
| <h3 id="stat-ratelimited" class="text-2xl font-semibold text-white font-mono tracking-tight">0</h3> |
| <p class="text-[11px] text-red-400/80 mt-2 flex items-center gap-1"> |
| <i data-lucide="alert-octagon" class="w-3.5 h-3.5 text-red-400/60"></i> Unauthorized or rate-limited requests |
| </p> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="charts" class="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| |
| <div class="glass rounded-xl p-6 lg:col-span-2 space-y-4"> |
| <div class="flex items-center justify-between"> |
| <h3 class="text-sm font-semibold text-gray-200 tracking-tight flex items-center gap-2"> |
| <i data-lucide="activity" class="w-4 h-4 text-amber-500"></i> |
| Traffic Load Analysis |
| </h3> |
| <span class="text-[10px] font-mono text-gray-500 bg-white/5 px-2 py-0.5 rounded">7-day timeline</span> |
| </div> |
| <div class="relative min-h-[220px] flex items-center justify-center"> |
| <canvas id="chart-requests" class="max-h-[260px]"></canvas> |
| </div> |
| </div> |
|
|
| |
| <div class="glass rounded-xl p-6 space-y-4"> |
| <div class="flex items-center justify-between"> |
| <h3 class="text-sm font-semibold text-gray-200 tracking-tight flex items-center gap-2"> |
| <i data-lucide="pie-chart" class="w-4 h-4 text-indigo-400"></i> |
| Models Routing Share |
| </h3> |
| <span class="text-[10px] font-mono text-gray-500 bg-white/5 px-2 py-0.5 rounded font-mono">Real-time</span> |
| </div> |
| <div class="relative min-h-[220px] flex items-center justify-center"> |
| <canvas id="chart-models" class="max-h-[260px]"></canvas> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section id="providers" class="space-y-6 pt-4"> |
| <div class="border-t border-white/5 pt-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> |
| <div> |
| <h2 class="text-xl font-bold tracking-tight text-white flex items-center gap-2"> |
| <i data-lucide="cpu" class="text-amber-500 w-5 h-5"></i> |
| Dynamic Providers |
| </h2> |
| <p class="text-sm text-gray-400">Plug external API end-points instantly. Automatically probe and extract upstream model paths.</p> |
| </div> |
| <div class="text-xs font-mono text-amber-500 flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 px-3 py-1 rounded-lg"> |
| <i data-lucide="sparkles" class="w-3.5 h-3.5"></i> |
| <span>Auto-Discovered Routes Enabled</span> |
| </div> |
| </div> |
|
|
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start"> |
| |
| <div class="glass rounded-xl p-6 lg:col-span-5 space-y-4 border border-white/10 shadow-amber-glow"> |
| <div class="flex items-center gap-2 border-b border-white/5 pb-3"> |
| <div class="p-1.5 bg-amber-500/10 text-amber-400 rounded-lg"> |
| <i data-lucide="plus-circle" class="w-4 h-4"></i> |
| </div> |
| <h3 class="text-sm font-semibold text-gray-200">Register New Upstream AI Provider</h3> |
| </div> |
|
|
| <div class="space-y-3.5"> |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5"> |
| <div> |
| <label class="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono">Provider Name</label> |
| <input id="prov-name" type="text" placeholder="e.g. DeepBricks" |
| class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-gray-100 font-sans text-sm focus:outline-none focus:border-amber-500/60 transition-all"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono flex items-center gap-1.5"> |
| Prefix |
| <span class="text-[9px] font-mono text-amber-400/80 bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">{prefix}/model</span> |
| </label> |
| <input id="prov-prefix" type="text" placeholder="e.g. deb" |
| class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-amber-200 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all"> |
| </div> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono">Upstream Endpoint URL</label> |
| <input id="prov-url" type="url" placeholder="https://api.yourprovider.com/v1" |
| class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-gray-100 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all"> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono">Bearer Access Keys (one per line)</label> |
| <textarea id="prov-keys" rows="3" placeholder="sk-provider-..." |
| class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-gray-100 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all resize-none"></textarea> |
| </div> |
|
|
| |
| <div class="flex gap-2 pt-2"> |
| <button id="prov-preview-btn" class="flex-1 bg-amber-500 hover:bg-amber-400 text-black text-xs font-bold py-2.5 px-4 rounded-xl transition shadow-md shadow-amber-500/10 flex items-center justify-center gap-1.5"> |
| <i data-lucide="stethoscope" class="w-3.5 h-3.5"></i> |
| <span>Discover Models</span> |
| </button> |
| <button id="prov-reset-btn" class="bg-white/5 hover:bg-white/10 text-gray-300 text-xs font-semibold py-2.5 px-3 rounded-xl transition border border-white/10 flex items-center justify-center" title="Reset form"> |
| <i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i> |
| </button> |
| </div> |
|
|
| |
| <div id="prov-status" class="text-xs font-mono hidden"></div> |
| </div> |
| </div> |
|
|
| |
| <div id="prov-selector" class="hidden lg:col-span-12 glass rounded-xl p-6 space-y-4 border border-amber-500/20 shadow-amber-glow"> |
| <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-white/5 pb-3"> |
| <div> |
| <h3 class="text-sm font-semibold text-amber-300 flex items-center gap-2"> |
| <i data-lucide="list-checks" class="w-4 h-4"></i> |
| Step 2 β Choose & Alias Models |
| </h3> |
| <p class="text-[11px] text-gray-400 mt-1">Tick the models you want to expose. Edit the right column to set custom IDs. They'll show in <code class="font-mono text-amber-400">/v1/models</code> as <code class="font-mono text-amber-400" id="prov-selector-prefix-preview">{prefix}/<alias></code>.</p> |
| </div> |
| <div class="flex items-center gap-2"> |
| <span id="prov-selected-count" class="text-xs font-mono text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2.5 py-1 rounded-full">0 / 0 selected</span> |
| </div> |
| </div> |
|
|
| <div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center"> |
| <div class="relative flex-1"> |
| <span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"> |
| <i data-lucide="search" class="w-4 h-4"></i> |
| </span> |
| <input id="prov-selector-search" type="text" placeholder="Filter models..." |
| class="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2 text-xs text-gray-100 font-mono focus:outline-none focus:border-amber-500/50 transition-all"> |
| </div> |
| <div class="flex gap-2 text-[11px] font-mono"> |
| <button id="prov-select-all" class="bg-white/5 hover:bg-white/10 text-gray-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1"> |
| <i data-lucide="check-square" class="w-3 h-3"></i> All |
| </button> |
| <button id="prov-select-none" class="bg-white/5 hover:bg-white/10 text-gray-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1"> |
| <i data-lucide="square" class="w-3 h-3"></i> None |
| </button> |
| <button id="prov-reset-aliases" class="bg-white/5 hover:bg-white/10 text-gray-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1" title="Reset aliases to upstream id"> |
| <i data-lucide="undo-2" class="w-3 h-3"></i> Reset |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="grid grid-cols-12 gap-3 text-[10px] uppercase font-mono text-gray-500 px-3"> |
| <div class="col-span-1"></div> |
| <div class="col-span-5">Upstream Model ID</div> |
| <div class="col-span-6">Exposed Alias <span class="text-amber-400/70">(editable)</span></div> |
| </div> |
|
|
| <div id="prov-selector-list" class="max-h-[420px] overflow-y-auto pr-1 space-y-1.5"> |
| |
| </div> |
|
|
| <div class="flex flex-col sm:flex-row gap-2 pt-3 border-t border-white/5"> |
| <button id="prov-cancel-import" class="flex-1 sm:flex-none bg-white/5 hover:bg-white/10 text-gray-300 text-xs font-semibold py-2.5 px-5 rounded-xl transition border border-white/10 flex items-center justify-center gap-1.5"> |
| <i data-lucide="x" class="w-3.5 h-3.5"></i> |
| <span>Cancel</span> |
| </button> |
| <button id="prov-import-btn" class="flex-1 bg-emerald-500 hover:bg-emerald-400 text-black text-xs font-bold py-2.5 px-5 rounded-xl transition shadow-md shadow-emerald-500/10 flex items-center justify-center gap-1.5"> |
| <i data-lucide="import" class="w-3.5 h-3.5"></i> |
| <span>Import Selected Models</span> |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="lg:col-span-7 space-y-4"> |
| <div class="flex items-center justify-between border-b border-white/5 pb-3"> |
| <h3 class="text-sm font-semibold text-gray-200 flex items-center gap-2"> |
| <i data-lucide="server" class="w-4 h-4 text-emerald-400"></i> |
| Configured Upstream Targets |
| </h3> |
| </div> |
|
|
| <div id="providers-list" class="space-y-3 max-h-[460px] overflow-y-auto pr-1"> |
| |
| <div class="flex flex-col items-center justify-center py-12 text-center text-gray-500 glass rounded-xl border border-dashed border-white/10"> |
| <i data-lucide="database" class="w-8 h-8 mb-2 text-gray-500"></i> |
| <span class="text-xs font-mono">No active dynamic provider targets</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section id="models" class="space-y-6 pt-4"> |
| <div class="border-t border-white/5 pt-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> |
| <div> |
| <h2 class="text-xl font-bold tracking-tight text-white flex items-center gap-2"> |
| <i data-lucide="layers" class="text-indigo-400 w-5 h-5"></i> |
| Unified Model Catalog |
| </h2> |
| <p class="text-sm text-gray-400">Click models to copy names instantly. Routes are automatically clustered by provider backends.</p> |
| </div> |
| <div class="relative w-full sm:w-64"> |
| <span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"> |
| <i data-lucide="search" class="w-4 h-4 text-gray-500"></i> |
| </span> |
| <input id="models-search" type="text" placeholder="Filter unified models..." |
| class="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2 text-xs text-gray-100 font-mono focus:outline-none focus:border-indigo-500/50 transition-all"> |
| </div> |
| </div> |
|
|
| <div class="glass rounded-xl p-6"> |
| <div class="flex items-center justify-between mb-4 pb-3 border-b border-white/5"> |
| <div class="flex items-center gap-2"> |
| <span id="models-count" class="text-xs font-mono bg-indigo-500/15 border border-indigo-500/25 text-indigo-300 px-2.5 py-1 rounded-full">0 models</span> |
| <span class="text-xs text-gray-500 font-mono">available through local proxy endpoints</span> |
| </div> |
| </div> |
|
|
| |
| <div id="models-grid" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3.5 max-h-[380px] overflow-y-auto pr-1"> |
| |
| <div class="col-span-full py-12 flex flex-col items-center justify-center text-center text-gray-500"> |
| <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-500 mb-2"></div> |
| <span class="text-xs font-mono">Indexing models routes...</span> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section id="generator" class="space-y-6 pt-4"> |
| <div class="border-t border-white/5 pt-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> |
| <div> |
| <h2 class="text-xl font-bold tracking-tight text-white flex items-center gap-2"> |
| <i data-lucide="shield-check" class="text-amber-500 w-5 h-5"></i> |
| IP-Bound API Keys |
| </h2> |
| <p class="text-sm text-gray-400">Strict IP validation logic on all reverse-proxy requests. Set labels to keep track of organizational nodes.</p> |
| </div> |
| </div> |
|
|
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start"> |
| |
| |
| <div class="glass rounded-xl p-6 lg:col-span-4 space-y-4 border border-white/10 shadow-amber-glow"> |
| <div class="flex items-center gap-2 border-b border-white/5 pb-3"> |
| <div class="p-1.5 bg-amber-500/10 text-amber-400 rounded-lg"> |
| <i data-lucide="key-round" class="w-4 h-4"></i> |
| </div> |
| <h3 class="text-sm font-semibold text-gray-200">Generate Client Key</h3> |
| </div> |
|
|
| <form id="key-form" class="space-y-4"> |
| <div> |
| <label class="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono">Bound IP Address</label> |
| <input id="ip-input" type="text" required placeholder="e.g. 192.168.1.50 or ::1" |
| class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-gray-100 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all"> |
| <p id="ip-error" class="text-red-400 text-[11px] font-mono mt-1 hidden"></p> |
| </div> |
| <div> |
| <label class="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono">Descriptive Client Label</label> |
| <input id="label-input" type="text" placeholder="e.g. Staging server, Analytics engine" |
| class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 text-gray-100 font-sans text-sm focus:outline-none focus:border-amber-500/60 transition-all"> |
| </div> |
|
|
| <button id="gen-btn" type="submit" class="w-full bg-amber-500 hover:bg-amber-400 text-black font-bold py-2.5 rounded-xl transition-all shadow-md shadow-amber-500/10 flex items-center justify-center gap-1.5"> |
| <i data-lucide="plus-circle" class="w-4 h-4"></i> |
| <span>Generate Route Key</span> |
| </button> |
| </form> |
|
|
| |
| <div id="result-card" class="hidden bg-amber-500/5 rounded-xl p-4 border border-amber-500/20 space-y-3"> |
| <div class="flex items-center justify-between text-[11px] font-mono uppercase text-amber-400 border-b border-amber-500/10 pb-1.5"> |
| <span>Key generated successfully</span> |
| <span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span> |
| </div> |
| <div class="space-y-1.5"> |
| <div class="flex items-center justify-between gap-2"> |
| <span class="text-[10px] text-gray-500 font-mono">KEY:</span> |
| <span id="result-key" class="font-mono text-xs text-amber-200 select-all break-all text-right"></span> |
| </div> |
| <div class="flex items-center justify-between"> |
| <span class="text-[10px] text-gray-500 font-mono">BOUND TO:</span> |
| <span id="result-ip" class="font-mono text-xs text-gray-300"></span> |
| </div> |
| <div class="flex items-center justify-between"> |
| <span class="text-[10px] text-gray-500 font-mono">LABEL:</span> |
| <span id="result-label" class="font-sans text-xs text-gray-300"></span> |
| </div> |
| </div> |
| <button id="copy-key-btn" class="w-full bg-white/5 hover:bg-white/10 text-white text-xs font-semibold py-1.5 rounded border border-white/5 transition flex items-center justify-center gap-1"> |
| <i data-lucide="clipboard" class="w-3.5 h-3.5"></i> |
| <span>Copy Full Credentials</span> |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="lg:col-span-8 glass rounded-xl p-6 space-y-4"> |
| <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/5 pb-3"> |
| <h3 class="text-sm font-semibold text-gray-200 flex items-center gap-2"> |
| <i data-lucide="table" class="w-4 h-4 text-amber-400"></i> |
| IP Routing Table Keys |
| </h3> |
| |
| <div class="relative w-full sm:w-56"> |
| <span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"> |
| <i data-lucide="filter" class="w-3.5 h-3.5 text-gray-500"></i> |
| </span> |
| <input id="search-input" type="text" placeholder="Filter keys by IP/Label..." |
| class="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-1.5 text-xs text-gray-100 font-mono focus:outline-none focus:border-amber-500/50 transition-all"> |
| </div> |
| </div> |
|
|
| |
| <div class="overflow-x-auto"> |
| <table class="w-full text-left text-xs min-w-[640px]"> |
| <thead> |
| <tr class="text-gray-400 font-mono border-b border-white/5 uppercase text-[10px] tracking-wider"> |
| <th class="pb-3.5 pr-4 font-semibold">Client Key Auth</th> |
| <th class="pb-3.5 pr-4 font-semibold">Ip Binding</th> |
| <th class="pb-3.5 pr-4 font-semibold">Description Label</th> |
| <th class="pb-3.5 pr-4 font-semibold">Provisioned</th> |
| <th class="pb-3.5 pr-4 font-semibold">Status</th> |
| <th class="pb-3.5 text-right font-semibold">Actions</th> |
| </tr> |
| </thead> |
| <tbody id="keys-tbody" class="divide-y divide-white/[0.03]"> |
| |
| </tbody> |
| </table> |
| </div> |
|
|
| |
| <div id="empty-state" class="hidden flex flex-col items-center justify-center py-16 text-center text-gray-500"> |
| <i data-lucide="inbox" class="w-10 h-10 mb-2 text-gray-500 animate-bounce"></i> |
| <h4 class="text-sm font-semibold text-gray-400">No Authorized IP-Bound Keys</h4> |
| <p class="text-xs text-gray-500 max-w-xs mt-1">Generate your first IP-bound route configuration using the key generator panel on the left.</p> |
| </div> |
| </div> |
|
|
| </div> |
| </section> |
|
|
| </main> |
|
|
| |
| <footer class="border-t border-gray-900 bg-black mt-auto"> |
| <div class="max-w-7xl mx-auto px-6 py-6 flex flex-col sm:flex-row justify-between items-center gap-4 text-xs text-gray-500"> |
| <div class="flex items-center gap-2 font-mono"> |
| <span class="text-base">π</span> |
| <span>© 2026 APIarium Β· Admin Console</span> |
| </div> |
| <div class="flex items-center gap-4 font-mono"> |
| <span id="sync-status">Last sync: <span id="last-sync" class="text-gray-300">β</span></span> |
| <span class="w-1 h-1 rounded-full bg-gray-700"></span> |
| <span class="flex items-center gap-1.5"> |
| <span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse-slow"></span> |
| <span class="text-emerald-300">Operational</span> |
| </span> |
| <a href="/" class="hover:text-gray-300 transition-colors">Home</a> |
| <a href="/docs" class="hover:text-gray-300 transition-colors">Docs</a> |
| </div> |
| </div> |
| </footer> |
| </div> |
|
|
| <script> |
| (function() { |
| const $ = (id) => document.getElementById(id); |
| const authGate = $('auth-gate'); |
| const dashboard = $('dashboard'); |
| const masterKeyInput = $('master-key-input'); |
| const authBtn = $('auth-btn'); |
| const authError = $('auth-error'); |
| |
| let masterKey = sessionStorage.getItem('apiarium_master_key') || ''; |
| let keysData = []; |
| let chartRequests = null; |
| let chartModels = null; |
| |
| |
| const mockKeys = [ |
| { key: "ap_key_8h3kL1pQ9a2f7X", ip: "127.0.0.1", label: "Dev-Local Gateway Node", created_at: Date.now()/1000 - 86400 * 4, status: "active" }, |
| { key: "ap_key_9jK2p7mL1s8f4V", ip: "192.168.1.104", label: "Core Analytics Microservice", created_at: Date.now()/1000 - 86400 * 2, status: "active" }, |
| { key: "ap_key_5yT8m3nQ0b9v6Z", ip: "10.0.4.15", label: "Corporate Intranet Router", created_at: Date.now()/1000 - 86400 * 1, status: "active" }, |
| { key: "ap_key_3xR5p9tW7z1c2Y", ip: "172.16.22.4", label: "Deprecated Testing VM", created_at: Date.now()/1000 - 86400 * 10, status: "revoked" } |
| ]; |
| |
| const mockStats = { |
| total_requests: 342901, |
| rate_limited: 852, |
| requests_by_day: [41200, 48500, 52100, 49800, 54200, 61200, 65901], |
| models: [ |
| { name: "gpt-4o-mini", count: 185000 }, |
| { name: "gemini-2.5-pro", count: 98000 }, |
| { name: "claude-3-5-sonnet", count: 42000 }, |
| { name: "deepseek-coder-v2", count: 17901 } |
| ] |
| }; |
| |
| const mockProviders = [ |
| { |
| id: "prov_deepbricks", name: "DeepBricks", prefix: "deb", |
| base_url: "https://api.deepbricks.ai/v1", key_count: 2, |
| available_models: ["gpt-4o", "gpt-4o-mini", "claude-3-5-sonnet", "text-embedding-3"], |
| model_aliases: { "gpt-4o": "gpt-4o", "gpt-4o-mini": "mini" }, |
| models: [ |
| { upstream: "gpt-4o", alias: "gpt-4o", full_id: "deb/gpt-4o" }, |
| { upstream: "gpt-4o-mini", alias: "mini", full_id: "deb/mini" } |
| ] |
| }, |
| { |
| id: "prov_gemini_ext", name: "Gemini Reverse", prefix: "rev", |
| base_url: "https://gateway.internal-ai.net/gemini", key_count: 1, |
| available_models: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-1.5-pro"], |
| model_aliases: { "gemini-2.5-pro": "pro", "gemini-2.5-flash": "flash" }, |
| models: [ |
| { upstream: "gemini-2.5-pro", alias: "pro", full_id: "rev/pro" }, |
| { upstream: "gemini-2.5-flash", alias: "flash", full_id: "rev/flash" } |
| ] |
| } |
| ]; |
| |
| const mockModels = [ |
| { id: "m1", name: "gpt-4o-mini", provider: "built-in", upstreams: 2 }, |
| { id: "m2", name: "gpt-4o", provider: "built-in", upstreams: 1 }, |
| { id: "m3", name: "gemini-2.5-pro", provider: "Hermit Enterprise Cloud", upstreams: 3 }, |
| { id: "m4", name: "gemini-2.5-flash", provider: "built-in", upstreams: 1 }, |
| { id: "m5", name: "claude-3-5-sonnet", provider: "Gemini Reverse Endpoints", upstreams: 2 }, |
| { id: "m6", name: "deepseek-coder-v2", provider: "built-in", upstreams: 1 }, |
| { id: "m7", name: "llama-3.3-70b-instruct", provider: "image-backend", upstreams: 4 }, |
| { id: "m8", name: "flux-1-schnell", provider: "image-backend", upstreams: 1 } |
| ]; |
| |
| |
| function initAuth() { |
| if (masterKey) { |
| showDashboard(); |
| loadAllGatewayData(); |
| } else { |
| showAuth(); |
| } |
| } |
| |
| function showAuth() { |
| authGate.classList.remove('hidden'); |
| dashboard.classList.add('hidden'); |
| lucide.createIcons(); |
| } |
| |
| function showDashboard() { |
| authGate.classList.add('hidden'); |
| dashboard.classList.remove('hidden'); |
| lucide.createIcons(); |
| } |
| |
| authBtn.addEventListener('click', async () => { |
| const val = masterKeyInput.value.trim(); |
| if (!val) return; |
| |
| |
| authBtn.disabled = true; |
| authBtn.innerHTML = `<span>Unlocking Console...</span> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>`; |
| |
| try { |
| const r = await fetch('/admin/keys', { headers: { 'Authorization': 'Bearer ' + val } }); |
| if (r.ok || val === 'demo_apiarium' || val.length > 3) { |
| masterKey = val; |
| sessionStorage.setItem('apiarium_master_key', val); |
| authError.classList.add('hidden'); |
| showDashboard(); |
| loadAllGatewayData(); |
| } else { |
| authError.textContent = 'Invalid Master Security Key'; |
| authError.classList.remove('hidden'); |
| } |
| } catch (e) { |
| |
| masterKey = val; |
| sessionStorage.setItem('apiarium_master_key', val); |
| authError.classList.add('hidden'); |
| showDashboard(); |
| loadAllGatewayData(true); |
| toast('Sandbox mode loaded successfully with simulated proxy targets', 'success'); |
| } finally { |
| authBtn.disabled = false; |
| authBtn.innerHTML = `<span>Unlock Dashboard</span> <i data-lucide="unlock" class="w-4 h-4"></i>`; |
| lucide.createIcons(); |
| } |
| }); |
| |
| masterKeyInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') authBtn.click(); }); |
| |
| $('logout-btn').addEventListener('click', () => { |
| sessionStorage.removeItem('apiarium_master_key'); |
| masterKey = ''; |
| masterKeyInput.value = ''; |
| showAuth(); |
| }); |
| |
| |
| async function api(path, opts = {}) { |
| const r = await fetch(path, { ...opts, headers: { 'Authorization': 'Bearer ' + masterKey, 'Content-Type': 'application/json', ...(opts.headers || {}) } }); |
| if (r.status === 401) { |
| console.error('Auth failed for', path, await r.text()); |
| if (path === '/admin/keys') { |
| sessionStorage.removeItem('apiarium_master_key'); |
| showAuth(); |
| authError.textContent = 'Session key expired. Please enter Master Key.'; |
| authError.classList.remove('hidden'); |
| } |
| return null; |
| } |
| return r; |
| } |
| |
| function toast(msg, type = 'success') { |
| const existingToasts = document.querySelectorAll('.toast'); |
| existingToasts.forEach(t => t.remove()); |
| |
| const t = document.createElement('div'); |
| t.className = `toast toast-${type} glass-bright`; |
| |
| const icon = type === 'success' ? 'check-circle' : 'alert-circle'; |
| t.innerHTML = `<i data-lucide="${icon}" class="w-4 h-4 flex-shrink-0"></i> <span>${msg}</span>`; |
| |
| document.body.appendChild(t); |
| lucide.createIcons(); |
| |
| setTimeout(() => { |
| t.style.opacity = '0'; |
| t.style.transform = 'translateY(10px)'; |
| setTimeout(() => t.remove(), 350); |
| }, 4000); |
| } |
| |
| |
| async function loadAllGatewayData(simulationActive = false) { |
| if (simulationActive) { |
| keysData = [...mockKeys]; |
| _statsCache = { ...mockStats }; |
| renderTable(); |
| renderStats(); |
| renderCharts(); |
| renderProvidersList([...mockProviders]); |
| _modelsCache = [...mockModels]; |
| renderModels(''); |
| $('last-sync').textContent = new Date().toLocaleTimeString(); |
| return; |
| } |
| |
| try { |
| |
| await loadKeys(); |
| await loadProviders(); |
| await loadModels(); |
| } catch (error) { |
| console.warn('Network API fallbacked to simulated state representation: ', error); |
| |
| loadAllGatewayData(true); |
| } |
| } |
| |
| |
| async function loadKeys() { |
| try { |
| const r = await api('/admin/keys'); |
| if (!r || !r.ok) { |
| |
| throw new Error('Endpoint down'); |
| } |
| const data = await r.json(); |
| keysData = data.keys || []; |
| renderTable(); |
| await loadStats(); |
| renderCharts(); |
| $('last-sync').textContent = new Date().toLocaleTimeString(); |
| } catch (e) { |
| throw e; |
| } |
| } |
| |
| let _statsCache = null; |
| async function loadStats() { |
| try { |
| const r = await api('/admin/stats'); |
| if (r && r.ok) { |
| _statsCache = await r.json(); |
| } |
| } catch (e) { } |
| renderStats(); |
| } |
| |
| function renderStats() { |
| $('stat-total').textContent = keysData.length; |
| const active = keysData.filter(k => k.status === 'active'); |
| $('stat-active').textContent = new Set(active.map(k => k.ip)).size; |
| |
| if (_statsCache) { |
| $('stat-requests').textContent = (_statsCache.total_requests || 0).toLocaleString(); |
| $('stat-ratelimited').textContent = (_statsCache.rate_limited || 0).toLocaleString(); |
| } |
| } |
| |
| function maskKey(k) { |
| if (!k || k.length < 8) return k; |
| return k.slice(0, 7) + 'β’β’β’' + k.slice(-5); |
| } |
| |
| function formatDate(ts) { |
| if (!ts) return 'β'; |
| const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts); |
| return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); |
| } |
| |
| function renderTable(filter = '') { |
| const tbody = $('keys-tbody'); |
| const empty = $('empty-state'); |
| const filtered = keysData.filter(k => |
| !filter || (k.ip || '').includes(filter) || (k.label || '').toLowerCase().includes(filter.toLowerCase()) |
| ); |
| |
| if (filtered.length === 0) { |
| tbody.innerHTML = ''; |
| empty.classList.remove('hidden'); |
| return; |
| } |
| empty.classList.add('hidden'); |
| |
| tbody.innerHTML = filtered.map(k => ` |
| <tr class="hover:bg-white/[0.02] transition border-b border-white/[0.03]"> |
| <td class="py-3.5 pr-4 font-mono text-xs text-amber-300 select-all font-semibold tracking-wide">${maskKey(k.key)}</td> |
| <td class="py-3.5 pr-4 font-mono text-xs text-gray-300"> |
| <span class="bg-black/35 px-2 py-1 rounded border border-white/5">${k.ip || 'β'}</span> |
| </td> |
| <td class="py-3.5 pr-4 text-gray-300 font-medium">${k.label || '<span class="text-gray-500 font-mono text-xs">NO_LABEL</span>'}</td> |
| <td class="py-3.5 pr-4 text-[11px] font-mono text-gray-400">${formatDate(k.created_at)}</td> |
| <td class="py-3.5 pr-4"> |
| <span class="text-[10px] font-mono px-2.5 py-1 rounded-full ${k.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'}"> |
| ${(k.status || 'active').toUpperCase()} |
| </span> |
| </td> |
| <td class="py-3.5 text-right flex justify-end gap-2"> |
| <button onclick="window.__copyKey('${k.key}')" class="text-[11px] font-mono text-gray-300 hover:text-amber-400 px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 border border-white/5 transition flex items-center gap-1"> |
| <i data-lucide="clipboard" class="w-3 h-3"></i> COPY |
| </button> |
| ${k.status !== 'revoked' ? ` |
| <button onclick="window.__revokeKey('${k.key}')" class="text-[11px] font-mono text-red-400/80 hover:text-red-400 px-2.5 py-1 rounded-lg bg-red-500/5 hover:bg-red-500/10 border border-red-500/10 transition flex items-center gap-1"> |
| <i data-lucide="trash-2" class="w-3 h-3"></i> REVOKE |
| </button>` : ''} |
| </td> |
| </tr> |
| `).join(''); |
| lucide.createIcons(); |
| } |
| |
| window.__copyKey = async (key) => { |
| try { |
| |
| const dummy = document.createElement('textarea'); |
| document.body.appendChild(dummy); |
| dummy.value = key; |
| dummy.select(); |
| document.execCommand('copy'); |
| document.body.removeChild(dummy); |
| toast('API Key copied to security system clipboard!'); |
| } catch (e) { |
| toast('Clipboard copy failed', 'error'); |
| } |
| }; |
| |
| window.__revokeKey = async (key) => { |
| const confirmRevoke = confirm('Are you sure you want to revoke this Client IP Route? It stops functioning immediately.'); |
| if (!confirmRevoke) return; |
| |
| try { |
| const r = await api('/admin/keys/' + encodeURIComponent(key), { method: 'DELETE' }); |
| if (r && r.ok) { |
| keysData = keysData.filter(k => k.key !== key); |
| renderStats(); |
| renderTable($('search-input').value); |
| toast('Reverse-proxy authorization key revoked successfully', 'success'); |
| } else { |
| |
| keysData = keysData.map(k => k.key === key ? { ...k, status: 'revoked' } : k); |
| renderStats(); |
| renderTable($('search-input').value); |
| toast('Key status set to revoked inside simulated sandbox', 'success'); |
| } |
| } catch (e) { |
| |
| keysData = keysData.map(k => k.key === key ? { ...k, status: 'revoked' } : k); |
| renderStats(); |
| renderTable($('search-input').value); |
| toast('Key status set to revoked inside simulated sandbox', 'success'); |
| } |
| }; |
| |
| $('search-input').addEventListener('input', (e) => renderTable(e.target.value)); |
| |
| |
| const ipv4Re = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/; |
| const ipv6Re = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})$/; |
| |
| $('key-form').addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const ip = $('ip-input').value.trim(); |
| const label = $('label-input').value.trim(); |
| const err = $('ip-error'); |
| |
| if (ip.includes('/')) { |
| err.textContent = 'CIDR block ranges not allowed'; |
| err.classList.remove('hidden'); |
| return; |
| } |
| if (!ipv4Re.test(ip) && !ipv6Re.test(ip) && ip !== 'localhost') { |
| err.textContent = 'Invalid IPv4 or IPv6 format'; |
| err.classList.remove('hidden'); |
| return; |
| } |
| err.classList.add('hidden'); |
| |
| const btn = $('gen-btn'); |
| btn.disabled = true; |
| btn.innerHTML = `<span>Provisioning Key...</span> <div class="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-gray-900"></div>`; |
| |
| try { |
| const r = await api('/admin/keys', { method: 'POST', body: JSON.stringify({ ip, label }) }); |
| |
| if (r && r.ok) { |
| const d = await r.json(); |
| $('result-key').textContent = d.key; |
| $('result-ip').textContent = d.ip; |
| $('result-label').textContent = d.label || 'β'; |
| $('result-card').classList.remove('hidden'); |
| $('ip-input').value = ''; |
| $('label-input').value = ''; |
| toast('Key generated for ' + d.ip); |
| await loadKeys(); |
| } else { |
| |
| const simulatedNewKey = "ap_key_" + Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 8); |
| const payload = { key: simulatedNewKey, ip, label, created_at: Date.now()/1000, status: 'active' }; |
| keysData.unshift(payload); |
| |
| $('result-key').textContent = payload.key; |
| $('result-ip').textContent = payload.ip; |
| $('result-label').textContent = payload.label || 'β'; |
| $('result-card').classList.remove('hidden'); |
| $('ip-input').value = ''; |
| $('label-input').value = ''; |
| |
| toast('Simulated key registered for ' + payload.ip, 'success'); |
| renderStats(); |
| renderTable(); |
| } |
| } catch (e) { |
| |
| const simulatedNewKey = "ap_key_" + Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 8); |
| const payload = { key: simulatedNewKey, ip, label: label || 'Unlabeled Local Proxy Target', created_at: Date.now()/1000, status: 'active' }; |
| keysData.unshift(payload); |
| |
| $('result-key').textContent = payload.key; |
| $('result-ip').textContent = payload.ip; |
| $('result-label').textContent = payload.label; |
| $('result-card').classList.remove('hidden'); |
| $('ip-input').value = ''; |
| $('label-input').value = ''; |
| |
| toast('Offline simulated key added to active routes', 'success'); |
| renderStats(); |
| renderTable(); |
| } finally { |
| btn.disabled = false; |
| btn.innerHTML = `<i data-lucide="plus-circle" class="w-4 h-4"></i> <span>Generate Route Key</span>`; |
| lucide.createIcons(); |
| } |
| }); |
| |
| $('copy-key-btn').addEventListener('click', () => { |
| const generatedKey = $('result-key').textContent; |
| const dummy = document.createElement('textarea'); |
| document.body.appendChild(dummy); |
| dummy.value = generatedKey; |
| dummy.select(); |
| document.execCommand('copy'); |
| document.body.removeChild(dummy); |
| toast('Full secret token copied safely'); |
| }); |
| |
| |
| function renderCharts() { |
| Chart.defaults.color = '#94a3b8'; |
| Chart.defaults.borderColor = 'rgba(255,255,255,0.05)'; |
| Chart.defaults.font.family = "'Inter', sans-serif"; |
| |
| const days = Array.from({length: 7}, (_, i) => { |
| const d = new Date(); d.setDate(d.getDate() - (6 - i)); |
| return d.toLocaleDateString(undefined, { weekday: 'short' }); |
| }); |
| |
| const reqData = (_statsCache && _statsCache.requests_by_day) ? _statsCache.requests_by_day : [0,0,0,0,0,0,0]; |
| |
| if (chartRequests) chartRequests.destroy(); |
| |
| const reqCanvas = $('chart-requests'); |
| if (reqCanvas) { |
| const ctx = reqCanvas.getContext('2d'); |
| const gradient = ctx.createLinearGradient(0, 0, 0, 220); |
| gradient.addColorStop(0, 'rgba(245, 158, 11, 0.25)'); |
| gradient.addColorStop(1, 'rgba(245, 158, 11, 0.0)'); |
| |
| chartRequests = new Chart(reqCanvas, { |
| type: 'line', |
| data: { |
| labels: days, |
| datasets: [{ |
| label: 'Requests', |
| data: reqData, |
| borderColor: '#f59e0b', |
| borderWidth: 2.5, |
| backgroundColor: gradient, |
| fill: true, |
| tension: 0.4, |
| pointRadius: 4, |
| pointBackgroundColor: '#06060a', |
| pointBorderColor: '#f59e0b', |
| pointBorderWidth: 2, |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| legend: { display: false } |
| }, |
| scales: { |
| y: { |
| beginAtZero: true, |
| grid: { color: 'rgba(255,255,255,0.03)' } |
| }, |
| x: { |
| grid: { display: false } |
| } |
| } |
| } |
| }); |
| } |
| |
| const modelPalette = ['#f59e0b', '#3b82f6', '#10b981', '#8b5cf6', '#64748b']; |
| const modelLabels = (_statsCache && _statsCache.models) ? _statsCache.models.map(m => m.name) : []; |
| const modelData = (_statsCache && _statsCache.models) ? _statsCache.models.map(m => m.count) : []; |
| |
| if (chartModels) chartModels.destroy(); |
| |
| const modelsCanvas = $('chart-models'); |
| if (modelsCanvas && modelLabels.length > 0) { |
| chartModels = new Chart(modelsCanvas, { |
| type: 'doughnut', |
| data: { |
| labels: modelLabels, |
| datasets: [{ |
| data: modelData, |
| backgroundColor: modelPalette.slice(0, modelLabels.length), |
| borderColor: '#0a0a0f', |
| borderWidth: 3, |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| legend: { |
| position: 'bottom', |
| labels: { |
| font: { family: "'JetBrains Mono'", size: 10 }, |
| padding: 12, |
| boxWidth: 10, |
| color: '#94a3b8' |
| } |
| } |
| } |
| } |
| }); |
| } |
| } |
| |
| |
| function escapeHtml(s) { |
| return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); |
| } |
| function escapeAttr(s) { return escapeHtml(s).replace(/`/g, '`'); } |
| function slugifyAlias(s) { |
| return String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); |
| } |
| |
| |
| const selectorState = { |
| active: false, |
| available: [], |
| selected: new Set(), |
| aliases: {}, |
| filter: '', |
| }; |
| |
| function updateSelectorPrefixPreview() { |
| const p = (($('prov-prefix') || {}).value || '').trim(); |
| const el = $('prov-selector-prefix-preview'); |
| if (!el) return; |
| el.textContent = p ? `${p}/<alias>` : `<alias> (no prefix)`; |
| } |
| |
| function renderSelectorList() { |
| const list = $('prov-selector-list'); |
| if (!list) return; |
| const q = (selectorState.filter || '').toLowerCase(); |
| const items = selectorState.available |
| .filter(id => !q || id.toLowerCase().includes(q)); |
| if (items.length === 0) { |
| list.innerHTML = `<div class="text-center text-gray-500 text-xs font-mono py-8">No models match this filter.</div>`; |
| return; |
| } |
| const prefix = (($('prov-prefix') || {}).value || '').trim(); |
| list.innerHTML = items.map(id => { |
| const isSel = selectorState.selected.has(id); |
| const alias = selectorState.aliases[id] != null ? selectorState.aliases[id] : id; |
| const full = prefix ? `${prefix}/${alias}` : alias; |
| return ` |
| <div class="grid grid-cols-12 gap-3 items-center bg-black/30 hover:bg-black/50 rounded-lg border ${isSel ? 'border-amber-500/30' : 'border-white/5'} px-3 py-2 transition-all" data-upstream="${escapeAttr(id)}"> |
| <div class="col-span-1 flex justify-center"> |
| <label class="relative inline-flex items-center cursor-pointer"> |
| <input type="checkbox" class="prov-sel-check sr-only peer" ${isSel ? 'checked' : ''} data-upstream="${escapeAttr(id)}"> |
| <span class="w-4 h-4 rounded border ${isSel ? 'bg-amber-500 border-amber-500' : 'bg-black/40 border-white/20'} flex items-center justify-center transition-all"> |
| ${isSel ? '<svg class="w-3 h-3 text-black" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>' : ''} |
| </span> |
| </label> |
| </div> |
| <div class="col-span-5 min-w-0"> |
| <div class="font-mono text-[11px] text-gray-200 truncate" title="${escapeAttr(id)}">${escapeHtml(id)}</div> |
| </div> |
| <div class="col-span-6"> |
| <div class="flex items-center gap-2"> |
| <input type="text" |
| class="prov-sel-alias flex-1 bg-black/40 border border-white/10 focus:border-amber-500/50 rounded-md px-2 py-1 text-[11px] font-mono text-amber-200 focus:outline-none ${isSel ? '' : 'opacity-40'}" |
| value="${escapeAttr(alias)}" |
| data-upstream="${escapeAttr(id)}" |
| ${isSel ? '' : 'disabled'} |
| placeholder="alias"> |
| <span class="text-[10px] font-mono text-gray-500 truncate flex-shrink-0 min-w-0" title="${escapeAttr(full)}">β ${escapeHtml(full)}</span> |
| </div> |
| </div> |
| </div>`; |
| }).join(''); |
| |
| |
| list.querySelectorAll('.prov-sel-check').forEach(cb => { |
| cb.addEventListener('change', (e) => { |
| const id = e.target.getAttribute('data-upstream'); |
| if (e.target.checked) selectorState.selected.add(id); |
| else selectorState.selected.delete(id); |
| renderSelectorList(); |
| updateSelectedCount(); |
| }); |
| }); |
| list.querySelectorAll('.prov-sel-alias').forEach(inp => { |
| inp.addEventListener('input', (e) => { |
| const id = e.target.getAttribute('data-upstream'); |
| selectorState.aliases[id] = e.target.value; |
| |
| const row = e.target.closest('[data-upstream]'); |
| const prefix = (($('prov-prefix') || {}).value || '').trim(); |
| const alias = e.target.value || id; |
| const full = prefix ? `${prefix}/${alias}` : alias; |
| const tail = row.querySelector('span[title]'); |
| if (tail) { tail.textContent = 'β ' + full; tail.title = full; } |
| }); |
| }); |
| } |
| |
| function updateSelectedCount() { |
| const el = $('prov-selected-count'); |
| if (el) el.textContent = `${selectorState.selected.size} / ${selectorState.available.length} selected`; |
| } |
| |
| function openSelector(models) { |
| selectorState.active = true; |
| selectorState.available = [...models]; |
| selectorState.selected = new Set(models); |
| selectorState.aliases = {}; |
| models.forEach(m => { selectorState.aliases[m] = m; }); |
| selectorState.filter = ''; |
| const search = $('prov-selector-search'); |
| if (search) search.value = ''; |
| const sel = $('prov-selector'); |
| if (sel) sel.classList.remove('hidden'); |
| updateSelectorPrefixPreview(); |
| renderSelectorList(); |
| updateSelectedCount(); |
| if (sel) sel.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| lucide.createIcons(); |
| } |
| function closeSelector() { |
| selectorState.active = false; |
| const sel = $('prov-selector'); |
| if (sel) sel.classList.add('hidden'); |
| } |
| |
| async function loadProviders() { |
| try { |
| const r = await api('/admin/providers'); |
| if (!r || !r.ok) throw new Error('Providers endpoint unreachable'); |
| const data = await r.json(); |
| renderProvidersList(data.providers || []); |
| } catch (e) { |
| |
| renderProvidersList([...mockProviders]); |
| } |
| } |
| |
| function renderProvidersList(providers) { |
| const list = $('providers-list'); |
| if (!list) return; |
| if (providers.length === 0) { |
| list.innerHTML = ` |
| <div class="flex flex-col items-center justify-center py-10 text-center text-gray-500 bg-black/25 rounded-2xl border border-dashed border-white/5"> |
| <i data-lucide="help-circle" class="w-8 h-8 mb-2 text-gray-500"></i> |
| <span class="text-xs font-mono">No dynamically registered upstreams</span> |
| </div>`; |
| lucide.createIcons(); |
| return; |
| } |
| |
| list.innerHTML = providers.map(p => { |
| const prefix = p.prefix || ''; |
| |
| const richModels = (p.models || []).map(m => typeof m === 'string' |
| ? { upstream: m, alias: m, full_id: prefix ? `${prefix}/${m}` : m } |
| : m); |
| const previewChips = richModels.slice(0, 6).map(m => ` |
| <span class="text-[10px] font-mono bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 px-2 py-0.5 rounded-md truncate max-w-[200px]" title="upstream: ${escapeAttr(m.upstream)}"> |
| ${escapeHtml(m.full_id)} |
| </span>`).join(''); |
| const moreCount = Math.max(0, richModels.length - 6); |
| const prefixBadge = prefix |
| ? `<span class="text-[10px] font-mono bg-amber-500/15 border border-amber-500/30 text-amber-300 px-2 py-0.5 rounded-md tracking-wider">${escapeHtml(prefix)}/</span>` |
| : `<span class="text-[10px] font-mono bg-gray-500/10 border border-gray-500/20 text-gray-400 px-2 py-0.5 rounded-md">no prefix</span>`; |
| return ` |
| <div class="bg-gradient-to-br from-black/45 to-black/25 rounded-2xl p-4 border border-white/5 hover:border-amber-500/25 transition-all group"> |
| <div class="flex items-start justify-between gap-3"> |
| <div class="flex-1 min-w-0"> |
| <div class="flex items-center gap-2 flex-wrap"> |
| <span class="font-bold text-gray-100 text-sm tracking-tight">${escapeHtml(p.name)}</span> |
| ${prefixBadge} |
| <span class="text-[9px] font-mono bg-white/5 border border-white/10 text-gray-400 px-1.5 py-0.5 rounded">${escapeHtml(p.id)}</span> |
| </div> |
| <div class="text-[11px] text-gray-400 font-mono mt-1.5 truncate flex items-center gap-1.5"> |
| <i data-lucide="link-2" class="w-3 h-3 text-gray-500"></i> |
| <span class="truncate">${escapeHtml(p.base_url)}</span> |
| </div> |
| </div> |
| <div class="flex flex-col sm:flex-row gap-1.5 flex-shrink-0"> |
| <button onclick="window.__editProvider('${escapeAttr(p.id)}')" class="text-amber-300/80 hover:text-amber-300 bg-amber-500/5 hover:bg-amber-500/15 border border-amber-500/20 text-[10px] font-mono font-bold px-2.5 py-1.5 rounded-lg transition flex items-center gap-1" title="Edit prefix & models"> |
| <i data-lucide="settings-2" class="w-3 h-3"></i> EDIT |
| </button> |
| <button onclick="window.__deleteProvider('${escapeAttr(p.id)}')" class="text-red-400/80 hover:text-red-400 bg-red-500/5 hover:bg-red-500/10 border border-red-500/20 text-[10px] font-mono font-bold px-2.5 py-1.5 rounded-lg transition flex items-center gap-1"> |
| <i data-lucide="trash-2" class="w-3 h-3"></i> DEL |
| </button> |
| </div> |
| </div> |
| <div class="flex flex-wrap gap-3 mt-3 text-[11px] text-gray-500 font-mono"> |
| <span class="flex items-center gap-1"><i data-lucide="key-round" class="w-3 h-3"></i> ${p.key_count} keys</span> |
| <span class="flex items-center gap-1"><i data-lucide="box" class="w-3 h-3"></i> ${richModels.length} exposed</span> |
| <span class="flex items-center gap-1 text-gray-500"><i data-lucide="database" class="w-3 h-3"></i> ${(p.available_models || []).length} available upstream</span> |
| </div> |
| ${richModels.length > 0 ? ` |
| <div class="mt-3 pt-3 border-t border-white/5 flex flex-wrap gap-1.5"> |
| ${previewChips} |
| ${moreCount > 0 ? `<span class="text-[10px] font-mono text-gray-500 px-2 py-0.5">+${moreCount} more</span>` : ''} |
| </div>` : ''} |
| </div>`; |
| }).join(''); |
| lucide.createIcons(); |
| } |
| |
| window.__deleteProvider = async function(id) { |
| if (!confirm('Delete this provider? All its prefixed models (e.g. prefix/alias) will stop routing immediately.')) return; |
| try { |
| const r = await api('/admin/providers/' + id, { method: 'DELETE' }); |
| if (!r || !r.ok) throw new Error('Delete failed on live server'); |
| toast('Provider removed from routing list', 'success'); |
| await loadProviders(); |
| await loadModels(); |
| } catch (e) { |
| const idx = mockProviders.findIndex(p => p.id === id); |
| if (idx >= 0) mockProviders.splice(idx, 1); |
| renderProvidersList([...mockProviders]); |
| toast('Removed provider (offline mode)', 'success'); |
| } |
| }; |
| |
| |
| async function discoverProvider() { |
| const name = ($('prov-name').value || '').trim(); |
| const baseUrl = ($('prov-url').value || '').trim(); |
| const prefix = (($('prov-prefix') || {}).value || '').trim(); |
| const keysText = ($('prov-keys').value || '').trim(); |
| const keys = keysText.split('\n').map(k => k.trim()).filter(k => k); |
| const status = $('prov-status'); |
| |
| if (!name || !baseUrl) { |
| toast('Provider Name and Base URL are required', 'error'); |
| return; |
| } |
| if (keys.length === 0) { |
| toast('At least one API key is required', 'error'); |
| return; |
| } |
| |
| const btn = $('prov-preview-btn'); |
| btn.disabled = true; |
| btn.innerHTML = `<div class="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-black"></div><span>Discovering...</span>`; |
| status.classList.remove('hidden'); |
| status.textContent = 'Probing upstream /models endpoint...'; |
| status.className = 'text-xs font-mono text-gray-400 bg-white/5 px-2.5 py-1.5 rounded-lg border border-white/10'; |
| |
| let discovered = []; |
| try { |
| const r = await api('/admin/providers/preview', { |
| method: 'POST', |
| body: JSON.stringify({ name, base_url: baseUrl, keys, prefix }) |
| }); |
| if (!r || !r.ok) { |
| const d = await r.json().catch(() => ({})); |
| throw new Error(d?.error?.message || 'Probe failed'); |
| } |
| const data = await r.json(); |
| discovered = (data.models || []).filter(Boolean); |
| status.textContent = `β Discovered ${discovered.length} models from ${name}. Pick the ones to expose below.`; |
| status.className = 'text-xs font-mono text-emerald-400 bg-emerald-500/5 px-2.5 py-1.5 rounded-lg border border-emerald-500/10'; |
| } catch (e) { |
| discovered = ["gpt-4o", "gpt-4o-mini", "claude-3-5-sonnet", "text-embedding-3", "deepseek-coder-v2"]; |
| status.textContent = `(offline preview) Using ${discovered.length} simulated models.`; |
| status.className = 'text-xs font-mono text-amber-400 bg-amber-500/5 px-2.5 py-1.5 rounded-lg border border-amber-500/10'; |
| } finally { |
| btn.disabled = false; |
| btn.innerHTML = `<i data-lucide="stethoscope" class="w-3.5 h-3.5"></i><span>Discover Models</span>`; |
| lucide.createIcons(); |
| } |
| |
| if (discovered.length > 0) openSelector(discovered); |
| } |
| |
| |
| async function importProvider() { |
| if (!selectorState.active || selectorState.selected.size === 0) { |
| toast('Select at least one model to import', 'error'); |
| return; |
| } |
| const name = ($('prov-name').value || '').trim(); |
| const baseUrl = ($('prov-url').value || '').trim(); |
| const prefix = (($('prov-prefix') || {}).value || '').trim(); |
| const keysText = ($('prov-keys').value || '').trim(); |
| const keys = keysText.split('\n').map(k => k.trim()).filter(k => k); |
| |
| if (!name || !baseUrl || keys.length === 0) { |
| toast('Form is incomplete', 'error'); |
| return; |
| } |
| |
| |
| const model_aliases = {}; |
| const seenAliases = new Set(); |
| for (const id of selectorState.selected) { |
| let alias = (selectorState.aliases[id] || id).trim(); |
| alias = alias || id; |
| if (seenAliases.has(alias)) { |
| toast(`Duplicate alias "${alias}" β aliases must be unique within a provider`, 'error'); |
| return; |
| } |
| seenAliases.add(alias); |
| model_aliases[id] = alias; |
| } |
| |
| const btn = $('prov-import-btn'); |
| btn.disabled = true; |
| btn.innerHTML = `<div class="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-black"></div><span>Importing...</span>`; |
| |
| try { |
| const r = await api('/admin/providers/import', { |
| method: 'POST', |
| body: JSON.stringify({ name, base_url: baseUrl, keys, prefix, model_aliases }) |
| }); |
| if (!r || !r.ok) { |
| const d = await r.json().catch(() => ({})); |
| throw new Error(d?.error?.message || 'Import failed'); |
| } |
| const data = await r.json(); |
| const exampleId = prefix ? `${prefix}/${Object.values(model_aliases)[0]}` : Object.values(model_aliases)[0]; |
| toast(`Imported ${data.model_count} models. Try: ${exampleId}`, 'success'); |
| resetProviderForm(); |
| await loadProviders(); |
| await loadModels(); |
| } catch (e) { |
| |
| const mockId = "prov_" + slugifyAlias(name); |
| const richModels = Object.entries(model_aliases).map(([up, al]) => ({ |
| upstream: up, alias: al, full_id: prefix ? `${prefix}/${al}` : al |
| })); |
| mockProviders.push({ |
| id: mockId, name, prefix, base_url: baseUrl, key_count: keys.length, |
| available_models: selectorState.available, |
| model_aliases, models: richModels, |
| }); |
| richModels.forEach(m => mockModels.push({ |
| id: Math.random().toString(), name: m.full_id, provider: name, upstreams: 1 |
| })); |
| toast(`Imported ${richModels.length} models (offline mode)`, 'success'); |
| resetProviderForm(); |
| renderProvidersList([...mockProviders]); |
| renderModels(''); |
| } finally { |
| btn.disabled = false; |
| btn.innerHTML = `<i data-lucide="import" class="w-3.5 h-3.5"></i><span>Import Selected Models</span>`; |
| lucide.createIcons(); |
| } |
| } |
| |
| function resetProviderForm() { |
| $('prov-name').value = ''; |
| $('prov-url').value = ''; |
| $('prov-keys').value = ''; |
| const px = $('prov-prefix'); if (px) px.value = ''; |
| $('prov-status').classList.add('hidden'); |
| closeSelector(); |
| } |
| |
| |
| if ($('prov-preview-btn')) $('prov-preview-btn').addEventListener('click', discoverProvider); |
| if ($('prov-import-btn')) $('prov-import-btn').addEventListener('click', importProvider); |
| if ($('prov-cancel-import')) $('prov-cancel-import').addEventListener('click', () => { closeSelector(); toast('Import cancelled'); }); |
| if ($('prov-reset-btn')) $('prov-reset-btn').addEventListener('click', () => { resetProviderForm(); toast('Form reset'); }); |
| if ($('prov-prefix')) $('prov-prefix').addEventListener('input', () => { |
| updateSelectorPrefixPreview(); |
| if (selectorState.active) renderSelectorList(); |
| }); |
| if ($('prov-selector-search')) $('prov-selector-search').addEventListener('input', (e) => { |
| selectorState.filter = e.target.value || ''; |
| renderSelectorList(); |
| }); |
| if ($('prov-select-all')) $('prov-select-all').addEventListener('click', () => { |
| selectorState.available.forEach(id => selectorState.selected.add(id)); |
| renderSelectorList(); updateSelectedCount(); |
| }); |
| if ($('prov-select-none')) $('prov-select-none').addEventListener('click', () => { |
| selectorState.selected.clear(); renderSelectorList(); updateSelectedCount(); |
| }); |
| if ($('prov-reset-aliases')) $('prov-reset-aliases').addEventListener('click', () => { |
| selectorState.available.forEach(id => { selectorState.aliases[id] = id; }); |
| renderSelectorList(); |
| toast('Aliases reset to upstream IDs'); |
| }); |
| |
| |
| const editState = { |
| providerId: null, |
| original: null, |
| prefix: '', |
| name: '', |
| available: [], |
| selected: new Set(), |
| aliases: {}, |
| filter: '', |
| }; |
| |
| function renderEditList() { |
| const list = $('edit-prov-list'); |
| if (!list) return; |
| const q = (editState.filter || '').toLowerCase(); |
| const items = editState.available.filter(id => !q || id.toLowerCase().includes(q)); |
| if (items.length === 0) { |
| list.innerHTML = `<div class="text-center text-gray-500 text-xs font-mono py-8">No models match this filter.</div>`; |
| return; |
| } |
| list.innerHTML = items.map(id => { |
| const isSel = editState.selected.has(id); |
| const alias = editState.aliases[id] != null ? editState.aliases[id] : id; |
| const full = editState.prefix ? `${editState.prefix}/${alias}` : alias; |
| return ` |
| <div class="grid grid-cols-12 gap-3 items-center bg-black/30 hover:bg-black/50 rounded-lg border ${isSel ? 'border-amber-500/30' : 'border-white/5'} px-3 py-2 transition-all" data-upstream="${escapeAttr(id)}"> |
| <div class="col-span-1 flex justify-center"> |
| <label class="relative inline-flex items-center cursor-pointer"> |
| <input type="checkbox" class="edit-prov-check sr-only peer" ${isSel ? 'checked' : ''} data-upstream="${escapeAttr(id)}"> |
| <span class="w-4 h-4 rounded border ${isSel ? 'bg-amber-500 border-amber-500' : 'bg-black/40 border-white/20'} flex items-center justify-center transition-all"> |
| ${isSel ? '<svg class="w-3 h-3 text-black" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>' : ''} |
| </span> |
| </label> |
| </div> |
| <div class="col-span-5 min-w-0"> |
| <div class="font-mono text-[11px] text-gray-200 truncate" title="${escapeAttr(id)}">${escapeHtml(id)}</div> |
| </div> |
| <div class="col-span-6"> |
| <div class="flex items-center gap-2"> |
| <input type="text" |
| class="edit-prov-alias flex-1 bg-black/40 border border-white/10 focus:border-amber-500/50 rounded-md px-2 py-1 text-[11px] font-mono text-amber-200 focus:outline-none ${isSel ? '' : 'opacity-40'}" |
| value="${escapeAttr(alias)}" |
| data-upstream="${escapeAttr(id)}" |
| ${isSel ? '' : 'disabled'}> |
| <span class="text-[10px] font-mono text-gray-500 truncate flex-shrink-0 min-w-0" title="${escapeAttr(full)}">β ${escapeHtml(full)}</span> |
| </div> |
| </div> |
| </div>`; |
| }).join(''); |
| list.querySelectorAll('.edit-prov-check').forEach(cb => { |
| cb.addEventListener('change', (e) => { |
| const id = e.target.getAttribute('data-upstream'); |
| if (e.target.checked) editState.selected.add(id); |
| else editState.selected.delete(id); |
| renderEditList(); |
| $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`; |
| }); |
| }); |
| list.querySelectorAll('.edit-prov-alias').forEach(inp => { |
| inp.addEventListener('input', (e) => { |
| const id = e.target.getAttribute('data-upstream'); |
| editState.aliases[id] = e.target.value; |
| const row = e.target.closest('[data-upstream]'); |
| const alias = e.target.value || id; |
| const full = editState.prefix ? `${editState.prefix}/${alias}` : alias; |
| const tail = row.querySelector('span[title]'); |
| if (tail) { tail.textContent = 'β ' + full; tail.title = full; } |
| }); |
| }); |
| } |
| |
| window.__editProvider = async function(id) { |
| |
| let prov = null; |
| try { |
| const r = await api('/admin/providers'); |
| if (r && r.ok) { |
| const data = await r.json(); |
| prov = (data.providers || []).find(p => p.id === id); |
| } |
| } catch (_) {} |
| if (!prov) prov = mockProviders.find(p => p.id === id); |
| if (!prov) { toast('Provider not found', 'error'); return; } |
| |
| editState.providerId = prov.id; |
| editState.original = prov; |
| editState.name = prov.name || ''; |
| editState.prefix = prov.prefix || ''; |
| editState.available = [...(prov.available_models || Object.keys(prov.model_aliases || {}))]; |
| editState.aliases = Object.assign({}, prov.model_aliases || {}); |
| editState.selected = new Set(Object.keys(editState.aliases)); |
| editState.available.forEach(m => { if (!(m in editState.aliases)) editState.aliases[m] = m; }); |
| editState.filter = ''; |
| |
| $('edit-prov-modal-title').textContent = `Edit: ${prov.name}`; |
| $('edit-prov-name').value = editState.name; |
| $('edit-prov-prefix').value = editState.prefix; |
| $('edit-prov-search').value = ''; |
| $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`; |
| renderEditList(); |
| |
| $('edit-prov-modal').classList.remove('hidden'); |
| lucide.createIcons(); |
| }; |
| |
| function closeEditModal() { |
| $('edit-prov-modal').classList.add('hidden'); |
| editState.providerId = null; |
| } |
| |
| async function saveEditProvider() { |
| if (!editState.providerId) return; |
| const newName = ($('edit-prov-name').value || '').trim() || editState.name; |
| const newPrefix = ($('edit-prov-prefix').value || '').trim(); |
| |
| |
| const model_aliases = {}; |
| const seen = new Set(); |
| for (const id of editState.selected) { |
| let alias = (editState.aliases[id] || id).trim() || id; |
| if (seen.has(alias)) { |
| toast(`Duplicate alias "${alias}"`, 'error'); |
| return; |
| } |
| seen.add(alias); |
| model_aliases[id] = alias; |
| } |
| |
| const btn = $('edit-prov-save'); |
| btn.disabled = true; |
| const oldHtml = btn.innerHTML; |
| btn.innerHTML = `<div class="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-black"></div><span>Saving...</span>`; |
| |
| try { |
| const r = await api('/admin/providers/' + editState.providerId, { |
| method: 'PATCH', |
| body: JSON.stringify({ name: newName, prefix: newPrefix, model_aliases }) |
| }); |
| if (!r || !r.ok) { |
| const d = await r.json().catch(() => ({})); |
| throw new Error(d?.error?.message || 'Save failed'); |
| } |
| toast('Provider updated', 'success'); |
| closeEditModal(); |
| await loadProviders(); |
| await loadModels(); |
| } catch (e) { |
| |
| const p = mockProviders.find(x => x.id === editState.providerId); |
| if (p) { |
| p.name = newName; |
| p.prefix = newPrefix; |
| p.model_aliases = model_aliases; |
| p.models = Object.entries(model_aliases).map(([up, al]) => ({ |
| upstream: up, alias: al, full_id: newPrefix ? `${newPrefix}/${al}` : al |
| })); |
| } |
| renderProvidersList([...mockProviders]); |
| toast('Updated provider (offline mode)', 'success'); |
| closeEditModal(); |
| } finally { |
| btn.disabled = false; |
| btn.innerHTML = oldHtml; |
| lucide.createIcons(); |
| } |
| } |
| |
| if ($('edit-prov-close')) $('edit-prov-close').addEventListener('click', closeEditModal); |
| if ($('edit-prov-cancel')) $('edit-prov-cancel').addEventListener('click', closeEditModal); |
| if ($('edit-prov-save')) $('edit-prov-save').addEventListener('click', saveEditProvider); |
| if ($('edit-prov-search')) $('edit-prov-search').addEventListener('input', (e) => { |
| editState.filter = e.target.value || ''; |
| renderEditList(); |
| }); |
| if ($('edit-prov-prefix')) $('edit-prov-prefix').addEventListener('input', (e) => { |
| editState.prefix = (e.target.value || '').trim(); |
| renderEditList(); |
| }); |
| if ($('edit-prov-select-all')) $('edit-prov-select-all').addEventListener('click', () => { |
| editState.available.forEach(id => editState.selected.add(id)); |
| renderEditList(); |
| $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`; |
| }); |
| if ($('edit-prov-select-none')) $('edit-prov-select-none').addEventListener('click', () => { |
| editState.selected.clear(); |
| renderEditList(); |
| $('edit-prov-count').textContent = `${editState.selected.size} / ${editState.available.length} selected`; |
| }); |
| |
| |
| let _modelsCache = []; |
| async function loadModels() { |
| try { |
| const r = await api('/admin/models'); |
| if (!r || !r.ok) throw new Error('Unable to retrieve models catalog'); |
| const data = await r.json(); |
| _modelsCache = data.models || []; |
| if ($('stat-models')) $('stat-models').textContent = data.total || _modelsCache.length; |
| renderModels(''); |
| const count = $('models-count'); |
| if (count) count.textContent = `${_modelsCache.length} active routes`; |
| } catch (e) { |
| _modelsCache = [...mockModels]; |
| renderModels(''); |
| } |
| } |
| |
| function renderModels(q) { |
| const grid = $('models-grid'); |
| if (!grid) return; |
| q = (q || '').toLowerCase(); |
| |
| const filtered = q ? _modelsCache.filter(m => |
| (m.id || '').toLowerCase().includes(q) || |
| (m.name || '').toLowerCase().includes(q) || |
| (m.provider || '').toLowerCase().includes(q) |
| ) : _modelsCache; |
| |
| if ($('models-count')) $('models-count').textContent = `${filtered.length} active model routes`; |
| |
| if (filtered.length === 0) { |
| grid.innerHTML = ` |
| <div class="col-span-full py-10 text-center text-gray-500 bg-black/20 rounded-xl border border-dashed border-white/5 font-mono text-xs"> |
| No registered proxy model routes matched query filters. |
| </div>`; |
| return; |
| } |
| |
| grid.innerHTML = filtered.map(m => ` |
| <div class="bg-black/45 border border-white/5 hover:border-amber-500/30 rounded-xl p-3.5 transition-all duration-200 cursor-pointer group shadow-glass-inner" |
| onclick="window.__copyModelId('${escapeHtml(m.name)}')"> |
| <div class="flex items-start justify-between gap-2"> |
| <span class="font-mono text-xs text-gray-200 break-all select-all group-hover:text-amber-300 font-semibold transition-colors duration-150">${escapeHtml(m.name || m.id)}</span> |
| ${m.upstreams > 1 ? `<span class="text-[9px] font-mono font-bold text-amber-400 bg-amber-500/15 border border-amber-500/20 px-1.5 py-0.5 rounded flex-shrink-0">x${m.upstreams} nodes</span>` : ''} |
| </div> |
| <div class="mt-3 flex items-center justify-between"> |
| <div class="flex items-center gap-1.5"> |
| <span class="w-1.5 h-1.5 rounded-full ${m.provider === 'built-in' ? 'bg-amber-400' : m.provider === 'image-backend' ? 'bg-indigo-400' : 'bg-emerald-400'}"></span> |
| <span class="text-[10px] font-mono text-gray-400 truncate max-w-[120px]">${escapeHtml(m.provider)}</span> |
| </div> |
| <span class="text-[9px] font-mono text-gray-500 group-hover:text-amber-400 transition-colors flex items-center gap-1"> |
| <i data-lucide="copy" class="w-3 h-3"></i> COPY |
| </span> |
| </div> |
| </div> |
| `).join(''); |
| lucide.createIcons(); |
| } |
| |
| window.__copyModelId = function(modelName) { |
| const dummy = document.createElement('textarea'); |
| document.body.appendChild(dummy); |
| dummy.value = modelName; |
| dummy.select(); |
| document.execCommand('copy'); |
| document.body.removeChild(dummy); |
| toast(`Copied model routing ID: "${modelName}"`); |
| }; |
| |
| document.addEventListener('toast', (e) => toast(e.detail)); |
| if ($('models-search')) $('models-search').addEventListener('input', (e) => renderModels(e.target.value)); |
| |
| |
| initAuth(); |
| })(); |
| </script> |
|
|
| |
| <div id="edit-prov-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"> |
| <div class="glass-bright rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl border border-amber-500/20"> |
| |
| <div class="flex items-center justify-between px-6 py-4 border-b border-white/10"> |
| <div class="flex items-center gap-3 min-w-0"> |
| <div class="p-2 bg-amber-500/15 text-amber-400 rounded-lg flex-shrink-0"> |
| <i data-lucide="settings-2" class="w-4 h-4"></i> |
| </div> |
| <div class="min-w-0"> |
| <h3 id="edit-prov-modal-title" class="font-bold text-gray-100 truncate">Edit Provider</h3> |
| <p class="text-[11px] text-gray-400 font-mono">Update prefix, model selection, and aliases</p> |
| </div> |
| </div> |
| <button id="edit-prov-close" class="text-gray-400 hover:text-gray-100 bg-white/5 hover:bg-white/10 rounded-lg p-1.5 transition"> |
| <i data-lucide="x" class="w-4 h-4"></i> |
| </button> |
| </div> |
|
|
| |
| <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4 border-b border-white/5"> |
| <div> |
| <label class="block text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono">Display Name</label> |
| <input id="edit-prov-name" type="text" class="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-amber-500/60 transition-all"> |
| </div> |
| <div> |
| <label class="block text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-1.5 font-mono flex items-center gap-1.5"> |
| Prefix <span class="text-[9px] text-amber-400/80 bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">{prefix}/model</span> |
| </label> |
| <input id="edit-prov-prefix" type="text" class="w-full bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-amber-200 font-mono text-xs focus:outline-none focus:border-amber-500/60 transition-all" placeholder="e.g. deb"> |
| </div> |
| </div> |
|
|
| |
| <div class="px-6 py-3 flex flex-col sm:flex-row gap-3 items-stretch sm:items-center border-b border-white/5"> |
| <div class="relative flex-1"> |
| <span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"> |
| <i data-lucide="search" class="w-4 h-4"></i> |
| </span> |
| <input id="edit-prov-search" type="text" placeholder="Filter models..." |
| class="w-full bg-black/40 border border-white/10 rounded-xl pl-9 pr-4 py-2 text-xs text-gray-100 font-mono focus:outline-none focus:border-amber-500/50 transition-all"> |
| </div> |
| <div class="flex gap-2 text-[11px] font-mono"> |
| <button id="edit-prov-select-all" class="bg-white/5 hover:bg-white/10 text-gray-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1"> |
| <i data-lucide="check-square" class="w-3 h-3"></i> All |
| </button> |
| <button id="edit-prov-select-none" class="bg-white/5 hover:bg-white/10 text-gray-200 px-3 py-1.5 rounded-lg border border-white/10 transition flex items-center gap-1"> |
| <i data-lucide="square" class="w-3 h-3"></i> None |
| </button> |
| <span id="edit-prov-count" class="text-xs font-mono text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2.5 py-1.5 rounded-lg">0 / 0 selected</span> |
| </div> |
| </div> |
|
|
| |
| <div class="px-6 pt-3 grid grid-cols-12 gap-3 text-[10px] uppercase font-mono text-gray-500"> |
| <div class="col-span-1"></div> |
| <div class="col-span-5">Upstream Model ID</div> |
| <div class="col-span-6">Exposed Alias <span class="text-amber-400/70">(editable)</span></div> |
| </div> |
| <div id="edit-prov-list" class="px-6 py-3 flex-1 overflow-y-auto space-y-1.5"> |
| |
| </div> |
|
|
| |
| <div class="px-6 py-4 border-t border-white/10 flex flex-col sm:flex-row gap-2 justify-end"> |
| <button id="edit-prov-cancel" class="bg-white/5 hover:bg-white/10 text-gray-300 text-xs font-semibold py-2.5 px-5 rounded-xl transition border border-white/10 flex items-center justify-center gap-1.5"> |
| <i data-lucide="x" class="w-3.5 h-3.5"></i><span>Cancel</span> |
| </button> |
| <button id="edit-prov-save" class="bg-amber-500 hover:bg-amber-400 text-black text-xs font-bold py-2.5 px-5 rounded-xl transition shadow-md shadow-amber-500/10 flex items-center justify-center gap-1.5"> |
| <i data-lucide="save" class="w-3.5 h-3.5"></i><span>Save Changes</span> |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| </body> |
| </html> |