chore: full stack stability and migration fixes, plus react UI progress
This commit is contained in:
871
web/admin.html
871
web/admin.html
@@ -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' ? '<< RECV' : msg.type === 'out' ? '>> 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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
400
web/js/admin.js
400
web/js/admin.js
@@ -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
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
13
web/vendor/vue.global.prod.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user