const { createApp } = Vue; const API = '/platform/v1'; createApp({ data() { return { // 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: '', users: [], metrics: 'Loading...', metricsInterval: null, pillars: [], pillarInterval: null, chart: null, // Auth Management authUsers: [], authUserSearch: '', selectedAuthUser: null, // Functions functions: [], newFunctionName: 'hello-world', newFunctionCode: 'export default async function(req) { \n return new Response("Hello from MadBase!"); \n}', selectedFunction: null, isDeploying: false, // Database tables: [], selectedTable: null, tableData: [], // Storage buckets: [], selectedBucket: null, objects: [], // Realtime ws: null, wsConnected: false, wsChannel: 'room:lobby', wsMessages: [], wsInput: '{"event":"broadcast","payload":{"message":"Hello"}}', rtStats: { connections: 0, channels: 0, events: 0 }, // Logs logQuery: '{app="gateway"}', logLimit: 100, 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() { await this.checkAuth(); this.isVerifyingAuth = false; }, methods: { // ─── Error Handling ─── showError(msg) { this.errorMessage = msg; setTimeout(() => this.errorMessage = '', 5000); }, async apiCall(url, options = {}) { try { // 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; await this.onAuthenticated(); } } catch { this.isAuthenticated = false; } }, async login() { this.isLoggingIn = true; this.loginError = null; try { 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.adminPassword = ''; await this.onAuthenticated(); } else { this.loginError = 'Invalid admin password'; } } catch (e) { this.loginError = 'Connection failed: ' + e.message; } finally { 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() { // 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(); this.fetchFunctions(); this.fetchMetrics(); this.fetchPillars(); this.initChart(); if (this.metricsInterval) clearInterval(this.metricsInterval); this.metricsInterval = setInterval(this.fetchMetrics, 5000); if (this.pillarInterval) clearInterval(this.pillarInterval); this.pillarInterval = setInterval(this.fetchPillars, 5000); }, // ─── Dashboard ─── async checkHealth() { try { const res = await fetch('/'); this.gatewayStatus = res.ok ? 'Online' : 'Error'; } catch { this.gatewayStatus = 'Offline'; } }, async fetchProjects() { const res = await this.apiCall(`${API}/projects`); if (res) this.projects = await res.json(); }, async createProject() { if (!this.newProjectName) return; const res = await this.apiCall(`${API}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: this.newProjectName, owner_id: null }) }); if (res) { this.newProjectName = ''; 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() { const res = await this.apiCall(`${API}/users`); if (res) this.users = await res.json(); }, async deleteUser(id) { 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'); 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 { /* ignore */ } }, async fetchPillars() { 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', target_worker_count: pillar === 'worker' ? targetCount : undefined, target_db_count: pillar === 'database' ? targetCount : undefined, target_control_count: pillar === 'proxyapi' ? targetCount : undefined, }; 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(plan.scaling_plan) }); this.fetchPillars(); } }, initChart() { if (typeof Chart === 'undefined') return; const ctx = document.getElementById('metricsChart'); if (!ctx) return; this.chart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'HTTP Requests', data: [], borderColor: '#0ea5e9', backgroundColor: 'rgba(14,165,233,0.1)', fill: true, tension: 0.4, borderWidth: 2, pointRadius: 0, pointHoverRadius: 4 }] }, options: { 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 } } } }, animation: { duration: 0 } } }); }, // ─── 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() { const res = await this.apiCall(`${API}/functions`); if (res) { try { this.functions = await res.json(); } catch { this.functions = []; } } }, initNewFunction() { this.selectedFunction = { name: 'new-func' }; this.newFunctionName = 'new-func'; this.newFunctionCode = 'export default async function(req) { \n return new Response("Hello!"); \n}'; }, async deployFunction() { this.isDeploying = true; try { 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' }, body: JSON.stringify(payload) }); if (res) { this.showError(''); // Clear any error this.fetchFunctions(); this.selectedFunction = null; } } finally { this.isDeploying = false; } }, async selectFunction(name) { 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); } }, // ─── Database ─── async fetchTables() { const res = await this.apiCall(`${API}/db/tables`); if (res) this.tables = await res.json(); }, async selectTable(t) { this.selectedTable = t; this.tableData = []; const res = await this.apiCall(`${API}/db/tables/${t.schema}/${t.name}`); if (res) this.tableData = await res.json(); }, // ─── Storage (admin-proxied, no service key) ─── async fetchBuckets() { const res = await this.apiCall(`${API}/storage/buckets`); if (res) this.buckets = await res.json(); }, async selectBucket(id) { this.selectedBucket = id; this.objects = []; 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; 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 `${API}/storage/${this.selectedBucket}/${name}`; }, formatBytes(bytes, decimals = 2) { if (!+bytes) return '0 B'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; 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 ─── 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?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 > 200) 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 { 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 ─── async fetchLogs() { 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 = []; } } } }, watch: { currentTab(val) { 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');