| .. | .. |
|---|
| 1 | | -/* ============================================================ |
|---|
| 2 | | - OPS Dashboard — Alpine.js Application Logic |
|---|
| 3 | | - ============================================================ */ |
|---|
| 4 | | - |
|---|
| 5 | 1 | 'use strict'; |
|---|
| 6 | 2 | |
|---|
| 7 | | -// ---------------------------------------------------------------- |
|---|
| 8 | | -// Helpers |
|---|
| 9 | | -// ---------------------------------------------------------------- |
|---|
| 3 | +// ============================================================ |
|---|
| 4 | +// OPS Dashboard — Vanilla JS Application |
|---|
| 5 | +// ============================================================ |
|---|
| 10 | 6 | |
|---|
| 7 | +// --------------------------------------------------------------------------- |
|---|
| 8 | +// State |
|---|
| 9 | +// --------------------------------------------------------------------------- |
|---|
| 10 | +let allServices = []; |
|---|
| 11 | +let currentPage = 'dashboard'; |
|---|
| 12 | +let drillLevel = 0; // 0=projects, 1=environments, 2=services |
|---|
| 13 | +let drillProject = null; |
|---|
| 14 | +let drillEnv = null; |
|---|
| 15 | +let refreshTimer = null; |
|---|
| 16 | +const REFRESH_INTERVAL = 30000; |
|---|
| 17 | + |
|---|
| 18 | +// Log modal state |
|---|
| 19 | +let logModalProject = null; |
|---|
| 20 | +let logModalEnv = null; |
|---|
| 21 | +let logModalService = null; |
|---|
| 22 | + |
|---|
| 23 | +// --------------------------------------------------------------------------- |
|---|
| 24 | +// Helpers |
|---|
| 25 | +// --------------------------------------------------------------------------- |
|---|
| 11 | 26 | function formatBytes(bytes) { |
|---|
| 12 | | - if (bytes == null || bytes === '') return '—'; |
|---|
| 27 | + if (bytes == null || bytes === '') return '\u2014'; |
|---|
| 13 | 28 | const n = Number(bytes); |
|---|
| 14 | 29 | if (isNaN(n) || n === 0) return '0 B'; |
|---|
| 15 | 30 | const k = 1024; |
|---|
| .. | .. |
|---|
| 19 | 34 | } |
|---|
| 20 | 35 | |
|---|
| 21 | 36 | function timeAgo(dateInput) { |
|---|
| 22 | | - if (!dateInput) return '—'; |
|---|
| 37 | + if (!dateInput) return '\u2014'; |
|---|
| 23 | 38 | const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; |
|---|
| 24 | | - if (isNaN(date)) return '—'; |
|---|
| 39 | + if (isNaN(date)) return '\u2014'; |
|---|
| 25 | 40 | const secs = Math.floor((Date.now() - date.getTime()) / 1000); |
|---|
| 26 | | - if (secs < 60) return secs + 's ago'; |
|---|
| 27 | | - if (secs < 3600) return Math.floor(secs / 60) + 'm ago'; |
|---|
| 28 | | - if (secs < 86400) return Math.floor(secs / 3600) + 'h ago'; |
|---|
| 41 | + if (secs < 60) return secs + 's ago'; |
|---|
| 42 | + if (secs < 3600) return Math.floor(secs / 60) + 'm ago'; |
|---|
| 43 | + if (secs < 86400) return Math.floor(secs / 3600) + 'h ago'; |
|---|
| 29 | 44 | return Math.floor(secs / 86400) + 'd ago'; |
|---|
| 30 | 45 | } |
|---|
| 31 | 46 | |
|---|
| 32 | | -function ageHours(dateInput) { |
|---|
| 33 | | - if (!dateInput) return 0; |
|---|
| 34 | | - const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; |
|---|
| 35 | | - if (isNaN(date)) return 0; |
|---|
| 36 | | - return (Date.now() - date.getTime()) / 3600000; |
|---|
| 37 | | -} |
|---|
| 38 | | - |
|---|
| 39 | | -function ageBadgeClass(dateInput) { |
|---|
| 40 | | - const h = ageHours(dateInput); |
|---|
| 41 | | - if (h >= 48) return 'badge-red'; |
|---|
| 42 | | - if (h >= 24) return 'badge-yellow'; |
|---|
| 43 | | - return 'badge-green'; |
|---|
| 44 | | -} |
|---|
| 45 | | - |
|---|
| 46 | | -function statusBadgeClass(status, health) { |
|---|
| 47 | | - const s = (status || '').toLowerCase(); |
|---|
| 48 | | - const h = (health || '').toLowerCase(); |
|---|
| 49 | | - if (s === 'running' && (h === 'healthy' || h === '')) return 'badge-green'; |
|---|
| 50 | | - if (s === 'running' && h === 'unhealthy') return 'badge-red'; |
|---|
| 51 | | - if (s === 'running' && h === 'starting') return 'badge-yellow'; |
|---|
| 52 | | - if (s === 'restarting' || h === 'starting') return 'badge-yellow'; |
|---|
| 53 | | - if (s === 'exited' || s === 'dead' || s === 'removed') return 'badge-red'; |
|---|
| 54 | | - if (s === 'paused') return 'badge-yellow'; |
|---|
| 55 | | - return 'badge-gray'; |
|---|
| 47 | +function escapeHtml(str) { |
|---|
| 48 | + const div = document.createElement('div'); |
|---|
| 49 | + div.textContent = str; |
|---|
| 50 | + return div.innerHTML; |
|---|
| 56 | 51 | } |
|---|
| 57 | 52 | |
|---|
| 58 | 53 | function statusDotClass(status, health) { |
|---|
| 59 | | - const cls = statusBadgeClass(status, health); |
|---|
| 60 | | - return cls.replace('badge-', 'status-dot-'); |
|---|
| 54 | + const s = (status || '').toLowerCase(); |
|---|
| 55 | + const h = (health || '').toLowerCase(); |
|---|
| 56 | + if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green'; |
|---|
| 57 | + if (s === 'up' && h === 'unhealthy') return 'status-dot-red'; |
|---|
| 58 | + if (s === 'up' && h === 'starting') return 'status-dot-yellow'; |
|---|
| 59 | + if (s === 'down' || s === 'exited') return 'status-dot-red'; |
|---|
| 60 | + return 'status-dot-gray'; |
|---|
| 61 | 61 | } |
|---|
| 62 | 62 | |
|---|
| 63 | | -function diskBarClass(pct) { |
|---|
| 64 | | - if (pct >= 90) return 'disk-danger'; |
|---|
| 65 | | - if (pct >= 75) return 'disk-warn'; |
|---|
| 63 | +function badgeClass(status, health) { |
|---|
| 64 | + const s = (status || '').toLowerCase(); |
|---|
| 65 | + const h = (health || '').toLowerCase(); |
|---|
| 66 | + if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green'; |
|---|
| 67 | + if (s === 'up' && h === 'unhealthy') return 'badge-red'; |
|---|
| 68 | + if (s === 'up' && h === 'starting') return 'badge-yellow'; |
|---|
| 69 | + if (s === 'down' || s === 'exited') return 'badge-red'; |
|---|
| 70 | + return 'badge-gray'; |
|---|
| 71 | +} |
|---|
| 72 | + |
|---|
| 73 | +function diskColorClass(pct) { |
|---|
| 74 | + const n = parseInt(pct); |
|---|
| 75 | + if (isNaN(n)) return 'disk-ok'; |
|---|
| 76 | + if (n >= 90) return 'disk-danger'; |
|---|
| 77 | + if (n >= 75) return 'disk-warn'; |
|---|
| 66 | 78 | return 'disk-ok'; |
|---|
| 67 | 79 | } |
|---|
| 68 | 80 | |
|---|
| 69 | | -// ---------------------------------------------------------------- |
|---|
| 70 | | -// Auth Store |
|---|
| 71 | | -// ---------------------------------------------------------------- |
|---|
| 72 | | - |
|---|
| 73 | | -function authStore() { |
|---|
| 74 | | - return { |
|---|
| 75 | | - token: localStorage.getItem('ops_token') || '', |
|---|
| 76 | | - loginInput: '', |
|---|
| 77 | | - loginError: '', |
|---|
| 78 | | - loading: false, |
|---|
| 79 | | - |
|---|
| 80 | | - get isAuthenticated() { |
|---|
| 81 | | - return !!this.token; |
|---|
| 82 | | - }, |
|---|
| 83 | | - |
|---|
| 84 | | - async login() { |
|---|
| 85 | | - this.loginError = ''; |
|---|
| 86 | | - if (!this.loginInput.trim()) { |
|---|
| 87 | | - this.loginError = 'Please enter your access token.'; |
|---|
| 88 | | - return; |
|---|
| 89 | | - } |
|---|
| 90 | | - this.loading = true; |
|---|
| 91 | | - try { |
|---|
| 92 | | - const res = await fetch('/api/status/', { |
|---|
| 93 | | - headers: { 'Authorization': 'Bearer ' + this.loginInput.trim() } |
|---|
| 94 | | - }); |
|---|
| 95 | | - if (res.ok || res.status === 200) { |
|---|
| 96 | | - this.token = this.loginInput.trim(); |
|---|
| 97 | | - localStorage.setItem('ops_token', this.token); |
|---|
| 98 | | - this.loginInput = ''; |
|---|
| 99 | | - // Trigger page load |
|---|
| 100 | | - this.$dispatch('authenticated'); |
|---|
| 101 | | - } else if (res.status === 401) { |
|---|
| 102 | | - this.loginError = 'Invalid token. Please try again.'; |
|---|
| 103 | | - } else { |
|---|
| 104 | | - this.loginError = 'Server error (' + res.status + '). Please try again.'; |
|---|
| 105 | | - } |
|---|
| 106 | | - } catch { |
|---|
| 107 | | - this.loginError = 'Could not reach the server.'; |
|---|
| 108 | | - } finally { |
|---|
| 109 | | - this.loading = false; |
|---|
| 110 | | - } |
|---|
| 111 | | - }, |
|---|
| 112 | | - |
|---|
| 113 | | - logout() { |
|---|
| 114 | | - this.token = ''; |
|---|
| 115 | | - localStorage.removeItem('ops_token'); |
|---|
| 116 | | - } |
|---|
| 117 | | - }; |
|---|
| 81 | +// --------------------------------------------------------------------------- |
|---|
| 82 | +// Auth |
|---|
| 83 | +// --------------------------------------------------------------------------- |
|---|
| 84 | +function getToken() { |
|---|
| 85 | + return localStorage.getItem('ops_token'); |
|---|
| 118 | 86 | } |
|---|
| 119 | 87 | |
|---|
| 120 | | -// ---------------------------------------------------------------- |
|---|
| 88 | +function doLogin() { |
|---|
| 89 | + const input = document.getElementById('login-token'); |
|---|
| 90 | + const errEl = document.getElementById('login-error'); |
|---|
| 91 | + const token = input.value.trim(); |
|---|
| 92 | + if (!token) { |
|---|
| 93 | + errEl.textContent = 'Please enter a token'; |
|---|
| 94 | + errEl.style.display = 'block'; |
|---|
| 95 | + return; |
|---|
| 96 | + } |
|---|
| 97 | + errEl.style.display = 'none'; |
|---|
| 98 | + |
|---|
| 99 | + // Validate token by calling the API |
|---|
| 100 | + fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } }) |
|---|
| 101 | + .then(r => { |
|---|
| 102 | + if (!r.ok) throw new Error('Invalid token'); |
|---|
| 103 | + return r.json(); |
|---|
| 104 | + }) |
|---|
| 105 | + .then(data => { |
|---|
| 106 | + localStorage.setItem('ops_token', token); |
|---|
| 107 | + allServices = data; |
|---|
| 108 | + document.getElementById('login-overlay').style.display = 'none'; |
|---|
| 109 | + document.getElementById('app').style.display = 'flex'; |
|---|
| 110 | + showPage('dashboard'); |
|---|
| 111 | + startAutoRefresh(); |
|---|
| 112 | + }) |
|---|
| 113 | + .catch(() => { |
|---|
| 114 | + errEl.textContent = 'Invalid token. Try again.'; |
|---|
| 115 | + errEl.style.display = 'block'; |
|---|
| 116 | + }); |
|---|
| 117 | +} |
|---|
| 118 | + |
|---|
| 119 | +function doLogout() { |
|---|
| 120 | + localStorage.removeItem('ops_token'); |
|---|
| 121 | + stopAutoRefresh(); |
|---|
| 122 | + document.getElementById('app').style.display = 'none'; |
|---|
| 123 | + document.getElementById('login-overlay').style.display = 'flex'; |
|---|
| 124 | + document.getElementById('login-token').value = ''; |
|---|
| 125 | +} |
|---|
| 126 | + |
|---|
| 127 | +// --------------------------------------------------------------------------- |
|---|
| 121 | 128 | // API Helper |
|---|
| 122 | | -// ---------------------------------------------------------------- |
|---|
| 123 | | - |
|---|
| 124 | | -function api(path, options = {}) { |
|---|
| 125 | | - const token = localStorage.getItem('ops_token') || ''; |
|---|
| 126 | | - const headers = Object.assign({ 'Authorization': 'Bearer ' + token }, options.headers || {}); |
|---|
| 127 | | - if (options.json) { |
|---|
| 128 | | - headers['Content-Type'] = 'application/json'; |
|---|
| 129 | | - options.body = JSON.stringify(options.json); |
|---|
| 130 | | - delete options.json; |
|---|
| 129 | +// --------------------------------------------------------------------------- |
|---|
| 130 | +async function api(path, opts = {}) { |
|---|
| 131 | + const token = getToken(); |
|---|
| 132 | + const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token }; |
|---|
| 133 | + const resp = await fetch(path, { ...opts, headers }); |
|---|
| 134 | + if (resp.status === 401) { |
|---|
| 135 | + doLogout(); |
|---|
| 136 | + throw new Error('Session expired'); |
|---|
| 131 | 137 | } |
|---|
| 132 | | - return fetch(path, Object.assign({}, options, { headers })).then(res => { |
|---|
| 133 | | - if (res.status === 401) { |
|---|
| 134 | | - localStorage.removeItem('ops_token'); |
|---|
| 135 | | - window.dispatchEvent(new CustomEvent('unauthorized')); |
|---|
| 136 | | - throw new Error('Unauthorized'); |
|---|
| 137 | | - } |
|---|
| 138 | | - return res; |
|---|
| 138 | + if (!resp.ok) { |
|---|
| 139 | + const body = await resp.text(); |
|---|
| 140 | + throw new Error(body || 'HTTP ' + resp.status); |
|---|
| 141 | + } |
|---|
| 142 | + const ct = resp.headers.get('content-type') || ''; |
|---|
| 143 | + if (ct.includes('json')) return resp.json(); |
|---|
| 144 | + return resp.text(); |
|---|
| 145 | +} |
|---|
| 146 | + |
|---|
| 147 | +async function fetchStatus() { |
|---|
| 148 | + allServices = await api('/api/status/'); |
|---|
| 149 | +} |
|---|
| 150 | + |
|---|
| 151 | +// --------------------------------------------------------------------------- |
|---|
| 152 | +// Toast Notifications |
|---|
| 153 | +// --------------------------------------------------------------------------- |
|---|
| 154 | +function toast(message, type = 'info') { |
|---|
| 155 | + const container = document.getElementById('toast-container'); |
|---|
| 156 | + const el = document.createElement('div'); |
|---|
| 157 | + el.className = 'toast toast-' + type; |
|---|
| 158 | + el.innerHTML = `<span>${escapeHtml(message)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">×</span>`; |
|---|
| 159 | + container.appendChild(el); |
|---|
| 160 | + setTimeout(() => { |
|---|
| 161 | + el.classList.add('toast-out'); |
|---|
| 162 | + setTimeout(() => el.remove(), 200); |
|---|
| 163 | + }, 4000); |
|---|
| 164 | +} |
|---|
| 165 | + |
|---|
| 166 | +// --------------------------------------------------------------------------- |
|---|
| 167 | +// Sidebar & Navigation |
|---|
| 168 | +// --------------------------------------------------------------------------- |
|---|
| 169 | +function toggleSidebar() { |
|---|
| 170 | + document.getElementById('sidebar').classList.toggle('open'); |
|---|
| 171 | + document.getElementById('mobile-overlay').classList.toggle('open'); |
|---|
| 172 | +} |
|---|
| 173 | + |
|---|
| 174 | +function showPage(page) { |
|---|
| 175 | + currentPage = page; |
|---|
| 176 | + drillLevel = 0; |
|---|
| 177 | + drillProject = null; |
|---|
| 178 | + drillEnv = null; |
|---|
| 179 | + |
|---|
| 180 | + // Update sidebar active |
|---|
| 181 | + document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => { |
|---|
| 182 | + el.classList.toggle('active', el.dataset.page === page); |
|---|
| 139 | 183 | }); |
|---|
| 184 | + |
|---|
| 185 | + // Close mobile sidebar |
|---|
| 186 | + document.getElementById('sidebar').classList.remove('open'); |
|---|
| 187 | + document.getElementById('mobile-overlay').classList.remove('open'); |
|---|
| 188 | + |
|---|
| 189 | + renderPage(); |
|---|
| 140 | 190 | } |
|---|
| 141 | 191 | |
|---|
| 142 | | -// ---------------------------------------------------------------- |
|---|
| 143 | | -// Toast Store |
|---|
| 144 | | -// ---------------------------------------------------------------- |
|---|
| 192 | +function renderPage() { |
|---|
| 193 | + const content = document.getElementById('page-content'); |
|---|
| 194 | + content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>'; |
|---|
| 145 | 195 | |
|---|
| 146 | | -function toastStore() { |
|---|
| 147 | | - return { |
|---|
| 148 | | - toasts: [], |
|---|
| 149 | | - _counter: 0, |
|---|
| 150 | | - |
|---|
| 151 | | - add(msg, type = 'info', duration = 4000) { |
|---|
| 152 | | - const id = ++this._counter; |
|---|
| 153 | | - this.toasts.push({ id, msg, type }); |
|---|
| 154 | | - if (duration > 0) { |
|---|
| 155 | | - setTimeout(() => this.remove(id), duration); |
|---|
| 156 | | - } |
|---|
| 157 | | - return id; |
|---|
| 158 | | - }, |
|---|
| 159 | | - |
|---|
| 160 | | - remove(id) { |
|---|
| 161 | | - const idx = this.toasts.findIndex(t => t.id === id); |
|---|
| 162 | | - if (idx !== -1) this.toasts.splice(idx, 1); |
|---|
| 163 | | - }, |
|---|
| 164 | | - |
|---|
| 165 | | - success(msg) { return this.add(msg, 'toast-success'); }, |
|---|
| 166 | | - error(msg) { return this.add(msg, 'toast-error', 6000); }, |
|---|
| 167 | | - warn(msg) { return this.add(msg, 'toast-warning'); }, |
|---|
| 168 | | - info(msg) { return this.add(msg, 'toast-info'); }, |
|---|
| 169 | | - |
|---|
| 170 | | - iconFor(type) { |
|---|
| 171 | | - const icons = { |
|---|
| 172 | | - 'toast-success': '✓', |
|---|
| 173 | | - 'toast-error': '✕', |
|---|
| 174 | | - 'toast-warning': '⚠', |
|---|
| 175 | | - 'toast-info': 'ℹ' |
|---|
| 176 | | - }; |
|---|
| 177 | | - return icons[type] || 'ℹ'; |
|---|
| 178 | | - } |
|---|
| 179 | | - }; |
|---|
| 180 | | -} |
|---|
| 181 | | - |
|---|
| 182 | | -// ---------------------------------------------------------------- |
|---|
| 183 | | -// App Root Store |
|---|
| 184 | | -// ---------------------------------------------------------------- |
|---|
| 185 | | - |
|---|
| 186 | | -function appStore() { |
|---|
| 187 | | - return { |
|---|
| 188 | | - page: 'dashboard', |
|---|
| 189 | | - sidebarOpen: false, |
|---|
| 190 | | - toast: null, |
|---|
| 191 | | - |
|---|
| 192 | | - init() { |
|---|
| 193 | | - this.toast = Alpine.store('toast'); |
|---|
| 194 | | - window.addEventListener('unauthorized', () => { |
|---|
| 195 | | - Alpine.store('auth').logout(); |
|---|
| 196 | | - }); |
|---|
| 197 | | - window.addEventListener('authenticated', () => { |
|---|
| 198 | | - this.loadPage('dashboard'); |
|---|
| 199 | | - }); |
|---|
| 200 | | - }, |
|---|
| 201 | | - |
|---|
| 202 | | - navigate(page) { |
|---|
| 203 | | - this.page = page; |
|---|
| 204 | | - this.sidebarOpen = false; |
|---|
| 205 | | - this.loadPage(page); |
|---|
| 206 | | - }, |
|---|
| 207 | | - |
|---|
| 208 | | - loadPage(page) { |
|---|
| 209 | | - const storeMap = { |
|---|
| 210 | | - dashboard: 'dashboard', |
|---|
| 211 | | - backups: 'backups', |
|---|
| 212 | | - restore: 'restore', |
|---|
| 213 | | - services: 'services', |
|---|
| 214 | | - system: 'system' |
|---|
| 215 | | - }; |
|---|
| 216 | | - const storeName = storeMap[page]; |
|---|
| 217 | | - if (storeName && Alpine.store(storeName) && Alpine.store(storeName).load) { |
|---|
| 218 | | - Alpine.store(storeName).load(); |
|---|
| 219 | | - } |
|---|
| 220 | | - } |
|---|
| 221 | | - }; |
|---|
| 222 | | -} |
|---|
| 223 | | - |
|---|
| 224 | | -// ---------------------------------------------------------------- |
|---|
| 225 | | -// Dashboard Store |
|---|
| 226 | | -// ---------------------------------------------------------------- |
|---|
| 227 | | - |
|---|
| 228 | | -function dashboardStore() { |
|---|
| 229 | | - return { |
|---|
| 230 | | - projects: [], |
|---|
| 231 | | - loading: false, |
|---|
| 232 | | - error: null, |
|---|
| 233 | | - lastRefresh: null, |
|---|
| 234 | | - refreshInterval: null, |
|---|
| 235 | | - autoRefreshEnabled: true, |
|---|
| 236 | | - refreshing: false, |
|---|
| 237 | | - |
|---|
| 238 | | - load() { |
|---|
| 239 | | - this.fetch(); |
|---|
| 240 | | - this.startAutoRefresh(); |
|---|
| 241 | | - }, |
|---|
| 242 | | - |
|---|
| 243 | | - async fetch() { |
|---|
| 244 | | - if (this.loading) return; |
|---|
| 245 | | - this.loading = true; |
|---|
| 246 | | - this.error = null; |
|---|
| 247 | | - try { |
|---|
| 248 | | - const res = await api('/api/status/'); |
|---|
| 249 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 250 | | - const data = await res.json(); |
|---|
| 251 | | - this.projects = this.groupByProject(data); |
|---|
| 252 | | - this.lastRefresh = new Date(); |
|---|
| 253 | | - } catch (e) { |
|---|
| 254 | | - if (e.message !== 'Unauthorized') { |
|---|
| 255 | | - this.error = e.message || 'Failed to load status'; |
|---|
| 256 | | - } |
|---|
| 257 | | - } finally { |
|---|
| 258 | | - this.loading = false; |
|---|
| 259 | | - this.refreshing = false; |
|---|
| 260 | | - } |
|---|
| 261 | | - }, |
|---|
| 262 | | - |
|---|
| 263 | | - groupByProject(data) { |
|---|
| 264 | | - // data may be array of containers or object keyed by project |
|---|
| 265 | | - let containers = Array.isArray(data) ? data : Object.values(data).flat(); |
|---|
| 266 | | - const map = {}; |
|---|
| 267 | | - for (const c of containers) { |
|---|
| 268 | | - const proj = c.project || 'Other'; |
|---|
| 269 | | - if (!map[proj]) map[proj] = []; |
|---|
| 270 | | - map[proj].push(c); |
|---|
| 271 | | - } |
|---|
| 272 | | - return Object.entries(map).map(([name, services]) => ({ name, services })) |
|---|
| 273 | | - .sort((a, b) => a.name.localeCompare(b.name)); |
|---|
| 274 | | - }, |
|---|
| 275 | | - |
|---|
| 276 | | - manualRefresh() { |
|---|
| 277 | | - this.refreshing = true; |
|---|
| 278 | | - this.fetch(); |
|---|
| 279 | | - }, |
|---|
| 280 | | - |
|---|
| 281 | | - startAutoRefresh() { |
|---|
| 282 | | - this.stopAutoRefresh(); |
|---|
| 283 | | - if (this.autoRefreshEnabled) { |
|---|
| 284 | | - this.refreshInterval = setInterval(() => this.fetch(), 30000); |
|---|
| 285 | | - } |
|---|
| 286 | | - }, |
|---|
| 287 | | - |
|---|
| 288 | | - stopAutoRefresh() { |
|---|
| 289 | | - if (this.refreshInterval) { |
|---|
| 290 | | - clearInterval(this.refreshInterval); |
|---|
| 291 | | - this.refreshInterval = null; |
|---|
| 292 | | - } |
|---|
| 293 | | - }, |
|---|
| 294 | | - |
|---|
| 295 | | - toggleAutoRefresh() { |
|---|
| 296 | | - this.autoRefreshEnabled = !this.autoRefreshEnabled; |
|---|
| 297 | | - if (this.autoRefreshEnabled) { |
|---|
| 298 | | - this.startAutoRefresh(); |
|---|
| 299 | | - } else { |
|---|
| 300 | | - this.stopAutoRefresh(); |
|---|
| 301 | | - } |
|---|
| 302 | | - }, |
|---|
| 303 | | - |
|---|
| 304 | | - destroy() { |
|---|
| 305 | | - this.stopAutoRefresh(); |
|---|
| 306 | | - }, |
|---|
| 307 | | - |
|---|
| 308 | | - badgeClass: statusBadgeClass, |
|---|
| 309 | | - dotClass: statusDotClass, |
|---|
| 310 | | - timeAgo |
|---|
| 311 | | - }; |
|---|
| 312 | | -} |
|---|
| 313 | | - |
|---|
| 314 | | -// ---------------------------------------------------------------- |
|---|
| 315 | | -// Backups Store |
|---|
| 316 | | -// ---------------------------------------------------------------- |
|---|
| 317 | | - |
|---|
| 318 | | -function backupsStore() { |
|---|
| 319 | | - return { |
|---|
| 320 | | - local: [], |
|---|
| 321 | | - offsite: [], |
|---|
| 322 | | - loading: false, |
|---|
| 323 | | - loadingOffsite: false, |
|---|
| 324 | | - error: null, |
|---|
| 325 | | - ops: {}, // track per-row operation state: key -> { loading, done, error } |
|---|
| 326 | | - |
|---|
| 327 | | - load() { |
|---|
| 328 | | - this.fetchLocal(); |
|---|
| 329 | | - this.fetchOffsite(); |
|---|
| 330 | | - }, |
|---|
| 331 | | - |
|---|
| 332 | | - async fetchLocal() { |
|---|
| 333 | | - this.loading = true; |
|---|
| 334 | | - this.error = null; |
|---|
| 335 | | - try { |
|---|
| 336 | | - const res = await api('/api/backups/'); |
|---|
| 337 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 338 | | - const data = await res.json(); |
|---|
| 339 | | - this.local = Array.isArray(data) ? data : Object.values(data).flat(); |
|---|
| 340 | | - } catch (e) { |
|---|
| 341 | | - if (e.message !== 'Unauthorized') this.error = e.message; |
|---|
| 342 | | - } finally { |
|---|
| 343 | | - this.loading = false; |
|---|
| 344 | | - } |
|---|
| 345 | | - }, |
|---|
| 346 | | - |
|---|
| 347 | | - async fetchOffsite() { |
|---|
| 348 | | - this.loadingOffsite = true; |
|---|
| 349 | | - try { |
|---|
| 350 | | - const res = await api('/api/backups/offsite'); |
|---|
| 351 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 352 | | - const data = await res.json(); |
|---|
| 353 | | - this.offsite = Array.isArray(data) ? data : Object.values(data).flat(); |
|---|
| 354 | | - } catch (e) { |
|---|
| 355 | | - if (e.message !== 'Unauthorized') |
|---|
| 356 | | - Alpine.store('toast').error('Offsite: ' + (e.message || 'Failed')); |
|---|
| 357 | | - } finally { |
|---|
| 358 | | - this.loadingOffsite = false; |
|---|
| 359 | | - } |
|---|
| 360 | | - }, |
|---|
| 361 | | - |
|---|
| 362 | | - opKey(project, env, action) { |
|---|
| 363 | | - return `${action}::${project}::${env}`; |
|---|
| 364 | | - }, |
|---|
| 365 | | - |
|---|
| 366 | | - isRunning(project, env, action) { |
|---|
| 367 | | - return !!(this.ops[this.opKey(project, env, action)]?.loading); |
|---|
| 368 | | - }, |
|---|
| 369 | | - |
|---|
| 370 | | - async backupNow(project, env) { |
|---|
| 371 | | - const key = this.opKey(project, env, 'backup'); |
|---|
| 372 | | - this.ops = { ...this.ops, [key]: { loading: true } }; |
|---|
| 373 | | - try { |
|---|
| 374 | | - const res = await api(`/api/backups/${project}/${env}`, { method: 'POST' }); |
|---|
| 375 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 376 | | - Alpine.store('toast').success(`Backup started: ${project}/${env}`); |
|---|
| 377 | | - setTimeout(() => this.fetchLocal(), 2000); |
|---|
| 378 | | - } catch (e) { |
|---|
| 379 | | - if (e.message !== 'Unauthorized') |
|---|
| 380 | | - Alpine.store('toast').error(`Backup failed: ${e.message}`); |
|---|
| 381 | | - } finally { |
|---|
| 382 | | - this.ops = { ...this.ops, [key]: { loading: false } }; |
|---|
| 383 | | - } |
|---|
| 384 | | - }, |
|---|
| 385 | | - |
|---|
| 386 | | - async uploadOffsite(project, env) { |
|---|
| 387 | | - const key = this.opKey(project, env, 'upload'); |
|---|
| 388 | | - this.ops = { ...this.ops, [key]: { loading: true } }; |
|---|
| 389 | | - try { |
|---|
| 390 | | - const res = await api(`/api/backups/offsite/upload/${project}/${env}`, { method: 'POST' }); |
|---|
| 391 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 392 | | - Alpine.store('toast').success(`Upload started: ${project}/${env}`); |
|---|
| 393 | | - setTimeout(() => this.fetchOffsite(), 3000); |
|---|
| 394 | | - } catch (e) { |
|---|
| 395 | | - if (e.message !== 'Unauthorized') |
|---|
| 396 | | - Alpine.store('toast').error(`Upload failed: ${e.message}`); |
|---|
| 397 | | - } finally { |
|---|
| 398 | | - this.ops = { ...this.ops, [key]: { loading: false } }; |
|---|
| 399 | | - } |
|---|
| 400 | | - }, |
|---|
| 401 | | - |
|---|
| 402 | | - retentionRunning: false, |
|---|
| 403 | | - |
|---|
| 404 | | - async applyRetention() { |
|---|
| 405 | | - this.retentionRunning = true; |
|---|
| 406 | | - try { |
|---|
| 407 | | - const res = await api('/api/backups/offsite/retention', { method: 'POST' }); |
|---|
| 408 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 409 | | - Alpine.store('toast').success('Retention policy applied'); |
|---|
| 410 | | - setTimeout(() => this.fetchOffsite(), 2000); |
|---|
| 411 | | - } catch (e) { |
|---|
| 412 | | - if (e.message !== 'Unauthorized') |
|---|
| 413 | | - Alpine.store('toast').error(`Retention failed: ${e.message}`); |
|---|
| 414 | | - } finally { |
|---|
| 415 | | - this.retentionRunning = false; |
|---|
| 416 | | - } |
|---|
| 417 | | - }, |
|---|
| 418 | | - |
|---|
| 419 | | - ageBadge: ageBadgeClass, |
|---|
| 420 | | - timeAgo, |
|---|
| 421 | | - formatBytes |
|---|
| 422 | | - }; |
|---|
| 423 | | -} |
|---|
| 424 | | - |
|---|
| 425 | | -// ---------------------------------------------------------------- |
|---|
| 426 | | -// Restore Store |
|---|
| 427 | | -// ---------------------------------------------------------------- |
|---|
| 428 | | - |
|---|
| 429 | | -function restoreStore() { |
|---|
| 430 | | - return { |
|---|
| 431 | | - source: 'local', |
|---|
| 432 | | - project: '', |
|---|
| 433 | | - env: '', |
|---|
| 434 | | - dryRun: false, |
|---|
| 435 | | - confirming: false, |
|---|
| 436 | | - running: false, |
|---|
| 437 | | - output: [], |
|---|
| 438 | | - sseSource: null, |
|---|
| 439 | | - projects: [], |
|---|
| 440 | | - envs: [], |
|---|
| 441 | | - loadingProjects: false, |
|---|
| 442 | | - error: null, |
|---|
| 443 | | - |
|---|
| 444 | | - load() { |
|---|
| 445 | | - this.loadProjectList(); |
|---|
| 446 | | - }, |
|---|
| 447 | | - |
|---|
| 448 | | - async loadProjectList() { |
|---|
| 449 | | - this.loadingProjects = true; |
|---|
| 450 | | - try { |
|---|
| 451 | | - const endpoint = this.source === 'offsite' ? '/api/backups/offsite' : '/api/backups/'; |
|---|
| 452 | | - const res = await api(endpoint); |
|---|
| 453 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 454 | | - const data = await res.json(); |
|---|
| 455 | | - const items = Array.isArray(data) ? data : Object.values(data).flat(); |
|---|
| 456 | | - const projSet = new Set(items.map(i => i.project).filter(Boolean)); |
|---|
| 457 | | - this.projects = Array.from(projSet).sort(); |
|---|
| 458 | | - this.project = this.projects[0] || ''; |
|---|
| 459 | | - this.updateEnvs(items); |
|---|
| 460 | | - } catch (e) { |
|---|
| 461 | | - if (e.message !== 'Unauthorized') this.error = e.message; |
|---|
| 462 | | - } finally { |
|---|
| 463 | | - this.loadingProjects = false; |
|---|
| 464 | | - } |
|---|
| 465 | | - }, |
|---|
| 466 | | - |
|---|
| 467 | | - updateEnvs(items) { |
|---|
| 468 | | - if (!items) return; |
|---|
| 469 | | - const envSet = new Set( |
|---|
| 470 | | - items.filter(i => i.project === this.project).map(i => i.env).filter(Boolean) |
|---|
| 471 | | - ); |
|---|
| 472 | | - this.envs = Array.from(envSet).sort(); |
|---|
| 473 | | - this.env = this.envs[0] || ''; |
|---|
| 474 | | - }, |
|---|
| 475 | | - |
|---|
| 476 | | - onSourceChange() { |
|---|
| 477 | | - this.project = ''; |
|---|
| 478 | | - this.env = ''; |
|---|
| 479 | | - this.envs = []; |
|---|
| 480 | | - this.loadProjectList(); |
|---|
| 481 | | - }, |
|---|
| 482 | | - |
|---|
| 483 | | - onProjectChange() { |
|---|
| 484 | | - // Re-fetch envs for this project from the already loaded data |
|---|
| 485 | | - this.loadProjectList(); |
|---|
| 486 | | - }, |
|---|
| 487 | | - |
|---|
| 488 | | - confirm() { |
|---|
| 489 | | - if (!this.project || !this.env) { |
|---|
| 490 | | - Alpine.store('toast').warn('Select project and environment first'); |
|---|
| 491 | | - return; |
|---|
| 492 | | - } |
|---|
| 493 | | - this.confirming = true; |
|---|
| 494 | | - }, |
|---|
| 495 | | - |
|---|
| 496 | | - cancel() { |
|---|
| 497 | | - this.confirming = false; |
|---|
| 498 | | - }, |
|---|
| 499 | | - |
|---|
| 500 | | - async execute() { |
|---|
| 501 | | - this.confirming = false; |
|---|
| 502 | | - this.running = true; |
|---|
| 503 | | - this.output = []; |
|---|
| 504 | | - |
|---|
| 505 | | - const params = new URLSearchParams({ |
|---|
| 506 | | - source: this.source, |
|---|
| 507 | | - dry_run: this.dryRun ? '1' : '0' |
|---|
| 508 | | - }); |
|---|
| 509 | | - const url = `/api/restore/${this.project}/${this.env}?${params}`; |
|---|
| 510 | | - |
|---|
| 511 | | - try { |
|---|
| 512 | | - this.sseSource = new EventSource(url + '&token=' + encodeURIComponent(localStorage.getItem('ops_token') || '')); |
|---|
| 513 | | - this.sseSource.onmessage = (e) => { |
|---|
| 514 | | - try { |
|---|
| 515 | | - const msg = JSON.parse(e.data); |
|---|
| 516 | | - if (msg.done) { |
|---|
| 517 | | - this.sseSource.close(); |
|---|
| 518 | | - this.sseSource = null; |
|---|
| 519 | | - this.running = false; |
|---|
| 520 | | - if (msg.success) { |
|---|
| 521 | | - Alpine.store('toast').success('Restore completed'); |
|---|
| 522 | | - } else { |
|---|
| 523 | | - Alpine.store('toast').error('Restore finished with errors'); |
|---|
| 524 | | - } |
|---|
| 525 | | - return; |
|---|
| 526 | | - } |
|---|
| 527 | | - const text = msg.line || e.data; |
|---|
| 528 | | - this.output.push({ text, cls: this.classifyLine(text) }); |
|---|
| 529 | | - } catch { |
|---|
| 530 | | - this.output.push({ text: e.data, cls: this.classifyLine(e.data) }); |
|---|
| 531 | | - } |
|---|
| 532 | | - this.$nextTick(() => { |
|---|
| 533 | | - const el = document.getElementById('restore-output'); |
|---|
| 534 | | - if (el) el.scrollTop = el.scrollHeight; |
|---|
| 535 | | - }); |
|---|
| 536 | | - }; |
|---|
| 537 | | - this.sseSource.onerror = () => { |
|---|
| 538 | | - if (this.running) { |
|---|
| 539 | | - this.running = false; |
|---|
| 540 | | - if (this.sseSource) this.sseSource.close(); |
|---|
| 541 | | - this.sseSource = null; |
|---|
| 542 | | - } |
|---|
| 543 | | - }; |
|---|
| 544 | | - } catch (e) { |
|---|
| 545 | | - this.running = false; |
|---|
| 546 | | - Alpine.store('toast').error('Restore failed: ' + (e.message || 'Unknown error')); |
|---|
| 547 | | - } |
|---|
| 548 | | - }, |
|---|
| 549 | | - |
|---|
| 550 | | - classifyLine(text) { |
|---|
| 551 | | - const t = text.toLowerCase(); |
|---|
| 552 | | - if (t.includes('error') || t.includes('fail') || t.includes('critical')) return 'line-error'; |
|---|
| 553 | | - if (t.includes('warn')) return 'line-warn'; |
|---|
| 554 | | - if (t.includes('ok') || t.includes('success') || t.includes('done')) return 'line-ok'; |
|---|
| 555 | | - if (t.startsWith('$') || t.startsWith('#') || t.startsWith('>')) return 'line-cmd'; |
|---|
| 556 | | - return ''; |
|---|
| 557 | | - }, |
|---|
| 558 | | - |
|---|
| 559 | | - abort() { |
|---|
| 560 | | - if (this.sseSource) { |
|---|
| 561 | | - this.sseSource.close(); |
|---|
| 562 | | - this.sseSource = null; |
|---|
| 563 | | - } |
|---|
| 564 | | - this.running = false; |
|---|
| 565 | | - this.output.push({ text: '--- aborted by user ---', cls: 'line-warn' }); |
|---|
| 566 | | - } |
|---|
| 567 | | - }; |
|---|
| 568 | | -} |
|---|
| 569 | | - |
|---|
| 570 | | -// ---------------------------------------------------------------- |
|---|
| 571 | | -// Services Store |
|---|
| 572 | | -// ---------------------------------------------------------------- |
|---|
| 573 | | - |
|---|
| 574 | | -function servicesStore() { |
|---|
| 575 | | - return { |
|---|
| 576 | | - projects: [], |
|---|
| 577 | | - loading: false, |
|---|
| 578 | | - error: null, |
|---|
| 579 | | - logModal: { open: false, title: '', lines: [], loading: false }, |
|---|
| 580 | | - confirmRestart: { open: false, project: '', env: '', service: '', running: false }, |
|---|
| 581 | | - |
|---|
| 582 | | - load() { |
|---|
| 583 | | - this.fetch(); |
|---|
| 584 | | - }, |
|---|
| 585 | | - |
|---|
| 586 | | - async fetch() { |
|---|
| 587 | | - this.loading = true; |
|---|
| 588 | | - this.error = null; |
|---|
| 589 | | - try { |
|---|
| 590 | | - const res = await api('/api/status/'); |
|---|
| 591 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 592 | | - const data = await res.json(); |
|---|
| 593 | | - this.projects = this.groupByProject(data); |
|---|
| 594 | | - } catch (e) { |
|---|
| 595 | | - if (e.message !== 'Unauthorized') this.error = e.message; |
|---|
| 596 | | - } finally { |
|---|
| 597 | | - this.loading = false; |
|---|
| 598 | | - } |
|---|
| 599 | | - }, |
|---|
| 600 | | - |
|---|
| 601 | | - groupByProject(data) { |
|---|
| 602 | | - let containers = Array.isArray(data) ? data : Object.values(data).flat(); |
|---|
| 603 | | - const map = {}; |
|---|
| 604 | | - for (const c of containers) { |
|---|
| 605 | | - const proj = c.project || 'Other'; |
|---|
| 606 | | - if (!map[proj]) map[proj] = []; |
|---|
| 607 | | - map[proj].push(c); |
|---|
| 608 | | - } |
|---|
| 609 | | - return Object.entries(map).map(([name, services]) => ({ name, services })) |
|---|
| 610 | | - .sort((a, b) => a.name.localeCompare(b.name)); |
|---|
| 611 | | - }, |
|---|
| 612 | | - |
|---|
| 613 | | - async viewLogs(project, env, service) { |
|---|
| 614 | | - this.logModal = { |
|---|
| 615 | | - open: true, |
|---|
| 616 | | - title: `${service} — logs`, |
|---|
| 617 | | - lines: [], |
|---|
| 618 | | - loading: true |
|---|
| 619 | | - }; |
|---|
| 620 | | - try { |
|---|
| 621 | | - const res = await api(`/api/services/logs/${project}/${env}/${service}?lines=150`); |
|---|
| 622 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 623 | | - const data = await res.json(); |
|---|
| 624 | | - this.logModal.lines = (data.logs || '').split('\n'); |
|---|
| 625 | | - } catch (e) { |
|---|
| 626 | | - if (e.message !== 'Unauthorized') |
|---|
| 627 | | - this.logModal.lines = ['Error: ' + e.message]; |
|---|
| 628 | | - } finally { |
|---|
| 629 | | - this.logModal.loading = false; |
|---|
| 630 | | - this.$nextTick(() => { |
|---|
| 631 | | - const el = document.getElementById('log-output'); |
|---|
| 632 | | - if (el) el.scrollTop = el.scrollHeight; |
|---|
| 633 | | - }); |
|---|
| 634 | | - } |
|---|
| 635 | | - }, |
|---|
| 636 | | - |
|---|
| 637 | | - closeLogs() { |
|---|
| 638 | | - this.logModal.open = false; |
|---|
| 639 | | - }, |
|---|
| 640 | | - |
|---|
| 641 | | - askRestart(project, env, service) { |
|---|
| 642 | | - this.confirmRestart = { open: true, project, env, service, running: false }; |
|---|
| 643 | | - }, |
|---|
| 644 | | - |
|---|
| 645 | | - cancelRestart() { |
|---|
| 646 | | - this.confirmRestart.open = false; |
|---|
| 647 | | - }, |
|---|
| 648 | | - |
|---|
| 649 | | - async doRestart() { |
|---|
| 650 | | - const { project, env, service } = this.confirmRestart; |
|---|
| 651 | | - this.confirmRestart.running = true; |
|---|
| 652 | | - try { |
|---|
| 653 | | - const res = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' }); |
|---|
| 654 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 655 | | - Alpine.store('toast').success(`${service} restarted`); |
|---|
| 656 | | - this.confirmRestart.open = false; |
|---|
| 657 | | - setTimeout(() => this.fetch(), 2000); |
|---|
| 658 | | - } catch (e) { |
|---|
| 659 | | - if (e.message !== 'Unauthorized') |
|---|
| 660 | | - Alpine.store('toast').error(`Restart failed: ${e.message}`); |
|---|
| 661 | | - } finally { |
|---|
| 662 | | - this.confirmRestart.running = false; |
|---|
| 663 | | - } |
|---|
| 664 | | - }, |
|---|
| 665 | | - |
|---|
| 666 | | - badgeClass: statusBadgeClass, |
|---|
| 667 | | - dotClass: statusDotClass |
|---|
| 668 | | - }; |
|---|
| 669 | | -} |
|---|
| 670 | | - |
|---|
| 671 | | -// ---------------------------------------------------------------- |
|---|
| 672 | | -// System Store |
|---|
| 673 | | -// ---------------------------------------------------------------- |
|---|
| 674 | | - |
|---|
| 675 | | -function systemStore() { |
|---|
| 676 | | - return { |
|---|
| 677 | | - disk: [], |
|---|
| 678 | | - health: [], |
|---|
| 679 | | - timers: [], |
|---|
| 680 | | - info: {}, |
|---|
| 681 | | - loading: { disk: false, health: false, timers: false, info: false }, |
|---|
| 682 | | - error: null, |
|---|
| 683 | | - |
|---|
| 684 | | - load() { |
|---|
| 685 | | - this.fetchDisk(); |
|---|
| 686 | | - this.fetchHealth(); |
|---|
| 687 | | - this.fetchTimers(); |
|---|
| 688 | | - this.fetchInfo(); |
|---|
| 689 | | - }, |
|---|
| 690 | | - |
|---|
| 691 | | - async fetchDisk() { |
|---|
| 692 | | - this.loading.disk = true; |
|---|
| 693 | | - try { |
|---|
| 694 | | - const res = await api('/api/system//disk'); |
|---|
| 695 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 696 | | - const data = await res.json(); |
|---|
| 697 | | - this.disk = Array.isArray(data) ? data : (data.filesystems || []); |
|---|
| 698 | | - } catch (e) { |
|---|
| 699 | | - if (e.message !== 'Unauthorized') this.error = e.message; |
|---|
| 700 | | - } finally { |
|---|
| 701 | | - this.loading.disk = false; |
|---|
| 702 | | - } |
|---|
| 703 | | - }, |
|---|
| 704 | | - |
|---|
| 705 | | - async fetchHealth() { |
|---|
| 706 | | - this.loading.health = true; |
|---|
| 707 | | - try { |
|---|
| 708 | | - const res = await api('/api/system//health'); |
|---|
| 709 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 710 | | - const data = await res.json(); |
|---|
| 711 | | - this.health = Array.isArray(data) ? data : (data.checks || []); |
|---|
| 712 | | - } catch (e) { |
|---|
| 713 | | - if (e.message !== 'Unauthorized') {} |
|---|
| 714 | | - } finally { |
|---|
| 715 | | - this.loading.health = false; |
|---|
| 716 | | - } |
|---|
| 717 | | - }, |
|---|
| 718 | | - |
|---|
| 719 | | - async fetchTimers() { |
|---|
| 720 | | - this.loading.timers = true; |
|---|
| 721 | | - try { |
|---|
| 722 | | - const res = await api('/api/system//timers'); |
|---|
| 723 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 724 | | - const data = await res.json(); |
|---|
| 725 | | - this.timers = Array.isArray(data) ? data : (data.timers || []); |
|---|
| 726 | | - } catch (e) { |
|---|
| 727 | | - if (e.message !== 'Unauthorized') {} |
|---|
| 728 | | - } finally { |
|---|
| 729 | | - this.loading.timers = false; |
|---|
| 730 | | - } |
|---|
| 731 | | - }, |
|---|
| 732 | | - |
|---|
| 733 | | - async fetchInfo() { |
|---|
| 734 | | - this.loading.info = true; |
|---|
| 735 | | - try { |
|---|
| 736 | | - const res = await api('/api/system//info'); |
|---|
| 737 | | - if (!res.ok) throw new Error('HTTP ' + res.status); |
|---|
| 738 | | - this.info = await res.json(); |
|---|
| 739 | | - } catch (e) { |
|---|
| 740 | | - if (e.message !== 'Unauthorized') {} |
|---|
| 741 | | - } finally { |
|---|
| 742 | | - this.loading.info = false; |
|---|
| 743 | | - } |
|---|
| 744 | | - }, |
|---|
| 745 | | - |
|---|
| 746 | | - diskBarClass, |
|---|
| 747 | | - formatBytes, |
|---|
| 748 | | - timeAgo |
|---|
| 749 | | - }; |
|---|
| 750 | | -} |
|---|
| 751 | | - |
|---|
| 752 | | -// ---------------------------------------------------------------- |
|---|
| 753 | | -// Alpine initialization |
|---|
| 754 | | -// ---------------------------------------------------------------- |
|---|
| 755 | | - |
|---|
| 756 | | -document.addEventListener('alpine:init', () => { |
|---|
| 757 | | - Alpine.store('auth', authStore()); |
|---|
| 758 | | - Alpine.store('toast', toastStore()); |
|---|
| 759 | | - Alpine.store('app', appStore()); |
|---|
| 760 | | - Alpine.store('dashboard', dashboardStore()); |
|---|
| 761 | | - Alpine.store('backups', backupsStore()); |
|---|
| 762 | | - Alpine.store('restore', restoreStore()); |
|---|
| 763 | | - Alpine.store('services', servicesStore()); |
|---|
| 764 | | - Alpine.store('system', systemStore()); |
|---|
| 765 | | - |
|---|
| 766 | | - // Init app store |
|---|
| 767 | | - Alpine.store('app').init(); |
|---|
| 768 | | - |
|---|
| 769 | | - // Load the dashboard if already authenticated |
|---|
| 770 | | - if (Alpine.store('auth').isAuthenticated) { |
|---|
| 771 | | - Alpine.store('dashboard').load(); |
|---|
| 196 | + switch (currentPage) { |
|---|
| 197 | + case 'dashboard': renderDashboard(); break; |
|---|
| 198 | + case 'services': renderServicesFlat(); break; |
|---|
| 199 | + case 'backups': renderBackups(); break; |
|---|
| 200 | + case 'system': renderSystem(); break; |
|---|
| 201 | + case 'restore': renderRestore(); break; |
|---|
| 202 | + default: renderDashboard(); |
|---|
| 772 | 203 | } |
|---|
| 773 | | -}); |
|---|
| 204 | +} |
|---|
| 205 | + |
|---|
| 206 | +function refreshCurrentPage() { |
|---|
| 207 | + showRefreshSpinner(); |
|---|
| 208 | + fetchStatus() |
|---|
| 209 | + .then(() => renderPage()) |
|---|
| 210 | + .catch(e => toast('Refresh failed: ' + e.message, 'error')) |
|---|
| 211 | + .finally(() => hideRefreshSpinner()); |
|---|
| 212 | +} |
|---|
| 213 | + |
|---|
| 214 | +// --------------------------------------------------------------------------- |
|---|
| 215 | +// Auto-refresh |
|---|
| 216 | +// --------------------------------------------------------------------------- |
|---|
| 217 | +function startAutoRefresh() { |
|---|
| 218 | + stopAutoRefresh(); |
|---|
| 219 | + refreshTimer = setInterval(() => { |
|---|
| 220 | + fetchStatus() |
|---|
| 221 | + .then(() => { |
|---|
| 222 | + if (currentPage === 'dashboard' || currentPage === 'services') renderPage(); |
|---|
| 223 | + }) |
|---|
| 224 | + .catch(() => {}); |
|---|
| 225 | + }, REFRESH_INTERVAL); |
|---|
| 226 | +} |
|---|
| 227 | + |
|---|
| 228 | +function stopAutoRefresh() { |
|---|
| 229 | + if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } |
|---|
| 230 | +} |
|---|
| 231 | + |
|---|
| 232 | +function showRefreshSpinner() { |
|---|
| 233 | + document.getElementById('refresh-indicator').classList.remove('paused'); |
|---|
| 234 | +} |
|---|
| 235 | +function hideRefreshSpinner() { |
|---|
| 236 | + document.getElementById('refresh-indicator').classList.add('paused'); |
|---|
| 237 | +} |
|---|
| 238 | + |
|---|
| 239 | +// --------------------------------------------------------------------------- |
|---|
| 240 | +// Breadcrumbs |
|---|
| 241 | +// --------------------------------------------------------------------------- |
|---|
| 242 | +function updateBreadcrumbs() { |
|---|
| 243 | + const bc = document.getElementById('breadcrumbs'); |
|---|
| 244 | + let html = ''; |
|---|
| 245 | + |
|---|
| 246 | + if (currentPage === 'dashboard') { |
|---|
| 247 | + if (drillLevel === 0) { |
|---|
| 248 | + html = '<span class="current">Dashboard</span>'; |
|---|
| 249 | + } else if (drillLevel === 1) { |
|---|
| 250 | + html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + escapeHtml(drillProject) + '</span>'; |
|---|
| 251 | + } else if (drillLevel === 2) { |
|---|
| 252 | + html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + escapeHtml(drillProject) + '</a><span class="sep">/</span><span class="current">' + escapeHtml(drillEnv) + '</span>'; |
|---|
| 253 | + } |
|---|
| 254 | + } else { |
|---|
| 255 | + const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' }; |
|---|
| 256 | + html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>'; |
|---|
| 257 | + } |
|---|
| 258 | + |
|---|
| 259 | + bc.innerHTML = html; |
|---|
| 260 | +} |
|---|
| 261 | + |
|---|
| 262 | +function drillBack(level) { |
|---|
| 263 | + if (level === 0) { |
|---|
| 264 | + drillLevel = 0; |
|---|
| 265 | + drillProject = null; |
|---|
| 266 | + drillEnv = null; |
|---|
| 267 | + } else if (level === 1) { |
|---|
| 268 | + drillLevel = 1; |
|---|
| 269 | + drillEnv = null; |
|---|
| 270 | + } |
|---|
| 271 | + renderDashboard(); |
|---|
| 272 | +} |
|---|
| 273 | + |
|---|
| 274 | +// --------------------------------------------------------------------------- |
|---|
| 275 | +// Dashboard — 3-level Drill |
|---|
| 276 | +// --------------------------------------------------------------------------- |
|---|
| 277 | +function renderDashboard() { |
|---|
| 278 | + currentPage = 'dashboard'; |
|---|
| 279 | + if (drillLevel === 0) renderProjects(); |
|---|
| 280 | + else if (drillLevel === 1) renderEnvironments(); |
|---|
| 281 | + else if (drillLevel === 2) renderServices(); |
|---|
| 282 | + updateBreadcrumbs(); |
|---|
| 283 | +} |
|---|
| 284 | + |
|---|
| 285 | +function renderProjects() { |
|---|
| 286 | + const content = document.getElementById('page-content'); |
|---|
| 287 | + const projects = groupByProject(allServices); |
|---|
| 288 | + |
|---|
| 289 | + // Summary stats |
|---|
| 290 | + const totalUp = allServices.filter(s => s.status === 'Up').length; |
|---|
| 291 | + const totalDown = allServices.length - totalUp; |
|---|
| 292 | + |
|---|
| 293 | + let html = '<div class="page-enter" style="padding:0;">'; |
|---|
| 294 | + |
|---|
| 295 | + // Summary bar |
|---|
| 296 | + html += '<div class="stat-grid" style="margin-bottom:1.5rem;">'; |
|---|
| 297 | + html += statCard('Projects', Object.keys(projects).length, '#3b82f6'); |
|---|
| 298 | + html += statCard('Services', allServices.length, '#8b5cf6'); |
|---|
| 299 | + html += statCard('Healthy', totalUp, '#10b981'); |
|---|
| 300 | + html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280'); |
|---|
| 301 | + html += '</div>'; |
|---|
| 302 | + |
|---|
| 303 | + // Project cards |
|---|
| 304 | + html += '<div class="project-grid">'; |
|---|
| 305 | + for (const [name, proj] of Object.entries(projects)) { |
|---|
| 306 | + const upCount = proj.services.filter(s => s.status === 'Up').length; |
|---|
| 307 | + const total = proj.services.length; |
|---|
| 308 | + const allUp = upCount === total; |
|---|
| 309 | + const envNames = [...new Set(proj.services.map(s => s.env))]; |
|---|
| 310 | + |
|---|
| 311 | + html += `<div class="card card-clickable" onclick="drillToProject('${escapeHtml(name)}')"> |
|---|
| 312 | + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;"> |
|---|
| 313 | + <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span> |
|---|
| 314 | + <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(name)}</span> |
|---|
| 315 | + <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span> |
|---|
| 316 | + </div> |
|---|
| 317 | + <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;"> |
|---|
| 318 | + ${envNames.map(e => `<span class="badge badge-blue">${escapeHtml(e)}</span>`).join('')} |
|---|
| 319 | + </div> |
|---|
| 320 | + <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div> |
|---|
| 321 | + </div>`; |
|---|
| 322 | + } |
|---|
| 323 | + html += '</div></div>'; |
|---|
| 324 | + content.innerHTML = html; |
|---|
| 325 | +} |
|---|
| 326 | + |
|---|
| 327 | +function renderEnvironments() { |
|---|
| 328 | + const content = document.getElementById('page-content'); |
|---|
| 329 | + const projServices = allServices.filter(s => s.project === drillProject); |
|---|
| 330 | + const envs = groupByEnv(projServices); |
|---|
| 331 | + |
|---|
| 332 | + let html = '<div class="page-enter" style="padding:0;">'; |
|---|
| 333 | + html += '<div class="env-grid">'; |
|---|
| 334 | + |
|---|
| 335 | + for (const [envName, services] of Object.entries(envs)) { |
|---|
| 336 | + const upCount = services.filter(s => s.status === 'Up').length; |
|---|
| 337 | + const total = services.length; |
|---|
| 338 | + const allUp = upCount === total; |
|---|
| 339 | + |
|---|
| 340 | + html += `<div class="card card-clickable" onclick="drillToEnv('${escapeHtml(envName)}')"> |
|---|
| 341 | + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;"> |
|---|
| 342 | + <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span> |
|---|
| 343 | + <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(envName).toUpperCase()}</span> |
|---|
| 344 | + <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span> |
|---|
| 345 | + </div> |
|---|
| 346 | + <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;"> |
|---|
| 347 | + ${services.map(s => `<span class="badge ${badgeClass(s.status, s.health)}">${escapeHtml(s.service)}</span>`).join('')} |
|---|
| 348 | + </div> |
|---|
| 349 | + <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div> |
|---|
| 350 | + </div>`; |
|---|
| 351 | + } |
|---|
| 352 | + |
|---|
| 353 | + html += '</div></div>'; |
|---|
| 354 | + content.innerHTML = html; |
|---|
| 355 | +} |
|---|
| 356 | + |
|---|
| 357 | +function renderServices() { |
|---|
| 358 | + const content = document.getElementById('page-content'); |
|---|
| 359 | + const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv); |
|---|
| 360 | + |
|---|
| 361 | + let html = '<div class="page-enter" style="padding:0;">'; |
|---|
| 362 | + html += '<div class="service-grid">'; |
|---|
| 363 | + |
|---|
| 364 | + for (const svc of services) { |
|---|
| 365 | + html += serviceCard(svc); |
|---|
| 366 | + } |
|---|
| 367 | + |
|---|
| 368 | + html += '</div></div>'; |
|---|
| 369 | + content.innerHTML = html; |
|---|
| 370 | +} |
|---|
| 371 | + |
|---|
| 372 | +function drillToProject(name) { |
|---|
| 373 | + drillProject = name; |
|---|
| 374 | + drillLevel = 1; |
|---|
| 375 | + renderDashboard(); |
|---|
| 376 | +} |
|---|
| 377 | + |
|---|
| 378 | +function drillToEnv(name) { |
|---|
| 379 | + drillEnv = name; |
|---|
| 380 | + drillLevel = 2; |
|---|
| 381 | + renderDashboard(); |
|---|
| 382 | +} |
|---|
| 383 | + |
|---|
| 384 | +// --------------------------------------------------------------------------- |
|---|
| 385 | +// Service Card (shared component) |
|---|
| 386 | +// --------------------------------------------------------------------------- |
|---|
| 387 | +function serviceCard(svc) { |
|---|
| 388 | + const proj = escapeHtml(svc.project); |
|---|
| 389 | + const env = escapeHtml(svc.env); |
|---|
| 390 | + const service = escapeHtml(svc.service); |
|---|
| 391 | + const bc = badgeClass(svc.status, svc.health); |
|---|
| 392 | + const dc = statusDotClass(svc.status, svc.health); |
|---|
| 393 | + |
|---|
| 394 | + return `<div class="card"> |
|---|
| 395 | + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;"> |
|---|
| 396 | + <span class="status-dot ${dc}"></span> |
|---|
| 397 | + <span style="font-weight:600;color:#f3f4f6;">${service}</span> |
|---|
| 398 | + <span class="badge ${bc}" style="margin-left:auto;">${escapeHtml(svc.status)}</span> |
|---|
| 399 | + </div> |
|---|
| 400 | + <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;"> |
|---|
| 401 | + Health: ${escapeHtml(svc.health || 'n/a')} · Uptime: ${escapeHtml(svc.uptime || 'n/a')} |
|---|
| 402 | + </div> |
|---|
| 403 | + <div style="display:flex;gap:0.5rem;flex-wrap:wrap;"> |
|---|
| 404 | + <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button> |
|---|
| 405 | + <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button> |
|---|
| 406 | + </div> |
|---|
| 407 | + </div>`; |
|---|
| 408 | +} |
|---|
| 409 | + |
|---|
| 410 | +function statCard(label, value, color) { |
|---|
| 411 | + return `<div class="card" style="text-align:center;"> |
|---|
| 412 | + <div style="font-size:1.75rem;font-weight:700;color:${color};">${value}</div> |
|---|
| 413 | + <div style="font-size:0.8125rem;color:#9ca3af;">${label}</div> |
|---|
| 414 | + </div>`; |
|---|
| 415 | +} |
|---|
| 416 | + |
|---|
| 417 | +// --------------------------------------------------------------------------- |
|---|
| 418 | +// Services (flat list page) |
|---|
| 419 | +// --------------------------------------------------------------------------- |
|---|
| 420 | +function renderServicesFlat() { |
|---|
| 421 | + updateBreadcrumbs(); |
|---|
| 422 | + const content = document.getElementById('page-content'); |
|---|
| 423 | + |
|---|
| 424 | + if (allServices.length === 0) { |
|---|
| 425 | + content.innerHTML = '<div style="text-align:center;padding:3rem;color:#6b7280;">No services found.</div>'; |
|---|
| 426 | + return; |
|---|
| 427 | + } |
|---|
| 428 | + |
|---|
| 429 | + let html = '<div class="page-enter" style="padding:0;">'; |
|---|
| 430 | + html += '<div class="table-wrapper"><table class="ops-table">'; |
|---|
| 431 | + html += '<thead><tr><th>Project</th><th>Env</th><th>Service</th><th>Status</th><th>Health</th><th>Uptime</th><th>Actions</th></tr></thead>'; |
|---|
| 432 | + html += '<tbody>'; |
|---|
| 433 | + |
|---|
| 434 | + for (const svc of allServices) { |
|---|
| 435 | + const bc = badgeClass(svc.status, svc.health); |
|---|
| 436 | + const proj = escapeHtml(svc.project); |
|---|
| 437 | + const env = escapeHtml(svc.env); |
|---|
| 438 | + const service = escapeHtml(svc.service); |
|---|
| 439 | + |
|---|
| 440 | + html += `<tr> |
|---|
| 441 | + <td style="font-weight:500;">${proj}</td> |
|---|
| 442 | + <td><span class="badge badge-blue">${env}</span></td> |
|---|
| 443 | + <td class="mono">${service}</td> |
|---|
| 444 | + <td><span class="badge ${bc}">${escapeHtml(svc.status)}</span></td> |
|---|
| 445 | + <td>${escapeHtml(svc.health || 'n/a')}</td> |
|---|
| 446 | + <td>${escapeHtml(svc.uptime || 'n/a')}</td> |
|---|
| 447 | + <td style="white-space:nowrap;"> |
|---|
| 448 | + <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button> |
|---|
| 449 | + <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button> |
|---|
| 450 | + </td> |
|---|
| 451 | + </tr>`; |
|---|
| 452 | + } |
|---|
| 453 | + |
|---|
| 454 | + html += '</tbody></table></div></div>'; |
|---|
| 455 | + content.innerHTML = html; |
|---|
| 456 | +} |
|---|
| 457 | + |
|---|
| 458 | +// --------------------------------------------------------------------------- |
|---|
| 459 | +// Backups Page |
|---|
| 460 | +// --------------------------------------------------------------------------- |
|---|
| 461 | +async function renderBackups() { |
|---|
| 462 | + updateBreadcrumbs(); |
|---|
| 463 | + const content = document.getElementById('page-content'); |
|---|
| 464 | + |
|---|
| 465 | + try { |
|---|
| 466 | + const [local, offsite] = await Promise.all([ |
|---|
| 467 | + api('/api/backups/'), |
|---|
| 468 | + api('/api/backups/offsite').catch(() => []), |
|---|
| 469 | + ]); |
|---|
| 470 | + |
|---|
| 471 | + let html = '<div class="page-enter" style="padding:0;">'; |
|---|
| 472 | + |
|---|
| 473 | + // Quick backup buttons |
|---|
| 474 | + html += '<div style="margin-bottom:1.5rem;">'; |
|---|
| 475 | + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>'; |
|---|
| 476 | + html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">'; |
|---|
| 477 | + for (const proj of ['mdf', 'seriousletter']) { |
|---|
| 478 | + for (const env of ['dev', 'int', 'prod']) { |
|---|
| 479 | + html += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${proj}','${env}')">${proj}/${env}</button>`; |
|---|
| 480 | + } |
|---|
| 481 | + } |
|---|
| 482 | + html += '</div></div>'; |
|---|
| 483 | + |
|---|
| 484 | + // Local backups |
|---|
| 485 | + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>'; |
|---|
| 486 | + if (local.length === 0) { |
|---|
| 487 | + html += '<div class="card" style="color:#6b7280;">No local backups found.</div>'; |
|---|
| 488 | + } else { |
|---|
| 489 | + html += '<div class="table-wrapper"><table class="ops-table">'; |
|---|
| 490 | + html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>'; |
|---|
| 491 | + for (const b of local) { |
|---|
| 492 | + html += `<tr> |
|---|
| 493 | + <td>${escapeHtml(b.project || '')}</td> |
|---|
| 494 | + <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td> |
|---|
| 495 | + <td>${escapeHtml(b.date || b.timestamp || '')}</td> |
|---|
| 496 | + <td>${escapeHtml(b.size || '')}</td> |
|---|
| 497 | + <td class="mono" style="font-size:0.75rem;">${escapeHtml(b.file || b.files || '')}</td> |
|---|
| 498 | + </tr>`; |
|---|
| 499 | + } |
|---|
| 500 | + html += '</tbody></table></div>'; |
|---|
| 501 | + } |
|---|
| 502 | + |
|---|
| 503 | + // Offsite backups |
|---|
| 504 | + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>'; |
|---|
| 505 | + if (offsite.length === 0) { |
|---|
| 506 | + html += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>'; |
|---|
| 507 | + } else { |
|---|
| 508 | + html += '<div class="table-wrapper"><table class="ops-table">'; |
|---|
| 509 | + html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>'; |
|---|
| 510 | + for (const b of offsite) { |
|---|
| 511 | + html += `<tr> |
|---|
| 512 | + <td>${escapeHtml(b.project || '')}</td> |
|---|
| 513 | + <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td> |
|---|
| 514 | + <td>${escapeHtml(b.date || b.timestamp || '')}</td> |
|---|
| 515 | + <td>${escapeHtml(b.size || '')}</td> |
|---|
| 516 | + </tr>`; |
|---|
| 517 | + } |
|---|
| 518 | + html += '</tbody></table></div>'; |
|---|
| 519 | + } |
|---|
| 520 | + |
|---|
| 521 | + html += '</div>'; |
|---|
| 522 | + content.innerHTML = html; |
|---|
| 523 | + } catch (e) { |
|---|
| 524 | + content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>'; |
|---|
| 525 | + } |
|---|
| 526 | +} |
|---|
| 527 | + |
|---|
| 528 | +// --------------------------------------------------------------------------- |
|---|
| 529 | +// System Page |
|---|
| 530 | +// --------------------------------------------------------------------------- |
|---|
| 531 | +async function renderSystem() { |
|---|
| 532 | + updateBreadcrumbs(); |
|---|
| 533 | + const content = document.getElementById('page-content'); |
|---|
| 534 | + |
|---|
| 535 | + try { |
|---|
| 536 | + const [disk, health, timers, info] = await Promise.all([ |
|---|
| 537 | + api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })), |
|---|
| 538 | + api('/api/system/health').catch(e => ({ checks: [], raw: e.message })), |
|---|
| 539 | + api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })), |
|---|
| 540 | + api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })), |
|---|
| 541 | + ]); |
|---|
| 542 | + |
|---|
| 543 | + let html = '<div class="page-enter" style="padding:0;">'; |
|---|
| 544 | + |
|---|
| 545 | + // System info bar |
|---|
| 546 | + html += '<div class="stat-grid" style="margin-bottom:1.5rem;">'; |
|---|
| 547 | + html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6'); |
|---|
| 548 | + html += statCard('Load', info.load || 'n/a', '#8b5cf6'); |
|---|
| 549 | + html += '</div>'; |
|---|
| 550 | + |
|---|
| 551 | + // Disk usage |
|---|
| 552 | + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>'; |
|---|
| 553 | + if (disk.filesystems && disk.filesystems.length > 0) { |
|---|
| 554 | + html += '<div style="display:grid;gap:0.75rem;margin-bottom:1.5rem;">'; |
|---|
| 555 | + for (const fs of disk.filesystems) { |
|---|
| 556 | + const pct = parseInt(fs.use_percent) || 0; |
|---|
| 557 | + html += `<div class="card"> |
|---|
| 558 | + <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;"> |
|---|
| 559 | + <span class="mono" style="font-size:0.8125rem;">${escapeHtml(fs.mount || fs.filesystem)}</span> |
|---|
| 560 | + <span style="font-size:0.8125rem;color:#9ca3af;">${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})</span> |
|---|
| 561 | + </div> |
|---|
| 562 | + <div class="progress-bar-track"> |
|---|
| 563 | + <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div> |
|---|
| 564 | + </div> |
|---|
| 565 | + </div>`; |
|---|
| 566 | + } |
|---|
| 567 | + html += '</div>'; |
|---|
| 568 | + } else { |
|---|
| 569 | + html += '<div class="card" style="color:#6b7280;">No disk data available.</div>'; |
|---|
| 570 | + } |
|---|
| 571 | + |
|---|
| 572 | + // Health checks |
|---|
| 573 | + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>'; |
|---|
| 574 | + if (health.checks && health.checks.length > 0) { |
|---|
| 575 | + html += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">'; |
|---|
| 576 | + for (const c of health.checks) { |
|---|
| 577 | + const st = (c.status || '').toUpperCase(); |
|---|
| 578 | + const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray'; |
|---|
| 579 | + html += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;"> |
|---|
| 580 | + <span class="badge ${cls}">${escapeHtml(st)}</span> |
|---|
| 581 | + <span style="font-size:0.875rem;">${escapeHtml(c.check)}</span> |
|---|
| 582 | + </div>`; |
|---|
| 583 | + } |
|---|
| 584 | + html += '</div>'; |
|---|
| 585 | + } else { |
|---|
| 586 | + html += '<div class="card" style="color:#6b7280;">No health check data.</div>'; |
|---|
| 587 | + } |
|---|
| 588 | + |
|---|
| 589 | + // Timers |
|---|
| 590 | + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>'; |
|---|
| 591 | + if (timers.timers && timers.timers.length > 0) { |
|---|
| 592 | + html += '<div class="table-wrapper"><table class="ops-table">'; |
|---|
| 593 | + html += '<thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>'; |
|---|
| 594 | + for (const t of timers.timers) { |
|---|
| 595 | + html += `<tr> |
|---|
| 596 | + <td class="mono">${escapeHtml(t.unit)}</td> |
|---|
| 597 | + <td>${escapeHtml(t.next)}</td> |
|---|
| 598 | + <td>${escapeHtml(t.left)}</td> |
|---|
| 599 | + <td>${escapeHtml(t.last)}</td> |
|---|
| 600 | + <td>${escapeHtml(t.passed)}</td> |
|---|
| 601 | + </tr>`; |
|---|
| 602 | + } |
|---|
| 603 | + html += '</tbody></table></div>'; |
|---|
| 604 | + } else { |
|---|
| 605 | + html += '<div class="card" style="color:#6b7280;">No timers found.</div>'; |
|---|
| 606 | + } |
|---|
| 607 | + |
|---|
| 608 | + html += '</div>'; |
|---|
| 609 | + content.innerHTML = html; |
|---|
| 610 | + } catch (e) { |
|---|
| 611 | + content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>'; |
|---|
| 612 | + } |
|---|
| 613 | +} |
|---|
| 614 | + |
|---|
| 615 | +// --------------------------------------------------------------------------- |
|---|
| 616 | +// Restore Page |
|---|
| 617 | +// --------------------------------------------------------------------------- |
|---|
| 618 | +function renderRestore() { |
|---|
| 619 | + updateBreadcrumbs(); |
|---|
| 620 | + const content = document.getElementById('page-content'); |
|---|
| 621 | + |
|---|
| 622 | + let html = '<div class="page-enter" style="padding:0;">'; |
|---|
| 623 | + html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>'; |
|---|
| 624 | + html += '<div class="card" style="max-width:480px;">'; |
|---|
| 625 | + |
|---|
| 626 | + html += '<div style="margin-bottom:1rem;">'; |
|---|
| 627 | + html += '<label class="form-label">Project</label>'; |
|---|
| 628 | + html += '<select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select>'; |
|---|
| 629 | + html += '</div>'; |
|---|
| 630 | + |
|---|
| 631 | + html += '<div style="margin-bottom:1rem;">'; |
|---|
| 632 | + html += '<label class="form-label">Environment</label>'; |
|---|
| 633 | + html += '<select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select>'; |
|---|
| 634 | + html += '</div>'; |
|---|
| 635 | + |
|---|
| 636 | + html += '<div style="margin-bottom:1rem;">'; |
|---|
| 637 | + html += '<label class="form-label">Source</label>'; |
|---|
| 638 | + html += '<select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select>'; |
|---|
| 639 | + html += '</div>'; |
|---|
| 640 | + |
|---|
| 641 | + html += '<div style="margin-bottom:1rem;">'; |
|---|
| 642 | + html += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;">'; |
|---|
| 643 | + html += '<input type="checkbox" id="restore-dry" checked> Dry run (preview only)'; |
|---|
| 644 | + html += '</label>'; |
|---|
| 645 | + html += '</div>'; |
|---|
| 646 | + |
|---|
| 647 | + html += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>'; |
|---|
| 648 | + html += '</div>'; |
|---|
| 649 | + |
|---|
| 650 | + html += '<div id="restore-output" style="display:none;margin-top:1rem;">'; |
|---|
| 651 | + html += '<h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3>'; |
|---|
| 652 | + html += '<div id="restore-terminal" class="terminal" style="max-height:400px;"></div>'; |
|---|
| 653 | + html += '</div>'; |
|---|
| 654 | + |
|---|
| 655 | + html += '</div>'; |
|---|
| 656 | + content.innerHTML = html; |
|---|
| 657 | +} |
|---|
| 658 | + |
|---|
| 659 | +async function startRestore() { |
|---|
| 660 | + const project = document.getElementById('restore-project').value; |
|---|
| 661 | + const env = document.getElementById('restore-env').value; |
|---|
| 662 | + const source = document.getElementById('restore-source').value; |
|---|
| 663 | + const dryRun = document.getElementById('restore-dry').checked; |
|---|
| 664 | + |
|---|
| 665 | + if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return; |
|---|
| 666 | + |
|---|
| 667 | + const outputDiv = document.getElementById('restore-output'); |
|---|
| 668 | + const terminal = document.getElementById('restore-terminal'); |
|---|
| 669 | + outputDiv.style.display = 'block'; |
|---|
| 670 | + terminal.textContent = 'Starting restore...\n'; |
|---|
| 671 | + |
|---|
| 672 | + const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`; |
|---|
| 673 | + const evtSource = new EventSource(url); |
|---|
| 674 | + |
|---|
| 675 | + evtSource.onmessage = function(e) { |
|---|
| 676 | + const data = JSON.parse(e.data); |
|---|
| 677 | + if (data.done) { |
|---|
| 678 | + evtSource.close(); |
|---|
| 679 | + terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; |
|---|
| 680 | + toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error'); |
|---|
| 681 | + return; |
|---|
| 682 | + } |
|---|
| 683 | + if (data.line) { |
|---|
| 684 | + terminal.textContent += data.line + '\n'; |
|---|
| 685 | + terminal.scrollTop = terminal.scrollHeight; |
|---|
| 686 | + } |
|---|
| 687 | + }; |
|---|
| 688 | + |
|---|
| 689 | + evtSource.onerror = function() { |
|---|
| 690 | + evtSource.close(); |
|---|
| 691 | + terminal.textContent += '\n--- Connection lost ---\n'; |
|---|
| 692 | + toast('Restore connection lost', 'error'); |
|---|
| 693 | + }; |
|---|
| 694 | +} |
|---|
| 695 | + |
|---|
| 696 | +// --------------------------------------------------------------------------- |
|---|
| 697 | +// Service Actions |
|---|
| 698 | +// --------------------------------------------------------------------------- |
|---|
| 699 | +async function restartService(project, env, service) { |
|---|
| 700 | + if (!confirm(`Restart ${service} in ${project}/${env}?`)) return; |
|---|
| 701 | + |
|---|
| 702 | + toast('Restarting ' + service + '...', 'info'); |
|---|
| 703 | + try { |
|---|
| 704 | + const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' }); |
|---|
| 705 | + toast(result.message || 'Restarted successfully', 'success'); |
|---|
| 706 | + setTimeout(() => refreshCurrentPage(), 3000); |
|---|
| 707 | + } catch (e) { |
|---|
| 708 | + toast('Restart failed: ' + e.message, 'error'); |
|---|
| 709 | + } |
|---|
| 710 | +} |
|---|
| 711 | + |
|---|
| 712 | +async function viewLogs(project, env, service) { |
|---|
| 713 | + logModalProject = project; |
|---|
| 714 | + logModalEnv = env; |
|---|
| 715 | + logModalService = service; |
|---|
| 716 | + |
|---|
| 717 | + document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`; |
|---|
| 718 | + document.getElementById('log-modal-content').textContent = 'Loading...'; |
|---|
| 719 | + document.getElementById('log-modal').style.display = 'flex'; |
|---|
| 720 | + |
|---|
| 721 | + await refreshLogs(); |
|---|
| 722 | +} |
|---|
| 723 | + |
|---|
| 724 | +async function refreshLogs() { |
|---|
| 725 | + if (!logModalProject) return; |
|---|
| 726 | + try { |
|---|
| 727 | + const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`); |
|---|
| 728 | + const terminal = document.getElementById('log-modal-content'); |
|---|
| 729 | + terminal.textContent = data.logs || 'No logs available.'; |
|---|
| 730 | + terminal.scrollTop = terminal.scrollHeight; |
|---|
| 731 | + } catch (e) { |
|---|
| 732 | + document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message; |
|---|
| 733 | + } |
|---|
| 734 | +} |
|---|
| 735 | + |
|---|
| 736 | +function closeLogModal() { |
|---|
| 737 | + document.getElementById('log-modal').style.display = 'none'; |
|---|
| 738 | + logModalProject = null; |
|---|
| 739 | + logModalEnv = null; |
|---|
| 740 | + logModalService = null; |
|---|
| 741 | +} |
|---|
| 742 | + |
|---|
| 743 | +// --------------------------------------------------------------------------- |
|---|
| 744 | +// Backup Actions |
|---|
| 745 | +// --------------------------------------------------------------------------- |
|---|
| 746 | +async function createBackup(project, env) { |
|---|
| 747 | + if (!confirm(`Create backup for ${project}/${env}?`)) return; |
|---|
| 748 | + toast('Creating backup for ' + project + '/' + env + '...', 'info'); |
|---|
| 749 | + try { |
|---|
| 750 | + await api(`/api/backups/${project}/${env}`, { method: 'POST' }); |
|---|
| 751 | + toast('Backup created for ' + project + '/' + env, 'success'); |
|---|
| 752 | + if (currentPage === 'backups') renderBackups(); |
|---|
| 753 | + } catch (e) { |
|---|
| 754 | + toast('Backup failed: ' + e.message, 'error'); |
|---|
| 755 | + } |
|---|
| 756 | +} |
|---|
| 757 | + |
|---|
| 758 | +// --------------------------------------------------------------------------- |
|---|
| 759 | +// Data Grouping |
|---|
| 760 | +// --------------------------------------------------------------------------- |
|---|
| 761 | +function groupByProject(services) { |
|---|
| 762 | + const map = {}; |
|---|
| 763 | + for (const s of services) { |
|---|
| 764 | + const key = s.project || 'other'; |
|---|
| 765 | + if (!map[key]) map[key] = { name: key, services: [] }; |
|---|
| 766 | + map[key].services.push(s); |
|---|
| 767 | + } |
|---|
| 768 | + return map; |
|---|
| 769 | +} |
|---|
| 770 | + |
|---|
| 771 | +function groupByEnv(services) { |
|---|
| 772 | + const map = {}; |
|---|
| 773 | + for (const s of services) { |
|---|
| 774 | + const key = s.env || 'default'; |
|---|
| 775 | + if (!map[key]) map[key] = []; |
|---|
| 776 | + map[key].push(s); |
|---|
| 777 | + } |
|---|
| 778 | + return map; |
|---|
| 779 | +} |
|---|
| 780 | + |
|---|
| 781 | +// --------------------------------------------------------------------------- |
|---|
| 782 | +// Init |
|---|
| 783 | +// --------------------------------------------------------------------------- |
|---|
| 784 | +(function init() { |
|---|
| 785 | + const token = getToken(); |
|---|
| 786 | + if (token) { |
|---|
| 787 | + // Validate and load |
|---|
| 788 | + fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } }) |
|---|
| 789 | + .then(r => { |
|---|
| 790 | + if (!r.ok) throw new Error('Invalid token'); |
|---|
| 791 | + return r.json(); |
|---|
| 792 | + }) |
|---|
| 793 | + .then(data => { |
|---|
| 794 | + allServices = data; |
|---|
| 795 | + document.getElementById('login-overlay').style.display = 'none'; |
|---|
| 796 | + document.getElementById('app').style.display = 'flex'; |
|---|
| 797 | + showPage('dashboard'); |
|---|
| 798 | + startAutoRefresh(); |
|---|
| 799 | + }) |
|---|
| 800 | + .catch(() => { |
|---|
| 801 | + localStorage.removeItem('ops_token'); |
|---|
| 802 | + document.getElementById('login-overlay').style.display = 'flex'; |
|---|
| 803 | + }); |
|---|
| 804 | + } |
|---|
| 805 | + |
|---|
| 806 | + // ESC to close modals |
|---|
| 807 | + document.addEventListener('keydown', e => { |
|---|
| 808 | + if (e.key === 'Escape') { |
|---|
| 809 | + closeLogModal(); |
|---|
| 810 | + } |
|---|
| 811 | + }); |
|---|
| 812 | +})(); |
|---|