'use strict'; const APP_VERSION = 'v15-20260226'; // ============================================================ // OPS Dashboard — Vanilla JS Application (v6) // ============================================================ // --------------------------------------------------------------------------- // 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 drill-down state let backupDrillLevel = 0; // 0=projects, 1=environments, 2=backup list let backupDrillProject = null; let backupDrillEnv = null; let cachedBackups = null; // merged array, fetched once per page visit // Log modal state let logCtx = { project: null, env: null, service: null }; // Restore modal state let restoreCtx = { project: null, env: null, source: null }; let restoreEventSource = null; // Backup multi-select state let selectedBackups = new Set(); // Operations state let opsEventSource = null; let opsCtx = { type: null, project: null, fromEnv: null, toEnv: null }; let cachedRegistry = null; let currentOpId = 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; } // --------------------------------------------------------------------------- // Progress Bar // --------------------------------------------------------------------------- function _setProgressState(barId, state) { const bar = document.getElementById(barId); if (!bar) return; bar.className = 'op-progress ' + (state === 'running' ? 'running' : state === 'ok' ? 'done-ok' : state === 'fail' ? 'done-fail' : 'hidden'); } // --------------------------------------------------------------------------- // 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; navigateToHash(); 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, cache: 'no-store' }); 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; backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; cachedBackups = 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(); pushHash(); } 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 'operations': renderOperations(); break; case 'schedules': renderSchedules(); break; default: renderDashboard(); } } function refreshCurrentPage() { showSpin(); cachedBackups = null; 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(); pushHash(); } function setTableFilter(filter, label) { tableFilter = filter; tableFilterLabel = label || filter; viewMode = 'table'; updateViewToggle(); renderDashboard(); pushHash(); } 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 if (currentPage === 'backups') { if (backupDrillLevel === 0) { h = 'Backups'; } else if (backupDrillLevel === 1) { h = 'Backups/' + esc(backupDrillProject) + ''; } else if (backupDrillLevel === 2) { h = 'Backups/' + esc(backupDrillProject) + '/' + esc(backupDrillEnv) + ''; } } else if (currentPage === 'schedules') { h = 'Schedules'; } else if (currentPage === 'system') { h = 'System'; } else if (currentPage === 'operations') { h = 'Operations'; } bc.innerHTML = h; } function drillBack(level) { if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; } else if (level === 1) { drillLevel = 1; drillEnv = null; } renderDashboard(); pushHash(); } // --------------------------------------------------------------------------- // 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(); pushHash(); } function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); pushHash(); } // --------------------------------------------------------------------------- // 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 — helpers // --------------------------------------------------------------------------- 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]}`; // ISO 8601: YYYY-MM-DDTHH:MM:SS const iso = String(raw).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}`; return raw; } // Parse backup date -> { dateKey: 'YYYY-MM-DD', timeStr: 'HH:MM' } function parseBackupDate(raw) { if (!raw) return { dateKey: '', timeStr: '' }; // YYYYMMDD_HHMMSS const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})/); if (m) return { dateKey: `${m[1]}-${m[2]}-${m[3]}`, timeStr: `${m[4]}:${m[5]}` }; // ISO 8601: YYYY-MM-DDTHH:MM:SS const iso = String(raw).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); if (iso) return { dateKey: `${iso[1]}-${iso[2]}-${iso[3]}`, timeStr: `${iso[4]}:${iso[5]}` }; return { dateKey: raw, timeStr: '' }; } // Format a YYYY-MM-DD key into a friendly group header label function fmtGroupHeader(dateKey) { if (!dateKey) return 'Unknown Date'; const d = new Date(dateKey + 'T00:00:00'); const today = new Date(); today.setHours(0, 0, 0, 0); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); const targetDay = new Date(dateKey + 'T00:00:00'); targetDay.setHours(0, 0, 0, 0); const longFmt = d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' }); if (targetDay.getTime() === today.getTime()) return 'Today \u2014 ' + longFmt; if (targetDay.getTime() === yesterday.getTime()) return 'Yesterday \u2014 ' + longFmt; return longFmt; } // Toggle a date group open/closed function toggleDateGroup(dateKey) { const body = document.getElementById('dg-body-' + dateKey); const chevron = document.getElementById('dg-chevron-' + dateKey); if (!body) return; const isOpen = body.classList.contains('open'); body.classList.toggle('open', !isOpen); if (chevron) chevron.classList.toggle('open', !isOpen); } // Normalize any backup date to ISO-sortable format (YYYY-MM-DDTHH:MM:SS) function normalizeBackupDate(raw) { if (!raw) return ''; // Compact: YYYYMMDD_HHMMSS -> YYYY-MM-DDTHH:MM:SS const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})(\d{2})?/); if (m) return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] || '00'}`; // Already ISO-ish: pass through return String(raw); } // --------------------------------------------------------------------------- // Backups — merge helper (dedup local+offsite by filename) // --------------------------------------------------------------------------- function mergeBackups(local, offsite) { const byName = new Map(); for (const b of local) { const name = b.name || b.file || ''; const key = name || (b.project + '/' + b.env + '/' + (b.date || b.mtime || b.timestamp)); byName.set(key, { project: b.project || '', env: b.env || b.environment || '', name: name, date: normalizeBackupDate(b.date || b.mtime || b.timestamp || ''), size_human: b.size_human || b.size || '', size_bytes: Number(b.size || 0), hasLocal: true, hasOffsite: false, }); } for (const b of offsite) { const name = b.name || ''; const key = name || (b.project + '/' + b.env + '/' + (b.date || '')); if (byName.has(key)) { const existing = byName.get(key); existing.hasOffsite = true; if (!existing.date && b.date) existing.date = normalizeBackupDate(b.date); } else { byName.set(key, { project: b.project || '', env: b.env || b.environment || '', name: name, date: normalizeBackupDate(b.date || ''), size_human: b.size || '', size_bytes: Number(b.size_bytes || 0), hasLocal: false, hasOffsite: true, }); } } return Array.from(byName.values()); } // --------------------------------------------------------------------------- // Backups — main render (v7: drill-down) // --------------------------------------------------------------------------- async function renderBackups() { updateBreadcrumbs(); const c = document.getElementById('page-content'); try { if (!cachedBackups) { const [local, offsite] = await Promise.all([ api('/api/backups/'), api('/api/backups/offsite').catch(() => []), ]); cachedBackups = mergeBackups(local, offsite); } if (backupDrillLevel === 0) renderBackupProjects(c); else if (backupDrillLevel === 1) renderBackupEnvironments(c); else renderBackupList(c); } catch (e) { c.innerHTML = '
Failed to load backups: ' + esc(e.message) + '
'; } } // --------------------------------------------------------------------------- // Backups — Level 0: Project cards // --------------------------------------------------------------------------- function renderBackupProjects(c) { const all = cachedBackups; const localCount = all.filter(b => b.hasLocal).length; const offsiteCount = all.filter(b => b.hasOffsite).length; const syncedCount = all.filter(b => b.hasLocal && b.hasOffsite).length; let latestTs = ''; for (const b of all) { if (b.date > latestTs) latestTs = b.date; } const latestDisplay = latestTs ? fmtBackupDate(latestTs) : '\u2014'; let h = '
'; // Create Backup buttons h += '
'; h += '

Create Backup

'; h += '
'; for (const p of ['mdf', 'seriousletter']) { for (const e of ['dev', 'int', 'prod']) { h += ``; } } h += '
'; // Global stat tiles h += '
'; h += statTile('Local', localCount, '#3b82f6'); h += statTile('Offsite', offsiteCount, '#8b5cf6'); h += statTile('Synced', syncedCount, '#10b981'); h += '
'; h += `
Latest backup: ${esc(latestDisplay)}
`; // Project cards const projects = groupBy(all, 'project'); h += '
'; for (const [name, backups] of Object.entries(projects)) { const envs = [...new Set(backups.map(b => b.env))].sort(); let projLatest = ''; for (const b of backups) { if (b.date > projLatest) projLatest = b.date; } const projSize = backups.reduce((acc, b) => acc + (b.size_bytes || 0), 0); h += `
${esc(name)} ${backups.length} backup${backups.length !== 1 ? 's' : ''}
${envs.map(e => `${esc(e)}`).join('')}
Latest: ${projLatest ? fmtBackupDate(projLatest) : '\u2014'} ${projSize > 0 ? ' · ' + fmtBytes(projSize) : ''}
`; } h += '
'; c.innerHTML = h; } // --------------------------------------------------------------------------- // Backups — Level 1: Environment cards for a project // --------------------------------------------------------------------------- function renderBackupEnvironments(c) { const projBackups = cachedBackups.filter(b => b.project === backupDrillProject); const envGroups = groupBy(projBackups, 'env'); const envOrder = ['dev', 'int', 'prod']; const sortedEnvs = Object.keys(envGroups).sort((a, b) => { const ai = envOrder.indexOf(a), bi = envOrder.indexOf(b); return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); }); let h = '
'; for (const envName of sortedEnvs) { const backups = envGroups[envName]; const count = backups.length; let envLatest = ''; for (const b of backups) { if (b.date > envLatest) envLatest = b.date; } const envSize = backups.reduce((acc, b) => acc + (b.size_bytes || 0), 0); const ep = esc(backupDrillProject), ee = esc(envName); // Restore button logic let restoreBtn = ''; if (count === 0) { restoreBtn = ``; } else if (count === 1) { const b = backups[0]; const src = b.hasLocal ? 'local' : 'offsite'; restoreBtn = ``; } else { restoreBtn = ``; } h += `
${ee.toUpperCase()} ${count} backup${count !== 1 ? 's' : ''}
Latest: ${envLatest ? fmtBackupDate(envLatest) : '\u2014'} ${envSize > 0 ? ' · ' + fmtBytes(envSize) : ''}
${restoreBtn}
`; } h += '
'; c.innerHTML = h; } // --------------------------------------------------------------------------- // Backups — Level 2: Individual backups for project/env // --------------------------------------------------------------------------- function renderBackupList(c) { const filtered = cachedBackups.filter(b => b.project === backupDrillProject && b.env === backupDrillEnv); let h = '
'; // Action bar: Create Backup + Upload h += `
`; h += ``; h += ``; if (filtered.some(b => b.hasOffsite && !b.hasLocal)) { h += ``; } h += `
`; // Selection action bar h += `
`; h += `${selectedBackups.size} selected`; h += ``; h += ``; h += `
`; if (filtered.length === 0) { h += '
No backups for ' + esc(backupDrillProject) + '/' + esc(backupDrillEnv) + '.
'; } else { // Group by date key (YYYY-MM-DD), sort descending const groups = {}; for (const b of filtered) { const { dateKey, timeStr } = parseBackupDate(b.date); b._dateKey = dateKey; b._timeStr = timeStr; if (!groups[dateKey]) groups[dateKey] = []; groups[dateKey].push(b); } const sortedKeys = Object.keys(groups).sort().reverse(); const today = new Date(); today.setHours(0, 0, 0, 0); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); for (const dateKey of sortedKeys) { const items = groups[dateKey].sort((a, b) => b.date.localeCompare(a.date)); const groupSizeBytes = items.reduce((acc, b) => acc + (b.size_bytes || 0), 0); const headerLabel = fmtGroupHeader(dateKey); const safeKey = backupDrillProject + backupDrillEnv + dateKey.replace(/-/g, ''); const targetDay = new Date(dateKey + 'T00:00:00'); targetDay.setHours(0, 0, 0, 0); const isRecent = targetDay.getTime() >= yesterday.getTime(); h += `
`; h += `
`; h += ``; h += `${esc(headerLabel)}`; h += `${items.length} backup${items.length !== 1 ? 's' : ''}`; if (groupSizeBytes > 0) { h += `${fmtBytes(groupSizeBytes)}`; } h += `
`; h += `
`; h += `
`; h += ``; for (const b of items) { let locationBadge; if (b.hasLocal && b.hasOffsite) { locationBadge = 'local + offsite'; } else if (b.hasLocal) { locationBadge = 'local'; } else { locationBadge = 'offsite'; } const restoreSource = b.hasLocal ? 'local' : 'offsite'; const checked = selectedBackups.has(b.name) ? ' checked' : ''; const deleteBtn = ``; const uploadBtn = (b.hasLocal && !b.hasOffsite) ? `` : ''; const downloadBtn = (!b.hasLocal && b.hasOffsite) ? `` : ''; const saveBtn = b.hasLocal ? `` : ''; h += ``; } h += `
LocationTimeSizeActions
${locationBadge} ${esc(b._timeStr || '\u2014')} ${esc(b.size_human || '\u2014')} ${saveBtn} ${uploadBtn} ${downloadBtn} ${deleteBtn}
`; h += `
`; h += `
`; } } h += '
'; c.innerHTML = h; } // --------------------------------------------------------------------------- // Backups — Drill-down navigation // --------------------------------------------------------------------------- function backupDrillToProject(name) { backupDrillProject = name; backupDrillLevel = 1; selectedBackups.clear(); renderBackups(); pushHash(); } function backupDrillToEnv(name) { backupDrillEnv = name; backupDrillLevel = 2; selectedBackups.clear(); renderBackups(); pushHash(); } function backupDrillBack(level) { if (level === 0) { backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; } else if (level === 1) { backupDrillLevel = 1; backupDrillEnv = null; } selectedBackups.clear(); renderBackups(); pushHash(); } // --------------------------------------------------------------------------- // Backup Multi-Select // --------------------------------------------------------------------------- function toggleBackupSelect(name) { if (selectedBackups.has(name)) selectedBackups.delete(name); else selectedBackups.add(name); updateSelectionBar(); } function toggleSelectAll(masterCb) { const table = masterCb.closest('table'); const cbs = table.querySelectorAll('.backup-cb'); if (masterCb.checked) { cbs.forEach(cb => { cb.checked = true; selectedBackups.add(cb.value); }); } else { cbs.forEach(cb => { cb.checked = false; selectedBackups.delete(cb.value); }); } updateSelectionBar(); } function clearSelection() { selectedBackups.clear(); document.querySelectorAll('.backup-cb').forEach(cb => { cb.checked = false; }); document.querySelectorAll('th input[type="checkbox"]').forEach(cb => { cb.checked = false; }); updateSelectionBar(); } function updateSelectionBar() { const bar = document.getElementById('backup-selection-bar'); const count = document.getElementById('selection-count'); if (bar) { bar.style.display = selectedBackups.size > 0 ? 'flex' : 'none'; if (count) count.textContent = selectedBackups.size + ' selected'; } } async function deleteSelected() { const names = [...selectedBackups]; if (names.length === 0) return; // Check if any selected backups have both locations const anyBoth = cachedBackups && cachedBackups.some(b => names.includes(b.name) && b.hasLocal && b.hasOffsite); let target = 'local'; if (anyBoth) { target = await showDeleteTargetDialog(names.length + ' selected backup(s)'); if (!target) return; } else { // Determine if all are offsite-only const allOffsite = cachedBackups && names.every(n => { const b = cachedBackups.find(x => x.name === n); return b && !b.hasLocal && b.hasOffsite; }); if (allOffsite) target = 'offsite'; } const label = target === 'both' ? 'local + offsite' : target; if (!await showConfirmDialog(`Delete ${names.length} backup${names.length > 1 ? 's' : ''} (${label})?\n\nThis cannot be undone.`, 'Delete', true)) return; toast(`Deleting ${names.length} backups (${label})...`, 'info'); let ok = 0, fail = 0; for (const name of names) { try { await api(`/api/backups/${encodeURIComponent(backupDrillProject)}/${encodeURIComponent(backupDrillEnv)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' }); ok++; } catch (_) { fail++; } } selectedBackups.clear(); cachedBackups = null; toast(`Deleted ${ok}${fail > 0 ? ', ' + fail + ' failed' : ''}`, fail > 0 ? 'warning' : 'success'); if (currentPage === 'backups') renderBackups(); } function downloadBackupFile(project, env, name) { window.open(`/api/backups/download/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?token=${encodeURIComponent(getToken())}`, '_blank'); } async function uploadOffsiteBackup(project, env, name) { const label = name ? name : `latest ${project}/${env}`; if (!await showConfirmDialog(`Upload ${label} to offsite storage?`, 'Upload')) return; // Open the ops modal with streaming output opsCtx = { type: 'upload', project, fromEnv: env, toEnv: null }; if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } const title = document.getElementById('ops-modal-title'); const info = document.getElementById('ops-modal-info'); const startBtn = document.getElementById('ops-start-btn'); const dryRunRow = document.getElementById('ops-dry-run-row'); const outputDiv = document.getElementById('ops-modal-output'); const term = document.getElementById('ops-modal-terminal'); title.textContent = 'Upload to Offsite'; let infoHtml = '
Project' + esc(project) + '
' + '
Environment' + esc(env) + '
'; if (name) infoHtml += '
File' + esc(name) + '
'; info.innerHTML = infoHtml; if (dryRunRow) dryRunRow.style.display = 'none'; startBtn.style.display = 'none'; outputDiv.style.display = 'block'; term.textContent = 'Starting upload...\n'; currentOpId = null; _setProgressState('ops-progress-bar', 'running'); document.getElementById('ops-modal').style.display = 'flex'; let url = '/api/backups/offsite/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?token=' + encodeURIComponent(getToken()); if (name) url += '&name=' + encodeURIComponent(name); const es = new EventSource(url); opsEventSource = es; es.onmessage = function(e) { try { const d = JSON.parse(e.data); if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } if (d.done) { es.close(); opsEventSource = null; currentOpId = null; const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Upload complete ---\n' : '\n--- Upload FAILED ---\n'; term.textContent += msg; term.scrollTop = term.scrollHeight; toast(d.cancelled ? 'Upload cancelled' : d.success ? 'Offsite upload complete for ' + project + '/' + env : 'Upload failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error'); _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); cachedBackups = null; if (d.success && currentPage === 'backups') renderBackups(); return; } if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } } catch (_) {} }; es.onerror = function() { es.close(); opsEventSource = null; currentOpId = null; term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); _setProgressState('ops-progress-bar', 'fail'); }; } // --------------------------------------------------------------------------- // Offsite Download (download to local storage, no restore) // --------------------------------------------------------------------------- async function downloadOffsiteBackup(project, env, name) { const label = name ? name : `latest offsite backup for ${project}/${env}`; if (!name) { // No specific name: find the latest offsite-only backup for this env const latest = cachedBackups && cachedBackups.find(b => b.project === project && b.env === env && b.hasOffsite && !b.hasLocal); if (!latest) { toast('No offsite-only backup found for ' + project + '/' + env, 'warning'); return; } name = latest.name; } if (!await showConfirmDialog(`Download "${name}" from offsite to local storage?`, 'Download')) return; // Open the ops modal with streaming output opsCtx = { type: 'download', project, fromEnv: env, toEnv: null }; if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } const title = document.getElementById('ops-modal-title'); const info = document.getElementById('ops-modal-info'); const startBtn = document.getElementById('ops-start-btn'); const dryRunRow = document.getElementById('ops-dry-run-row'); const outputDiv = document.getElementById('ops-modal-output'); const term = document.getElementById('ops-modal-terminal'); title.textContent = 'Download from Offsite'; let infoHtml = '
Project' + esc(project) + '
' + '
Environment' + esc(env) + '
' + '
File' + esc(name) + '
'; info.innerHTML = infoHtml; if (dryRunRow) dryRunRow.style.display = 'none'; startBtn.style.display = 'none'; outputDiv.style.display = 'block'; term.textContent = 'Starting download...\n'; currentOpId = null; _setProgressState('ops-progress-bar', 'running'); document.getElementById('ops-modal').style.display = 'flex'; const url = '/api/backups/offsite/download/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?name=' + encodeURIComponent(name) + '&token=' + encodeURIComponent(getToken()); const es = new EventSource(url); opsEventSource = es; es.onmessage = function(e) { try { const d = JSON.parse(e.data); if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } if (d.done) { es.close(); opsEventSource = null; currentOpId = null; const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Download complete ---\n' : '\n--- Download FAILED ---\n'; term.textContent += msg; term.scrollTop = term.scrollHeight; toast(d.cancelled ? 'Download cancelled' : d.success ? 'Downloaded ' + (d.name || name) + ' to local storage' : 'Download failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error'); _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); cachedBackups = null; if (d.success && currentPage === 'backups') renderBackups(); return; } if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } } catch (_) {} }; es.onerror = function() { es.close(); opsEventSource = null; currentOpId = null; term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); _setProgressState('ops-progress-bar', 'fail'); }; } // --------------------------------------------------------------------------- // Restore Modal // --------------------------------------------------------------------------- function openRestoreModal(project, env, source, name, hasLocal, hasOffsite) { restoreCtx = { project, env, source, name, hasLocal: !!hasLocal, hasOffsite: !!hasOffsite }; // Close any running event source if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; } // Populate modal info document.getElementById('restore-modal-project').textContent = project + '/' + env; document.getElementById('restore-modal-name').textContent = name || '(latest)'; document.getElementById('restore-dry-run').checked = false; // Source selector: show radios when both local+offsite, static text otherwise const sourceRow = document.getElementById('restore-source-row'); const sourceSelector = document.getElementById('restore-source-selector'); if (hasLocal && hasOffsite) { sourceRow.style.display = 'none'; sourceSelector.style.display = 'block'; document.querySelectorAll('input[name="restore-source"]').forEach(r => { r.checked = r.value === source; }); } else { sourceRow.style.display = 'flex'; sourceSelector.style.display = 'none'; document.getElementById('restore-modal-source').textContent = source; } // Reset mode to "full" const modeRadios = document.querySelectorAll('input[name="restore-mode"]'); modeRadios.forEach(r => { r.checked = r.value === 'full'; }); // Reset terminal const term = document.getElementById('restore-modal-terminal'); term.textContent = ''; document.getElementById('restore-modal-output').style.display = 'none'; // Enable start button const startBtn = document.getElementById('restore-start-btn'); startBtn.disabled = false; startBtn.textContent = 'Start Restore'; document.getElementById('restore-modal').style.display = 'flex'; } function closeRestoreModal() { if (currentOpId && restoreEventSource) { fetch('/api/operations/' + currentOpId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + getToken() } }).catch(() => {}); } if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; } currentOpId = null; _setProgressState('restore-progress-bar', 'hidden'); document.getElementById('restore-modal').style.display = 'none'; restoreCtx = { project: null, env: null, source: null, name: null }; } function startRestore() { const { project, env, hasLocal, hasOffsite } = restoreCtx; if (!project || !env) return; // Determine source: from radio if both available, otherwise from context let source = restoreCtx.source; if (hasLocal && hasOffsite) { const srcEl = document.querySelector('input[name="restore-source"]:checked'); if (srcEl) source = srcEl.value; } const dryRun = document.getElementById('restore-dry-run').checked; const startBtn = document.getElementById('restore-start-btn'); // Show terminal const outputDiv = document.getElementById('restore-modal-output'); const term = document.getElementById('restore-modal-terminal'); outputDiv.style.display = 'block'; term.textContent = 'Starting restore...\n'; startBtn.disabled = true; startBtn.textContent = dryRun ? 'Running preview...' : 'Restoring...'; const name = restoreCtx.name || ''; const modeEl = document.querySelector('input[name="restore-mode"]:checked'); const mode = modeEl ? modeEl.value : 'full'; const url = `/api/restore/${encodeURIComponent(project)}/${encodeURIComponent(env)}?source=${encodeURIComponent(source)}${dryRun ? '&dry_run=true' : ''}&token=${encodeURIComponent(getToken())}${name ? '&name=' + encodeURIComponent(name) : ''}&mode=${encodeURIComponent(mode)}`; currentOpId = null; _setProgressState('restore-progress-bar', 'running'); const es = new EventSource(url); restoreEventSource = es; es.onmessage = function(e) { try { const d = JSON.parse(e.data); if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } if (d.done) { es.close(); restoreEventSource = null; currentOpId = null; const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; term.textContent += msg; term.scrollTop = term.scrollHeight; const toastMsg = d.cancelled ? 'Restore cancelled' : d.success ? 'Restore completed' : 'Restore failed'; toast(toastMsg, d.success ? 'success' : d.cancelled ? 'warning' : 'error'); _setProgressState('restore-progress-bar', d.success ? 'ok' : 'fail'); startBtn.disabled = false; startBtn.textContent = 'Start Restore'; return; } if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } } catch (_) {} }; es.onerror = function() { es.close(); restoreEventSource = null; currentOpId = null; term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); _setProgressState('restore-progress-bar', 'fail'); startBtn.disabled = false; startBtn.textContent = 'Start Restore'; }; } // --------------------------------------------------------------------------- // 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' })), ]); 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('Containers', info.containers || 'n/a', '#8b5cf6'); h += statTile('Processes', info.processes || '0', '#f59e0b'); 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) + '
'; } } // --------------------------------------------------------------------------- // Schedules Page // --------------------------------------------------------------------------- let cachedSchedules = null; async function renderSchedules() { updateBreadcrumbs(); const c = document.getElementById('page-content'); try { const schedules = await api('/api/schedule/'); cachedSchedules = schedules; let h = '
'; h += '

Backup Schedules

'; h += '

Managed via registry.yaml. Changes regenerate systemd timers on the server.

'; h += '
' + '' + '' + ''; for (const s of schedules) { if (s.static) continue; // skip static sites const enabled = s.enabled; const enabledBadge = enabled ? 'On' : 'Off'; const schedule = s.schedule || '\u2014'; const envs = (s.backup_environments || s.environments || []).join(', ') || '\u2014'; const offsiteBadge = s.offsite ? 'Yes' : 'No'; const retLocal = s.retention_local_days != null ? s.retention_local_days + 'd local' : ''; const retOffsite = s.retention_offsite_days != null ? s.retention_offsite_days + 'd offsite' : ''; const retention = [retLocal, retOffsite].filter(Boolean).join(', ') || '\u2014'; const canEdit = s.has_backup_dir || s.has_cli; const editBtn = canEdit ? `` : 'n/a'; const runBtn = canEdit ? `` : ''; h += ``; } h += '
ProjectEnabledScheduleEnvironmentsOffsiteRetention
${esc(s.project)} ${enabledBadge} ${esc(schedule)} ${esc(envs)} ${offsiteBadge} ${esc(retention)} ${editBtn} ${runBtn}
'; h += '
'; c.innerHTML = h; } catch (e) { c.innerHTML = '
Failed to load schedules: ' + esc(e.message) + '
'; } } let _schedClockInterval = null; function _startScheduleClock() { _stopScheduleClock(); const el = document.getElementById('sched-server-clock'); const tick = () => { const now = new Date(); el.textContent = 'Server now: ' + now.toISOString().slice(11, 19) + ' UTC'; }; tick(); _schedClockInterval = setInterval(tick, 1000); } function _stopScheduleClock() { if (_schedClockInterval) { clearInterval(_schedClockInterval); _schedClockInterval = null; } } function openScheduleEdit(project) { const s = (cachedSchedules || []).find(x => x.project === project); if (!s) return; const envOptions = (s.environments || []).map(e => { const checked = (s.backup_environments || s.environments || []).includes(e) ? 'checked' : ''; return ``; }).join(''); const offsiteEnvOptions = (s.environments || []).map(e => { const checked = (s.offsite_envs || ['prod']).includes(e) ? 'checked' : ''; return ``; }).join(''); const modal = document.getElementById('schedule-modal'); document.getElementById('schedule-modal-title').textContent = 'Edit Schedule: ' + project; document.getElementById('sched-project').value = project; document.getElementById('sched-enabled').checked = s.enabled; document.getElementById('sched-time').value = s.schedule || '03:00'; document.getElementById('sched-envs').innerHTML = envOptions; document.getElementById('sched-command').value = s.command || ''; document.getElementById('sched-offsite').checked = s.offsite; document.getElementById('sched-offsite-envs').innerHTML = offsiteEnvOptions; document.getElementById('sched-offsite-section').style.display = s.offsite ? '' : 'none'; document.getElementById('sched-retention-local').value = s.retention_local_days != null ? s.retention_local_days : 7; document.getElementById('sched-retention-offsite').value = s.retention_offsite_days != null ? s.retention_offsite_days : 30; document.getElementById('sched-save-btn').disabled = false; document.getElementById('sched-save-btn').textContent = 'Save'; _startScheduleClock(); modal.style.display = 'flex'; } function closeScheduleModal() { _stopScheduleClock(); document.getElementById('schedule-modal').style.display = 'none'; } function toggleOffsiteSection() { const show = document.getElementById('sched-offsite').checked; document.getElementById('sched-offsite-section').style.display = show ? '' : 'none'; } async function saveSchedule() { const project = document.getElementById('sched-project').value; const btn = document.getElementById('sched-save-btn'); btn.disabled = true; btn.textContent = 'Saving...'; const envCheckboxes = document.querySelectorAll('input[name="sched-env"]:checked'); const environments = Array.from(envCheckboxes).map(cb => cb.value); const offsiteEnvCheckboxes = document.querySelectorAll('input[name="sched-offsite-env"]:checked'); const offsite_envs = Array.from(offsiteEnvCheckboxes).map(cb => cb.value); const body = { enabled: document.getElementById('sched-enabled').checked, schedule: document.getElementById('sched-time').value, environments: environments.length ? environments : null, command: document.getElementById('sched-command').value || null, offsite: document.getElementById('sched-offsite').checked, offsite_envs: offsite_envs.length ? offsite_envs : null, retention_local_days: parseInt(document.getElementById('sched-retention-local').value) || null, retention_offsite_days: parseInt(document.getElementById('sched-retention-offsite').value) || null, }; try { await api('/api/schedule/' + encodeURIComponent(project), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); toast('Schedule updated for ' + project, 'success'); closeScheduleModal(); cachedSchedules = null; renderSchedules(); } catch (e) { toast('Failed to save schedule: ' + e.message, 'error'); btn.disabled = false; btn.textContent = 'Save'; } } async function runBackupNow(project) { if (!await showConfirmDialog(`Run backup now for ${project}?`, 'Run Backup')) return; opsCtx = { type: 'backup', project, fromEnv: null, toEnv: null }; if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } const title = document.getElementById('ops-modal-title'); const info = document.getElementById('ops-modal-info'); const startBtn = document.getElementById('ops-start-btn'); const dryRunRow = document.getElementById('ops-dry-run-row'); const outputDiv = document.getElementById('ops-modal-output'); const term = document.getElementById('ops-modal-terminal'); title.textContent = 'Backup: ' + project; info.innerHTML = '
Project' + esc(project) + '
'; if (dryRunRow) dryRunRow.style.display = 'none'; startBtn.style.display = 'none'; outputDiv.style.display = 'block'; term.textContent = 'Starting backup...\n'; currentOpId = null; _setProgressState('ops-progress-bar', 'running'); document.getElementById('ops-modal').style.display = 'flex'; const url = '/api/schedule/' + encodeURIComponent(project) + '/run?token=' + encodeURIComponent(getToken()); const es = new EventSource(url); opsEventSource = es; es.onmessage = function(e) { try { const d = JSON.parse(e.data); if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } if (d.done) { es.close(); opsEventSource = null; _setProgressState('ops-progress-bar', d.success ? 'done' : 'error'); if (d.cancelled) term.textContent += '\n--- Cancelled ---\n'; else if (d.success) term.textContent += '\n--- Done ---\n'; else term.textContent += '\n--- Failed ---\n'; return; } if (d.line != null) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } } catch {} }; es.onerror = function() { es.close(); opsEventSource = null; _setProgressState('ops-progress-bar', 'error'); }; } // --------------------------------------------------------------------------- // Operations Page // --------------------------------------------------------------------------- async function renderOperations() { updateBreadcrumbs(); const c = document.getElementById('page-content'); // Fetch registry if not cached if (!cachedRegistry) { try { cachedRegistry = await api('/api/registry/'); } catch (e) { c.innerHTML = '
Failed to load registry: ' + esc(e.message) + '
'; return; } } const projects = cachedRegistry.projects || {}; let h = '
'; // Section: Promote Code (Forward) h += '

Promote Code

'; h += '

Push code forward: dev → int → prod. Each project defines its own promotion type (git pull or rsync).

'; h += '
'; for (const [name, cfg] of Object.entries(projects)) { if (!cfg.promote || cfg.static || cfg.infrastructure) continue; const pType = cfg.promote.type || 'unknown'; const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name); const typeBadge = pType === 'git' ? 'git' : 'rsync'; h += '
'; h += '
'; h += '' + esc(name) + ''; h += typeBadge; h += '
'; const promotions = []; if (envs.includes('dev') && envs.includes('int')) promotions.push(['dev', 'int']); if (envs.includes('int') && envs.includes('prod')) promotions.push(['int', 'prod']); if (promotions.length === 0) { h += '
No promotion paths available
'; } else { h += '
'; for (const [from, to] of promotions) { h += ''; } h += '
'; } h += '
'; } h += '
'; // Section: Sync Data (Backward) h += '

Sync Data

'; h += '

Sync content between environments. Choose the direction when syncing.

'; h += '
'; for (const [name, cfg] of Object.entries(projects)) { if (!cfg.has_cli || cfg.static || cfg.infrastructure) continue; const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name); h += '
'; h += '
' + esc(name) + '
'; const syncPairs = []; if (envs.includes('prod') && envs.includes('int')) syncPairs.push(['prod', 'int']); if (envs.includes('int') && envs.includes('dev')) syncPairs.push(['int', 'dev']); if (syncPairs.length === 0) { h += '
No sync paths available
'; } else { h += '
'; for (const [a, b] of syncPairs) { h += ''; } h += '
'; } h += '
'; } h += '
'; // Section: Container Lifecycle h += '

Container Lifecycle

'; h += '

Manage container state via docker compose. ' + 'Restart is safe. ' + 'Rebuild refreshes the image.

'; h += '
'; for (const [name, cfg] of Object.entries(projects)) { if (cfg.type === 'static' || cfg.type === 'infrastructure') continue; const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name).filter(e => e !== 'infra'); if (!envs.length) continue; h += '
'; h += '
' + esc(name) + '
'; h += '
'; for (const env of envs) { h += '
'; // Environment label h += '' + esc(env) + ''; // Restart (green) h += ''; // Rebuild (yellow) h += ''; // Backup (blue) h += ''; // Restore (navigate to backups page) h += ''; h += '
'; } h += '
'; } h += '
'; c.innerHTML = h; } // --------------------------------------------------------------------------- // Operations Modal // --------------------------------------------------------------------------- function openSyncModal(project, envA, envB) { // Show direction picker in the ops modal opsCtx = { type: 'sync', project: project, fromEnv: envA, toEnv: envB }; if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } const title = document.getElementById('ops-modal-title'); const info = document.getElementById('ops-modal-info'); const startBtn = document.getElementById('ops-start-btn'); title.textContent = 'Sync Data'; let ih = '
Project' + esc(project) + '
'; ih += '
Direction
'; ih += '
'; ih += ''; ih += ''; ih += '
'; ih += ''; info.innerHTML = ih; startBtn.className = 'btn btn-primary btn-sm'; startBtn.textContent = 'Sync'; document.getElementById('ops-dry-run').checked = true; document.getElementById('ops-modal-output').style.display = 'none'; document.getElementById('ops-modal-terminal').textContent = ''; startBtn.disabled = false; document.getElementById('ops-modal').style.display = 'flex'; } function updateSyncDir() { const dir = document.querySelector('input[name="sync-dir"]:checked').value; const downLabel = document.getElementById('sync-dir-down'); const upLabel = document.getElementById('sync-dir-up'); if (dir === 'down') { downLabel.style.background = 'rgba(96,165,250,0.1)'; upLabel.style.background = 'transparent'; // envA -> envB (default / downward) opsCtx.fromEnv = downLabel.querySelector('span[style*="color:#60a5fa"]').textContent; opsCtx.toEnv = downLabel.querySelector('span[style*="color:#fbbf24"]').textContent; } else { downLabel.style.background = 'transparent'; upLabel.style.background = 'rgba(251,191,36,0.1)'; // envB -> envA (upward) opsCtx.fromEnv = upLabel.querySelector('span[style*="color:#fbbf24"]').textContent; opsCtx.toEnv = upLabel.querySelector('span[style*="color:#60a5fa"]').textContent; } } function openOpsModal(type, project, fromEnv, toEnv) { opsCtx = { type, project, fromEnv, toEnv }; if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } const title = document.getElementById('ops-modal-title'); const info = document.getElementById('ops-modal-info'); const startBtn = document.getElementById('ops-start-btn'); if (type === 'promote') { title.textContent = 'Promote Code'; info.innerHTML = '
Project' + esc(project) + '
' + '
Direction' + esc(fromEnv) + ' → ' + esc(toEnv) + '
'; startBtn.className = 'btn btn-primary btn-sm'; startBtn.textContent = 'Promote'; } else if (type === 'sync') { title.textContent = 'Sync Data'; info.innerHTML = '
Project' + esc(project) + '
' + '
Direction' + esc(fromEnv) + ' → ' + esc(toEnv) + '
'; startBtn.className = 'btn btn-primary btn-sm'; startBtn.textContent = 'Sync'; } document.getElementById('ops-dry-run').checked = true; document.getElementById('ops-modal-output').style.display = 'none'; document.getElementById('ops-modal-terminal').textContent = ''; startBtn.disabled = false; document.getElementById('ops-modal').style.display = 'flex'; } // --------------------------------------------------------------------------- // Lifecycle Modal (Restart / Rebuild / Backup) // --------------------------------------------------------------------------- function openLifecycleModal(action, project, env) { opsCtx = { type: action, project, fromEnv: env, toEnv: null }; if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } const title = document.getElementById('ops-modal-title'); const info = document.getElementById('ops-modal-info'); const startBtn = document.getElementById('ops-start-btn'); const dryRunRow = document.getElementById('ops-dry-run-row'); // Hide the dry-run checkbox — lifecycle ops don't use it if (dryRunRow) dryRunRow.style.display = 'none'; if (action === 'restart') { title.textContent = 'Restart Containers'; info.innerHTML = '' + '
Project' + esc(project) + '
' + '
Environment' + esc(env) + '
' + '
' + 'Safe operation. Runs docker restart on each container. No image changes, no data loss.
'; startBtn.className = 'btn btn-sm'; startBtn.style.cssText = 'background:#065f46;color:#6ee7b7;border:1px solid rgba(110,231,179,0.3);'; startBtn.textContent = 'Restart'; } else if (action === 'rebuild') { title.textContent = 'Rebuild Environment'; info.innerHTML = '' + '
Project' + esc(project) + '
' + '
Environment' + esc(env) + '
' + '
' + 'Runs docker compose down, rebuilds the image, then starts again. No data loss.
'; startBtn.className = 'btn btn-sm'; startBtn.style.cssText = 'background:#78350f;color:#fde68a;border:1px solid rgba(251,191,36,0.3);'; startBtn.textContent = 'Rebuild'; } else if (action === 'backup') { title.textContent = 'Create Backup'; info.innerHTML = '' + '
Project' + esc(project) + '
' + '
Environment' + esc(env) + '
' + '
' + 'Creates a backup of the database and uploads for this environment.
'; startBtn.className = 'btn btn-primary btn-sm'; startBtn.style.cssText = ''; startBtn.textContent = 'Create Backup'; } document.getElementById('ops-modal-output').style.display = 'none'; document.getElementById('ops-modal-terminal').textContent = ''; document.getElementById('ops-modal').style.display = 'flex'; } function closeOpsModal() { if (currentOpId && opsEventSource) { fetch('/api/operations/' + currentOpId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + getToken() } }).catch(() => {}); } if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } currentOpId = null; _setProgressState('ops-progress-bar', 'hidden'); document.getElementById('ops-modal').style.display = 'none'; // Refresh backup list if we just ran a backup or upload if ((opsCtx.type === 'backup' || opsCtx.type === 'upload') && currentPage === 'backups') { cachedBackups = null; renderBackups(); } opsCtx = { type: null, project: null, fromEnv: null, toEnv: null }; // Restore dry-run row visibility for promote/sync operations const dryRunRow = document.getElementById('ops-dry-run-row'); if (dryRunRow) dryRunRow.style.display = ''; // Reset start button style and visibility const startBtn = document.getElementById('ops-start-btn'); if (startBtn) { startBtn.style.cssText = ''; startBtn.style.display = ''; startBtn.disabled = false; } } function _btnLabelForType(type) { if (type === 'promote') return 'Promote'; if (type === 'sync') return 'Sync'; if (type === 'restart') return 'Restart'; if (type === 'rebuild') return 'Rebuild'; if (type === 'backup') return 'Create Backup'; return 'Run'; } function startOperation() { const { type, project, fromEnv, toEnv } = opsCtx; if (!type || !project) return; const dryRun = document.getElementById('ops-dry-run').checked; const startBtn = document.getElementById('ops-start-btn'); const outputDiv = document.getElementById('ops-modal-output'); const term = document.getElementById('ops-modal-terminal'); outputDiv.style.display = 'block'; // Remove leftover banners from previous operations outputDiv.querySelectorAll('div').forEach(el => { if (el !== term) el.remove(); }); term.textContent = 'Starting...\n'; startBtn.disabled = true; startBtn.textContent = 'Running...'; let url; if (type === 'promote') { url = '/api/promote/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '/' + encodeURIComponent(toEnv) + '?dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken()); } else if (type === 'sync') { const skipBackupEl = document.getElementById('ops-sync-skip-backup'); const skipBackup = skipBackupEl ? skipBackupEl.checked : false; url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + (skipBackup ? '&skip_backup=true' : '') + '&token=' + encodeURIComponent(getToken()); } else if (type === 'restart' || type === 'rebuild') { url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken()); } else if (type === 'backup') { url = '/api/backups/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '?token=' + encodeURIComponent(getToken()); } currentOpId = null; _setProgressState('ops-progress-bar', 'running'); const es = new EventSource(url); opsEventSource = es; let opDone = false; es.onmessage = function(e) { try { const d = JSON.parse(e.data); if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } if (d.done) { opDone = true; es.close(); opsEventSource = null; currentOpId = null; const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n'; term.textContent += msg; term.scrollTop = term.scrollHeight; const toastMsg = d.cancelled ? 'Operation cancelled' : d.success ? 'Operation completed' : 'Operation failed'; toast(toastMsg, d.success ? 'success' : d.cancelled ? 'warning' : 'error'); _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); startBtn.disabled = false; startBtn.textContent = _btnLabelForType(type); // After a successful backup, invalidate cache so backups page refreshes if (type === 'backup' && d.success) { cachedBackups = null; } return; } if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } } catch (_) {} }; es.onerror = function() { es.close(); opsEventSource = null; currentOpId = null; if (opDone) return; term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); _setProgressState('ops-progress-bar', 'fail'); startBtn.disabled = false; startBtn.textContent = _btnLabelForType(type); }; } // --------------------------------------------------------------------------- // Service Actions // --------------------------------------------------------------------------- async function restartService(project, env, service) { if (!await showConfirmDialog(`Restart ${service} in ${project}/${env}?`, 'Restart')) 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 }; } async function createBackup(project, env) { if (!await showConfirmDialog(`Create backup for ${project}/${env}?`, 'Create Backup')) return; // Open the ops modal with streaming output opsCtx = { type: 'backup', project, fromEnv: env, toEnv: null }; if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } const title = document.getElementById('ops-modal-title'); const info = document.getElementById('ops-modal-info'); const startBtn = document.getElementById('ops-start-btn'); const dryRunRow = document.getElementById('ops-dry-run-row'); const outputDiv = document.getElementById('ops-modal-output'); const term = document.getElementById('ops-modal-terminal'); title.textContent = 'Create Backup'; info.innerHTML = '
Project' + esc(project) + '
' + '
Environment' + esc(env) + '
'; if (dryRunRow) dryRunRow.style.display = 'none'; startBtn.style.display = 'none'; outputDiv.style.display = 'block'; term.textContent = 'Starting backup...\n'; currentOpId = null; _setProgressState('ops-progress-bar', 'running'); document.getElementById('ops-modal').style.display = 'flex'; const url = '/api/backups/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?token=' + encodeURIComponent(getToken()); const es = new EventSource(url); opsEventSource = es; es.onmessage = function(e) { try { const d = JSON.parse(e.data); if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } if (d.done) { es.close(); opsEventSource = null; currentOpId = null; const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Backup complete ---\n' : '\n--- Backup FAILED ---\n'; term.textContent += msg; term.scrollTop = term.scrollHeight; toast(d.cancelled ? 'Backup cancelled' : d.success ? 'Backup created for ' + project + '/' + env : 'Backup failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error'); _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); cachedBackups = null; return; } if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } } catch (_) {} }; es.onerror = function() { es.close(); opsEventSource = null; currentOpId = null; term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); _setProgressState('ops-progress-bar', 'fail'); }; } async function deleteBackup(project, env, name, hasLocal, hasOffsite) { let target; if (hasLocal && hasOffsite) { target = await showDeleteTargetDialog(name); if (!target) return; } else if (hasLocal) { target = 'local'; } else { target = 'offsite'; } const label = target === 'both' ? 'local + offsite' : target; if (!await showConfirmDialog(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`, 'Delete', true)) return; toast('Deleting backup (' + label + ')...', 'info'); try { await api(`/api/backups/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' }); toast('Backup deleted: ' + name + ' (' + label + ')', 'success'); cachedBackups = null; if (currentPage === 'backups') renderBackups(); } catch (e) { toast('Delete failed: ' + e.message, 'error'); } } function showConfirmDialog(message, confirmLabel = 'Confirm', isDanger = false) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:9999;animation:fadeIn 0.15s ease-out;'; const box = document.createElement('div'); box.style.cssText = 'background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;min-width:320px;max-width:420px;color:#e2e8f0;animation:modalIn 0.2s ease-out;'; const btnClass = isDanger ? 'btn btn-danger' : 'btn btn-primary'; box.innerHTML = `

${esc(message)}

`; overlay.appendChild(box); document.body.appendChild(overlay); box.addEventListener('click', e => { const btn = e.target.closest('[data-action]'); if (!btn) return; document.body.removeChild(overlay); resolve(btn.dataset.action === 'confirm'); }); overlay.addEventListener('click', e => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } }); const onKey = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); document.body.removeChild(overlay); resolve(false); } }; document.addEventListener('keydown', onKey); }); } function showDeleteTargetDialog(name) { return new Promise(resolve => { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:9999;'; const box = document.createElement('div'); box.style.cssText = 'background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;min-width:320px;max-width:420px;color:#e2e8f0;'; box.innerHTML = `

Delete from where?

This backup exists in both local and offsite storage.

`; overlay.appendChild(box); document.body.appendChild(overlay); box.addEventListener('click', e => { const btn = e.target.closest('[data-target]'); if (!btn) return; document.body.removeChild(overlay); resolve(btn.dataset.target || null); }); overlay.addEventListener('click', e => { if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); } }); }); } // --------------------------------------------------------------------------- // 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; } // --------------------------------------------------------------------------- // URL Hash Routing // --------------------------------------------------------------------------- function pushHash() { let hash = ''; if (currentPage === 'dashboard') { if (viewMode === 'table') { hash = '/dashboard/table'; if (tableFilter) hash += '/' + encodeURIComponent(tableFilter); } else if (drillLevel === 2) { hash = '/dashboard/' + encodeURIComponent(drillProject) + '/' + encodeURIComponent(drillEnv); } else if (drillLevel === 1) { hash = '/dashboard/' + encodeURIComponent(drillProject); } else { hash = '/dashboard'; } } else if (currentPage === 'backups') { if (backupDrillLevel === 2) { hash = '/backups/' + encodeURIComponent(backupDrillProject) + '/' + encodeURIComponent(backupDrillEnv); } else if (backupDrillLevel === 1) { hash = '/backups/' + encodeURIComponent(backupDrillProject); } else { hash = '/backups'; } } else if (currentPage === 'schedules') { hash = '/schedules'; } else if (currentPage === 'system') { hash = '/system'; } else if (currentPage === 'operations') { hash = '/operations'; } const newHash = '#' + hash; if (window.location.hash !== newHash) { history.replaceState(null, '', newHash); } } function navigateToHash() { const raw = (window.location.hash || '').replace(/^#\/?/, ''); const parts = raw.split('/').map(decodeURIComponent).filter(Boolean); if (!parts.length) { showPage('dashboard'); return; } const page = parts[0]; if (page === 'dashboard') { currentPage = 'dashboard'; drillLevel = 0; drillProject = null; drillEnv = null; viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; cachedBackups = null; backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; if (parts[1] === 'table') { viewMode = 'table'; if (parts[2]) { tableFilter = parts[2]; tableFilterLabel = parts[2]; } } else if (parts[1]) { drillProject = parts[1]; drillLevel = 1; if (parts[2]) { drillEnv = parts[2]; drillLevel = 2; } } document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => el.classList.toggle('active', el.dataset.page === 'dashboard')); renderPage(); } else if (page === 'backups') { currentPage = 'backups'; drillLevel = 0; drillProject = null; drillEnv = null; viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; cachedBackups = null; backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; selectedBackups.clear(); if (parts[1]) { backupDrillProject = parts[1]; backupDrillLevel = 1; if (parts[2]) { backupDrillEnv = parts[2]; backupDrillLevel = 2; } } document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => el.classList.toggle('active', el.dataset.page === 'backups')); renderPage(); } else if (page === 'schedules') { showPage('schedules'); } else if (page === 'system') { showPage('system'); } else if (page === 'operations') { showPage('operations'); } else { showPage('dashboard'); } } // --------------------------------------------------------------------------- // 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; navigateToHash(); startAutoRefresh(); }) .catch(() => { localStorage.removeItem('ops_token'); }); } document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeLogModal(); closeRestoreModal(); closeOpsModal(); } }); window.addEventListener('hashchange', () => { if (getToken()) navigateToHash(); }); })();