428 lines
38 KiB
HTML
428 lines
38 KiB
HTML
<!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>
|
|
<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 src="/vendor/vue.global.prod.js"></script>
|
|
<script src="/vendor/chart.umd.min.js"></script>
|
|
<link rel="stylesheet" href="/css/admin.css">
|
|
</head>
|
|
<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>
|
|
<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>
|
|
|
|
<!-- 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 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>
|
|
</header>
|
|
|
|
<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>
|
|
|
|
<!-- 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="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 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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
</div>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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="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>
|
|
|
|
<!-- 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>
|
|
<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>
|
|
|
|
<!-- 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="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>
|
|
|
|
<!-- 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 src="/js/admin.js"></script>
|
|
</body>
|
|
</html>
|