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>

View File

@@ -1,160 +1,315 @@
/* Studio Layout & Theme */
/* MadBase Studio — Admin CSS */
/* No @apply directives — plain CSS only for CDN-free compatibility */
:root {
--primary: #10b981; /* Supabase Emerald */
--primary-dark: #059669;
--sidebar-bg: #1c1c1c;
--secondary-sidebar-bg: #ffffff;
--primary: #0ea5e9;
--primary-dark: #0284c7;
--primary-light: #e0f2fe;
--sidebar-bg: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
}
.studio-primary-sidebar {
width: 64px;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
gap: 8px;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.studio-secondary-sidebar {
width: 260px;
background: var(--secondary-sidebar-bg);
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
}
.sidebar-icon-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #9ca3af;
transition: all 0.2s;
}
.sidebar-icon-btn.active {
background: rgba(16, 185, 129, 0.1);
color: var(--primary);
}
.sidebar-icon-btn:hover:not(.active) {
color: #ffffff;
background: rgba(255, 255, 255, 0.05);
}
/* Scrollbars */
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 10px; }
.custom-scrollbar:hover::-webkit-scrollbar-thumb { background: #d1d5db; }
/* Code Editor Dark */
.editor-bg { background: #1c1c1c; }
/* Premium Grid */
.studio-grid-header {
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.studio-grid-cell {
border-right: 1px solid #f3f4f6;
border-bottom: 1px solid #f3f4f6;
}
/* Studio Buttons */
.btn-primary {
@apply bg-emerald-600 text-white px-4 py-2 rounded-lg font-bold shadow-md shadow-emerald-900/10 hover:bg-emerald-700 transition-all active:scale-95;
}
.btn-secondary {
@apply bg-white border border-slate-200 text-slate-600 px-4 py-2 rounded-lg font-bold hover:bg-slate-50 transition-all active:scale-95;
}
/* Secondary Sidebar Hover */
.studio-secondary-sidebar button:hover svg {
@apply text-emerald-500 opacity-100;
}
/* Table Animation */
.studio-grid-cell {
transition: background-color 0.1s;
}
/* Iframe Container */
iframe {
background: #f8fafc;
}
/* Custom Glassmorphism Utility */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.dark .glass {
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Premium Typography Adjustments */
/* ── Base ── */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
margin: 0;
background: #f8fafc;
color: #1e293b;
}
/* Micro-animations */
.hover-scale {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Buttons ── */
.btn-primary {
background: #0284c7;
color: #fff;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(2, 132, 199, 0.15);
}
.hover-scale:hover {
transform: scale(1.02);
.btn-primary:hover { background: #0369a1; }
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: #fff;
border: 1px solid #e2e8f0;
color: #475569;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover { background: #f8fafc; }
.btn-secondary:active { transform: scale(0.97); }
.btn-danger {
background: #fff;
border: 1px solid #fecaca;
color: #dc2626;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger:hover { background: #fef2f2; border-color: #f87171; }
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 0.375rem;
border-radius: 0.375rem;
transition: all 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover { background: #f1f5f9; }
.btn-icon.danger { color: #ef4444; }
.btn-icon.danger:hover { background: #fef2f2; }
/* ── Login Screen ── */
.login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
}
.login-card {
background: #fff;
border-radius: 1rem;
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
}
.login-card h1 { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 700; color: #0f172a; }
.login-card p { margin: 0 0 1.5rem; color: #64748b; font-size: 0.875rem; }
.login-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: all 0.2s;
box-sizing: border-box;
}
.login-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(14,165,233,0.15); }
.login-btn {
width: 100%;
padding: 0.75rem;
margin-top: 1rem;
background: var(--primary);
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.login-btn:hover { background: var(--primary-dark); }
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.login-error {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.375rem;
font-size: 0.813rem;
border: 1px solid #fecaca;
}
.active-nav {
@apply bg-primary-600 text-white shadow-lg shadow-primary-900/20;
/* ── Error Toast ── */
.error-toast {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
background: #dc2626;
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
animation: toast-in 0.3s ease-out;
max-width: 400px;
}
@keyframes toast-in {
from { transform: translateY(-1rem); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Custom Scrollbar for dark code areas */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
/* ── Layout ── */
.app-layout { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.app-header {
background: #fff;
border-bottom: 1px solid #e2e8f0;
height: 3.5rem;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
.app-body { display: flex; flex: 1; overflow: hidden; }
/* Sidebar */
.app-sidebar {
width: 16rem;
background: var(--sidebar-bg);
color: #94a3b8;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
.sidebar-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
transition: all 0.2s;
text-decoration: none;
color: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
.sidebar-link:hover { background: rgba(255,255,255,0.05); color: #fff; }
.sidebar-link.active { background: var(--primary); color: #fff; box-shadow: 0 4px 6px -1px rgba(14,165,233,0.3); }
.sidebar-link svg { width: 1.25rem; height: 1.25rem; opacity: 0.75; }
/* Main content */
.app-main {
flex: 1;
overflow-y: auto;
padding: 1.5rem 2rem;
background: rgba(248,250,252,0.5);
scroll-behavior: smooth;
}
/* Sidebar glass */
nav {
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
border-right: 1px solid rgba(255, 255, 255, 0.05);
/* ── Cards & Tables ── */
.card {
background: #fff;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
overflow: hidden;
}
.card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(248,250,252,0.5);
}
.card-header h2, .card-header h3 { margin: 0; font-size: 0.875rem; font-weight: 700; color: #1e293b; }
.card-body { padding: 1rem 1.25rem; }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; text-align: left; }
.data-table thead { background: #f8fafc; }
.data-table th { padding: 0.75rem 1.25rem; font-size: 0.688rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; border-bottom: 1px solid #e2e8f0; }
.data-table td { padding: 0.75rem 1.25rem; border-bottom: 1px solid #f1f5f9; color: #334155; }
.data-table tbody tr:hover { background: #f8fafc; }
.data-table .mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.75rem; color: #64748b; }
/* ── Stat Cards ── */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; }
.stat-card {
background: #fff;
padding: 1.25rem;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.stat-label { font-size: 0.688rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 0.25rem; }
.stat-value { font-size: 1.875rem; font-weight: 700; color: #0f172a; }
.stat-icon { padding: 0.5rem; border-radius: 0.5rem; }
/* ── Status Badge ── */
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-green { background: #dcfce7; color: #166534; }
.badge-yellow { background: #fef9c3; color: #854d0e; }
.badge-red { background: #fee2e2; color: #991b1b; }
/* ── Code / Terminal ── */
.terminal-bg {
background: #0f172a;
border-radius: 0.5rem;
padding: 1rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #94a3b8;
overflow: auto;
line-height: 1.6;
}
.terminal-bg pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
/* ── Search Input ── */
.search-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: all 0.2s;
box-sizing: border-box;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'/%3E%3C/svg%3E") no-repeat 0.5rem center;
}
.search-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(14,165,233,0.1); }
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ── Transitions ── */
.fade-in { animation: fadeIn 0.2s ease-out; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Modal-like card glow */
.glass:hover {
border-color: rgba(14, 165, 233, 0.3);
box-shadow: 0 10px 25px -5px rgba(14, 165, 233, 0.05), 0 8px 10px -6px rgba(14, 165, 233, 0.05);
}
/* ── Realtime Console ── */
.rt-stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1rem; }
.rt-stat { text-align: center; padding: 1rem; background: #f8fafc; border-radius: 0.5rem; border: 1px solid #e2e8f0; }
.rt-stat .value { font-size: 1.5rem; font-weight: 700; color: #0f172a; }
.rt-stat .label { font-size: 0.688rem; text-transform: uppercase; color: #64748b; font-weight: 600; margin-top: 0.25rem; }
/* Smooth layout transitions */
main {
transition: all 0.3s ease;
}
/* Code area glow */
textarea:focus {
box-shadow: inset 0 0 20px rgba(14, 165, 233, 0.1);
/* ── Responsive ── */
@media (max-width: 1024px) {
.app-sidebar { width: 12rem; }
.app-main { padding: 1rem; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
}

View File

@@ -1,20 +1,24 @@
const { createApp } = Vue;
const API_BASE = '/platform/v1';
const API = '/platform/v1';
createApp({
data() {
return {
currentTab: 'dashboard',
gatewayStatus: 'Checking...',
serviceKey: '',
// Auth State
// Auth
isAuthenticated: false,
isVerifyingAuth: true,
isLoggingIn: false,
adminPassword: '',
loginError: null,
csrfToken: '',
// UI
currentTab: 'dashboard',
gatewayStatus: 'Checking...',
errorMessage: '',
grafanaUrl: window.MADBASE_GRAFANA_URL || '/grafana',
appVersion: '',
// Dashboard
projects: [],
newProjectName: '',
@@ -23,6 +27,12 @@ createApp({
metricsInterval: null,
pillars: [],
pillarInterval: null,
chart: null,
// Auth Management
authUsers: [],
authUserSearch: '',
selectedAuthUser: null,
// Functions
functions: [],
@@ -47,6 +57,7 @@ createApp({
wsChannel: 'room:lobby',
wsMessages: [],
wsInput: '{"event":"broadcast","payload":{"message":"Hello"}}',
rtStats: { connections: 0, channels: 0, events: 0 },
// Logs
logQuery: '{app="gateway"}',
@@ -54,21 +65,59 @@ createApp({
logs: []
}
},
computed: {
filteredAuthUsers() {
if (!this.authUserSearch) return this.authUsers;
const q = this.authUserSearch.toLowerCase();
return this.authUsers.filter(u =>
(u.email || '').toLowerCase().includes(q) ||
(u.id || '').toLowerCase().includes(q)
);
}
},
async mounted() {
console.log('MadBase Studio: Mounting...');
await this.checkAuth();
this.isVerifyingAuth = false;
},
methods: {
async checkAuth() {
// Quick check if we can access the platform API
// ─── Error Handling ───
showError(msg) {
this.errorMessage = msg;
setTimeout(() => this.errorMessage = '', 5000);
},
async apiCall(url, options = {}) {
try {
const res = await fetch(`${API_BASE}/projects`);
// Add CSRF token to mutations
if (options.method && options.method !== 'GET') {
options.headers = options.headers || {};
options.headers['X-CSRF-Token'] = this.csrfToken;
}
const resp = await fetch(url, options);
if (!resp.ok) {
let errMsg = 'Request failed';
try {
const err = await resp.json();
errMsg = err.error || err.message || errMsg;
} catch { /* ignore */ }
this.showError(errMsg);
return null;
}
return resp;
} catch (e) {
this.showError(e.message || 'Network error');
return null;
}
},
// ─── Auth ───
async checkAuth() {
try {
const res = await fetch(`${API}/projects`);
if (res.status === 401) {
this.isAuthenticated = false;
} else {
this.isAuthenticated = true;
this.onAuthenticated();
await this.onAuthenticated();
}
} catch {
this.isAuthenticated = false;
@@ -78,15 +127,15 @@ createApp({
this.isLoggingIn = true;
this.loginError = null;
try {
const res = await fetch(`${API_BASE}/login`, {
const res = await fetch(`${API}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: this.adminPassword })
});
if (res.ok) {
this.isAuthenticated = true;
this.onAuthenticated();
this.adminPassword = '';
await this.onAuthenticated();
} else {
this.loginError = 'Invalid admin password';
}
@@ -96,11 +145,43 @@ createApp({
this.isLoggingIn = false;
}
},
async logout() {
await fetch(`${API}/logout`, { method: 'POST' });
this.isAuthenticated = false;
this.projects = [];
this.users = [];
this.authUsers = [];
this.functions = [];
this.buckets = [];
this.objects = [];
this.pillars = [];
this.csrfToken = '';
if (this.metricsInterval) clearInterval(this.metricsInterval);
if (this.pillarInterval) clearInterval(this.pillarInterval);
if (this.ws) { this.ws.close(); this.wsConnected = false; }
this.currentTab = 'dashboard';
},
async onAuthenticated() {
// First fetch config (service key)
await this.fetchAdminConfig();
// Then init regular data
// Fetch CSRF token
try {
const res = await fetch(`${API}/csrf-token`);
if (res.ok) {
const data = await res.json();
this.csrfToken = data.token;
}
} catch { /* ignore */ }
// Fetch admin config
try {
const res = await fetch(`${API}/admin/config`);
if (res.ok) {
const data = await res.json();
this.grafanaUrl = data.grafana_url || this.grafanaUrl;
this.appVersion = data.version || '';
}
} catch { /* ignore */ }
// Init data
this.checkHealth();
this.fetchProjects();
this.fetchUsers();
@@ -112,19 +193,9 @@ createApp({
this.metricsInterval = setInterval(this.fetchMetrics, 5000);
if (this.pillarInterval) clearInterval(this.pillarInterval);
this.pillarInterval = setInterval(this.fetchPillars, 5000);
console.log('MadBase Studio: Ready.');
},
async fetchAdminConfig() {
try {
const res = await fetch(`${API_BASE}/admin/config`);
const data = await res.json();
this.serviceKey = data.service_role_key;
console.log('MadBase Studio: Service Key Provisioned.');
} catch (e) {
console.error('Failed to fetch admin config:', e);
}
},
// Dashboard
// ─── Dashboard ───
async checkHealth() {
try {
const res = await fetch('/');
@@ -132,108 +203,95 @@ createApp({
} catch { this.gatewayStatus = 'Offline'; }
},
async fetchProjects() {
try {
const res = await fetch(`${API_BASE}/projects`);
this.projects = await res.json();
} catch (e) { console.error(e); }
const res = await this.apiCall(`${API}/projects`);
if (res) this.projects = await res.json();
},
async createProject() {
if (!this.newProjectName) return;
await fetch(`${API_BASE}/projects`, {
const res = await this.apiCall(`${API}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: this.newProjectName, owner_id: null })
});
this.newProjectName = '';
this.fetchProjects();
if (res) {
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 deleteProject(id, name) {
if (!confirm(`Delete project "${name || id}"? This cannot be undone.`)) return;
const res = await this.apiCall(`${API}/projects/${id}`, { method: 'DELETE' });
if (res) this.fetchProjects();
},
async fetchUsers() {
try {
const res = await fetch(`${API_BASE}/users`);
this.users = await res.json();
} catch (e) { console.error(e); }
const res = await this.apiCall(`${API}/users`);
if (res) this.users = await res.json();
},
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();
if (!confirm(`Delete user ${id}? This cannot be undone.`)) return;
const res = await this.apiCall(`${API}/users/${id}`, { method: 'DELETE' });
if (res) 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');
// Parse for chart
const match = text.match(/axum_http_requests_total\{.*?\} (\d+)/);
if (match && this.chart) {
const val = parseInt(match[1]);
const now = new Date().toLocaleTimeString();
this.chart.data.labels.push(now);
this.chart.data.datasets[0].data.push(val);
if (this.chart.data.labels.length > 20) {
this.chart.data.labels.shift();
this.chart.data.datasets[0].data.shift();
}
this.chart.update('none');
}
} catch {}
} catch { /* ignore */ }
},
async fetchPillars() {
try {
const res = await fetch(`${API_BASE}/cluster/pillars`);
if (res.ok) {
this.pillars = await res.json();
}
} catch (e) { console.error('Pillar fetch failed:', e); }
const res = await this.apiCall(`${API}/cluster/pillars`);
if (res) {
try { this.pillars = await res.json(); } catch { /* ignore */ }
}
},
async scalePillar(pillar, direction) {
const p = this.pillars.find(x => x.pillar === pillar);
if (!p) return;
const targetCount = direction === 'up' ? p.node_count + 1 : p.node_count - 1;
if (targetCount < 1) return;
const req = {
provider: 'hetzner', // Default for now
provider: 'hetzner',
target_worker_count: pillar === 'worker' ? targetCount : undefined,
target_db_count: pillar === 'database' ? targetCount : undefined,
target_control_count: pillar === 'proxyapi' ? targetCount : undefined,
};
try {
const res = await fetch(`${API_BASE}/cluster/scale-plan`, {
const res = await this.apiCall(`${API}/cluster/scale-plan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req)
});
if (!res) return;
const plan = await res.json();
if (confirm(`Scaling ${pillar} to ${targetCount} nodes. Monthly cost: €${plan.total_cost_monthly}. Proceed?`)) {
await this.apiCall(`${API}/cluster/scale-execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req)
body: JSON.stringify(plan.scaling_plan)
});
const plan = await res.json();
if (confirm(`Scaling ${pillar} to ${targetCount} nodes. Monthly cost impact: €${plan.total_cost_monthly}. Proceed?`)) {
await fetch(`${API_BASE}/cluster/scale-execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(plan.scaling_plan)
});
this.fetchPillars();
}
} catch (e) { alert('Scaling failed: ' + e.message); }
this.fetchPillars();
}
},
initChart() {
if (typeof Chart === 'undefined') {
console.warn('MadBase Studio: Chart.js is not loaded yet.');
return;
}
if (typeof Chart === 'undefined') return;
const ctx = document.getElementById('metricsChart');
if (!ctx) return;
this.chart = new Chart(ctx, {
type: 'line',
data: {
@@ -242,39 +300,47 @@ createApp({
label: 'HTTP Requests',
data: [],
borderColor: '#0ea5e9',
backgroundColor: 'rgba(14, 165, 233, 0.1)',
fill: true,
tension: 0.4,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4
backgroundColor: 'rgba(14,165,233,0.1)',
fill: true, tension: 0.4, borderWidth: 2,
pointRadius: 0, pointHoverRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { display: false },
y: {
beginAtZero: false,
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: { font: { size: 10 } }
}
y: { beginAtZero: false, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { font: { size: 10 } } }
},
animation: { duration: 0 }
}
});
},
// Functions
// ─── Auth Management ───
async fetchAuthUsers() {
const res = await this.apiCall(`${API}/users`);
if (res) this.authUsers = await res.json();
},
selectAuthUser(user) {
this.selectedAuthUser = user;
},
async deleteAuthUser(id) {
if (!confirm(`Delete user ${id}? This cannot be undone.`)) return;
const res = await this.apiCall(`${API}/users/${id}`, { method: 'DELETE' });
if (res) {
this.selectedAuthUser = null;
this.fetchAuthUsers();
this.fetchUsers();
}
},
// ─── Functions ───
async fetchFunctions() {
try {
const res = await fetch('/functions/v1', {
headers: { 'x-project-ref': 'default', 'Authorization': `Bearer ${this.serviceKey}` }
});
this.functions = await res.json();
} catch (e) { console.error(e); }
const res = await this.apiCall(`${API}/functions`);
if (res) {
try { this.functions = await res.json(); } catch { this.functions = []; }
}
},
initNewFunction() {
this.selectedFunction = { name: 'new-func' };
@@ -284,129 +350,102 @@ createApp({
async deployFunction() {
this.isDeploying = true;
try {
const payload = {
name: this.newFunctionName,
runtime: 'deno',
code_base64: btoa(this.newFunctionCode)
};
const res = await fetch('/functions/v1', {
const payload = { name: this.newFunctionName, runtime: 'deno', code_base64: btoa(this.newFunctionCode) };
const res = await this.apiCall(`${API}/functions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-project-ref': 'default',
'Authorization': `Bearer ${this.serviceKey}`
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
alert('Function deployed successfully!');
if (res) {
this.showError(''); // Clear any error
this.fetchFunctions();
this.selectedFunction = null; // Clear view
} else {
alert('Deployment failed: ' + await res.text());
this.selectedFunction = null;
}
} catch (e) { alert('Deployment error: ' + e); }
finally { this.isDeploying = false; }
} finally { this.isDeploying = false; }
},
async selectFunction(name) {
try {
const res = await fetch(`/functions/v1/${name}`, {
headers: { 'x-project-ref': 'default', 'Authorization': `Bearer ${this.serviceKey}` }
});
const res = await this.apiCall(`${API}/functions/${name}`);
if (res) {
const func = await res.json();
this.selectedFunction = func;
this.newFunctionName = func.name;
this.newFunctionCode = atob(func.code);
} catch (e) { console.error(e); }
}
},
// Database
// ─── Database ───
async fetchTables() {
try {
const res = await fetch(`${API_BASE}/db/tables`);
this.tables = await res.json();
} catch (e) { console.error(e); }
const res = await this.apiCall(`${API}/db/tables`);
if (res) this.tables = await res.json();
},
async selectTable(t) {
this.selectedTable = t;
this.tableData = [];
try {
const res = await fetch(`${API_BASE}/db/tables/${t.schema}/${t.name}`);
this.tableData = await res.json();
} catch (e) { console.error(e); }
const res = await this.apiCall(`${API}/db/tables/${t.schema}/${t.name}`);
if (res) this.tableData = await res.json();
},
// Storage
// ─── Storage (admin-proxied, no service key) ───
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.'); }
const res = await this.apiCall(`${API}/storage/buckets`);
if (res) this.buckets = await res.json();
},
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); }
const res = await this.apiCall(`${API}/storage/buckets/${id}/objects`, { method: 'POST' });
if (res) this.objects = await res.json();
},
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'); }
const res = await this.apiCall(`${API}/storage/upload/${this.selectedBucket}/${file.name}`, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file
});
if (res) {
this.selectBucket(this.selectedBucket);
}
event.target.value = '';
},
async deleteObject(bucketId, objectName) {
if (!confirm(`Delete "${objectName}"?`)) return;
const res = await this.apiCall(`${API}/storage/${bucketId}/${objectName}`, { method: 'DELETE' });
if (res) this.selectBucket(bucketId);
},
getObjectUrl(name) {
return `/storage/v1/object/${this.selectedBucket}/${name}?token=SERVICE_ROLE_BYPASS_NOT_IMPLEMENTED`;
return `${API}/storage/${this.selectedBucket}/${name}`;
},
formatBytes(bytes, decimals = 2) {
if (!+bytes) return '0 Bytes';
if (!+bytes) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB'];
const sizes = ['B', '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
// ─── 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`;
const url = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/realtime/v1/websocket?vsn=1.0.0`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.wsConnected = true;
this.rtStats.connections++;
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.rtStats.events++;
this.wsMessages.push({ type: 'in', time: new Date().toLocaleTimeString(), data: e.data });
if (this.wsMessages.length > 100) this.wsMessages.shift();
if (this.wsMessages.length > 200) this.wsMessages.shift();
};
this.ws.onclose = () => {
this.wsConnected = false;
this.wsMessages.push({ type: 'sys', time: new Date().toLocaleTimeString(), data: 'Connection Closed' });
@@ -419,31 +458,29 @@ createApp({
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'); }
} catch { this.showError('Invalid JSON'); }
},
sendJson(data) {
const str = JSON.stringify(data);
this.ws.send(str);
this.wsMessages.push({ type: 'out', time: new Date().toLocaleTimeString(), data: str });
},
clearWsMessages() {
this.wsMessages = [];
this.rtStats.events = 0;
},
// Logs
// ─── 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 params = new URLSearchParams({ query: this.logQuery, limit: this.logLimit });
const res = await this.apiCall(`${API}/logs?${params}`);
if (res) {
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);
}
}
},
@@ -452,6 +489,7 @@ createApp({
if (val === 'storage' && !this.buckets.length) this.fetchBuckets();
if (val === 'functions' && !this.functions.length) this.fetchFunctions();
if (val === 'database' && !this.tables.length) this.fetchTables();
if (val === 'auth' && !this.authUsers.length) this.fetchAuthUsers();
}
}
}).mount('#app');

14
web/vendor/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

13
web/vendor/vue.global.prod.js vendored Normal file

File diff suppressed because one or more lines are too long