added more support for supabase-js
This commit is contained in:
578
web/admin.html
Normal file
578
web/admin.html
Normal file
@@ -0,0 +1,578 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<h1 class="font-bold text-lg tracking-tight text-slate-800">MadBase <span class="text-slate-400 font-normal">Console</span></h1>
|
||||
</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 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>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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 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>
|
||||
{{ 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>
|
||||
</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">
|
||||
</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>
|
||||
</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"}'>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user