'use strict';
const APP_VERSION = 'v13-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;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtBytes(b) {
if (b == null) return '\u2014';
const n = Number(b);
if (isNaN(n) || n === 0) return '0 B';
const k = 1024, s = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(Math.abs(n)) / Math.log(k));
return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + s[i];
}
function esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function dotClass(status, health) {
const s = (status || '').toLowerCase(), h = (health || '').toLowerCase();
if (s === 'up' && (h === 'healthy' || !h)) return 'status-dot-green';
if (s === 'up' && h === 'unhealthy') return 'status-dot-red';
if (s === 'up' && h === 'starting') return 'status-dot-yellow';
if (s === 'down' || s === 'exited') return 'status-dot-red';
return 'status-dot-gray';
}
function badgeCls(status, health) {
const s = (status || '').toLowerCase(), h = (health || '').toLowerCase();
if (s === 'up' && (h === 'healthy' || !h)) return 'badge-green';
if (s === 'up' && h === 'unhealthy') return 'badge-red';
if (s === 'up' && h === 'starting') return 'badge-yellow';
if (s === 'down' || s === 'exited') return 'badge-red';
return 'badge-gray';
}
function diskColor(pct) {
const n = parseInt(pct);
if (n >= 90) return 'disk-danger';
if (n >= 75) return 'disk-warn';
return 'disk-ok';
}
function isHealthy(svc) {
return svc.status === 'Up' && (svc.health === 'healthy' || !svc.health);
}
function isDown(svc) { return !isHealthy(svc); }
function filterServices(list) {
if (!tableFilter) return list;
if (tableFilter === 'healthy') return list.filter(isHealthy);
if (tableFilter === 'down') return list.filter(isDown);
if (tableFilter.startsWith('project:')) {
const p = tableFilter.slice(8);
return list.filter(s => s.project === p);
}
if (tableFilter.startsWith('env:')) {
const e = tableFilter.slice(4);
return list.filter(s => s.env === e);
}
return list;
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function getToken() { return localStorage.getItem('ops_token'); }
function doLogin() {
const input = document.getElementById('login-token');
const err = document.getElementById('login-error');
const token = input.value.trim();
if (!token) { err.textContent = 'Please enter a token'; err.style.display = 'block'; return; }
err.style.display = 'none';
fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => {
localStorage.setItem('ops_token', token);
allServices = data;
document.getElementById('login-overlay').style.display = 'none';
document.getElementById('app').style.display = 'flex';
const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION;
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 });
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;
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 === '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]}`;
// YYYY-MM-DD passthrough
return raw;
}
// Parse YYYYMMDD_HHMMSS -> { dateKey: 'YYYY-MM-DD', timeStr: 'HH:MM' }
function parseBackupDate(raw) {
if (!raw) return { dateKey: '', timeStr: '' };
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]}` };
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);
}
// ---------------------------------------------------------------------------
// 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.timestamp));
byName.set(key, {
project: b.project || '',
env: b.env || b.environment || '',
name: name,
date: b.date || 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)) {
byName.get(key).hasOffsite = true;
} else {
byName.set(key, {
project: b.project || '',
env: b.env || b.environment || '',
name: name,
date: 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 += statTile('Latest', latestDisplay, '#f59e0b');
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 = '
';
// 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 += `
Location
Time
Size
Actions
`;
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)
? ``
: '';
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) + '
';
}
}
// ---------------------------------------------------------------------------
// 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 || [];
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 || [];
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 Coolify API. '
+ 'Restart is safe. '
+ 'Rebuild refreshes the image. '
+ 'Recreate wipes data (disaster recovery only).
';
h += '
';
for (const [name, cfg] of Object.entries(projects)) {
if (cfg.static || cfg.infrastructure || !cfg.has_coolify) continue;
const envs = (cfg.environments || []).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 += '';
// Recreate (red)
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 = '