APIarium / app /templates /dashboard.html
rnilkyway's picture
feat(admin): unify dashboard + login design with landing/docs theme
19a497d
Raw
History Blame Contribute Delete
102 kB
<!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;
}
/* Background grid pattern, matches landing.html */
.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%);
}
/* Legacy aliases kept so existing JS classes keep working. */
.glass {
background-color: #0A0A0A; /* gray-900 */
border: 1px solid #171717; /* gray-800 */
}
.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 anchor offset under sticky header */
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">
<!-- Background grid pattern (matches landing.html / docs.html) -->
<div class="absolute inset-0 bg-grid-pattern pointer-events-none z-0"></div>
<!-- Auth Gate -->
<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>
<!-- Dashboard Container -->
<div id="dashboard" class="hidden flex-1 flex flex-col relative z-10 w-full">
<!-- Header Navigation -->
<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 Workspace Content -->
<main class="flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full space-y-8">
<!-- STATS SECTION / DASHBOARD SUMMARY -->
<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>
<!-- Stats KPI Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Card 1: Unique Client IPs -->
<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>
<!-- Card 2: Total API Keys -->
<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>
<!-- Card 3: Total Requests routed -->
<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>
<!-- Card 4: Rate Limited attempts -->
<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>
<!-- ANALYTICS CHARTS PANEL -->
<div id="charts" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Chart 1: Request Load over 7 Days -->
<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>
<!-- Chart 2: Popular Models Routing share -->
<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>
<!-- DYNAMIC UPSTREAM PROVIDERS MANAGEMENT -->
<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">
<!-- Add Provider form card -->
<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>
<!-- Step 1: Discover Models -->
<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>
<!-- Status line -->
<div id="prov-status" class="text-xs font-mono hidden"></div>
</div>
</div>
<!-- Step 2: Model Selection + Alias Editor (full width when active) -->
<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}/&lt;alias&gt;</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>
<!-- Table header -->
<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">
<!-- Rows injected here -->
</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>
<!-- Existing active dynamic upstreams -->
<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">
<!-- Dynamic providers list gets injected here -->
<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>
<!-- MODELS CATALOG WITH UPSTREAM CHANNELS -->
<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>
<!-- Unified Grid of Route Models -->
<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">
<!-- Loaded dynamically -->
<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>
<!-- API KEYS GENERATOR & LISTING (CLIENT BOUND CRUMBS) -->
<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">
<!-- KEY GENERATION COMPONENT -->
<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>
<!-- Successful generated card result block -->
<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>
<!-- TABLE OF CURRENT KEYS -->
<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>
<!-- Client Key Database table element -->
<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]">
<!-- Injected dynamically by script -->
</tbody>
</table>
</div>
<!-- Empty placeholder state -->
<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 Area -->
<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>&copy; 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;
// Default high fidelity mock data in case live server connection is not established or running in sandbox/preview
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 }
];
// ── Auth Init ─────────────────────────────────────────────────────
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;
// Visual loading state
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) { // Support simple dev unlock as fallback
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) {
// Live Server not responding yet, auto-fallback to Sandbox High-Fidelity Simulator
masterKey = val;
sessionStorage.setItem('apiarium_master_key', val);
authError.classList.add('hidden');
showDashboard();
loadAllGatewayData(true); // Simulator active flag
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();
});
// ── API Routing Helpers ─────────────────────────────────────────────
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()); // Limit stack buildup
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);
}
// ── Primary Controller: Load Dashboard Data ────────────────────────────
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 {
// Sequential API network queries
await loadKeys();
await loadProviders();
await loadModels();
} catch (error) {
console.warn('Network API fallbacked to simulated state representation: ', error);
// Graceful fallback to Simulator so UI never sits broken or empty
loadAllGatewayData(true);
}
}
// ── Load Keys ──────────────────────────────────────────────
async function loadKeys() {
try {
const r = await api('/admin/keys');
if (!r || !r.ok) {
// If endpoint returns broken state, fallback
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) { /* silent fail */ }
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 {
// Cross-platform clipboard safe execution
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 {
// Support simulating revokes dynamically in playground fallback
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) {
// Simulated fallback actions
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));
// ── Generate Key Actions ───────────────────────────────────────────
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 {
// Generate simulated credentials dynamically for playground UI representation
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) {
// Simulated local offline generator
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');
});
// ── Charts Rendering Engine ─────────────────────────────────────────────────
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'
}
}
}
}
});
}
}
// ── Dynamic Provider Management ─────────────────────────────────────
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function escapeAttr(s) { return escapeHtml(s).replace(/`/g, '&#96;'); }
function slugifyAlias(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
}
// ── Selector State (Step 2: choose & alias models before import) ─────
const selectorState = {
active: false,
available: [], // array of upstream model ids
selected: new Set(), // selected upstream ids
aliases: {}, // upstream_id -> custom alias
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('');
// Bind checkbox events
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;
// Live-update the visible "β†’ prefix/alias" text without full re-render
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); // default: all selected
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) {
// Offline representation fallback
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 || '';
// p.models can be either rich objects {upstream,alias,full_id} (new) or plain strings (legacy)
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');
}
};
// ── Step 1: Discover models, then open Step 2 selector ─────────────
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);
}
// ── Step 3: Import with selected models + custom aliases ───────────
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;
}
// Build model_aliases map (only for selected models)
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) {
// Offline fallback
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();
}
// Wire up Step 1/2 controls
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');
});
// ── Edit Provider Modal ─────────────────────────────────────────────
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) {
// Resolve provider snapshot. Try API first, then mock.
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();
// Build model_aliases map from selection
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) {
// Offline: mutate mock
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`;
});
// ── Models Catalog ─────────────────────────────────────────
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));
// Start Up Execution
initAuth();
})();
</script>
<!-- ── Edit Provider Modal ─────────────────────────────────────────── -->
<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">
<!-- Header -->
<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>
<!-- Top: name + prefix -->
<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>
<!-- Filter & bulk -->
<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>
<!-- Table -->
<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">
<!-- Rows injected -->
</div>
<!-- Footer -->
<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>