From 7d94ec0d18b46893e23680cf8438109a34cc2a10 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 22 Feb 2026 16:55:03 +0100
Subject: [PATCH] feat: promote/sync/rebuild UI, operations page, bidirectional sync, lifecycle ops
---
static/js/app.js | 1215 +++++++++++++++++++++++++++++++++++++++++++++++++++------
1 files changed, 1,079 insertions(+), 136 deletions(-)
diff --git a/static/js/app.js b/static/js/app.js
index 9facb3a..ee4c5e8 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,8 +1,8 @@
'use strict';
-const APP_VERSION = 'v4-20260222';
+const APP_VERSION = 'v13-20260222';
// ============================================================
-// OPS Dashboard — Vanilla JS Application (v4)
+// OPS Dashboard — Vanilla JS Application (v6)
// ============================================================
// ---------------------------------------------------------------------------
@@ -19,12 +19,25 @@
let refreshTimer = null;
const REFRESH_INTERVAL = 30000;
-// Backup filter state
-let backupFilterProject = null; // null = all
-let backupFilterEnv = null; // null = all
+// 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
@@ -109,7 +122,7 @@
document.getElementById('login-overlay').style.display = 'none';
document.getElementById('app').style.display = 'flex';
const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION;
- showPage('dashboard');
+ navigateToHash();
startAutoRefresh();
})
.catch(() => { err.textContent = 'Invalid token.'; err.style.display = 'block'; });
@@ -161,6 +174,8 @@
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 =>
@@ -169,6 +184,7 @@
document.getElementById('mobile-overlay').classList.remove('open');
renderPage();
+ pushHash();
}
function renderPage() {
@@ -180,13 +196,14 @@
case 'dashboard': renderDashboard(); break;
case 'backups': renderBackups(); break;
case 'system': renderSystem(); break;
- case 'restore': renderRestore(); 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);
}
@@ -198,6 +215,7 @@
if (mode === 'cards') { tableFilter = null; tableFilterLabel = ''; }
updateViewToggle();
renderDashboard();
+ pushHash();
}
function setTableFilter(filter, label) {
@@ -206,6 +224,7 @@
viewMode = 'table';
updateViewToggle();
renderDashboard();
+ pushHash();
}
function clearFilter() {
@@ -261,9 +280,18 @@
} else if (drillLevel === 2) {
h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + esc(drillProject) + '</a><span class="sep">/</span><span class="current">' + esc(drillEnv) + '</span>';
}
- } else {
- const names = { backups: 'Backups', system: 'System', restore: 'Restore' };
- h = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
+ } else if (currentPage === 'backups') {
+ if (backupDrillLevel === 0) {
+ h = '<span class="current">Backups</span>';
+ } else if (backupDrillLevel === 1) {
+ h = '<a onclick="backupDrillBack(0)">Backups</a><span class="sep">/</span><span class="current">' + esc(backupDrillProject) + '</span>';
+ } else if (backupDrillLevel === 2) {
+ h = '<a onclick="backupDrillBack(0)">Backups</a><span class="sep">/</span><a onclick="backupDrillBack(1)">' + esc(backupDrillProject) + '</a><span class="sep">/</span><span class="current">' + esc(backupDrillEnv) + '</span>';
+ }
+ } else if (currentPage === 'system') {
+ h = '<span class="current">System</span>';
+ } else if (currentPage === 'operations') {
+ h = '<span class="current">Operations</span>';
}
bc.innerHTML = h;
}
@@ -272,6 +300,7 @@
if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; }
else if (level === 1) { drillLevel = 1; drillEnv = null; }
renderDashboard();
+ pushHash();
}
// ---------------------------------------------------------------------------
@@ -357,8 +386,8 @@
c.innerHTML = h;
}
-function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); }
-function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); }
+function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); pushHash(); }
+function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); pushHash(); }
// ---------------------------------------------------------------------------
// Dashboard — Table View
@@ -463,7 +492,7 @@
}
// ---------------------------------------------------------------------------
-// Backups
+// Backups — helpers
// ---------------------------------------------------------------------------
function fmtBackupDate(raw) {
if (!raw) return '\u2014';
@@ -474,96 +503,510 @@
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 {
- const [local, offsite] = await Promise.all([
- api('/api/backups/'),
- api('/api/backups/offsite').catch(() => []),
- ]);
-
- // Apply filters
- const filteredLocal = local.filter(b => {
- if (backupFilterProject && b.project !== backupFilterProject) return false;
- if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false;
- return true;
- });
- const filteredOffsite = offsite.filter(b => {
- if (backupFilterProject && b.project !== backupFilterProject) return false;
- if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false;
- return true;
- });
-
- let h = '<div class="page-enter">';
-
- // Quick backup buttons
- h += '<div style="margin-bottom:1.5rem;">';
- h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
- h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
- for (const p of ['mdf', 'seriousletter']) {
- for (const e of ['dev', 'int', 'prod']) {
- h += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${p}','${e}')">${p}/${e}</button>`;
- }
- }
- h += '</div></div>';
-
- // Filter bar
- const activeStyle = 'background:rgba(59,130,246,0.2);color:#60a5fa;';
- h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;margin-bottom:1.5rem;padding:0.75rem 1rem;background:#1f2937;border-radius:0.5rem;">';
- h += '<span style="color:#9ca3af;font-size:0.875rem;margin-right:0.25rem;">Project:</span>';
- h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === null ? activeStyle : ''}" onclick="setBackupFilter('project',null)">All</button>`;
- h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === 'mdf' ? activeStyle : ''}" onclick="setBackupFilter('project','mdf')">mdf</button>`;
- h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === 'seriousletter' ? activeStyle : ''}" onclick="setBackupFilter('project','seriousletter')">seriousletter</button>`;
- h += '<span style="color:#374151;margin:0 0.25rem;">|</span>';
- h += '<span style="color:#9ca3af;font-size:0.875rem;margin-right:0.25rem;">Env:</span>';
- h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === null ? activeStyle : ''}" onclick="setBackupFilter('env',null)">All</button>`;
- h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'dev' ? activeStyle : ''}" onclick="setBackupFilter('env','dev')">dev</button>`;
- h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'int' ? activeStyle : ''}" onclick="setBackupFilter('env','int')">int</button>`;
- h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'prod' ? activeStyle : ''}" onclick="setBackupFilter('env','prod')">prod</button>`;
- h += '</div>';
-
- // Local
- h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
- if (filteredLocal.length === 0) {
- h += '<div class="card" style="color:#6b7280;">No local backups match the current filter.</div>';
- } else {
- h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>File</th><th>Date</th><th>Size</th></tr></thead><tbody>';
- for (const b of filteredLocal) {
- h += `<tr>
- <td>${esc(b.project||'')}</td>
- <td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td>
- <td class="mono" style="font-size:0.8125rem;">${esc(b.name||b.file||'')}</td>
- <td>${esc(fmtBackupDate(b.date||b.timestamp||''))}</td>
- <td>${esc(b.size_human||b.size||'')}</td>
- </tr>`;
- }
- h += '</tbody></table></div>';
+ if (!cachedBackups) {
+ const [local, offsite] = await Promise.all([
+ api('/api/backups/'),
+ api('/api/backups/offsite').catch(() => []),
+ ]);
+ cachedBackups = mergeBackups(local, offsite);
}
- // Offsite
- h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
- if (filteredOffsite.length === 0) {
- h += '<div class="card" style="color:#6b7280;">No offsite backups match the current filter.</div>';
- } else {
- h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>File</th><th>Date</th><th>Size</th></tr></thead><tbody>';
- for (const b of filteredOffsite) {
- h += `<tr>
- <td>${esc(b.project||'')}</td>
- <td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td>
- <td class="mono" style="font-size:0.8125rem;">${esc(b.name||'')}</td>
- <td>${esc(fmtBackupDate(b.date||''))}</td>
- <td>${esc(b.size||'')}</td>
- </tr>`;
- }
- h += '</tbody></table></div>';
- }
-
- h += '</div>';
- c.innerHTML = h;
+ if (backupDrillLevel === 0) renderBackupProjects(c);
+ else if (backupDrillLevel === 1) renderBackupEnvironments(c);
+ else renderBackupList(c);
} catch (e) {
c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + esc(e.message) + '</div>';
}
+}
+
+// ---------------------------------------------------------------------------
+// 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 = '<div class="page-enter">';
+
+ // Create Backup buttons
+ h += '<div style="margin-bottom:1.5rem;">';
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
+ h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
+ for (const p of ['mdf', 'seriousletter']) {
+ for (const e of ['dev', 'int', 'prod']) {
+ h += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${p}','${e}')">${p}/${e}</button>`;
+ }
+ }
+ h += '</div></div>';
+
+ // Global stat tiles
+ h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
+ h += statTile('Local', localCount, '#3b82f6');
+ h += statTile('Offsite', offsiteCount, '#8b5cf6');
+ h += statTile('Synced', syncedCount, '#10b981');
+ h += statTile('Latest', latestDisplay, '#f59e0b');
+ h += '</div>';
+
+ // Project cards
+ const projects = groupBy(all, 'project');
+ h += '<div class="grid-auto">';
+ 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 += `<div class="card card-clickable" onclick="backupDrillToProject('${esc(name)}')">
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(name)}</span>
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${backups.length} backup${backups.length !== 1 ? 's' : ''}</span>
+ </div>
+ <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
+ ${envs.map(e => `<span class="badge badge-blue">${esc(e)}</span>`).join('')}
+ </div>
+ <div style="font-size:0.8125rem;color:#9ca3af;">
+ Latest: ${projLatest ? fmtBackupDate(projLatest) : '\u2014'}
+ ${projSize > 0 ? ' · ' + fmtBytes(projSize) : ''}
+ </div>
+ </div>`;
+ }
+ h += '</div></div>';
+ 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 = '<div class="page-enter"><div class="grid-auto">';
+ 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 = `<button class="btn btn-danger btn-xs" disabled>Restore</button>`;
+ } else if (count === 1) {
+ const b = backups[0];
+ const src = b.hasLocal ? 'local' : 'offsite';
+ restoreBtn = `<button class="btn btn-danger btn-xs" onclick="event.stopPropagation();openRestoreModal('${ep}','${ee}','${src}','${esc(b.name)}')">Restore</button>`;
+ } else {
+ restoreBtn = `<button class="btn btn-danger btn-xs" onclick="event.stopPropagation();backupDrillToEnv('${ee}')">Restore (${count})</button>`;
+ }
+
+ h += `<div class="card card-clickable" onclick="backupDrillToEnv('${ee}')">
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${ee.toUpperCase()}</span>
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${count} backup${count !== 1 ? 's' : ''}</span>
+ </div>
+ <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">
+ Latest: ${envLatest ? fmtBackupDate(envLatest) : '\u2014'}
+ ${envSize > 0 ? ' · ' + fmtBytes(envSize) : ''}
+ </div>
+ <div style="display:flex;gap:0.5rem;">
+ <button class="btn btn-ghost btn-xs" onclick="event.stopPropagation();createBackup('${ep}','${ee}')">Create Backup</button>
+ ${restoreBtn}
+ </div>
+ </div>`;
+ }
+ h += '</div></div>';
+ 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 = '<div class="page-enter">';
+
+ // Selection action bar
+ h += `<div id="backup-selection-bar" class="selection-bar" style="display:${selectedBackups.size > 0 ? 'flex' : 'none'};">`;
+ h += `<span id="selection-count">${selectedBackups.size} selected</span>`;
+ h += `<button class="btn btn-danger btn-xs" onclick="deleteSelected()">Delete selected</button>`;
+ h += `<button class="btn btn-ghost btn-xs" onclick="clearSelection()">Clear</button>`;
+ h += `</div>`;
+
+ if (filtered.length === 0) {
+ h += '<div class="card" style="color:#6b7280;">No backups for ' + esc(backupDrillProject) + '/' + esc(backupDrillEnv) + '.</div>';
+ } 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 += `<div class="date-group">`;
+ h += `<div class="date-group-header" onclick="toggleDateGroup('${safeKey}')">`;
+ h += `<span class="chevron${isRecent ? ' open' : ''}" id="dg-chevron-${safeKey}">▶</span>`;
+ h += `<span class="date-group-title">${esc(headerLabel)}</span>`;
+ h += `<span class="date-group-meta">${items.length} backup${items.length !== 1 ? 's' : ''}</span>`;
+ if (groupSizeBytes > 0) {
+ h += `<span class="date-group-size">${fmtBytes(groupSizeBytes)}</span>`;
+ }
+ h += `</div>`;
+
+ h += `<div class="date-group-body${isRecent ? ' open' : ''}" id="dg-body-${safeKey}">`;
+ h += `<div class="table-wrapper"><table class="ops-table">`;
+ h += `<thead><tr><th style="width:2rem;padding-left:0.75rem;"><input type="checkbox" onclick="toggleSelectAll(this)" style="accent-color:#3b82f6;cursor:pointer;"></th><th>Location</th><th>Time</th><th>Size</th><th>Actions</th></tr></thead><tbody>`;
+ for (const b of items) {
+ let locationBadge;
+ if (b.hasLocal && b.hasOffsite) {
+ locationBadge = '<span class="badge badge-synced">local + offsite</span>';
+ } else if (b.hasLocal) {
+ locationBadge = '<span class="badge badge-local">local</span>';
+ } else {
+ locationBadge = '<span class="badge badge-offsite">offsite</span>';
+ }
+
+ const restoreSource = b.hasLocal ? 'local' : 'offsite';
+ const checked = selectedBackups.has(b.name) ? ' checked' : '';
+ const deleteBtn = `<button class="btn btn-ghost btn-xs" style="color:#f87171;border-color:#7f1d1d;" onclick="deleteBackup('${esc(b.project)}','${esc(b.env)}','${esc(b.name)}',${b.hasLocal},${b.hasOffsite})">Delete</button>`;
+ const uploadBtn = (b.hasLocal && !b.hasOffsite)
+ ? `<button class="btn btn-ghost btn-xs" style="color:#a78bfa;border-color:rgba(167,139,250,0.25);" onclick="uploadOffsiteBackup('${esc(b.project)}','${esc(b.env)}')">Upload</button>`
+ : '';
+ h += `<tr>
+ <td style="padding-left:0.75rem;"><input type="checkbox" class="backup-cb" value="${esc(b.name)}"${checked} onclick="toggleBackupSelect('${esc(b.name)}')" style="accent-color:#3b82f6;cursor:pointer;"></td>
+ <td>${locationBadge}</td>
+ <td class="mono">${esc(b._timeStr || '\u2014')}</td>
+ <td>${esc(b.size_human || '\u2014')}</td>
+ <td style="white-space:nowrap;">
+ <button class="btn btn-danger btn-xs" onclick="openRestoreModal('${esc(b.project)}','${esc(b.env)}','${restoreSource}','${esc(b.name)}',${b.hasLocal},${b.hasOffsite})">Restore</button>
+ ${uploadBtn}
+ ${deleteBtn}
+ </td>
+ </tr>`;
+ }
+ h += `</tbody></table></div>`;
+ h += `</div>`;
+ h += `</div>`;
+ }
+ }
+
+ h += '</div>';
+ 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 (!confirm(`Delete ${names.length} backup${names.length > 1 ? 's' : ''} (${label})?\n\nThis cannot be undone.`)) 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();
+}
+
+async function uploadOffsiteBackup(project, env) {
+ if (!confirm(`Upload latest ${project}/${env} backup to offsite storage?`)) return;
+ toast('Uploading to offsite...', 'info');
+ try {
+ await api(`/api/backups/offsite/upload/${encodeURIComponent(project)}/${encodeURIComponent(env)}`, { method: 'POST' });
+ toast('Offsite upload complete for ' + project + '/' + env, 'success');
+ cachedBackups = null;
+ if (currentPage === 'backups') renderBackups();
+ } catch (e) { toast('Upload failed: ' + e.message, 'error'); }
+}
+
+// ---------------------------------------------------------------------------
+// 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 (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; }
+ 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)}`;
+ const es = new EventSource(url);
+ restoreEventSource = es;
+
+ es.onmessage = function(e) {
+ try {
+ const d = JSON.parse(e.data);
+ if (d.done) {
+ es.close();
+ restoreEventSource = null;
+ const msg = d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
+ term.textContent += msg;
+ term.scrollTop = term.scrollHeight;
+ toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error');
+ 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;
+ term.textContent += '\n--- Connection lost ---\n';
+ toast('Connection lost', 'error');
+ startBtn.disabled = false;
+ startBtn.textContent = 'Start Restore';
+ };
}
// ---------------------------------------------------------------------------
@@ -577,7 +1020,7 @@
api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
api('/api/system/health').catch(e => ({ checks: [], raw: e.message })),
api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })),
- api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
+ api('/api/system/info').catch(e => ({ uptime: 'error' })),
]);
let h = '<div class="page-enter">';
@@ -614,7 +1057,8 @@
// Quick stats row
h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
h += statTile('Uptime', info.uptime || 'n/a', '#3b82f6');
- h += statTile('Load', info.load || 'n/a', '#8b5cf6');
+ h += statTile('Containers', info.containers || 'n/a', '#8b5cf6');
+ h += statTile('Processes', info.processes || '0', '#f59e0b');
h += '</div>';
// Disk usage — only real filesystems
@@ -676,50 +1120,412 @@
}
// ---------------------------------------------------------------------------
-// Restore
+// Operations Page
// ---------------------------------------------------------------------------
-function renderRestore() {
+async function renderOperations() {
updateBreadcrumbs();
const c = document.getElementById('page-content');
- let h = '<div class="page-enter">';
- h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
- h += '<div class="card" style="max-width:480px;">';
- h += '<div style="margin-bottom:1rem;"><label class="form-label">Project</label><select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select></div>';
- h += '<div style="margin-bottom:1rem;"><label class="form-label">Environment</label><select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select></div>';
- h += '<div style="margin-bottom:1rem;"><label class="form-label">Source</label><select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select></div>';
- h += '<div style="margin-bottom:1rem;"><label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;"><input type="checkbox" id="restore-dry" checked> Dry run (preview only)</label></div>';
- h += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
+
+ // Fetch registry if not cached
+ if (!cachedRegistry) {
+ try {
+ cachedRegistry = await api('/api/registry/');
+ } catch (e) {
+ c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load registry: ' + esc(e.message) + '</div>';
+ return;
+ }
+ }
+
+ const projects = cachedRegistry.projects || {};
+
+ let h = '<div style="max-width:900px;">';
+
+ // Section: Promote Code (Forward)
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Promote Code</h2>';
+ h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Push code forward: dev → int → prod. Each project defines its own promotion type (git pull or rsync).</p>';
+ h += '<div class="grid-auto" style="margin-bottom:2rem;">';
+
+ 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'
+ ? '<span class="badge badge-blue" style="font-size:0.6875rem;">git</span>'
+ : '<span class="badge badge-purple" style="font-size:0.6875rem;">rsync</span>';
+
+ h += '<div class="card">';
+ h += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">';
+ h += '<span style="font-weight:600;color:#f3f4f6;">' + esc(name) + '</span>';
+ h += typeBadge;
+ h += '</div>';
+
+ 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 += '<div style="font-size:0.8125rem;color:#6b7280;">No promotion paths available</div>';
+ } else {
+ h += '<div style="display:flex;flex-direction:column;gap:0.5rem;">';
+ for (const [from, to] of promotions) {
+ h += '<button class="btn btn-ghost btn-sm" style="justify-content:flex-start;" onclick="openOpsModal('promote','' + esc(name) + '','' + esc(from) + '','' + esc(to) + '')">';
+ h += '<span style="color:#60a5fa;">' + esc(from) + '</span>';
+ h += ' <span style="color:#6b7280;">→</span> ';
+ h += '<span style="color:#fbbf24;">' + esc(to) + '</span>';
+ h += '</button>';
+ }
+ h += '</div>';
+ }
+ h += '</div>';
+ }
h += '</div>';
- h += '<div id="restore-output" style="display:none;margin-top:1rem;"><h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3><div id="restore-terminal" class="terminal" style="max-height:400px;"></div></div>';
+
+ // Section: Sync Data (Backward)
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Sync Data</h2>';
+ h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Sync content between environments. Choose the direction when syncing.</p>';
+ h += '<div class="grid-auto" style="margin-bottom:2rem;">';
+
+ for (const [name, cfg] of Object.entries(projects)) {
+ if (!cfg.has_cli || cfg.static || cfg.infrastructure) continue;
+ const envs = cfg.environments || [];
+
+ h += '<div class="card">';
+ h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>';
+
+ 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 += '<div style="font-size:0.8125rem;color:#6b7280;">No sync paths available</div>';
+ } else {
+ h += '<div style="display:flex;flex-direction:column;gap:0.5rem;">';
+ for (const [a, b] of syncPairs) {
+ h += '<button class="btn btn-ghost btn-sm" style="justify-content:flex-start;" onclick="openSyncModal('' + esc(name) + '','' + esc(a) + '','' + esc(b) + '')">';
+ h += '<span style="color:#60a5fa;">' + esc(a) + '</span>';
+ h += ' <span style="color:#6b7280;">↔</span> ';
+ h += '<span style="color:#fbbf24;">' + esc(b) + '</span>';
+ h += '</button>';
+ }
+ h += '</div>';
+ }
+ h += '</div>';
+ }
h += '</div>';
+
+ // Section: Container Lifecycle
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.375rem;">Container Lifecycle</h2>';
+ h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Manage container state via Coolify API. '
+ + '<span style="color:#6ee7b7;">Restart</span> is safe. '
+ + '<span style="color:#fbbf24;">Rebuild</span> refreshes the image. '
+ + '<span style="color:#f87171;">Recreate</span> wipes data (disaster recovery only).</p>';
+ h += '<div class="grid-auto" style="margin-bottom:2rem;">';
+
+ 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 += '<div class="card">';
+ h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>';
+ h += '<div style="display:flex;flex-direction:column;gap:0.625rem;">';
+
+ for (const env of envs) {
+ h += '<div style="display:flex;align-items:center;gap:0.5rem;">';
+ // Environment label
+ h += '<span style="min-width:2.5rem;font-size:0.75rem;color:#9ca3af;font-weight:500;">' + esc(env) + '</span>';
+ // Restart (green)
+ h += '<button class="btn btn-ghost btn-xs" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);" '
+ + 'onclick="openLifecycleModal('restart','' + esc(name) + '','' + esc(env) + '')">'
+ + 'Restart</button>';
+ // Rebuild (yellow)
+ h += '<button class="btn btn-ghost btn-xs" style="color:#fbbf24;border-color:rgba(251,191,36,0.3);" '
+ + 'onclick="openLifecycleModal('rebuild','' + esc(name) + '','' + esc(env) + '')">'
+ + 'Rebuild</button>';
+ // Recreate (red)
+ h += '<button class="btn btn-ghost btn-xs" style="color:#f87171;border-color:rgba(248,113,113,0.3);" '
+ + 'onclick="openLifecycleModal('recreate','' + esc(name) + '','' + esc(env) + '')">'
+ + 'Recreate</button>';
+ h += '</div>';
+ }
+
+ h += '</div></div>';
+ }
+ h += '</div></div>';
+
c.innerHTML = h;
}
-async function startRestore() {
- const project = document.getElementById('restore-project').value;
- const env = document.getElementById('restore-env').value;
- const source = document.getElementById('restore-source').value;
- const dryRun = document.getElementById('restore-dry').checked;
- if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}?`)) return;
+// ---------------------------------------------------------------------------
+// Operations Modal
+// ---------------------------------------------------------------------------
+function openSyncModal(project, envA, envB) {
+ // Show direction picker in the ops modal
+ opsCtx = { type: 'sync', project: project, fromEnv: envA, toEnv: envB };
- const out = document.getElementById('restore-output');
- const term = document.getElementById('restore-terminal');
- out.style.display = 'block';
- term.textContent = 'Starting restore...\n';
+ if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
- const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;
+ 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 = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>';
+ ih += '<div style="margin-top:0.75rem;margin-bottom:0.25rem;font-size:0.8125rem;color:#9ca3af;">Direction</div>';
+ ih += '<div style="display:flex;flex-direction:column;gap:0.5rem;">';
+ ih += '<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid #374151;'
+ + 'background:rgba(96,165,250,0.1);" id="sync-dir-down">';
+ ih += '<input type="radio" name="sync-dir" value="down" checked onchange="updateSyncDir()" style="accent-color:#60a5fa;">';
+ ih += '<span style="color:#60a5fa;font-weight:600;">' + esc(envA) + '</span>';
+ ih += '<span style="color:#6b7280;">→</span>';
+ ih += '<span style="color:#fbbf24;font-weight:600;">' + esc(envB) + '</span>';
+ ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows down</span>';
+ ih += '</label>';
+ ih += '<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid #374151;" id="sync-dir-up">';
+ ih += '<input type="radio" name="sync-dir" value="up" onchange="updateSyncDir()" style="accent-color:#fbbf24;">';
+ ih += '<span style="color:#fbbf24;font-weight:600;">' + esc(envB) + '</span>';
+ ih += '<span style="color:#6b7280;">→</span>';
+ ih += '<span style="color:#60a5fa;font-weight:600;">' + esc(envA) + '</span>';
+ ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows up</span>';
+ ih += '</label>';
+ ih += '</div>';
+
+ 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 = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+ + '<div class="restore-info-row"><span class="restore-info-label">Direction</span><span class="restore-info-value">' + esc(fromEnv) + ' → ' + esc(toEnv) + '</span></div>';
+ startBtn.className = 'btn btn-primary btn-sm';
+ startBtn.textContent = 'Promote';
+ } else if (type === 'sync') {
+ title.textContent = 'Sync Data';
+ info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+ + '<div class="restore-info-row"><span class="restore-info-label">Direction</span><span class="restore-info-value">' + esc(fromEnv) + ' → ' + esc(toEnv) + '</span></div>';
+ 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 / Recreate)
+// ---------------------------------------------------------------------------
+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 = ''
+ + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+ + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'
+ + '<div style="background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.25);border-radius:0.5rem;padding:0.625rem 0.875rem;font-size:0.8125rem;color:#6ee7b7;margin-top:0.75rem;">'
+ + 'Safe operation. Runs <code>docker restart</code> on each container. No image changes, no data loss.</div>';
+ 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 = ''
+ + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+ + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'
+ + '<div style="background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.25);border-radius:0.5rem;padding:0.625rem 0.875rem;font-size:0.8125rem;color:#fde68a;margin-top:0.75rem;">'
+ + 'Stops containers via Coolify, rebuilds the Docker image, then starts again. No data loss.</div>';
+ 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 === 'recreate') {
+ title.textContent = 'Recreate Environment';
+ info.innerHTML = ''
+ + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+ + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'
+ + '<div style="background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);border-radius:0.5rem;padding:0.75rem 1rem;font-size:0.8125rem;color:#fca5a5;margin-top:0.75rem;">'
+ + '<strong style="display:block;margin-bottom:0.375rem;">DESTRUCTIVE — Disaster Recovery Only</strong>'
+ + 'Stops containers, wipes all data volumes, rebuilds image, starts fresh. '
+ + 'You must restore a backup afterwards.</div>'
+ + '<div style="margin-top:0.875rem;">'
+ + '<label style="font-size:0.8125rem;color:#9ca3af;display:block;margin-bottom:0.375rem;">Type the environment name to confirm:</label>'
+ + '<input id="recreate-confirm-input" type="text" placeholder="' + esc(env) + '" '
+ + 'style="width:100%;box-sizing:border-box;padding:0.5rem 0.75rem;background:#1f2937;border:1px solid rgba(220,38,38,0.4);border-radius:0.375rem;color:#f3f4f6;font-size:0.875rem;" '
+ + 'oninput="checkRecreateConfirm(\'' + esc(env) + '\')">'
+ + '</div>';
+ startBtn.className = 'btn btn-danger btn-sm';
+ startBtn.style.cssText = '';
+ startBtn.textContent = 'Recreate';
+ startBtn.disabled = true; // enabled after typing env name
+ }
+
+ document.getElementById('ops-modal-output').style.display = 'none';
+ document.getElementById('ops-modal-terminal').textContent = '';
+
+ document.getElementById('ops-modal').style.display = 'flex';
+ if (action === 'recreate') {
+ setTimeout(() => {
+ const inp = document.getElementById('recreate-confirm-input');
+ if (inp) inp.focus();
+ }, 100);
+ }
+}
+
+function checkRecreateConfirm(expectedEnv) {
+ const inp = document.getElementById('recreate-confirm-input');
+ const startBtn = document.getElementById('ops-start-btn');
+ if (!inp || !startBtn) return;
+ startBtn.disabled = inp.value.trim() !== expectedEnv;
+}
+
+function closeOpsModal() {
+ if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+ document.getElementById('ops-modal').style.display = 'none';
+ 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
+ const startBtn = document.getElementById('ops-start-btn');
+ if (startBtn) { startBtn.style.cssText = ''; 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 === 'recreate') return 'Recreate';
+ 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';
+ 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') {
+ url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken());
+ } else if (type === 'restart' || type === 'rebuild' || type === 'recreate') {
+ // All three lifecycle ops go through /api/rebuild/{project}/{env}?action=...
+ url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv)
+ + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken());
+ }
+
const es = new EventSource(url);
+ opsEventSource = es;
+ let opDone = false;
+
es.onmessage = function(e) {
- const d = JSON.parse(e.data);
- if (d.done) {
- es.close();
- term.textContent += d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
- toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error');
- return;
- }
- if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; }
+ try {
+ const d = JSON.parse(e.data);
+ if (d.done) {
+ opDone = true;
+ es.close();
+ opsEventSource = null;
+ const msg = d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n';
+ term.textContent += msg;
+ term.scrollTop = term.scrollHeight;
+ toast(d.success ? 'Operation completed' : 'Operation failed', d.success ? 'success' : 'error');
+ startBtn.disabled = false;
+ startBtn.textContent = _btnLabelForType(type);
+
+ // Show "Go to Backups" banner after recreate (or legacy rebuild)
+ const showBackupBanner = (type === 'recreate') && d.success && d.project && d.env;
+ if (showBackupBanner) {
+ const restoreProject = d.project;
+ const restoreEnv = d.env;
+ const banner = document.createElement('div');
+ banner.style.cssText = 'margin-top:1rem;padding:0.75rem 1rem;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:0.5rem;display:flex;align-items:center;gap:0.75rem;';
+ banner.innerHTML = '<span style="color:#6ee7b7;font-size:0.8125rem;flex:1;">Environment recreated. Next step: restore a backup.</span>'
+ + '<button class="btn btn-ghost btn-sm" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);white-space:nowrap;" '
+ + 'onclick="closeOpsModal();currentPage=\'backups\';backupDrillLevel=2;backupDrillProject=\'' + restoreProject + '\';backupDrillEnv=\'' + restoreEnv + '\';cachedBackups=null;selectedBackups.clear();document.querySelectorAll(\'#sidebar-nav .sidebar-link\').forEach(el=>el.classList.toggle(\'active\',el.dataset.page===\'backups\'));renderPage();pushHash();">'
+ + 'Go to Backups →</button>';
+ outputDiv.appendChild(banner);
+ }
+
+ return;
+ }
+ if (d.line) {
+ term.textContent += d.line + '\n';
+ term.scrollTop = term.scrollHeight;
+ }
+ } catch (_) {}
};
- es.onerror = function() { es.close(); term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); };
+
+ es.onerror = function() {
+ es.close();
+ opsEventSource = null;
+ if (opDone) return;
+ term.textContent += '\n--- Connection lost ---\n';
+ toast('Connection lost', 'error');
+ startBtn.disabled = false;
+ startBtn.textContent = _btnLabelForType(type);
+ };
}
// ---------------------------------------------------------------------------
@@ -758,20 +1564,65 @@
logCtx = { project: null, env: null, service: null };
}
-function setBackupFilter(type, value) {
- if (type === 'project') backupFilterProject = value;
- if (type === 'env') backupFilterEnv = value;
- renderBackups();
-}
-
async function createBackup(project, env) {
if (!confirm(`Create backup for ${project}/${env}?`)) return;
toast('Creating backup...', 'info');
try {
await api(`/api/backups/${project}/${env}`, { method: 'POST' });
toast('Backup created for ' + project + '/' + env, 'success');
+ cachedBackups = null;
if (currentPage === 'backups') renderBackups();
} catch (e) { toast('Backup failed: ' + e.message, 'error'); }
+}
+
+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 (!confirm(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`)) 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 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 = `
+ <h3 style="margin:0 0 0.5rem;font-size:1rem;color:#f1f5f9;">Delete from where?</h3>
+ <p style="margin:0 0 1.25rem;font-size:0.85rem;color:#94a3b8;">This backup exists in both local and offsite storage.</p>
+ <div style="display:flex;flex-direction:column;gap:0.5rem;">
+ <button class="btn btn-ghost" style="justify-content:flex-start;color:#f87171;border-color:#7f1d1d;" data-target="local">Local only</button>
+ <button class="btn btn-ghost" style="justify-content:flex-start;color:#a78bfa;border-color:rgba(167,139,250,0.25);" data-target="offsite">Offsite only</button>
+ <button class="btn btn-danger" style="justify-content:flex-start;" data-target="both">Both (local + offsite)</button>
+ <button class="btn btn-ghost" style="justify-content:flex-start;margin-top:0.25rem;" data-target="">Cancel</button>
+ </div>`;
+ 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); }
+ });
+ });
}
// ---------------------------------------------------------------------------
@@ -781,6 +1632,89 @@
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 === '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 === 'system') {
+ showPage('system');
+ } else if (page === 'operations') {
+ showPage('operations');
+ } else {
+ showPage('dashboard');
+ }
}
// ---------------------------------------------------------------------------
@@ -795,11 +1729,20 @@
allServices = data;
document.getElementById('login-overlay').style.display = 'none';
document.getElementById('app').style.display = 'flex';
- const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION;
- showPage('dashboard');
+ 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(); });
+ document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') {
+ closeLogModal();
+ closeRestoreModal();
+ closeOpsModal();
+ }
+ });
+ window.addEventListener('hashchange', () => {
+ if (getToken()) navigateToHash();
+ });
})();
--
Gitblit v1.3.1