chore: full stack stability and migration fixes, plus react UI progress
Some checks failed
CI / podman-build (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-03-18 09:01:38 +02:00
parent 38cab8c246
commit a66d908eff
142 changed files with 12210 additions and 3402 deletions

View File

@@ -4,575 +4,424 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MadBase Console</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<meta name="description" content="MadBase Admin Console — Manage projects, users, storage, functions, and infrastructure.">
<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@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
}
}
}
}
}
</script>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
<script src="/vendor/vue.global.prod.js"></script>
<script src="/vendor/chart.umd.min.js"></script>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="bg-slate-50 text-slate-800 h-screen flex flex-col font-sans antialiased overflow-hidden">
<div id="app" class="flex flex-col h-full">
<!-- Header -->
<header class="bg-white border-b border-slate-200 h-14 px-4 flex justify-between items-center z-10 shadow-sm">
<div class="flex items-center gap-3">
<div class="bg-primary-600 text-white p-1.5 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"></path><path d="M8.5 8.5v.01"></path><path d="M16 16v.01"></path><path d="M12 12v.01"></path></svg>
<body>
<div id="app">
<!-- Error Toast -->
<div v-if="errorMessage" class="error-toast">{{ errorMessage }}</div>
<!-- Login Screen -->
<div v-if="!isAuthenticated && !isVerifyingAuth" class="login-screen">
<div class="login-card">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem">
<div style="background:#0284c7;color:#fff;padding:0.5rem;border-radius:0.5rem">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 16v.01"/><path d="M12 12v.01"/></svg>
</div>
<div>
<h1>MadBase</h1>
<p style="margin:0;color:#64748b;font-size:0.813rem">Admin Console</p>
</div>
</div>
<h1 class="font-bold text-lg tracking-tight text-slate-800">MadBase <span class="text-slate-400 font-normal">Console</span></h1>
<form @submit.prevent="login">
<input v-model="adminPassword" type="password" class="login-input" placeholder="Admin Password" autofocus autocomplete="current-password">
<button type="submit" class="login-btn" :disabled="isLoggingIn">
{{ isLoggingIn ? 'Signing in…' : 'Sign In' }}
</button>
</form>
<div v-if="loginError" class="login-error">{{ loginError }}</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-xs px-3 py-1.5 bg-slate-100 rounded-full border border-slate-200">
<div :class="['w-2 h-2 rounded-full animate-pulse', gatewayStatus === 'Online' ? 'bg-emerald-500' : 'bg-rose-500']"></div>
<span :class="gatewayStatus === 'Online' ? 'text-slate-700' : 'text-rose-600'">{{ gatewayStatus }}</span>
</div>
</div>
</header>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->
<nav class="w-64 bg-slate-900 text-slate-300 flex flex-col flex-shrink-0">
<div class="p-4">
<label class="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-2">Environment</label>
<div class="relative group">
<input v-model="serviceKey" type="password" class="w-full text-xs bg-slate-800 border border-slate-700 rounded p-2.5 text-slate-300 focus:border-primary-500 focus:ring-1 focus:ring-primary-500 outline-none transition-all placeholder-slate-600" placeholder="Service Role Key">
<div class="absolute inset-y-0 right-2 flex items-center">
<svg class="h-4 w-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
</div>
<!-- Loading -->
<div v-else-if="isVerifyingAuth" style="display:flex;align-items:center;justify-content:center;height:100vh;color:#64748b;font-size:0.875rem">
Verifying session…
</div>
<!-- Main App -->
<div v-else class="app-layout">
<!-- Header -->
<header class="app-header">
<div style="display:flex;align-items:center;gap:0.75rem">
<div style="background:#0284c7;color:#fff;padding:0.375rem;border-radius:0.5rem">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 16v.01"/><path d="M12 12v.01"/></svg>
</div>
<span style="font-weight:700;font-size:1.125rem;color:#0f172a">MadBase <span style="font-weight:400;color:#94a3b8">Console</span></span>
</div>
<div class="flex-1 overflow-y-auto py-2 space-y-1 px-3">
<a href="#" @click="currentTab = 'dashboard'" :class="['flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group', currentTab === 'dashboard' ? 'bg-primary-600 text-white shadow-lg shadow-primary-900/20' : 'hover:bg-slate-800 hover:text-white']">
<svg class="h-5 w-5 opacity-75" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
<span class="text-sm font-medium">Dashboard</span>
</a>
<a href="#" @click="currentTab = 'storage'" :class="['flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group', currentTab === 'storage' ? 'bg-primary-600 text-white shadow-lg shadow-primary-900/20' : 'hover:bg-slate-800 hover:text-white']">
<svg class="h-5 w-5 opacity-75" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="text-sm font-medium">Storage</span>
</a>
<a href="#" @click="currentTab = 'realtime'" :class="['flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group', currentTab === 'realtime' ? 'bg-primary-600 text-white shadow-lg shadow-primary-900/20' : 'hover:bg-slate-800 hover:text-white']">
<svg class="h-5 w-5 opacity-75" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span class="text-sm font-medium">Realtime</span>
</a>
<a href="#" @click="currentTab = 'logs'" :class="['flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group', currentTab === 'logs' ? 'bg-primary-600 text-white shadow-lg shadow-primary-900/20' : 'hover:bg-slate-800 hover:text-white']">
<svg class="h-5 w-5 opacity-75" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<span class="text-sm font-medium">Logs</span>
</a>
</div>
<div class="p-4 border-t border-slate-800 text-xs text-slate-500">
<div class="flex justify-between">
<span>Version</span>
<span class="text-slate-400">v4.1.0</span>
<div style="display:flex;align-items:center;gap:1rem">
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;padding:0.375rem 0.75rem;background:#f8fafc;border-radius:1rem;border:1px solid #e2e8f0">
<div :style="{width:'0.5rem',height:'0.5rem',borderRadius:'50%',background: gatewayStatus === 'Online' ? '#10b981' : '#ef4444'}"></div>
<span :style="{color: gatewayStatus === 'Online' ? '#334155' : '#dc2626'}">{{ gatewayStatus }}</span>
</div>
<span v-if="appVersion" style="font-size:0.688rem;color:#94a3b8">v{{ appVersion }}</span>
<button @click="logout" class="btn-secondary" style="padding:0.375rem 0.75rem;font-size:0.75rem" title="Sign Out">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</nav>
</header>
<!-- Main Content Area -->
<main class="flex-1 overflow-y-auto bg-slate-50/50 p-6 md:p-8 relative scroll-smooth">
<!-- Dashboard View -->
<div v-if="currentTab === 'dashboard'" class="max-w-6xl mx-auto space-y-6 fade-enter-active">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Stat Cards -->
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-sm flex items-start justify-between">
<div>
<div class="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Total Projects</div>
<div class="text-3xl font-bold text-slate-800">{{ projects.length }}</div>
</div>
<div class="p-2 bg-blue-50 text-blue-600 rounded-lg">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
</div>
</div>
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-sm flex items-start justify-between">
<div>
<div class="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Total Users</div>
<div class="text-3xl font-bold text-slate-800">{{ users.length }}</div>
</div>
<div class="p-2 bg-indigo-50 text-indigo-600 rounded-lg">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
</div>
</div>
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-sm flex items-start justify-between">
<div>
<div class="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">System Health</div>
<div class="text-3xl font-bold text-emerald-600">100%</div>
</div>
<div class="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
</div>
<div class="app-body">
<!-- Sidebar -->
<nav class="app-sidebar">
<div style="flex:1;overflow-y:auto;padding:1rem 0.75rem;display:flex;flex-direction:column;gap:0.25rem">
<button @click="currentTab = 'dashboard'" :class="['sidebar-link', currentTab === 'dashboard' && 'active']">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
Dashboard
</button>
<button @click="currentTab = 'auth'" :class="['sidebar-link', currentTab === 'auth' && 'active']">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
Auth
</button>
<button @click="currentTab = 'storage'" :class="['sidebar-link', currentTab === 'storage' && 'active']">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
Storage
</button>
<button @click="currentTab = 'functions'" :class="['sidebar-link', currentTab === 'functions' && 'active']">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Functions
</button>
<button @click="currentTab = 'database'" :class="['sidebar-link', currentTab === 'database' && 'active']">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
Database
</button>
<button @click="currentTab = 'realtime'" :class="['sidebar-link', currentTab === 'realtime' && 'active']">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Realtime
</button>
<button @click="currentTab = 'logs'" :class="['sidebar-link', currentTab === 'logs' && 'active']">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Logs
</button>
</div>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Projects Table -->
<div class="bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
<div class="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h2 class="font-bold text-slate-800">Projects</h2>
<button @click="fetchProjects" class="text-primary-600 hover:text-primary-700 p-1 rounded hover:bg-primary-50 transition-colors">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
<!-- Main Content -->
<main class="app-main">
<!-- Dashboard -->
<div v-if="currentTab === 'dashboard'" class="fade-in" style="max-width:72rem;margin:0 auto">
<div class="stat-grid" style="margin-bottom:1.5rem">
<div class="stat-card">
<div><div class="stat-label">Total Projects</div><div class="stat-value">{{ projects.length }}</div></div>
<div class="stat-icon" style="background:#eff6ff;color:#2563eb"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg></div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase font-semibold">
<tr><th class="px-5 py-3">Name</th><th class="px-5 py-3">Status</th><th class="px-5 py-3 text-right">Actions</th></tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="p in projects" :key="p.id" class="hover:bg-slate-50 transition-colors">
<td class="px-5 py-3 font-medium text-slate-700">{{ p.name }}</td>
<td class="px-5 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-800">
{{ p.status }}
</span>
</td>
<td class="px-5 py-3 text-right">
<button @click="deleteProject(p.id)" class="text-rose-500 hover:text-rose-700 hover:bg-rose-50 p-1.5 rounded transition-colors" title="Delete Project">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</td>
</tr>
<tr v-if="projects.length === 0">
<td colspan="3" class="px-5 py-8 text-center text-slate-400 italic">No projects found.</td>
<div class="stat-card">
<div><div class="stat-label">Total Users</div><div class="stat-value">{{ users.length }}</div></div>
<div class="stat-icon" style="background:#eef2ff;color:#4f46e5"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg></div>
</div>
<div class="stat-card">
<div><div class="stat-label">System Health</div><div class="stat-value" style="color:#059669">100%</div></div>
<div class="stat-icon" style="background:#ecfdf5;color:#059669"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-bottom:1.5rem">
<!-- Projects -->
<div class="card">
<div class="card-header">
<h2>Projects</h2>
<button @click="fetchProjects" class="btn-icon" title="Refresh"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0284c7" stroke-width="2"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></button>
</div>
<table class="data-table">
<thead><tr><th>Name</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
<tbody>
<tr v-for="p in projects" :key="p.id">
<td style="font-weight:500">{{ p.name }}</td>
<td><span class="badge badge-green">{{ p.status }}</span></td>
<td style="text-align:right"><button @click="deleteProject(p.id, p.name)" class="btn-icon danger" title="Delete"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button></td>
</tr>
<tr v-if="projects.length === 0"><td colspan="3" style="text-align:center;padding:2rem;color:#94a3b8;font-style:italic">No projects</td></tr>
</tbody>
</table>
</div>
<div class="p-4 bg-slate-50 border-t border-slate-100">
<div class="flex gap-2">
<input v-model="newProjectName" class="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-all" placeholder="New Project Name">
<button @click="createProject" class="bg-slate-800 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-700 transition-colors flex items-center gap-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
Create
</button>
<div class="card-body" style="border-top:1px solid #f1f5f9;display:flex;gap:0.5rem">
<input v-model="newProjectName" style="flex:1;padding:0.5rem 0.75rem;border:1px solid #e2e8f0;border-radius:0.5rem;font-size:0.875rem;outline:none" placeholder="New Project Name" @keyup.enter="createProject">
<button @click="createProject" class="btn-primary" style="display:flex;align-items:center;gap:0.375rem"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v16m8-8H4"/></svg> Create</button>
</div>
</div>
</div>
<!-- Users Table -->
<div class="bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
<div class="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h2 class="font-bold text-slate-800">Global Users</h2>
<button @click="fetchUsers" class="text-primary-600 hover:text-primary-700 p-1 rounded hover:bg-primary-50 transition-colors">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
</div>
<div class="overflow-x-auto flex-1">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase font-semibold">
<tr><th class="px-5 py-3">ID</th><th class="px-5 py-3">Email</th><th class="px-5 py-3 text-right">Actions</th></tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="u in users" :key="u.id" class="hover:bg-slate-50 transition-colors">
<td class="px-5 py-3 font-mono text-xs text-slate-500">{{ u.id.slice(0,8) }}...</td>
<td class="px-5 py-3 text-slate-700">{{ u.email }}</td>
<td class="px-5 py-3 text-right">
<button @click="deleteUser(u.id)" class="text-rose-500 hover:text-rose-700 hover:bg-rose-50 p-1.5 rounded transition-colors" title="Delete User">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="3" class="px-5 py-8 text-center text-slate-400 italic">No users found.</td>
<!-- Users -->
<div class="card">
<div class="card-header">
<h2>Global Users</h2>
<button @click="fetchUsers" class="btn-icon" title="Refresh"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0284c7" stroke-width="2"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></button>
</div>
<table class="data-table">
<thead><tr><th>ID</th><th>Email</th><th style="text-align:right">Actions</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="mono">{{ u.id.slice(0,8) }}…</td>
<td>{{ u.email }}</td>
<td style="text-align:right"><button @click="deleteUser(u.id)" class="btn-icon danger" title="Delete"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button></td>
</tr>
<tr v-if="users.length === 0"><td colspan="3" style="text-align:center;padding:2rem;color:#94a3b8;font-style:italic">No users</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Metrics -->
<div class="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
<h2 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
<svg class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
System Metrics
</h2>
<div class="bg-slate-900 rounded-lg p-4 font-mono text-xs text-emerald-400 overflow-auto h-48 shadow-inner custom-scrollbar">
<pre>{{ metrics }}</pre>
<!-- Metrics Chart -->
<div class="card" style="margin-bottom:1.5rem">
<div class="card-header"><h2>HTTP Requests</h2></div>
<div class="card-body" style="height:200px"><canvas id="metricsChart"></canvas></div>
</div>
<!-- Raw Metrics -->
<div class="card">
<div class="card-header"><h2>System Metrics</h2></div>
<div class="terminal-bg" style="margin:0;border-radius:0;max-height:12rem"><pre>{{ metrics }}</pre></div>
</div>
</div>
</div>
<!-- Storage View -->
<div v-if="currentTab === 'storage'" class="h-full flex flex-col gap-6 fade-enter-active">
<div class="flex flex-1 gap-6 overflow-hidden">
<!-- Buckets List -->
<div class="w-1/3 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
<div class="p-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h3 class="font-bold text-slate-700">Buckets</h3>
<button @click="fetchBuckets" class="text-primary-600 hover:text-primary-700 text-xs font-medium">Refresh</button>
<!-- Auth Management -->
<div v-if="currentTab === 'auth'" class="fade-in" style="max-width:72rem;margin:0 auto">
<div style="display:flex;gap:1.5rem;height:calc(100vh - 7rem)">
<!-- User List -->
<div class="card" style="flex:1;display:flex;flex-direction:column">
<div class="card-header">
<h2>Auth Users</h2>
<button @click="fetchAuthUsers" class="btn-icon" title="Refresh"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0284c7" stroke-width="2"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></button>
</div>
<div style="padding:0.75rem 1rem;border-bottom:1px solid #f1f5f9">
<input v-model="authUserSearch" class="search-input" placeholder="Search by email or ID…">
</div>
<div style="flex:1;overflow-y:auto">
<table class="data-table">
<thead><tr><th>Email</th><th>Created</th><th style="text-align:right">Actions</th></tr></thead>
<tbody>
<tr v-for="u in filteredAuthUsers" :key="u.id" @click="selectAuthUser(u)" style="cursor:pointer" :style="selectedAuthUser?.id === u.id ? 'background:#eff6ff' : ''">
<td style="font-weight:500">{{ u.email }}</td>
<td class="mono">{{ u.created_at ? new Date(u.created_at).toLocaleDateString() : '—' }}</td>
<td style="text-align:right">
<button @click.stop="deleteAuthUser(u.id)" class="btn-icon danger" title="Delete User"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
</td>
</tr>
<tr v-if="filteredAuthUsers.length === 0"><td colspan="3" style="text-align:center;padding:3rem;color:#94a3b8">No users found</td></tr>
</tbody>
</table>
</div>
</div>
<div class="overflow-y-auto flex-1 p-2 space-y-1">
<div v-for="b in buckets" :key="b.id"
@click="selectBucket(b.id)"
:class="['p-3 rounded-lg cursor-pointer flex justify-between items-center transition-all', selectedBucket === b.id ? 'bg-primary-50 text-primary-700 ring-1 ring-primary-200' : 'hover:bg-slate-50 text-slate-600']">
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-yellow-500 opacity-80" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg>
<span class="font-medium text-sm">{{ b.id }}</span>
<!-- User Detail -->
<div class="card" style="width:22rem;display:flex;flex-direction:column">
<div class="card-header"><h3>User Detail</h3></div>
<div v-if="selectedAuthUser" class="card-body" style="flex:1">
<div style="margin-bottom:1rem">
<div class="stat-label">Email</div>
<div style="font-weight:600;color:#0f172a">{{ selectedAuthUser.email }}</div>
</div>
<div style="margin-bottom:1rem">
<div class="stat-label">User ID</div>
<div class="mono" style="font-size:0.75rem;word-break:break-all">{{ selectedAuthUser.id }}</div>
</div>
<div style="margin-bottom:1rem">
<div class="stat-label">Created At</div>
<div>{{ selectedAuthUser.created_at ? new Date(selectedAuthUser.created_at).toLocaleString() : '—' }}</div>
</div>
<div style="border-top:1px solid #e2e8f0;padding-top:1rem;margin-top:1rem">
<button @click="deleteAuthUser(selectedAuthUser.id)" class="btn-danger" style="width:100%">Delete User</button>
</div>
<span v-if="b.public" class="text-[10px] bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-bold uppercase">Public</span>
</div>
<div v-if="buckets.length === 0" class="p-8 text-center text-slate-400 text-sm">
No buckets found
<div v-else style="flex:1;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:0.875rem;padding:2rem;text-align:center">
Select a user to view details
</div>
</div>
</div>
</div>
<!-- Objects List -->
<div class="w-2/3 bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
<div class="p-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h3 class="font-bold text-slate-700 flex items-center gap-2">
<svg v-if="selectedBucket" class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg>
{{ selectedBucket ? selectedBucket : 'Select a Bucket' }}
</h3>
<div v-if="selectedBucket">
<label class="bg-primary-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer hover:bg-primary-700 transition-colors shadow-sm flex items-center gap-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
Upload File
<input type="file" class="hidden" @change="uploadFile">
</label>
<!-- Storage -->
<div v-if="currentTab === 'storage'" class="fade-in" style="display:flex;gap:1.5rem;height:calc(100vh - 7rem)">
<!-- Buckets -->
<div class="card" style="width:20rem;display:flex;flex-direction:column">
<div class="card-header">
<h3>Buckets</h3>
<button @click="fetchBuckets" style="border:none;background:none;color:#0284c7;font-size:0.75rem;font-weight:600;cursor:pointer">Refresh</button>
</div>
<div style="flex:1;overflow-y:auto;padding:0.5rem">
<div v-for="b in buckets" :key="b.id" @click="selectBucket(b.id)" style="padding:0.75rem;border-radius:0.5rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;transition:all 0.15s" :style="selectedBucket === b.id ? 'background:#eff6ff;color:#0369a1;outline:1px solid #bae6fd' : 'color:#475569'">
<div style="display:flex;align-items:center;gap:0.75rem">
<svg width="20" height="20" viewBox="0 0 20 20" fill="#eab308"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>
<span style="font-weight:500;font-size:0.875rem">{{ b.id }}</span>
</div>
<span v-if="b.public" style="font-size:0.625rem;background:#e2e8f0;color:#475569;padding:0.125rem 0.375rem;border-radius:0.25rem;font-weight:700;text-transform:uppercase">Public</span>
</div>
<div v-if="buckets.length === 0" style="padding:3rem;text-align:center;color:#94a3b8;font-size:0.875rem">No buckets</div>
</div>
<div v-if="!selectedBucket" class="flex-1 flex flex-col items-center justify-center text-slate-300">
<svg class="h-16 w-16 mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="text-sm">Select a bucket to view its contents</span>
</div>
</div>
<div v-else class="flex-1 overflow-y-auto">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase font-semibold sticky top-0 z-10">
<tr><th class="px-5 py-3 border-b">Name</th><th class="px-5 py-3 border-b">Size</th><th class="px-5 py-3 border-b">Type</th><th class="px-5 py-3 border-b text-right">Actions</th></tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="obj in objects" :key="obj.name" class="hover:bg-slate-50 transition-colors group">
<td class="px-5 py-3 font-medium text-slate-700 flex items-center gap-2">
<svg class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<!-- Objects -->
<div class="card" style="flex:1;display:flex;flex-direction:column">
<div class="card-header">
<h3>{{ selectedBucket || 'Select a Bucket' }}</h3>
<label v-if="selectedBucket" class="btn-primary" style="cursor:pointer;display:flex;align-items:center;gap:0.375rem;padding:0.375rem 0.75rem;font-size:0.813rem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
Upload
<input type="file" style="display:none" @change="uploadFile">
</label>
</div>
<div v-if="!selectedBucket" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#cbd5e1">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.5;margin-bottom:0.5rem"><path d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
<span style="font-size:0.875rem">Select a bucket</span>
</div>
<div v-else style="flex:1;overflow-y:auto">
<table class="data-table">
<thead><tr><th>Name</th><th>Size</th><th>Type</th><th style="text-align:right">Actions</th></tr></thead>
<tbody>
<tr v-for="obj in objects" :key="obj.name">
<td style="font-weight:500;display:flex;align-items:center;gap:0.5rem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
{{ obj.name }}
</td>
<td class="px-5 py-3 text-slate-500 font-mono text-xs">{{ formatBytes(obj.metadata?.size) }}</td>
<td class="px-5 py-3 text-slate-500 text-xs">{{ obj.metadata?.mimetype }}</td>
<td class="px-5 py-3 text-right">
<a :href="getObjectUrl(obj.name)" target="_blank" class="text-primary-600 hover:text-primary-800 hover:bg-primary-50 px-2 py-1 rounded text-xs font-medium transition-colors opacity-0 group-hover:opacity-100">Download</a>
</td>
</tr>
<tr v-if="objects.length === 0">
<td colspan="4" class="px-5 py-12 text-center text-slate-400">
<div class="flex flex-col items-center">
<svg class="h-8 w-8 mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /></svg>
Empty bucket
</div>
<td class="mono">{{ formatBytes(obj.metadata?.size) }}</td>
<td style="font-size:0.75rem;color:#64748b">{{ obj.metadata?.mimetype }}</td>
<td style="text-align:right;display:flex;gap:0.25rem;justify-content:flex-end">
<a :href="getObjectUrl(obj.name)" target="_blank" class="btn-icon" title="Download"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#0284c7" stroke-width="2"><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg></a>
<button @click="deleteObject(selectedBucket, obj.name)" class="btn-icon danger" title="Delete"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
</td>
</tr>
<tr v-if="objects.length === 0"><td colspan="4" style="text-align:center;padding:3rem;color:#94a3b8">Empty bucket</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Realtime View -->
<div v-if="currentTab === 'realtime'" class="h-full flex flex-col bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden fade-enter-active">
<div class="p-4 border-b border-slate-100 flex gap-4 items-center bg-slate-50/50">
<div class="flex items-center gap-2 bg-white border border-slate-200 rounded-lg px-3 py-1.5 shadow-sm">
<span class="text-xs font-bold text-slate-500">CHANNEL</span>
<input v-model="wsChannel" class="text-sm outline-none text-slate-700 w-48 font-mono" placeholder="room:lobby">
<!-- Functions -->
<div v-if="currentTab === 'functions'" class="fade-in" style="display:flex;gap:1.5rem;height:calc(100vh - 7rem)">
<div class="card" style="width:18rem;display:flex;flex-direction:column">
<div class="card-header">
<h3>Functions</h3>
<button @click="initNewFunction" class="btn-primary" style="padding:0.25rem 0.625rem;font-size:0.75rem">+ New</button>
</div>
<div style="flex:1;overflow-y:auto;padding:0.5rem">
<div v-for="f in functions" :key="f.name" @click="selectFunction(f.name)" style="padding:0.625rem 0.75rem;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;font-weight:500;transition:background 0.15s" :style="selectedFunction?.name === f.name ? 'background:#eff6ff;color:#0369a1' : 'color:#475569'">
{{ f.name }}
</div>
<div v-if="functions.length === 0" style="padding:3rem;text-align:center;color:#94a3b8;font-size:0.875rem">No functions deployed</div>
</div>
</div>
<button @click="toggleWs" :class="['px-4 py-1.5 rounded-lg text-sm font-medium shadow-sm transition-all flex items-center gap-2', wsConnected ? 'bg-rose-50 text-rose-600 border border-rose-200 hover:bg-rose-100' : 'bg-emerald-600 text-white hover:bg-emerald-700']">
<span :class="['w-2 h-2 rounded-full', wsConnected ? 'bg-rose-500' : 'bg-white']"></span>
{{ wsConnected ? 'Disconnect' : 'Connect' }}
</button>
<div class="ml-auto text-xs font-mono">
Status: <span :class="wsConnected ? 'text-emerald-600 font-bold' : 'text-slate-400'">{{ wsConnected ? 'CONNECTED' : 'DISCONNECTED' }}</span>
<div class="card" style="flex:1;display:flex;flex-direction:column">
<div v-if="selectedFunction" class="card-header">
<div>
<input v-model="newFunctionName" style="border:1px solid #e2e8f0;border-radius:0.375rem;padding:0.375rem 0.625rem;font-size:0.875rem;font-family:'JetBrains Mono',monospace;outline:none">
</div>
<button @click="deployFunction" :disabled="isDeploying" class="btn-primary" style="padding:0.375rem 1rem;font-size:0.813rem">{{ isDeploying ? 'Deploying…' : 'Deploy' }}</button>
</div>
<div v-if="selectedFunction" style="flex:1;display:flex;flex-direction:column">
<textarea v-model="newFunctionCode" style="flex:1;resize:none;border:none;padding:1rem;font-family:'JetBrains Mono',monospace;font-size:0.813rem;background:#0f172a;color:#e2e8f0;outline:none;line-height:1.6" spellcheck="false"></textarea>
</div>
<div v-else style="flex:1;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:0.875rem">Select or create a function</div>
</div>
</div>
<div class="flex-1 p-4 overflow-y-auto bg-slate-900 font-mono text-xs text-slate-300 custom-scrollbar">
<div v-for="(msg, i) in wsMessages" :key="i" class="mb-1.5 hover:bg-slate-800/50 p-1 rounded -mx-1">
<span class="text-slate-500 select-none mr-2">[{{ msg.time }}]</span>
<span :class="['font-bold mr-2', msg.type === 'in' ? 'text-emerald-400' : msg.type === 'out' ? 'text-blue-400' : 'text-yellow-400']">
{{ msg.type === 'in' ? '<< RECV' : msg.type === 'out' ? '>> SENT' : '-- SYS' }}
</span>
<span class="text-slate-300 break-all">{{ msg.data }}</span>
</div>
<div v-if="wsMessages.length === 0" class="h-full flex items-center justify-center text-slate-600">
Waiting for messages...
</div>
</div>
<div class="p-4 bg-slate-50 border-t border-slate-200 flex gap-2">
<input v-model="wsInput" @keyup.enter="sendWs" :disabled="!wsConnected" class="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-primary-500 outline-none disabled:bg-slate-100 disabled:text-slate-400" placeholder='{"event":"broadcast","payload":{"message":"Hello"}}'>
<button @click="sendWs" :disabled="!wsConnected" class="bg-primary-600 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">Send</button>
</div>
</div>
<!-- Logs View -->
<div v-if="currentTab === 'logs'" class="h-full flex flex-col bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden fade-enter-active">
<div class="p-4 border-b border-slate-100 flex gap-4 items-end bg-slate-50/50">
<div class="flex-1">
<label class="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">LogQL Query</label>
<input v-model="logQuery" class="border border-slate-300 rounded-lg p-2 text-sm w-full font-mono focus:ring-2 focus:ring-primary-500 outline-none" placeholder='{app="gateway"}'>
<!-- Database -->
<div v-if="currentTab === 'database'" class="fade-in" style="display:flex;gap:1.5rem;height:calc(100vh - 7rem)">
<div class="card" style="width:18rem;display:flex;flex-direction:column">
<div class="card-header"><h3>Tables</h3></div>
<div style="flex:1;overflow-y:auto;padding:0.5rem">
<div v-for="t in tables" :key="t.name" @click="selectTable(t)" style="padding:0.625rem 0.75rem;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;transition:background 0.15s" :style="selectedTable?.name === t.name ? 'background:#eff6ff;color:#0369a1;font-weight:500' : 'color:#475569'">
<span style="color:#94a3b8;font-size:0.75rem">{{ t.schema }}.</span>{{ t.name }}
</div>
<div v-if="tables.length === 0" style="padding:3rem;text-align:center;color:#94a3b8;font-size:0.875rem">No tables</div>
</div>
</div>
<div class="w-32">
<label class="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1">Limit</label>
<input v-model="logLimit" type="number" class="border border-slate-300 rounded-lg p-2 text-sm w-full outline-none focus:ring-2 focus:ring-primary-500">
</div>
<button @click="fetchLogs" class="bg-slate-800 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-slate-700 h-[38px] flex items-center gap-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
Search
</button>
</div>
<div class="flex-1 p-4 overflow-y-auto bg-slate-900 font-mono text-xs custom-scrollbar">
<div v-if="logs.length === 0" class="h-full flex flex-col items-center justify-center text-slate-600">
<svg class="h-10 w-10 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /></svg>
<span>No logs found or query not run</span>
</div>
<div v-for="(log, i) in logs" :key="i" class="mb-1 border-b border-slate-800 pb-1 last:border-0 hover:bg-slate-800/50 -mx-2 px-2 rounded">
<span class="text-slate-500 select-none">{{ new Date(log[0] / 1000000).toISOString() }}</span>
<span class="text-slate-300 ml-3">{{ log[1] }}</span>
<div class="card" style="flex:1;display:flex;flex-direction:column;overflow:hidden">
<div v-if="selectedTable" class="card-header"><h3>{{ selectedTable.schema }}.{{ selectedTable.name }}</h3></div>
<div v-if="selectedTable" style="flex:1;overflow:auto">
<table class="data-table" v-if="tableData.length > 0">
<thead><tr><th v-for="col in Object.keys(tableData[0])" :key="col">{{ col }}</th></tr></thead>
<tbody><tr v-for="(row, i) in tableData" :key="i"><td v-for="col in Object.keys(row)" :key="col" class="mono" style="max-width:15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ row[col] }}</td></tr></tbody>
</table>
<div v-else style="padding:3rem;text-align:center;color:#94a3b8">No data</div>
</div>
<div v-else style="flex:1;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:0.875rem">Select a table</div>
</div>
</div>
</div>
</main>
<!-- Realtime Console -->
<div v-if="currentTab === 'realtime'" class="fade-in" style="display:flex;flex-direction:column;height:calc(100vh - 7rem)">
<div class="rt-stat-grid">
<div class="rt-stat"><div class="value">{{ rtStats.connections }}</div><div class="label">Connections</div></div>
<div class="rt-stat"><div class="value">{{ wsChannel }}</div><div class="label">Channel</div></div>
<div class="rt-stat"><div class="value">{{ rtStats.events }}</div><div class="label">Events</div></div>
</div>
<div class="card" style="flex:1;display:flex;flex-direction:column;overflow:hidden">
<div class="card-header">
<div style="display:flex;align-items:center;gap:0.75rem">
<div style="display:flex;align-items:center;gap:0.5rem;background:#fff;border:1px solid #e2e8f0;border-radius:0.5rem;padding:0.375rem 0.75rem">
<span style="font-size:0.688rem;font-weight:700;color:#64748b">CHANNEL</span>
<input v-model="wsChannel" style="border:none;outline:none;font-size:0.875rem;width:10rem;font-family:'JetBrains Mono',monospace;color:#334155" placeholder="room:lobby">
</div>
<button @click="toggleWs" :style="wsConnected ? 'background:#fef2f2;color:#dc2626;border:1px solid #fecaca' : 'background:#059669;color:#fff;border:none'" style="padding:0.375rem 1rem;border-radius:0.5rem;font-size:0.875rem;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:0.5rem;transition:all 0.2s">
<span :style="{width:'0.5rem',height:'0.5rem',borderRadius:'50%',background:wsConnected ? '#dc2626' : '#fff',display:'inline-block'}"></span>
{{ wsConnected ? 'Disconnect' : 'Connect' }}
</button>
</div>
<div style="display:flex;align-items:center;gap:0.75rem">
<button @click="clearWsMessages" class="btn-secondary" style="padding:0.25rem 0.5rem;font-size:0.75rem">Clear</button>
<span style="font-size:0.75rem;font-family:'JetBrains Mono',monospace">
Status: <span :style="{color: wsConnected ? '#059669' : '#94a3b8', fontWeight: wsConnected ? 700 : 400}">{{ wsConnected ? 'CONNECTED' : 'DISCONNECTED' }}</span>
</span>
</div>
</div>
<div class="terminal-bg" style="flex:1;border-radius:0;overflow-y:auto">
<div v-for="(msg, i) in wsMessages" :key="i" style="margin-bottom:0.375rem;padding:0.25rem 0.5rem;border-radius:0.25rem;transition:background 0.1s" onmouseover="this.style.background='rgba(255,255,255,0.03)'" onmouseout="this.style.background='transparent'">
<span style="color:#64748b;margin-right:0.5rem;user-select:none">[{{ msg.time }}]</span>
<span :style="{fontWeight:700, marginRight:'0.5rem', color: msg.type === 'in' ? '#34d399' : msg.type === 'out' ? '#60a5fa' : '#fbbf24'}">
{{ msg.type === 'in' ? '&lt;&lt; RECV' : msg.type === 'out' ? '&gt;&gt; SENT' : '-- SYS' }}
</span>
<span style="color:#cbd5e1;word-break:break-all">{{ msg.data }}</span>
</div>
<div v-if="wsMessages.length === 0" style="display:flex;align-items:center;justify-content:center;height:100%;color:#475569">
Waiting for messages…
</div>
</div>
<div style="padding:0.75rem 1rem;border-top:1px solid #e2e8f0;display:flex;gap:0.5rem;background:#f8fafc">
<input v-model="wsInput" @keyup.enter="sendWs" :disabled="!wsConnected" style="flex:1;border:1px solid #e2e8f0;border-radius:0.5rem;padding:0.5rem 0.75rem;font-size:0.875rem;font-family:'JetBrains Mono',monospace;outline:none" :style="!wsConnected ? 'background:#f1f5f9;color:#94a3b8' : ''" placeholder='{"event":"broadcast","payload":{"message":"Hello"}}'>
<button @click="sendWs" :disabled="!wsConnected" class="btn-primary" :style="!wsConnected ? 'opacity:0.5;cursor:not-allowed' : ''">Send</button>
</div>
</div>
</div>
<!-- Logs -->
<div v-if="currentTab === 'logs'" class="fade-in" style="display:flex;flex-direction:column;height:calc(100vh - 7rem)">
<div class="card" style="flex:1;display:flex;flex-direction:column;overflow:hidden">
<div class="card-header" style="gap:0.75rem">
<div style="flex:1">
<div class="stat-label" style="margin-bottom:0.25rem">LogQL Query</div>
<input v-model="logQuery" style="width:100%;border:1px solid #e2e8f0;border-radius:0.5rem;padding:0.5rem 0.75rem;font-size:0.875rem;font-family:'JetBrains Mono',monospace;outline:none" placeholder='{app="gateway"}'>
</div>
<div style="width:5rem">
<div class="stat-label" style="margin-bottom:0.25rem">Limit</div>
<input v-model="logLimit" type="number" style="width:100%;border:1px solid #e2e8f0;border-radius:0.5rem;padding:0.5rem 0.75rem;font-size:0.875rem;outline:none">
</div>
<div style="align-self:flex-end">
<button @click="fetchLogs" class="btn-primary" style="display:flex;align-items:center;gap:0.375rem"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Search</button>
</div>
</div>
<div class="terminal-bg" style="flex:1;border-radius:0;overflow-y:auto">
<div v-if="logs.length === 0" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#475569">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="opacity:0.5;margin-bottom:0.75rem"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg>
<span>Run a query to see logs</span>
</div>
<div v-for="(log, i) in logs" :key="i" style="margin-bottom:0.25rem;padding-bottom:0.25rem;border-bottom:1px solid rgba(255,255,255,0.05)">
<span style="color:#64748b;user-select:none">{{ new Date(log[0] / 1000000).toISOString() }}</span>
<span style="color:#cbd5e1;margin-left:0.75rem">{{ log[1] }}</span>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
const API_BASE = '/platform/v1';
createApp({
data() {
return {
currentTab: 'dashboard',
gatewayStatus: 'Checking...',
serviceKey: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoibWFkYmFzZSIsImlhdCI6MTc3MzIyMDkzNSwiZXhwIjoyMDg4NTgwOTM1LCJzdWIiOiJzZXJ2aWNlX3JvbGUifQ.pKC5lkmYSXtz0xVtDt_bIl9euVfv49L3HGIA9b3YyaE',
// Dashboard
projects: [],
newProjectName: '',
users: [],
metrics: 'Loading...',
metricsInterval: null,
// Storage
buckets: [],
selectedBucket: null,
objects: [],
// Realtime
ws: null,
wsConnected: false,
wsChannel: 'room:lobby',
wsMessages: [],
wsInput: '{"event":"broadcast","payload":{"message":"Hello"}}',
// Logs
logQuery: '{app="gateway"}',
logLimit: 100,
logs: []
}
},
mounted() {
this.checkHealth();
this.fetchProjects();
this.fetchUsers();
this.fetchMetrics();
this.metricsInterval = setInterval(this.fetchMetrics, 5000);
},
methods: {
// Dashboard
async checkHealth() {
try {
const res = await fetch('/');
this.gatewayStatus = res.ok ? 'Online' : 'Error';
} catch { this.gatewayStatus = 'Offline'; }
},
async fetchProjects() {
try {
const res = await fetch(`${API_BASE}/projects`);
this.projects = await res.json();
} catch (e) { console.error(e); }
},
async createProject() {
if (!this.newProjectName) return;
await fetch(`${API_BASE}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: this.newProjectName, owner_id: null })
});
this.newProjectName = '';
this.fetchProjects();
},
async deleteProject(id) {
if (!confirm('Are you sure you want to delete this project?')) return;
await fetch(`${API_BASE}/projects/${id}`, { method: 'DELETE' });
this.fetchProjects();
},
async fetchUsers() {
try {
const res = await fetch(`${API_BASE}/users`);
this.users = await res.json();
} catch (e) { console.error(e); }
},
async deleteUser(id) {
if (!confirm('Are you sure you want to delete this user?')) return;
await fetch(`${API_BASE}/users/${id}`, { method: 'DELETE' });
this.fetchUsers();
},
async fetchMetrics() {
try {
const res = await fetch('/metrics');
const text = await res.text();
this.metrics = text.split('\n').filter(l => !l.startsWith('#') && l.trim()).slice(0, 20).join('\n');
} catch {}
},
// Storage
async fetchBuckets() {
try {
const res = await fetch('/storage/v1/bucket', {
headers: { 'Authorization': `Bearer ${this.serviceKey}`, 'x-project-ref': 'default' }
});
this.buckets = await res.json();
} catch (e) { alert('Failed to fetch buckets. Check Service Key.'); }
},
async selectBucket(id) {
this.selectedBucket = id;
this.objects = [];
try {
const res = await fetch(`/storage/v1/object/list/${id}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.serviceKey}`, 'x-project-ref': 'default' }
});
this.objects = await res.json();
} catch (e) { console.error(e); }
},
async uploadFile(event) {
const file = event.target.files[0];
if (!file || !this.selectedBucket) return;
try {
await fetch(`/storage/v1/object/${this.selectedBucket}/${file.name}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.serviceKey}`,
'x-project-ref': 'default',
'Content-Type': file.type || 'application/octet-stream'
},
body: file
});
this.selectBucket(this.selectedBucket); // Refresh
alert('Upload successful');
} catch (e) { alert('Upload failed'); }
},
getObjectUrl(name) {
return `/storage/v1/object/${this.selectedBucket}/${name}?token=SERVICE_ROLE_BYPASS_NOT_IMPLEMENTED`;
},
formatBytes(bytes, decimals = 2) {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
},
// Realtime
toggleWs() {
if (this.wsConnected) {
this.ws.close();
this.wsConnected = false;
this.wsMessages.push({ type: 'sys', time: new Date().toLocaleTimeString(), data: 'Disconnected' });
} else {
const url = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/realtime/v1/websocket?apikey=${this.serviceKey}&vsn=1.0.0`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.wsConnected = true;
this.wsMessages.push({ type: 'sys', time: new Date().toLocaleTimeString(), data: 'Connected' });
this.sendJson({ event: 'phx_join', topic: this.wsChannel, payload: {}, ref: '1' });
};
this.ws.onmessage = (e) => {
this.wsMessages.push({ type: 'in', time: new Date().toLocaleTimeString(), data: e.data });
if (this.wsMessages.length > 100) this.wsMessages.shift();
};
this.ws.onclose = () => {
this.wsConnected = false;
this.wsMessages.push({ type: 'sys', time: new Date().toLocaleTimeString(), data: 'Connection Closed' });
};
}
},
sendWs() {
if (!this.wsConnected) return;
try {
const payload = JSON.parse(this.wsInput);
const msg = { event: payload.event || 'broadcast', topic: this.wsChannel, payload: payload.payload || payload, ref: '2' };
this.sendJson(msg);
} catch (e) { alert('Invalid JSON'); }
},
sendJson(data) {
const str = JSON.stringify(data);
this.ws.send(str);
this.wsMessages.push({ type: 'out', time: new Date().toLocaleTimeString(), data: str });
},
// Logs
async fetchLogs() {
try {
const params = new URLSearchParams({
query: this.logQuery,
limit: this.logLimit
});
const res = await fetch(`${API_BASE}/logs?${params}`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.data && data.data.result) {
this.logs = data.data.result.flatMap(r => r.values).sort((a, b) => b[0] - a[0]);
} else {
this.logs = [];
}
} catch (e) {
alert('Log fetch failed: ' + e.message);
}
}
},
watch: {
currentTab(val) {
if (val === 'storage' && !this.buckets.length) this.fetchBuckets();
}
}
}).mount('#app');
</script>
<script src="/js/admin.js"></script>
</body>
</html>