'use strict'; // ============================================================ // OPS Dashboard — Vanilla JS Application // ============================================================ // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let allServices = []; let currentPage = 'dashboard'; let drillLevel = 0; // 0=projects, 1=environments, 2=services let drillProject = null; let drillEnv = null; let refreshTimer = null; const REFRESH_INTERVAL = 30000; // Log modal state let logModalProject = null; let logModalEnv = null; let logModalService = null; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatBytes(bytes) { if (bytes == null || bytes === '') return '\u2014'; const n = Number(bytes); if (isNaN(n) || n === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(Math.abs(n)) / Math.log(k)); return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i]; } function timeAgo(dateInput) { if (!dateInput) return '\u2014'; const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; if (isNaN(date)) return '\u2014'; const secs = Math.floor((Date.now() - date.getTime()) / 1000); if (secs < 60) return secs + 's ago'; if (secs < 3600) return Math.floor(secs / 60) + 'm ago'; if (secs < 86400) return Math.floor(secs / 3600) + 'h ago'; return Math.floor(secs / 86400) + 'd ago'; } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function statusDotClass(status, health) { const s = (status || '').toLowerCase(); const h = (health || '').toLowerCase(); if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green'; if (s === 'up' && h === 'unhealthy') return 'status-dot-red'; if (s === 'up' && h === 'starting') return 'status-dot-yellow'; if (s === 'down' || s === 'exited') return 'status-dot-red'; return 'status-dot-gray'; } function badgeClass(status, health) { const s = (status || '').toLowerCase(); const h = (health || '').toLowerCase(); if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green'; if (s === 'up' && h === 'unhealthy') return 'badge-red'; if (s === 'up' && h === 'starting') return 'badge-yellow'; if (s === 'down' || s === 'exited') return 'badge-red'; return 'badge-gray'; } function diskColorClass(pct) { const n = parseInt(pct); if (isNaN(n)) return 'disk-ok'; if (n >= 90) return 'disk-danger'; if (n >= 75) return 'disk-warn'; return 'disk-ok'; } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- function getToken() { return localStorage.getItem('ops_token'); } function doLogin() { const input = document.getElementById('login-token'); const errEl = document.getElementById('login-error'); const token = input.value.trim(); if (!token) { errEl.textContent = 'Please enter a token'; errEl.style.display = 'block'; return; } errEl.style.display = 'none'; // Validate token by calling the API fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } }) .then(r => { if (!r.ok) throw new Error('Invalid token'); return r.json(); }) .then(data => { localStorage.setItem('ops_token', token); allServices = data; document.getElementById('login-overlay').style.display = 'none'; document.getElementById('app').style.display = 'flex'; showPage('dashboard'); startAutoRefresh(); }) .catch(() => { errEl.textContent = 'Invalid token. Try again.'; errEl.style.display = 'block'; }); } function doLogout() { localStorage.removeItem('ops_token'); stopAutoRefresh(); document.getElementById('app').style.display = 'none'; document.getElementById('login-overlay').style.display = 'flex'; document.getElementById('login-token').value = ''; } // --------------------------------------------------------------------------- // API Helper // --------------------------------------------------------------------------- async function api(path, opts = {}) { const token = getToken(); const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token }; const resp = await fetch(path, { ...opts, headers }); if (resp.status === 401) { doLogout(); throw new Error('Session expired'); } if (!resp.ok) { const body = await resp.text(); throw new Error(body || 'HTTP ' + resp.status); } const ct = resp.headers.get('content-type') || ''; if (ct.includes('json')) return resp.json(); return resp.text(); } async function fetchStatus() { allServices = await api('/api/status/'); } // --------------------------------------------------------------------------- // Toast Notifications // --------------------------------------------------------------------------- function toast(message, type = 'info') { const container = document.getElementById('toast-container'); const el = document.createElement('div'); el.className = 'toast toast-' + type; el.innerHTML = `${escapeHtml(message)}×`; container.appendChild(el); setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 200); }, 4000); } // --------------------------------------------------------------------------- // Sidebar & Navigation // --------------------------------------------------------------------------- function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); document.getElementById('mobile-overlay').classList.toggle('open'); } function showPage(page) { currentPage = page; drillLevel = 0; drillProject = null; drillEnv = null; // Update sidebar active document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => { el.classList.toggle('active', el.dataset.page === page); }); // Close mobile sidebar document.getElementById('sidebar').classList.remove('open'); document.getElementById('mobile-overlay').classList.remove('open'); renderPage(); } function renderPage() { const content = document.getElementById('page-content'); content.innerHTML = '
'; switch (currentPage) { case 'dashboard': renderDashboard(); break; case 'services': renderServicesFlat(); break; case 'backups': renderBackups(); break; case 'system': renderSystem(); break; case 'restore': renderRestore(); break; default: renderDashboard(); } } function refreshCurrentPage() { showRefreshSpinner(); fetchStatus() .then(() => renderPage()) .catch(e => toast('Refresh failed: ' + e.message, 'error')) .finally(() => hideRefreshSpinner()); } // --------------------------------------------------------------------------- // Auto-refresh // --------------------------------------------------------------------------- function startAutoRefresh() { stopAutoRefresh(); refreshTimer = setInterval(() => { fetchStatus() .then(() => { if (currentPage === 'dashboard' || currentPage === 'services') renderPage(); }) .catch(() => {}); }, REFRESH_INTERVAL); } function stopAutoRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } } function showRefreshSpinner() { document.getElementById('refresh-indicator').classList.remove('paused'); } function hideRefreshSpinner() { document.getElementById('refresh-indicator').classList.add('paused'); } // --------------------------------------------------------------------------- // Breadcrumbs // --------------------------------------------------------------------------- function updateBreadcrumbs() { const bc = document.getElementById('breadcrumbs'); let html = ''; if (currentPage === 'dashboard') { if (drillLevel === 0) { html = 'Dashboard'; } else if (drillLevel === 1) { html = 'Dashboard/' + escapeHtml(drillProject) + ''; } else if (drillLevel === 2) { html = 'Dashboard/' + escapeHtml(drillProject) + '/' + escapeHtml(drillEnv) + ''; } } else { const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' }; html = '' + (names[currentPage] || currentPage) + ''; } bc.innerHTML = html; } function drillBack(level) { if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; } else if (level === 1) { drillLevel = 1; drillEnv = null; } renderDashboard(); } // --------------------------------------------------------------------------- // Dashboard — 3-level Drill // --------------------------------------------------------------------------- function renderDashboard() { currentPage = 'dashboard'; if (drillLevel === 0) renderProjects(); else if (drillLevel === 1) renderEnvironments(); else if (drillLevel === 2) renderServices(); updateBreadcrumbs(); } function renderProjects() { const content = document.getElementById('page-content'); const projects = groupByProject(allServices); // Summary stats const totalUp = allServices.filter(s => s.status === 'Up').length; const totalDown = allServices.length - totalUp; let html = '
'; // Summary bar html += '
'; html += statCard('Projects', Object.keys(projects).length, '#3b82f6'); html += statCard('Services', allServices.length, '#8b5cf6'); html += statCard('Healthy', totalUp, '#10b981'); html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280'); html += '
'; // Project cards html += '
'; for (const [name, proj] of Object.entries(projects)) { const upCount = proj.services.filter(s => s.status === 'Up').length; const total = proj.services.length; const allUp = upCount === total; const envNames = [...new Set(proj.services.map(s => s.env))]; html += `
${escapeHtml(name)} ${total} services
${envNames.map(e => `${escapeHtml(e)}`).join('')}
${upCount}/${total} healthy
`; } html += '
'; content.innerHTML = html; } function renderEnvironments() { const content = document.getElementById('page-content'); const projServices = allServices.filter(s => s.project === drillProject); const envs = groupByEnv(projServices); let html = '
'; html += '
'; for (const [envName, services] of Object.entries(envs)) { const upCount = services.filter(s => s.status === 'Up').length; const total = services.length; const allUp = upCount === total; html += `
${escapeHtml(envName).toUpperCase()} ${total} services
${services.map(s => `${escapeHtml(s.service)}`).join('')}
${upCount}/${total} healthy
`; } html += '
'; content.innerHTML = html; } function renderServices() { const content = document.getElementById('page-content'); const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv); let html = '
'; html += '
'; for (const svc of services) { html += serviceCard(svc); } html += '
'; content.innerHTML = html; } function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); } function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); } // --------------------------------------------------------------------------- // Service Card (shared component) // --------------------------------------------------------------------------- function serviceCard(svc) { const proj = escapeHtml(svc.project); const env = escapeHtml(svc.env); const service = escapeHtml(svc.service); const bc = badgeClass(svc.status, svc.health); const dc = statusDotClass(svc.status, svc.health); return `
${service} ${escapeHtml(svc.status)}
Health: ${escapeHtml(svc.health || 'n/a')} · Uptime: ${escapeHtml(svc.uptime || 'n/a')}
`; } function statCard(label, value, color) { return `
${value}
${label}
`; } // --------------------------------------------------------------------------- // Services (flat list page) // --------------------------------------------------------------------------- function renderServicesFlat() { updateBreadcrumbs(); const content = document.getElementById('page-content'); if (allServices.length === 0) { content.innerHTML = '
No services found.
'; return; } let html = '
'; html += '
'; html += ''; html += ''; for (const svc of allServices) { const bc = badgeClass(svc.status, svc.health); const proj = escapeHtml(svc.project); const env = escapeHtml(svc.env); const service = escapeHtml(svc.service); html += ``; } html += '
ProjectEnvServiceStatusHealthUptimeActions
${proj} ${env} ${service} ${escapeHtml(svc.status)} ${escapeHtml(svc.health || 'n/a')} ${escapeHtml(svc.uptime || 'n/a')}
'; content.innerHTML = html; } // --------------------------------------------------------------------------- // Backups Page // --------------------------------------------------------------------------- async function renderBackups() { updateBreadcrumbs(); const content = document.getElementById('page-content'); try { const [local, offsite] = await Promise.all([ api('/api/backups/'), api('/api/backups/offsite').catch(() => []), ]); let html = '
'; // Quick backup buttons html += '
'; html += '

Create Backup

'; html += '
'; for (const proj of ['mdf', 'seriousletter']) { for (const env of ['dev', 'int', 'prod']) { html += ``; } } html += '
'; // Local backups html += '

Local Backups

'; if (local.length === 0) { html += '
No local backups found.
'; } else { html += '
'; html += ''; for (const b of local) { html += ``; } html += '
ProjectEnvDateSizeFiles
${escapeHtml(b.project || '')} ${escapeHtml(b.env || b.environment || '')} ${escapeHtml(b.date || b.timestamp || '')} ${escapeHtml(b.size || '')} ${escapeHtml(b.file || b.files || '')}
'; } // Offsite backups html += '

Offsite Backups

'; if (offsite.length === 0) { html += '
No offsite backups found.
'; } else { html += '
'; html += ''; for (const b of offsite) { html += ``; } html += '
ProjectEnvDateSize
${escapeHtml(b.project || '')} ${escapeHtml(b.env || b.environment || '')} ${escapeHtml(b.date || b.timestamp || '')} ${escapeHtml(b.size || '')}
'; } html += '
'; content.innerHTML = html; } catch (e) { content.innerHTML = '
Failed to load backups: ' + escapeHtml(e.message) + '
'; } } // --------------------------------------------------------------------------- // System Page // --------------------------------------------------------------------------- async function renderSystem() { updateBreadcrumbs(); const content = document.getElementById('page-content'); try { const [disk, health, timers, info] = await Promise.all([ api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })), api('/api/system/health').catch(e => ({ checks: [], raw: e.message })), api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })), api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })), ]); let html = '
'; // System info bar html += '
'; html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6'); html += statCard('Load', info.load || 'n/a', '#8b5cf6'); html += '
'; // Disk usage html += '

Disk Usage

'; if (disk.filesystems && disk.filesystems.length > 0) { html += '
'; for (const fs of disk.filesystems) { const pct = parseInt(fs.use_percent) || 0; html += `
${escapeHtml(fs.mount || fs.filesystem)} ${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})
`; } html += '
'; } else { html += '
No disk data available.
'; } // Health checks html += '

Health Checks

'; if (health.checks && health.checks.length > 0) { html += '
'; for (const c of health.checks) { const st = (c.status || '').toUpperCase(); const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray'; html += `
${escapeHtml(st)} ${escapeHtml(c.check)}
`; } html += '
'; } else { html += '
No health check data.
'; } // Timers html += '

Systemd Timers

'; if (timers.timers && timers.timers.length > 0) { html += '
'; html += ''; for (const t of timers.timers) { html += ``; } html += '
UnitNextLeftLastPassed
${escapeHtml(t.unit)} ${escapeHtml(t.next)} ${escapeHtml(t.left)} ${escapeHtml(t.last)} ${escapeHtml(t.passed)}
'; } else { html += '
No timers found.
'; } html += '
'; content.innerHTML = html; } catch (e) { content.innerHTML = '
Failed to load system info: ' + escapeHtml(e.message) + '
'; } } // --------------------------------------------------------------------------- // Restore Page // --------------------------------------------------------------------------- function renderRestore() { updateBreadcrumbs(); const content = document.getElementById('page-content'); let html = '
'; html += '

Restore Backup

'; html += '
'; html += '
'; html += ''; html += ''; html += '
'; html += '
'; html += ''; html += ''; html += '
'; html += '
'; html += ''; html += ''; html += '
'; html += '
'; html += ''; html += '
'; html += ''; html += '
'; html += ''; html += '
'; content.innerHTML = html; } async function startRestore() { const project = document.getElementById('restore-project').value; const env = document.getElementById('restore-env').value; const source = document.getElementById('restore-source').value; const dryRun = document.getElementById('restore-dry').checked; if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return; const outputDiv = document.getElementById('restore-output'); const terminal = document.getElementById('restore-terminal'); outputDiv.style.display = 'block'; terminal.textContent = 'Starting restore...\n'; const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`; const evtSource = new EventSource(url); evtSource.onmessage = function(e) { const data = JSON.parse(e.data); if (data.done) { evtSource.close(); terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error'); return; } if (data.line) { terminal.textContent += data.line + '\n'; terminal.scrollTop = terminal.scrollHeight; } }; evtSource.onerror = function() { evtSource.close(); terminal.textContent += '\n--- Connection lost ---\n'; toast('Restore connection lost', 'error'); }; } // --------------------------------------------------------------------------- // Service Actions // --------------------------------------------------------------------------- async function restartService(project, env, service) { if (!confirm(`Restart ${service} in ${project}/${env}?`)) return; toast('Restarting ' + service + '...', 'info'); try { const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' }); toast(result.message || 'Restarted successfully', 'success'); setTimeout(() => refreshCurrentPage(), 3000); } catch (e) { toast('Restart failed: ' + e.message, 'error'); } } async function viewLogs(project, env, service) { logModalProject = project; logModalEnv = env; logModalService = service; document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`; document.getElementById('log-modal-content').textContent = 'Loading...'; document.getElementById('log-modal').style.display = 'flex'; await refreshLogs(); } async function refreshLogs() { if (!logModalProject) return; try { const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`); const terminal = document.getElementById('log-modal-content'); terminal.textContent = data.logs || 'No logs available.'; terminal.scrollTop = terminal.scrollHeight; } catch (e) { document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message; } } function closeLogModal() { document.getElementById('log-modal').style.display = 'none'; logModalProject = null; logModalEnv = null; logModalService = null; } // --------------------------------------------------------------------------- // Backup Actions // --------------------------------------------------------------------------- async function createBackup(project, env) { if (!confirm(`Create backup for ${project}/${env}?`)) return; toast('Creating backup for ' + project + '/' + env + '...', 'info'); try { await api(`/api/backups/${project}/${env}`, { method: 'POST' }); toast('Backup created for ' + project + '/' + env, 'success'); if (currentPage === 'backups') renderBackups(); } catch (e) { toast('Backup failed: ' + e.message, 'error'); } } // --------------------------------------------------------------------------- // Data Grouping // --------------------------------------------------------------------------- function groupByProject(services) { const map = {}; for (const s of services) { const key = s.project || 'other'; if (!map[key]) map[key] = { name: key, services: [] }; map[key].services.push(s); } return map; } function groupByEnv(services) { const map = {}; for (const s of services) { const key = s.env || 'default'; if (!map[key]) map[key] = []; map[key].push(s); } return map; } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- (function init() { const token = getToken(); if (token) { // Validate and load fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } }) .then(r => { if (!r.ok) throw new Error('Invalid token'); return r.json(); }) .then(data => { allServices = data; document.getElementById('login-overlay').style.display = 'none'; document.getElementById('app').style.display = 'flex'; showPage('dashboard'); startAutoRefresh(); }) .catch(() => { localStorage.removeItem('ops_token'); document.getElementById('login-overlay').style.display = 'flex'; }); } // ESC to close modals document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeLogModal(); } }); })();