/* ============================================================ OPS Dashboard — Alpine.js Application Logic ============================================================ */ 'use strict'; // ---------------------------------------------------------------- // Helpers // ---------------------------------------------------------------- function formatBytes(bytes) { if (bytes == null || bytes === '') return '—'; const n = Number(bytes); if (isNaN(n) || n === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(Math.abs(n)) / Math.log(k)); return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i]; } function timeAgo(dateInput) { if (!dateInput) return '—'; const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; if (isNaN(date)) return '—'; const secs = Math.floor((Date.now() - date.getTime()) / 1000); if (secs < 60) return secs + 's ago'; if (secs < 3600) return Math.floor(secs / 60) + 'm ago'; if (secs < 86400) return Math.floor(secs / 3600) + 'h ago'; return Math.floor(secs / 86400) + 'd ago'; } function ageHours(dateInput) { if (!dateInput) return 0; const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; if (isNaN(date)) return 0; return (Date.now() - date.getTime()) / 3600000; } function ageBadgeClass(dateInput) { const h = ageHours(dateInput); if (h >= 48) return 'badge-red'; if (h >= 24) return 'badge-yellow'; return 'badge-green'; } function statusBadgeClass(status, health) { const s = (status || '').toLowerCase(); const h = (health || '').toLowerCase(); if (s === 'running' && (h === 'healthy' || h === '')) return 'badge-green'; if (s === 'running' && h === 'unhealthy') return 'badge-red'; if (s === 'running' && h === 'starting') return 'badge-yellow'; if (s === 'restarting' || h === 'starting') return 'badge-yellow'; if (s === 'exited' || s === 'dead' || s === 'removed') return 'badge-red'; if (s === 'paused') return 'badge-yellow'; return 'badge-gray'; } function statusDotClass(status, health) { const cls = statusBadgeClass(status, health); return cls.replace('badge-', 'status-dot-'); } function diskBarClass(pct) { if (pct >= 90) return 'disk-danger'; if (pct >= 75) return 'disk-warn'; return 'disk-ok'; } // ---------------------------------------------------------------- // Auth Store // ---------------------------------------------------------------- function authStore() { return { token: localStorage.getItem('ops_token') || '', loginInput: '', loginError: '', loading: false, get isAuthenticated() { return !!this.token; }, async login() { this.loginError = ''; if (!this.loginInput.trim()) { this.loginError = 'Please enter your access token.'; return; } this.loading = true; try { const res = await fetch('/api/status', { headers: { 'Authorization': 'Bearer ' + this.loginInput.trim() } }); if (res.ok || res.status === 200) { this.token = this.loginInput.trim(); localStorage.setItem('ops_token', this.token); this.loginInput = ''; // Trigger page load this.$dispatch('authenticated'); } else if (res.status === 401) { this.loginError = 'Invalid token. Please try again.'; } else { this.loginError = 'Server error (' + res.status + '). Please try again.'; } } catch { this.loginError = 'Could not reach the server.'; } finally { this.loading = false; } }, logout() { this.token = ''; localStorage.removeItem('ops_token'); } }; } // ---------------------------------------------------------------- // API Helper // ---------------------------------------------------------------- function api(path, options = {}) { const token = localStorage.getItem('ops_token') || ''; const headers = Object.assign({ 'Authorization': 'Bearer ' + token }, options.headers || {}); if (options.json) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(options.json); delete options.json; } return fetch(path, Object.assign({}, options, { headers })).then(res => { if (res.status === 401) { localStorage.removeItem('ops_token'); window.dispatchEvent(new CustomEvent('unauthorized')); throw new Error('Unauthorized'); } return res; }); } // ---------------------------------------------------------------- // Toast Store // ---------------------------------------------------------------- function toastStore() { return { toasts: [], _counter: 0, add(msg, type = 'info', duration = 4000) { const id = ++this._counter; this.toasts.push({ id, msg, type }); if (duration > 0) { setTimeout(() => this.remove(id), duration); } return id; }, remove(id) { const idx = this.toasts.findIndex(t => t.id === id); if (idx !== -1) this.toasts.splice(idx, 1); }, success(msg) { return this.add(msg, 'toast-success'); }, error(msg) { return this.add(msg, 'toast-error', 6000); }, warn(msg) { return this.add(msg, 'toast-warning'); }, info(msg) { return this.add(msg, 'toast-info'); }, iconFor(type) { const icons = { 'toast-success': '✓', 'toast-error': '✕', 'toast-warning': '⚠', 'toast-info': 'ℹ' }; return icons[type] || 'ℹ'; } }; } // ---------------------------------------------------------------- // App Root Store // ---------------------------------------------------------------- function appStore() { return { page: 'dashboard', sidebarOpen: false, toast: null, init() { this.toast = Alpine.store('toast'); window.addEventListener('unauthorized', () => { Alpine.store('auth').logout(); }); window.addEventListener('authenticated', () => { this.loadPage('dashboard'); }); }, navigate(page) { this.page = page; this.sidebarOpen = false; this.loadPage(page); }, loadPage(page) { const storeMap = { dashboard: 'dashboard', backups: 'backups', restore: 'restore', services: 'services', system: 'system' }; const storeName = storeMap[page]; if (storeName && Alpine.store(storeName) && Alpine.store(storeName).load) { Alpine.store(storeName).load(); } } }; } // ---------------------------------------------------------------- // Dashboard Store // ---------------------------------------------------------------- function dashboardStore() { return { projects: [], loading: false, error: null, lastRefresh: null, refreshInterval: null, autoRefreshEnabled: true, refreshing: false, load() { this.fetch(); this.startAutoRefresh(); }, async fetch() { if (this.loading) return; this.loading = true; this.error = null; try { const res = await api('/api/status'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.projects = this.groupByProject(data); this.lastRefresh = new Date(); } catch (e) { if (e.message !== 'Unauthorized') { this.error = e.message || 'Failed to load status'; } } finally { this.loading = false; this.refreshing = false; } }, groupByProject(data) { // data may be array of containers or object keyed by project let containers = Array.isArray(data) ? data : Object.values(data).flat(); const map = {}; for (const c of containers) { const proj = c.project || 'Other'; if (!map[proj]) map[proj] = []; map[proj].push(c); } return Object.entries(map).map(([name, services]) => ({ name, services })) .sort((a, b) => a.name.localeCompare(b.name)); }, manualRefresh() { this.refreshing = true; this.fetch(); }, startAutoRefresh() { this.stopAutoRefresh(); if (this.autoRefreshEnabled) { this.refreshInterval = setInterval(() => this.fetch(), 30000); } }, stopAutoRefresh() { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } }, toggleAutoRefresh() { this.autoRefreshEnabled = !this.autoRefreshEnabled; if (this.autoRefreshEnabled) { this.startAutoRefresh(); } else { this.stopAutoRefresh(); } }, destroy() { this.stopAutoRefresh(); }, badgeClass: statusBadgeClass, dotClass: statusDotClass, timeAgo }; } // ---------------------------------------------------------------- // Backups Store // ---------------------------------------------------------------- function backupsStore() { return { local: [], offsite: [], loading: false, loadingOffsite: false, error: null, ops: {}, // track per-row operation state: key -> { loading, done, error } load() { this.fetchLocal(); this.fetchOffsite(); }, async fetchLocal() { this.loading = true; this.error = null; try { const res = await api('/api/backups'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.local = Array.isArray(data) ? data : Object.values(data).flat(); } catch (e) { if (e.message !== 'Unauthorized') this.error = e.message; } finally { this.loading = false; } }, async fetchOffsite() { this.loadingOffsite = true; try { const res = await api('/api/backups/offsite'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.offsite = Array.isArray(data) ? data : Object.values(data).flat(); } catch (e) { if (e.message !== 'Unauthorized') Alpine.store('toast').error('Offsite: ' + (e.message || 'Failed')); } finally { this.loadingOffsite = false; } }, opKey(project, env, action) { return `${action}::${project}::${env}`; }, isRunning(project, env, action) { return !!(this.ops[this.opKey(project, env, action)]?.loading); }, async backupNow(project, env) { const key = this.opKey(project, env, 'backup'); this.ops = { ...this.ops, [key]: { loading: true } }; try { const res = await api(`/api/backups/${project}/${env}`, { method: 'POST' }); if (!res.ok) throw new Error('HTTP ' + res.status); Alpine.store('toast').success(`Backup started: ${project}/${env}`); setTimeout(() => this.fetchLocal(), 2000); } catch (e) { if (e.message !== 'Unauthorized') Alpine.store('toast').error(`Backup failed: ${e.message}`); } finally { this.ops = { ...this.ops, [key]: { loading: false } }; } }, async uploadOffsite(project, env) { const key = this.opKey(project, env, 'upload'); this.ops = { ...this.ops, [key]: { loading: true } }; try { const res = await api(`/api/backups/offsite/upload/${project}/${env}`, { method: 'POST' }); if (!res.ok) throw new Error('HTTP ' + res.status); Alpine.store('toast').success(`Upload started: ${project}/${env}`); setTimeout(() => this.fetchOffsite(), 3000); } catch (e) { if (e.message !== 'Unauthorized') Alpine.store('toast').error(`Upload failed: ${e.message}`); } finally { this.ops = { ...this.ops, [key]: { loading: false } }; } }, retentionRunning: false, async applyRetention() { this.retentionRunning = true; try { const res = await api('/api/backups/offsite/retention', { method: 'POST' }); if (!res.ok) throw new Error('HTTP ' + res.status); Alpine.store('toast').success('Retention policy applied'); setTimeout(() => this.fetchOffsite(), 2000); } catch (e) { if (e.message !== 'Unauthorized') Alpine.store('toast').error(`Retention failed: ${e.message}`); } finally { this.retentionRunning = false; } }, ageBadge: ageBadgeClass, timeAgo, formatBytes }; } // ---------------------------------------------------------------- // Restore Store // ---------------------------------------------------------------- function restoreStore() { return { source: 'local', project: '', env: '', dryRun: false, confirming: false, running: false, output: [], sseSource: null, projects: [], envs: [], loadingProjects: false, error: null, load() { this.loadProjectList(); }, async loadProjectList() { this.loadingProjects = true; try { const endpoint = this.source === 'offsite' ? '/api/backups/offsite' : '/api/backups'; const res = await api(endpoint); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); const items = Array.isArray(data) ? data : Object.values(data).flat(); const projSet = new Set(items.map(i => i.project).filter(Boolean)); this.projects = Array.from(projSet).sort(); this.project = this.projects[0] || ''; this.updateEnvs(items); } catch (e) { if (e.message !== 'Unauthorized') this.error = e.message; } finally { this.loadingProjects = false; } }, updateEnvs(items) { if (!items) return; const envSet = new Set( items.filter(i => i.project === this.project).map(i => i.env).filter(Boolean) ); this.envs = Array.from(envSet).sort(); this.env = this.envs[0] || ''; }, onSourceChange() { this.project = ''; this.env = ''; this.envs = []; this.loadProjectList(); }, onProjectChange() { // Re-fetch envs for this project from the already loaded data this.loadProjectList(); }, confirm() { if (!this.project || !this.env) { Alpine.store('toast').warn('Select project and environment first'); return; } this.confirming = true; }, cancel() { this.confirming = false; }, async execute() { this.confirming = false; this.running = true; this.output = []; const params = new URLSearchParams({ source: this.source, dry_run: this.dryRun ? '1' : '0' }); const url = `/api/restore/${this.project}/${this.env}?${params}`; try { this.sseSource = new EventSource(url + '&token=' + encodeURIComponent(localStorage.getItem('ops_token') || '')); this.sseSource.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.done) { this.sseSource.close(); this.sseSource = null; this.running = false; if (msg.success) { Alpine.store('toast').success('Restore completed'); } else { Alpine.store('toast').error('Restore finished with errors'); } return; } const text = msg.line || e.data; this.output.push({ text, cls: this.classifyLine(text) }); } catch { this.output.push({ text: e.data, cls: this.classifyLine(e.data) }); } this.$nextTick(() => { const el = document.getElementById('restore-output'); if (el) el.scrollTop = el.scrollHeight; }); }; this.sseSource.onerror = () => { if (this.running) { this.running = false; if (this.sseSource) this.sseSource.close(); this.sseSource = null; } }; } catch (e) { this.running = false; Alpine.store('toast').error('Restore failed: ' + (e.message || 'Unknown error')); } }, classifyLine(text) { const t = text.toLowerCase(); if (t.includes('error') || t.includes('fail') || t.includes('critical')) return 'line-error'; if (t.includes('warn')) return 'line-warn'; if (t.includes('ok') || t.includes('success') || t.includes('done')) return 'line-ok'; if (t.startsWith('$') || t.startsWith('#') || t.startsWith('>')) return 'line-cmd'; return ''; }, abort() { if (this.sseSource) { this.sseSource.close(); this.sseSource = null; } this.running = false; this.output.push({ text: '--- aborted by user ---', cls: 'line-warn' }); } }; } // ---------------------------------------------------------------- // Services Store // ---------------------------------------------------------------- function servicesStore() { return { projects: [], loading: false, error: null, logModal: { open: false, title: '', lines: [], loading: false }, confirmRestart: { open: false, project: '', env: '', service: '', running: false }, load() { this.fetch(); }, async fetch() { this.loading = true; this.error = null; try { const res = await api('/api/status'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.projects = this.groupByProject(data); } catch (e) { if (e.message !== 'Unauthorized') this.error = e.message; } finally { this.loading = false; } }, groupByProject(data) { let containers = Array.isArray(data) ? data : Object.values(data).flat(); const map = {}; for (const c of containers) { const proj = c.project || 'Other'; if (!map[proj]) map[proj] = []; map[proj].push(c); } return Object.entries(map).map(([name, services]) => ({ name, services })) .sort((a, b) => a.name.localeCompare(b.name)); }, async viewLogs(project, env, service) { this.logModal = { open: true, title: `${service} — logs`, lines: [], loading: true }; try { const res = await api(`/api/services/logs/${project}/${env}/${service}?lines=150`); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.logModal.lines = (data.logs || '').split('\n'); } catch (e) { if (e.message !== 'Unauthorized') this.logModal.lines = ['Error: ' + e.message]; } finally { this.logModal.loading = false; this.$nextTick(() => { const el = document.getElementById('log-output'); if (el) el.scrollTop = el.scrollHeight; }); } }, closeLogs() { this.logModal.open = false; }, askRestart(project, env, service) { this.confirmRestart = { open: true, project, env, service, running: false }; }, cancelRestart() { this.confirmRestart.open = false; }, async doRestart() { const { project, env, service } = this.confirmRestart; this.confirmRestart.running = true; try { const res = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' }); if (!res.ok) throw new Error('HTTP ' + res.status); Alpine.store('toast').success(`${service} restarted`); this.confirmRestart.open = false; setTimeout(() => this.fetch(), 2000); } catch (e) { if (e.message !== 'Unauthorized') Alpine.store('toast').error(`Restart failed: ${e.message}`); } finally { this.confirmRestart.running = false; } }, badgeClass: statusBadgeClass, dotClass: statusDotClass }; } // ---------------------------------------------------------------- // System Store // ---------------------------------------------------------------- function systemStore() { return { disk: [], health: [], timers: [], info: {}, loading: { disk: false, health: false, timers: false, info: false }, error: null, load() { this.fetchDisk(); this.fetchHealth(); this.fetchTimers(); this.fetchInfo(); }, async fetchDisk() { this.loading.disk = true; try { const res = await api('/api/system/disk'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.disk = Array.isArray(data) ? data : (data.filesystems || []); } catch (e) { if (e.message !== 'Unauthorized') this.error = e.message; } finally { this.loading.disk = false; } }, async fetchHealth() { this.loading.health = true; try { const res = await api('/api/system/health'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.health = Array.isArray(data) ? data : (data.checks || []); } catch (e) { if (e.message !== 'Unauthorized') {} } finally { this.loading.health = false; } }, async fetchTimers() { this.loading.timers = true; try { const res = await api('/api/system/timers'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); this.timers = Array.isArray(data) ? data : (data.timers || []); } catch (e) { if (e.message !== 'Unauthorized') {} } finally { this.loading.timers = false; } }, async fetchInfo() { this.loading.info = true; try { const res = await api('/api/system/info'); if (!res.ok) throw new Error('HTTP ' + res.status); this.info = await res.json(); } catch (e) { if (e.message !== 'Unauthorized') {} } finally { this.loading.info = false; } }, diskBarClass, formatBytes, timeAgo }; } // ---------------------------------------------------------------- // Alpine initialization // ---------------------------------------------------------------- document.addEventListener('alpine:init', () => { Alpine.store('auth', authStore()); Alpine.store('toast', toastStore()); Alpine.store('app', appStore()); Alpine.store('dashboard', dashboardStore()); Alpine.store('backups', backupsStore()); Alpine.store('restore', restoreStore()); Alpine.store('services', servicesStore()); Alpine.store('system', systemStore()); // Init app store Alpine.store('app').init(); // Load the dashboard if already authenticated if (Alpine.store('auth').isAuthenticated) { Alpine.store('dashboard').load(); } });