'use strict';
const APP_VERSION = 'v14-20260222';
// ============================================================
// 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 += '
`;
}
// ---------------------------------------------------------------------------
// 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 += '
';
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 += `
';
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 += '
Unit
Next
Left
Last
Passed
';
for (const t of timers.timers) {
h += `
${esc(t.unit)}
${esc(t.next)}
${esc(t.left)}
${esc(t.last)}
${esc(t.passed)}
`;
}
h += '
';
} 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.