'use strict'; const APP_VERSION = 'v4-20260222'; // ============================================================ // OPS Dashboard — Vanilla JS Application (v4) // ============================================================ // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let allServices = []; let currentPage = 'dashboard'; let viewMode = 'cards'; // 'cards' | 'table' let tableFilter = null; // null | 'healthy' | 'down' | 'project:name' | 'env:name' let tableFilterLabel = ''; let drillLevel = 0; // 0=projects, 1=environments, 2=services let drillProject = null; let drillEnv = null; let refreshTimer = null; const REFRESH_INTERVAL = 30000; // Backup filter state let backupFilterProject = null; // null = all let backupFilterEnv = null; // null = all // Log modal state let logCtx = { project: null, env: null, service: null }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function fmtBytes(b) { if (b == null) return '\u2014'; const n = Number(b); if (isNaN(n) || n === 0) return '0 B'; const k = 1024, s = ['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) + ' ' + s[i]; } function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } function dotClass(status, health) { const s = (status || '').toLowerCase(), 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 badgeCls(status, health) { const s = (status || '').toLowerCase(), 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 diskColor(pct) { const n = parseInt(pct); if (n >= 90) return 'disk-danger'; if (n >= 75) return 'disk-warn'; return 'disk-ok'; } function isHealthy(svc) { return svc.status === 'Up' && (svc.health === 'healthy' || !svc.health); } function isDown(svc) { return !isHealthy(svc); } function filterServices(list) { if (!tableFilter) return list; if (tableFilter === 'healthy') return list.filter(isHealthy); if (tableFilter === 'down') return list.filter(isDown); if (tableFilter.startsWith('project:')) { const p = tableFilter.slice(8); return list.filter(s => s.project === p); } if (tableFilter.startsWith('env:')) { const e = tableFilter.slice(4); return list.filter(s => s.env === e); } return list; } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- function getToken() { return localStorage.getItem('ops_token'); } function doLogin() { const input = document.getElementById('login-token'); const err = document.getElementById('login-error'); const token = input.value.trim(); if (!token) { err.textContent = 'Please enter a token'; err.style.display = 'block'; return; } err.style.display = 'none'; fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } }) .then(r => { if (!r.ok) throw new Error(); 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'; const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION; showPage('dashboard'); startAutoRefresh(); }) .catch(() => { err.textContent = 'Invalid token.'; err.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 // --------------------------------------------------------------------------- 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 b = await resp.text(); throw new Error(b || 'HTTP ' + resp.status); } const ct = resp.headers.get('content-type') || ''; return ct.includes('json') ? resp.json() : resp.text(); } async function fetchStatus() { allServices = await api('/api/status/'); } // --------------------------------------------------------------------------- // Toast // --------------------------------------------------------------------------- function toast(msg, type = 'info') { const c = document.getElementById('toast-container'); const el = document.createElement('div'); el.className = 'toast toast-' + type; el.innerHTML = `${esc(msg)}×`; c.appendChild(el); setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 200); }, 4000); } // --------------------------------------------------------------------------- // 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; if (page !== 'dashboard') { viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; } document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => el.classList.toggle('active', el.dataset.page === page)); document.getElementById('sidebar').classList.remove('open'); document.getElementById('mobile-overlay').classList.remove('open'); renderPage(); } function renderPage() { const c = document.getElementById('page-content'); c.innerHTML = '
'; updateViewToggle(); switch (currentPage) { case 'dashboard': renderDashboard(); break; case 'backups': renderBackups(); break; case 'system': renderSystem(); break; case 'restore': renderRestore(); break; default: renderDashboard(); } } function refreshCurrentPage() { showSpin(); fetchStatus().then(() => renderPage()).catch(e => toast('Refresh failed: ' + e.message, 'error')).finally(hideSpin); } // --------------------------------------------------------------------------- // View Mode & Filters // --------------------------------------------------------------------------- function setViewMode(mode) { viewMode = mode; if (mode === 'cards') { tableFilter = null; tableFilterLabel = ''; } updateViewToggle(); renderDashboard(); } function setTableFilter(filter, label) { tableFilter = filter; tableFilterLabel = label || filter; viewMode = 'table'; updateViewToggle(); renderDashboard(); } function clearFilter() { tableFilter = null; tableFilterLabel = ''; renderDashboard(); } function updateViewToggle() { const wrap = document.getElementById('view-toggle-wrap'); const btnCards = document.getElementById('btn-view-cards'); const btnTable = document.getElementById('btn-view-table'); if (currentPage === 'dashboard') { wrap.style.display = ''; btnCards.classList.toggle('active', viewMode === 'cards'); btnTable.classList.toggle('active', viewMode === 'table'); } else { wrap.style.display = 'none'; } } // --------------------------------------------------------------------------- // Auto-refresh // --------------------------------------------------------------------------- function startAutoRefresh() { stopAutoRefresh(); refreshTimer = setInterval(() => { fetchStatus().then(() => { if (currentPage === 'dashboard') renderPage(); }).catch(() => {}); }, REFRESH_INTERVAL); } function stopAutoRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } } function showSpin() { document.getElementById('refresh-indicator').classList.remove('paused'); } function hideSpin() { document.getElementById('refresh-indicator').classList.add('paused'); } // --------------------------------------------------------------------------- // Breadcrumbs // --------------------------------------------------------------------------- function updateBreadcrumbs() { const bc = document.getElementById('breadcrumbs'); let h = ''; if (currentPage === 'dashboard') { if (viewMode === 'table') { h = 'Dashboard/'; h += 'All Services'; if (tableFilter) { h += ' ' + esc(tableFilterLabel) + ' '; } } else if (drillLevel === 0) { h = 'Dashboard'; } else if (drillLevel === 1) { h = 'Dashboard/' + esc(drillProject) + ''; } else if (drillLevel === 2) { h = 'Dashboard/' + esc(drillProject) + '/' + esc(drillEnv) + ''; } } else { const names = { backups: 'Backups', system: 'System', restore: 'Restore' }; h = '' + (names[currentPage] || currentPage) + ''; } bc.innerHTML = h; } function drillBack(level) { if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; } else if (level === 1) { drillLevel = 1; drillEnv = null; } renderDashboard(); } // --------------------------------------------------------------------------- // Dashboard — Cards + Table modes // --------------------------------------------------------------------------- function renderDashboard() { currentPage = 'dashboard'; if (viewMode === 'table') { renderDashboardTable(); } else if (drillLevel === 0) { renderProjects(); } else if (drillLevel === 1) { renderEnvironments(); } else { renderDrillServices(); } updateBreadcrumbs(); } function renderProjects() { const c = document.getElementById('page-content'); const projects = groupBy(allServices, 'project'); const totalUp = allServices.filter(isHealthy).length; const totalDown = allServices.length - totalUp; let h = '
'; // Stat tiles — clickable h += '
'; h += statTile('Projects', Object.keys(projects).length, '#3b82f6'); h += statTile('Services', allServices.length, '#8b5cf6', "setViewMode('table')"); h += statTile('Healthy', totalUp, '#10b981', "setTableFilter('healthy','Healthy')"); h += statTile('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280', totalDown > 0 ? "setTableFilter('down','Down')" : null); h += '
'; // Project cards h += '
'; for (const [name, svcs] of Object.entries(projects)) { const up = svcs.filter(isHealthy).length; const total = svcs.length; const envs = [...new Set(svcs.map(s => s.env))]; h += `
${esc(name)} ${total} svc
${envs.map(e => `${esc(e)}`).join('')}
${up}/${total} healthy
`; } h += '
'; c.innerHTML = h; } function renderEnvironments() { const c = document.getElementById('page-content'); const envs = groupBy(allServices.filter(s => s.project === drillProject), 'env'); let h = '
'; for (const [envName, svcs] of Object.entries(envs)) { const up = svcs.filter(isHealthy).length; const total = svcs.length; h += `
${esc(envName).toUpperCase()} ${total} svc
${svcs.map(s => `${esc(s.service)}`).join('')}
${up}/${total} healthy
`; } h += '
'; c.innerHTML = h; } function renderDrillServices() { const c = document.getElementById('page-content'); const svcs = allServices.filter(s => s.project === drillProject && s.env === drillEnv); let h = '
'; for (const svc of svcs) h += serviceCard(svc); h += '
'; c.innerHTML = h; } function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); } function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); } // --------------------------------------------------------------------------- // Dashboard — Table View // --------------------------------------------------------------------------- function renderDashboardTable() { const c = document.getElementById('page-content'); const svcs = filterServices(allServices); let h = '
'; // Quick filter row h += '
'; h += filterBtn('All', null); h += filterBtn('Healthy', 'healthy'); h += filterBtn('Down', 'down'); h += '|'; const projects = [...new Set(allServices.map(s => s.project))].sort(); for (const p of projects) { h += filterBtn(p, 'project:' + p); } h += '
'; // Table if (svcs.length === 0) { h += '
No services match this filter.
'; } else { h += '
'; h += ''; for (const svc of svcs) { h += ``; } h += '
ProjectEnvServiceStatusHealthUptimeActions
${esc(svc.project)} ${esc(svc.env)} ${esc(svc.service)} ${esc(svc.status)} ${esc(svc.health || 'n/a')} ${esc(svc.uptime || 'n/a')}
'; } h += '
'; c.innerHTML = h; } // --------------------------------------------------------------------------- // Shared Components // --------------------------------------------------------------------------- function serviceCard(svc) { const p = esc(svc.project), e = esc(svc.env), s = esc(svc.service); return `
${s} ${esc(svc.status)}
Health: ${esc(svc.health || 'n/a')} · Uptime: ${esc(svc.uptime || 'n/a')}
`; } function statTile(label, value, color, onclick) { const click = onclick ? ` onclick="${onclick}"` : ''; const cls = onclick ? ' stat-tile' : ''; return `
${value}
${label}
`; } function filterBtn(label, filter) { const active = tableFilter === filter; const cls = active ? 'btn btn-primary btn-xs' : 'btn btn-ghost btn-xs'; if (filter === null) { return ``; } return ``; } function metricBar(label, used, total, unit, color) { if (!total || total === 0) return ''; const pct = Math.round(used / total * 100); const cls = pct >= 90 ? 'disk-danger' : pct >= 75 ? 'disk-warn' : color || 'disk-ok'; return `
${label} ${fmtBytes(used)} / ${fmtBytes(total)} (${pct}%)
`; } // --------------------------------------------------------------------------- // Backups // --------------------------------------------------------------------------- function fmtBackupDate(raw) { if (!raw) return '\u2014'; // YYYYMMDD_HHMMSS -> YYYY-MM-DD HH:MM const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})/); if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`; // YYYY-MM-DD passthrough return raw; } async function renderBackups() { updateBreadcrumbs(); const c = document.getElementById('page-content'); try { const [local, offsite] = await Promise.all([ api('/api/backups/'), api('/api/backups/offsite').catch(() => []), ]); // Apply filters const filteredLocal = local.filter(b => { if (backupFilterProject && b.project !== backupFilterProject) return false; if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false; return true; }); const filteredOffsite = offsite.filter(b => { if (backupFilterProject && b.project !== backupFilterProject) return false; if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false; return true; }); let h = '
'; // Quick backup buttons h += '
'; h += '

Create Backup

'; h += '
'; for (const p of ['mdf', 'seriousletter']) { for (const e of ['dev', 'int', 'prod']) { h += ``; } } h += '
'; // Filter bar const activeStyle = 'background:rgba(59,130,246,0.2);color:#60a5fa;'; h += '
'; h += 'Project:'; h += ``; h += ``; h += ``; h += '|'; h += 'Env:'; h += ``; h += ``; h += ``; h += ``; h += '
'; // Local h += '

Local Backups

'; if (filteredLocal.length === 0) { h += '
No local backups match the current filter.
'; } else { h += '
'; for (const b of filteredLocal) { h += ``; } h += '
ProjectEnvFileDateSize
${esc(b.project||'')} ${esc(b.env||b.environment||'')} ${esc(b.name||b.file||'')} ${esc(fmtBackupDate(b.date||b.timestamp||''))} ${esc(b.size_human||b.size||'')}
'; } // Offsite h += '

Offsite Backups

'; if (filteredOffsite.length === 0) { h += '
No offsite backups match the current filter.
'; } else { h += '
'; for (const b of filteredOffsite) { h += ``; } h += '
ProjectEnvFileDateSize
${esc(b.project||'')} ${esc(b.env||b.environment||'')} ${esc(b.name||'')} ${esc(fmtBackupDate(b.date||''))} ${esc(b.size||'')}
'; } h += '
'; c.innerHTML = h; } catch (e) { c.innerHTML = '
Failed to load backups: ' + esc(e.message) + '
'; } } // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- async function renderSystem() { updateBreadcrumbs(); const c = 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 h = '
'; // Resource metrics (CPU, Memory, Swap) h += '

Resources

'; h += '
'; if (info.cpu) { const cpu = info.cpu; const cpuPct = cpu.usage_percent || 0; const cpuCls = cpuPct >= 90 ? 'disk-danger' : cpuPct >= 75 ? 'disk-warn' : 'disk-ok'; h += `
CPU ${cpuPct}% (${cpu.cores} cores)
`; } if (info.memory) { h += metricBar('Memory', info.memory.used, info.memory.total); } if (info.swap && info.swap.total > 0) { h += metricBar('Swap', info.swap.used, info.swap.total); } h += '
'; // Quick stats row h += '
'; h += statTile('Uptime', info.uptime || 'n/a', '#3b82f6'); h += statTile('Load', info.load || 'n/a', '#8b5cf6'); h += '
'; // Disk usage — only real filesystems h += '

Disk Usage

'; const realFs = (disk.filesystems || []).filter(f => f.filesystem && f.filesystem.startsWith('/dev')); if (realFs.length > 0) { h += '
'; for (const fs of realFs) { const pct = parseInt(fs.use_percent) || 0; h += `
${esc(fs.mount || fs.filesystem)} ${esc(fs.used)} / ${esc(fs.size)} (${esc(fs.use_percent)})
`; } h += '
'; } else { h += '
No disk data.
'; } // Health checks h += '

Health Checks

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

Systemd Timers

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

Restore Backup

'; h += '
'; h += '
'; h += '
'; h += '
'; h += '
'; h += ''; h += '
'; h += ''; h += '
'; c.innerHTML = h; } 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)' : ''}?`)) return; const out = document.getElementById('restore-output'); const term = document.getElementById('restore-terminal'); out.style.display = 'block'; term.textContent = 'Starting restore...\n'; const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`; const es = new EventSource(url); es.onmessage = function(e) { const d = JSON.parse(e.data); if (d.done) { es.close(); term.textContent += d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error'); return; } if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } }; es.onerror = function() { es.close(); term.textContent += '\n--- Connection lost ---\n'; toast('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 r = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' }); toast(r.message || 'Restarted', 'success'); setTimeout(refreshCurrentPage, 3000); } catch (e) { toast('Restart failed: ' + e.message, 'error'); } } async function viewLogs(project, env, service) { logCtx = { project, env, 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 (!logCtx.project) return; try { const d = await api(`/api/services/logs/${logCtx.project}/${logCtx.env}/${logCtx.service}?lines=200`); const t = document.getElementById('log-modal-content'); t.textContent = d.logs || 'No logs available.'; t.scrollTop = t.scrollHeight; } catch (e) { document.getElementById('log-modal-content').textContent = 'Error: ' + e.message; } } function closeLogModal() { document.getElementById('log-modal').style.display = 'none'; logCtx = { project: null, env: null, service: null }; } function setBackupFilter(type, value) { if (type === 'project') backupFilterProject = value; if (type === 'env') backupFilterEnv = value; renderBackups(); } async function createBackup(project, env) { if (!confirm(`Create backup for ${project}/${env}?`)) return; toast('Creating backup...', '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'); } } // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- function groupBy(arr, key) { const m = {}; for (const item of arr) { const k = item[key] || 'other'; (m[k] = m[k] || []).push(item); } return m; } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- (function init() { const token = getToken(); if (token) { fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } }) .then(r => { if (!r.ok) throw new Error(); return r.json(); }) .then(data => { allServices = data; document.getElementById('login-overlay').style.display = 'none'; document.getElementById('app').style.display = 'flex'; const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION; showPage('dashboard'); startAutoRefresh(); }) .catch(() => { localStorage.removeItem('ops_token'); }); } document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLogModal(); }); })();