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