From fd03c16eca085423267c163137b28ccb60de8db0 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Wed, 25 Feb 2026 00:45:13 +0100
Subject: [PATCH] feat: multi-compose rebuild (Seafile), cancel endpoint, schedule router, project descriptor

---
 static/js/app.js |  685 ++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 files changed, 587 insertions(+), 98 deletions(-)

diff --git a/static/js/app.js b/static/js/app.js
index ee4c5e8..a23028a 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,5 +1,5 @@
 'use strict';
-const APP_VERSION = 'v13-20260222';
+const APP_VERSION = 'v14-20260222';
 
 // ============================================================
 // OPS Dashboard — Vanilla JS Application (v6)
@@ -38,6 +38,7 @@
 let opsEventSource = null;
 let opsCtx = { type: null, project: null, fromEnv: null, toEnv: null };
 let cachedRegistry = null;
+let currentOpId = null;
 
 // ---------------------------------------------------------------------------
 // Helpers
@@ -104,6 +105,15 @@
 }
 
 // ---------------------------------------------------------------------------
+// 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'); }
@@ -142,7 +152,7 @@
 async function api(path, opts = {}) {
   const token = getToken();
   const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token };
-  const resp = await fetch(path, { ...opts, headers });
+  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') || '';
@@ -197,6 +207,7 @@
     case 'backups':   renderBackups(); break;
     case 'system':    renderSystem(); break;
     case 'operations': renderOperations(); break;
+    case 'schedules': renderSchedules(); break;
     default:          renderDashboard();
   }
 }
@@ -288,6 +299,8 @@
     } 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 === 'schedules') {
+    h = '<span class="current">Schedules</span>';
   } else if (currentPage === 'system') {
     h = '<span class="current">System</span>';
   } else if (currentPage === 'operations') {
@@ -499,15 +512,21 @@
   // 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
+  // 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 YYYYMMDD_HHMMSS -> { dateKey: 'YYYY-MM-DD', timeStr: 'HH:MM' }
+// 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: '' };
 }
 
@@ -536,6 +555,16 @@
   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)
 // ---------------------------------------------------------------------------
@@ -544,12 +573,12 @@
 
   for (const b of local) {
     const name = b.name || b.file || '';
-    const key = name || (b.project + '/' + b.env + '/' + (b.date || b.timestamp));
+    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: b.date || b.timestamp || '',
+      date: normalizeBackupDate(b.date || b.mtime || b.timestamp || ''),
       size_human: b.size_human || b.size || '',
       size_bytes: Number(b.size || 0),
       hasLocal: true,
@@ -561,13 +590,15 @@
     const name = b.name || '';
     const key = name || (b.project + '/' + b.env + '/' + (b.date || ''));
     if (byName.has(key)) {
-      byName.get(key).hasOffsite = true;
+      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: b.date || '',
+        date: normalizeBackupDate(b.date || ''),
         size_human: b.size || '',
         size_bytes: Number(b.size_bytes || 0),
         hasLocal: false,
@@ -628,12 +659,12 @@
   h += '</div></div>';
 
   // Global stat tiles
-  h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
+  h += '<div class="grid-stats" style="margin-bottom:0.5rem;">';
   h += statTile('Local', localCount, '#3b82f6');
   h += statTile('Offsite', offsiteCount, '#8b5cf6');
   h += statTile('Synced', syncedCount, '#10b981');
-  h += statTile('Latest', latestDisplay, '#f59e0b');
   h += '</div>';
+  h += `<div style="margin-bottom:1.5rem;font-size:0.8125rem;color:#9ca3af;">Latest backup: <span style="color:#f59e0b;">${esc(latestDisplay)}</span></div>`;
 
   // Project cards
   const projects = groupBy(all, 'project');
@@ -722,6 +753,15 @@
 
   let h = '<div class="page-enter">';
 
+  // Action bar: Create Backup + Upload
+  h += `<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">`;
+  h += `<button class="btn btn-primary btn-sm" onclick="createBackup('${esc(backupDrillProject)}','${esc(backupDrillEnv)}')">Create Backup</button>`;
+  h += `<button class="btn btn-ghost btn-sm" style="color:#a78bfa;border-color:rgba(167,139,250,0.25);" onclick="uploadOffsiteBackup('${esc(backupDrillProject)}','${esc(backupDrillEnv)}')">Upload to Offsite</button>`;
+  if (filtered.some(b => b.hasOffsite && !b.hasLocal)) {
+    h += `<button class="btn btn-ghost btn-sm" style="color:#34d399;border-color:rgba(52,211,153,0.25);" onclick="downloadOffsiteBackup('${esc(backupDrillProject)}','${esc(backupDrillEnv)}')">Download from Offsite</button>`;
+  }
+  h += `</div>`;
+
   // 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>`;
@@ -782,7 +822,10 @@
         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>`
+          ? `<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)}','${esc(b.name)}')">Upload</button>`
+          : '';
+        const downloadBtn = (!b.hasLocal && b.hasOffsite)
+          ? `<button class="btn btn-ghost btn-xs" style="color:#34d399;border-color:rgba(52,211,153,0.25);" onclick="downloadOffsiteBackup('${esc(b.project)}','${esc(b.env)}','${esc(b.name)}')">Download</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>
@@ -792,6 +835,7 @@
           <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}
+            ${downloadBtn}
             ${deleteBtn}
           </td>
         </tr>`;
@@ -870,7 +914,7 @@
     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;
+  if (!await showConfirmDialog(`Delete ${names.length} backup${names.length > 1 ? 's' : ''} (${label})?\n\nThis cannot be undone.`, 'Delete', true)) return;
   toast(`Deleting ${names.length} backups (${label})...`, 'info');
   let ok = 0, fail = 0;
   for (const name of names) {
@@ -885,15 +929,153 @@
   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'); }
+async function uploadOffsiteBackup(project, env, name) {
+  const label = name ? name : `latest ${project}/${env}`;
+  if (!await showConfirmDialog(`Upload ${label} to offsite storage?`, 'Upload')) return;
+
+  // Open the ops modal with streaming output
+  opsCtx = { type: 'upload', project, fromEnv: env, toEnv: null };
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+
+  const title = document.getElementById('ops-modal-title');
+  const info = document.getElementById('ops-modal-info');
+  const startBtn = document.getElementById('ops-start-btn');
+  const dryRunRow = document.getElementById('ops-dry-run-row');
+  const outputDiv = document.getElementById('ops-modal-output');
+  const term = document.getElementById('ops-modal-terminal');
+
+  title.textContent = 'Upload to Offsite';
+  let infoHtml = '<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>';
+  if (name) infoHtml += '<div class="restore-info-row"><span class="restore-info-label">File</span><span class="restore-info-value mono">' + esc(name) + '</span></div>';
+  info.innerHTML = infoHtml;
+  if (dryRunRow) dryRunRow.style.display = 'none';
+  startBtn.style.display = 'none';
+
+  outputDiv.style.display = 'block';
+  term.textContent = 'Starting upload...\n';
+  currentOpId = null;
+  _setProgressState('ops-progress-bar', 'running');
+
+  document.getElementById('ops-modal').style.display = 'flex';
+
+  let url = '/api/backups/offsite/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?token=' + encodeURIComponent(getToken());
+  if (name) url += '&name=' + encodeURIComponent(name);
+  const es = new EventSource(url);
+  opsEventSource = es;
+
+  es.onmessage = function(e) {
+    try {
+      const d = JSON.parse(e.data);
+      if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; }
+      if (d.done) {
+        es.close();
+        opsEventSource = null;
+        currentOpId = null;
+        const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Upload complete ---\n' : '\n--- Upload FAILED ---\n';
+        term.textContent += msg;
+        term.scrollTop = term.scrollHeight;
+        toast(d.cancelled ? 'Upload cancelled' : d.success ? 'Offsite upload complete for ' + project + '/' + env : 'Upload failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error');
+        _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail');
+        cachedBackups = null;
+        if (d.success && currentPage === 'backups') renderBackups();
+        return;
+      }
+      if (d.line) {
+        term.textContent += d.line + '\n';
+        term.scrollTop = term.scrollHeight;
+      }
+    } catch (_) {}
+  };
+
+  es.onerror = function() {
+    es.close();
+    opsEventSource = null;
+    currentOpId = null;
+    term.textContent += '\n--- Connection lost ---\n';
+    toast('Connection lost', 'error');
+    _setProgressState('ops-progress-bar', 'fail');
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Offsite Download (download to local storage, no restore)
+// ---------------------------------------------------------------------------
+async function downloadOffsiteBackup(project, env, name) {
+  const label = name ? name : `latest offsite backup for ${project}/${env}`;
+  if (!name) {
+    // No specific name: find the latest offsite-only backup for this env
+    const latest = cachedBackups && cachedBackups.find(b => b.project === project && b.env === env && b.hasOffsite && !b.hasLocal);
+    if (!latest) {
+      toast('No offsite-only backup found for ' + project + '/' + env, 'warning');
+      return;
+    }
+    name = latest.name;
+  }
+  if (!await showConfirmDialog(`Download "${name}" from offsite to local storage?`, 'Download')) return;
+
+  // Open the ops modal with streaming output
+  opsCtx = { type: 'download', project, fromEnv: env, toEnv: null };
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+
+  const title = document.getElementById('ops-modal-title');
+  const info = document.getElementById('ops-modal-info');
+  const startBtn = document.getElementById('ops-start-btn');
+  const dryRunRow = document.getElementById('ops-dry-run-row');
+  const outputDiv = document.getElementById('ops-modal-output');
+  const term = document.getElementById('ops-modal-terminal');
+
+  title.textContent = 'Download from Offsite';
+  let infoHtml = '<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 class="restore-info-row"><span class="restore-info-label">File</span><span class="restore-info-value mono">' + esc(name) + '</span></div>';
+  info.innerHTML = infoHtml;
+  if (dryRunRow) dryRunRow.style.display = 'none';
+  startBtn.style.display = 'none';
+
+  outputDiv.style.display = 'block';
+  term.textContent = 'Starting download...\n';
+  currentOpId = null;
+  _setProgressState('ops-progress-bar', 'running');
+
+  document.getElementById('ops-modal').style.display = 'flex';
+
+  const url = '/api/backups/offsite/download/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?name=' + encodeURIComponent(name) + '&token=' + encodeURIComponent(getToken());
+  const es = new EventSource(url);
+  opsEventSource = es;
+
+  es.onmessage = function(e) {
+    try {
+      const d = JSON.parse(e.data);
+      if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; }
+      if (d.done) {
+        es.close();
+        opsEventSource = null;
+        currentOpId = null;
+        const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Download complete ---\n' : '\n--- Download FAILED ---\n';
+        term.textContent += msg;
+        term.scrollTop = term.scrollHeight;
+        toast(d.cancelled ? 'Download cancelled' : d.success ? 'Downloaded ' + (d.name || name) + ' to local storage' : 'Download failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error');
+        _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail');
+        cachedBackups = null;
+        if (d.success && currentPage === 'backups') renderBackups();
+        return;
+      }
+      if (d.line) {
+        term.textContent += d.line + '\n';
+        term.scrollTop = term.scrollHeight;
+      }
+    } catch (_) {}
+  };
+
+  es.onerror = function() {
+    es.close();
+    opsEventSource = null;
+    currentOpId = null;
+    term.textContent += '\n--- Connection lost ---\n';
+    toast('Connection lost', 'error');
+    _setProgressState('ops-progress-bar', 'fail');
+  };
 }
 
 // ---------------------------------------------------------------------------
@@ -943,7 +1125,12 @@
 }
 
 function closeRestoreModal() {
+  if (currentOpId && restoreEventSource) {
+    fetch('/api/operations/' + currentOpId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + getToken() } }).catch(() => {});
+  }
   if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; }
+  currentOpId = null;
+  _setProgressState('restore-progress-bar', 'hidden');
   document.getElementById('restore-modal').style.display = 'none';
   restoreCtx = { project: null, env: null, source: null, name: null };
 }
@@ -975,19 +1162,25 @@
   const modeEl = document.querySelector('input[name="restore-mode"]:checked');
   const mode = modeEl ? modeEl.value : 'full';
   const url = `/api/restore/${encodeURIComponent(project)}/${encodeURIComponent(env)}?source=${encodeURIComponent(source)}${dryRun ? '&dry_run=true' : ''}&token=${encodeURIComponent(getToken())}${name ? '&name=' + encodeURIComponent(name) : ''}&mode=${encodeURIComponent(mode)}`;
+  currentOpId = null;
+  _setProgressState('restore-progress-bar', 'running');
   const es = new EventSource(url);
   restoreEventSource = es;
 
   es.onmessage = function(e) {
     try {
       const d = JSON.parse(e.data);
+      if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; }
       if (d.done) {
         es.close();
         restoreEventSource = null;
-        const msg = d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
+        currentOpId = null;
+        const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
         term.textContent += msg;
         term.scrollTop = term.scrollHeight;
-        toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error');
+        const toastMsg = d.cancelled ? 'Restore cancelled' : d.success ? 'Restore completed' : 'Restore failed';
+        toast(toastMsg, d.success ? 'success' : d.cancelled ? 'warning' : 'error');
+        _setProgressState('restore-progress-bar', d.success ? 'ok' : 'fail');
         startBtn.disabled = false;
         startBtn.textContent = 'Start Restore';
         return;
@@ -1002,8 +1195,10 @@
   es.onerror = function() {
     es.close();
     restoreEventSource = null;
+    currentOpId = null;
     term.textContent += '\n--- Connection lost ---\n';
     toast('Connection lost', 'error');
+    _setProgressState('restore-progress-bar', 'fail');
     startBtn.disabled = false;
     startBtn.textContent = 'Start Restore';
   };
@@ -1120,6 +1315,220 @@
 }
 
 // ---------------------------------------------------------------------------
+// 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 = '<div class="page-enter">';
+    h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Backup Schedules</h2>';
+    h += '<p style="font-size:0.8125rem;color:#6b7280;margin-bottom:1rem;">Managed via registry.yaml. Changes regenerate systemd timers on the server.</p>';
+
+    h += '<div class="table-wrapper"><table class="ops-table"><thead><tr>'
+       + '<th>Project</th><th>Enabled</th><th>Schedule</th><th>Environments</th>'
+       + '<th>Offsite</th><th>Retention</th><th></th>'
+       + '</tr></thead><tbody>';
+
+    for (const s of schedules) {
+      if (s.static) continue; // skip static sites
+
+      const enabled = s.enabled;
+      const enabledBadge = enabled
+        ? '<span class="badge badge-green">On</span>'
+        : '<span class="badge badge-gray">Off</span>';
+      const schedule = s.schedule || '\u2014';
+      const envs = (s.backup_environments || s.environments || []).join(', ') || '\u2014';
+      const offsiteBadge = s.offsite
+        ? '<span class="badge badge-blue" style="background:rgba(59,130,246,0.15);color:#60a5fa;border-color:rgba(59,130,246,0.3);">Yes</span>'
+        : '<span class="badge badge-gray">No</span>';
+      const retLocal = s.retention_local_days != null ? s.retention_local_days + 'd local' : '';
+      const retOffsite = s.retention_offsite_days != null ? s.retention_offsite_days + 'd offsite' : '';
+      const retention = [retLocal, retOffsite].filter(Boolean).join(', ') || '\u2014';
+
+      const canEdit = s.has_backup_dir || s.has_cli;
+      const editBtn = canEdit
+        ? `<button class="btn btn-ghost btn-xs" onclick="openScheduleEdit('${esc(s.project)}')">Edit</button>`
+        : '<span style="color:#4b5563;font-size:0.75rem;">n/a</span>';
+      const runBtn = canEdit
+        ? `<button class="btn btn-ghost btn-xs" onclick="runBackupNow('${esc(s.project)}')">Run Now</button>`
+        : '';
+
+      h += `<tr>
+        <td style="font-weight:500;">${esc(s.project)}</td>
+        <td>${enabledBadge}</td>
+        <td class="mono">${esc(schedule)}</td>
+        <td>${esc(envs)}</td>
+        <td>${offsiteBadge}</td>
+        <td style="font-size:0.8125rem;color:#9ca3af;">${esc(retention)}</td>
+        <td style="display:flex;gap:0.25rem;">${editBtn} ${runBtn}</td>
+      </tr>`;
+    }
+    h += '</tbody></table></div>';
+    h += '</div>';
+    c.innerHTML = h;
+  } catch (e) {
+    c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load schedules: ' + esc(e.message) + '</div>';
+  }
+}
+
+let _schedClockInterval = null;
+function _startScheduleClock() {
+  _stopScheduleClock();
+  const el = document.getElementById('sched-server-clock');
+  const tick = () => {
+    const now = new Date();
+    el.textContent = 'Server now: ' + now.toISOString().slice(11, 19) + ' UTC';
+  };
+  tick();
+  _schedClockInterval = setInterval(tick, 1000);
+}
+function _stopScheduleClock() {
+  if (_schedClockInterval) { clearInterval(_schedClockInterval); _schedClockInterval = null; }
+}
+
+function openScheduleEdit(project) {
+  const s = (cachedSchedules || []).find(x => x.project === project);
+  if (!s) return;
+
+  const envOptions = (s.environments || []).map(e => {
+    const checked = (s.backup_environments || s.environments || []).includes(e) ? 'checked' : '';
+    return `<label style="display:flex;align-items:center;gap:0.375rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;">
+      <input type="checkbox" name="sched-env" value="${esc(e)}" ${checked} style="accent-color:#3b82f6;"> ${esc(e)}
+    </label>`;
+  }).join('');
+
+  const offsiteEnvOptions = (s.environments || []).map(e => {
+    const checked = (s.offsite_envs || ['prod']).includes(e) ? 'checked' : '';
+    return `<label style="display:flex;align-items:center;gap:0.375rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;">
+      <input type="checkbox" name="sched-offsite-env" value="${esc(e)}" ${checked} style="accent-color:#3b82f6;"> ${esc(e)}
+    </label>`;
+  }).join('');
+
+  const modal = document.getElementById('schedule-modal');
+  document.getElementById('schedule-modal-title').textContent = 'Edit Schedule: ' + project;
+  document.getElementById('sched-project').value = project;
+  document.getElementById('sched-enabled').checked = s.enabled;
+  document.getElementById('sched-time').value = s.schedule || '03:00';
+  document.getElementById('sched-envs').innerHTML = envOptions;
+  document.getElementById('sched-command').value = s.command || '';
+  document.getElementById('sched-offsite').checked = s.offsite;
+  document.getElementById('sched-offsite-envs').innerHTML = offsiteEnvOptions;
+  document.getElementById('sched-offsite-section').style.display = s.offsite ? '' : 'none';
+  document.getElementById('sched-retention-local').value = s.retention_local_days != null ? s.retention_local_days : 7;
+  document.getElementById('sched-retention-offsite').value = s.retention_offsite_days != null ? s.retention_offsite_days : 30;
+  document.getElementById('sched-save-btn').disabled = false;
+  document.getElementById('sched-save-btn').textContent = 'Save';
+  _startScheduleClock();
+  modal.style.display = 'flex';
+}
+
+function closeScheduleModal() {
+  _stopScheduleClock();
+  document.getElementById('schedule-modal').style.display = 'none';
+}
+
+function toggleOffsiteSection() {
+  const show = document.getElementById('sched-offsite').checked;
+  document.getElementById('sched-offsite-section').style.display = show ? '' : 'none';
+}
+
+async function saveSchedule() {
+  const project = document.getElementById('sched-project').value;
+  const btn = document.getElementById('sched-save-btn');
+  btn.disabled = true;
+  btn.textContent = 'Saving...';
+
+  const envCheckboxes = document.querySelectorAll('input[name="sched-env"]:checked');
+  const environments = Array.from(envCheckboxes).map(cb => cb.value);
+  const offsiteEnvCheckboxes = document.querySelectorAll('input[name="sched-offsite-env"]:checked');
+  const offsite_envs = Array.from(offsiteEnvCheckboxes).map(cb => cb.value);
+
+  const body = {
+    enabled: document.getElementById('sched-enabled').checked,
+    schedule: document.getElementById('sched-time').value,
+    environments: environments.length ? environments : null,
+    command: document.getElementById('sched-command').value || null,
+    offsite: document.getElementById('sched-offsite').checked,
+    offsite_envs: offsite_envs.length ? offsite_envs : null,
+    retention_local_days: parseInt(document.getElementById('sched-retention-local').value) || null,
+    retention_offsite_days: parseInt(document.getElementById('sched-retention-offsite').value) || null,
+  };
+
+  try {
+    await api('/api/schedule/' + encodeURIComponent(project), {
+      method: 'PUT',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+    toast('Schedule updated for ' + project, 'success');
+    closeScheduleModal();
+    cachedSchedules = null;
+    renderSchedules();
+  } catch (e) {
+    toast('Failed to save schedule: ' + e.message, 'error');
+    btn.disabled = false;
+    btn.textContent = 'Save';
+  }
+}
+
+async function runBackupNow(project) {
+  if (!await showConfirmDialog(`Run backup now for ${project}?`, 'Run Backup')) return;
+
+  opsCtx = { type: 'backup', project, fromEnv: null, toEnv: null };
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+
+  const title = document.getElementById('ops-modal-title');
+  const info = document.getElementById('ops-modal-info');
+  const startBtn = document.getElementById('ops-start-btn');
+  const dryRunRow = document.getElementById('ops-dry-run-row');
+  const outputDiv = document.getElementById('ops-modal-output');
+  const term = document.getElementById('ops-modal-terminal');
+
+  title.textContent = 'Backup: ' + project;
+  info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>';
+  if (dryRunRow) dryRunRow.style.display = 'none';
+  startBtn.style.display = 'none';
+
+  outputDiv.style.display = 'block';
+  term.textContent = 'Starting backup...\n';
+  currentOpId = null;
+  _setProgressState('ops-progress-bar', 'running');
+
+  document.getElementById('ops-modal').style.display = 'flex';
+
+  const url = '/api/schedule/' + encodeURIComponent(project) + '/run?token=' + encodeURIComponent(getToken());
+  const es = new EventSource(url);
+  opsEventSource = es;
+
+  es.onmessage = function(e) {
+    try {
+      const d = JSON.parse(e.data);
+      if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; }
+      if (d.done) {
+        es.close();
+        opsEventSource = null;
+        _setProgressState('ops-progress-bar', d.success ? 'done' : 'error');
+        if (d.cancelled) term.textContent += '\n--- Cancelled ---\n';
+        else if (d.success) term.textContent += '\n--- Done ---\n';
+        else term.textContent += '\n--- Failed ---\n';
+        return;
+      }
+      if (d.line != null) {
+        term.textContent += d.line + '\n';
+        term.scrollTop = term.scrollHeight;
+      }
+    } catch {}
+  };
+  es.onerror = function() { es.close(); opsEventSource = null; _setProgressState('ops-progress-bar', 'error'); };
+}
+
+// ---------------------------------------------------------------------------
 // Operations Page
 // ---------------------------------------------------------------------------
 async function renderOperations() {
@@ -1148,7 +1557,7 @@
   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 envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name);
     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>';
@@ -1187,7 +1596,7 @@
 
   for (const [name, cfg] of Object.entries(projects)) {
     if (!cfg.has_cli || cfg.static || cfg.infrastructure) continue;
-    const envs = cfg.environments || [];
+    const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name);
 
     h += '<div class="card">';
     h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>';
@@ -1215,15 +1624,14 @@
 
   // 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. '
+  h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Manage container state via docker compose. '
     + '<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;">';
+    + '<span style="color:#fbbf24;">Rebuild</span> refreshes the image.</p>';
+  h += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;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 (cfg.type === 'static' || cfg.type === 'infrastructure') continue;
+    const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name).filter(e => e !== 'infra');
     if (!envs.length) continue;
 
     h += '<div class="card">';
@@ -1231,21 +1639,25 @@
     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;">';
+      h += '<div style="display:flex;align-items:center;gap:0.375rem;">';
       // Environment label
-      h += '<span style="min-width:2.5rem;font-size:0.75rem;color:#9ca3af;font-weight:500;">' + esc(env) + '</span>';
+      h += '<span style="min-width:2.25rem;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);" '
+      h += '<button class="btn btn-ghost btn-xs" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);padding:0.125rem 0.375rem;font-size:0.6875rem;" '
         + 'onclick="openLifecycleModal(&apos;restart&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(env) + '&apos;)">'
         + 'Restart</button>';
       // Rebuild (yellow)
-      h += '<button class="btn btn-ghost btn-xs" style="color:#fbbf24;border-color:rgba(251,191,36,0.3);" '
+      h += '<button class="btn btn-ghost btn-xs" style="color:#fbbf24;border-color:rgba(251,191,36,0.3);padding:0.125rem 0.375rem;font-size:0.6875rem;" '
         + 'onclick="openLifecycleModal(&apos;rebuild&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(env) + '&apos;)">'
         + '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(&apos;recreate&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(env) + '&apos;)">'
-        + 'Recreate</button>';
+      // Backup (blue)
+      h += '<button class="btn btn-ghost btn-xs" style="color:#60a5fa;border-color:rgba(96,165,250,0.3);padding:0.125rem 0.375rem;font-size:0.6875rem;" '
+        + 'onclick="openLifecycleModal(&apos;backup&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(env) + '&apos;)">'
+        + 'Backup</button>';
+      // Restore (navigate to backups page)
+      h += '<button class="btn btn-ghost btn-xs" style="color:#a78bfa;border-color:rgba(167,139,250,0.3);padding:0.125rem 0.375rem;font-size:0.6875rem;" '
+        + 'onclick="currentPage=&apos;backups&apos;;backupDrillLevel=2;backupDrillProject=&apos;' + esc(name) + '&apos;;backupDrillEnv=&apos;' + esc(env) + '&apos;;cachedBackups=null;selectedBackups.clear();document.querySelectorAll(&apos;#sidebar-nav .sidebar-link&apos;).forEach(el=>el.classList.toggle(&apos;active&apos;,el.dataset.page===&apos;backups&apos;));renderPage();pushHash();">'
+        + 'Restore</button>';
       h += '</div>';
     }
 
@@ -1353,7 +1765,7 @@
 }
 
 // ---------------------------------------------------------------------------
-// Lifecycle Modal (Restart / Rebuild / Recreate)
+// Lifecycle Modal (Restart / Rebuild / Backup)
 // ---------------------------------------------------------------------------
 function openLifecycleModal(action, project, env) {
   opsCtx = { type: action, project, fromEnv: env, toEnv: null };
@@ -1385,61 +1797,49 @@
       + '<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>';
+      + 'Runs <code>docker compose down</code>, rebuilds the 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';
+  } else if (action === 'backup') {
+    title.textContent = 'Create Backup';
     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';
+      + '<div style="background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.25);border-radius:0.5rem;padding:0.625rem 0.875rem;font-size:0.8125rem;color:#93c5fd;margin-top:0.75rem;">'
+      + 'Creates a backup of the database and uploads for this environment.</div>';
+    startBtn.className = 'btn btn-primary btn-sm';
     startBtn.style.cssText = '';
-    startBtn.textContent = 'Recreate';
-    startBtn.disabled = true;  // enabled after typing env name
+    startBtn.textContent = 'Create Backup';
   }
 
   document.getElementById('ops-modal-output').style.display = 'none';
   document.getElementById('ops-modal-terminal').textContent = '';
 
   document.getElementById('ops-modal').style.display = 'flex';
-  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 (currentOpId && opsEventSource) {
+    fetch('/api/operations/' + currentOpId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + getToken() } }).catch(() => {});
+  }
   if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+  currentOpId = null;
+  _setProgressState('ops-progress-bar', 'hidden');
   document.getElementById('ops-modal').style.display = 'none';
+  // Refresh backup list if we just ran a backup or upload
+  if ((opsCtx.type === 'backup' || opsCtx.type === 'upload') && currentPage === 'backups') {
+    cachedBackups = null;
+    renderBackups();
+  }
   opsCtx = { type: null, project: null, fromEnv: null, toEnv: null };
   // Restore dry-run row visibility for promote/sync operations
   const dryRunRow = document.getElementById('ops-dry-run-row');
   if (dryRunRow) dryRunRow.style.display = '';
-  // Reset start button style
+  // Reset start button style and visibility
   const startBtn = document.getElementById('ops-start-btn');
-  if (startBtn) { startBtn.style.cssText = ''; startBtn.disabled = false; }
+  if (startBtn) { startBtn.style.cssText = ''; startBtn.style.display = ''; startBtn.disabled = false; }
 }
 
 function _btnLabelForType(type) {
@@ -1447,7 +1847,7 @@
   if (type === 'sync') return 'Sync';
   if (type === 'restart') return 'Restart';
   if (type === 'rebuild') return 'Rebuild';
-  if (type === 'recreate') return 'Recreate';
+  if (type === 'backup') return 'Create Backup';
   return 'Run';
 }
 
@@ -1461,6 +1861,8 @@
   const term = document.getElementById('ops-modal-terminal');
 
   outputDiv.style.display = 'block';
+  // Remove leftover banners from previous operations
+  outputDiv.querySelectorAll('div').forEach(el => { if (el !== term) el.remove(); });
   term.textContent = 'Starting...\n';
   startBtn.disabled = true;
   startBtn.textContent = 'Running...';
@@ -1470,12 +1872,16 @@
     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=...
+  } else if (type === 'restart' || type === 'rebuild') {
     url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv)
       + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken());
+  } else if (type === 'backup') {
+    url = '/api/backups/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv)
+      + '?token=' + encodeURIComponent(getToken());
   }
 
+  currentOpId = null;
+  _setProgressState('ops-progress-bar', 'running');
   const es = new EventSource(url);
   opsEventSource = es;
   let opDone = false;
@@ -1483,29 +1889,24 @@
   es.onmessage = function(e) {
     try {
       const d = JSON.parse(e.data);
+      if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; }
       if (d.done) {
         opDone = true;
         es.close();
         opsEventSource = null;
-        const msg = d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n';
+        currentOpId = null;
+        const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n';
         term.textContent += msg;
         term.scrollTop = term.scrollHeight;
-        toast(d.success ? 'Operation completed' : 'Operation failed', d.success ? 'success' : 'error');
+        const toastMsg = d.cancelled ? 'Operation cancelled' : d.success ? 'Operation completed' : 'Operation failed';
+        toast(toastMsg, d.success ? 'success' : d.cancelled ? 'warning' : 'error');
+        _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail');
         startBtn.disabled = false;
         startBtn.textContent = _btnLabelForType(type);
 
-        // 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 &rarr;</button>';
-          outputDiv.appendChild(banner);
+        // After a successful backup, invalidate cache so backups page refreshes
+        if (type === 'backup' && d.success) {
+          cachedBackups = null;
         }
 
         return;
@@ -1520,9 +1921,11 @@
   es.onerror = function() {
     es.close();
     opsEventSource = null;
+    currentOpId = null;
     if (opDone) return;
     term.textContent += '\n--- Connection lost ---\n';
     toast('Connection lost', 'error');
+    _setProgressState('ops-progress-bar', 'fail');
     startBtn.disabled = false;
     startBtn.textContent = _btnLabelForType(type);
   };
@@ -1532,7 +1935,7 @@
 // Service Actions
 // ---------------------------------------------------------------------------
 async function restartService(project, env, service) {
-  if (!confirm(`Restart ${service} in ${project}/${env}?`)) return;
+  if (!await showConfirmDialog(`Restart ${service} in ${project}/${env}?`, 'Restart')) return;
   toast('Restarting ' + service + '...', 'info');
   try {
     const r = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
@@ -1565,14 +1968,67 @@
 }
 
 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'); }
+  if (!await showConfirmDialog(`Create backup for ${project}/${env}?`, 'Create Backup')) return;
+
+  // Open the ops modal with streaming output
+  opsCtx = { type: 'backup', project, fromEnv: env, toEnv: null };
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+
+  const title = document.getElementById('ops-modal-title');
+  const info = document.getElementById('ops-modal-info');
+  const startBtn = document.getElementById('ops-start-btn');
+  const dryRunRow = document.getElementById('ops-dry-run-row');
+  const outputDiv = document.getElementById('ops-modal-output');
+  const term = document.getElementById('ops-modal-terminal');
+
+  title.textContent = 'Create Backup';
+  info.innerHTML = '<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>';
+  if (dryRunRow) dryRunRow.style.display = 'none';
+  startBtn.style.display = 'none';
+
+  outputDiv.style.display = 'block';
+  term.textContent = 'Starting backup...\n';
+  currentOpId = null;
+  _setProgressState('ops-progress-bar', 'running');
+
+  document.getElementById('ops-modal').style.display = 'flex';
+
+  const url = '/api/backups/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?token=' + encodeURIComponent(getToken());
+  const es = new EventSource(url);
+  opsEventSource = es;
+
+  es.onmessage = function(e) {
+    try {
+      const d = JSON.parse(e.data);
+      if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; }
+      if (d.done) {
+        es.close();
+        opsEventSource = null;
+        currentOpId = null;
+        const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Backup complete ---\n' : '\n--- Backup FAILED ---\n';
+        term.textContent += msg;
+        term.scrollTop = term.scrollHeight;
+        toast(d.cancelled ? 'Backup cancelled' : d.success ? 'Backup created for ' + project + '/' + env : 'Backup failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error');
+        _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail');
+        cachedBackups = null;
+        return;
+      }
+      if (d.line) {
+        term.textContent += d.line + '\n';
+        term.scrollTop = term.scrollHeight;
+      }
+    } catch (_) {}
+  };
+
+  es.onerror = function() {
+    es.close();
+    opsEventSource = null;
+    currentOpId = null;
+    term.textContent += '\n--- Connection lost ---\n';
+    toast('Connection lost', 'error');
+    _setProgressState('ops-progress-bar', 'fail');
+  };
 }
 
 async function deleteBackup(project, env, name, hasLocal, hasOffsite) {
@@ -1586,7 +2042,7 @@
     target = 'offsite';
   }
   const label = target === 'both' ? 'local + offsite' : target;
-  if (!confirm(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`)) return;
+  if (!await showConfirmDialog(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`, 'Delete', true)) return;
   toast('Deleting backup (' + label + ')...', 'info');
   try {
     await api(`/api/backups/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' });
@@ -1594,6 +2050,35 @@
     cachedBackups = null;
     if (currentPage === 'backups') renderBackups();
   } catch (e) { toast('Delete failed: ' + e.message, 'error'); }
+}
+
+function showConfirmDialog(message, confirmLabel = 'Confirm', isDanger = false) {
+  return new Promise(resolve => {
+    const overlay = document.createElement('div');
+    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:9999;animation:fadeIn 0.15s ease-out;';
+    const box = document.createElement('div');
+    box.style.cssText = 'background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;min-width:320px;max-width:420px;color:#e2e8f0;animation:modalIn 0.2s ease-out;';
+    const btnClass = isDanger ? 'btn btn-danger' : 'btn btn-primary';
+    box.innerHTML = `
+      <p style="margin:0 0 1.25rem;font-size:0.9rem;color:#d1d5db;white-space:pre-line;">${esc(message)}</p>
+      <div style="display:flex;gap:0.75rem;justify-content:flex-end;">
+        <button class="btn btn-ghost" data-action="cancel">Cancel</button>
+        <button class="${btnClass}" data-action="confirm">${esc(confirmLabel)}</button>
+      </div>`;
+    overlay.appendChild(box);
+    document.body.appendChild(overlay);
+    box.addEventListener('click', e => {
+      const btn = e.target.closest('[data-action]');
+      if (!btn) return;
+      document.body.removeChild(overlay);
+      resolve(btn.dataset.action === 'confirm');
+    });
+    overlay.addEventListener('click', e => {
+      if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); }
+    });
+    const onKey = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); document.body.removeChild(overlay); resolve(false); } };
+    document.addEventListener('keydown', onKey);
+  });
 }
 
 function showDeleteTargetDialog(name) {
@@ -1658,6 +2143,8 @@
     } else {
       hash = '/backups';
     }
+  } else if (currentPage === 'schedules') {
+    hash = '/schedules';
   } else if (currentPage === 'system') {
     hash = '/system';
   } else if (currentPage === 'operations') {
@@ -1708,6 +2195,8 @@
     document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el =>
       el.classList.toggle('active', el.dataset.page === 'backups'));
     renderPage();
+  } else if (page === 'schedules') {
+    showPage('schedules');
   } else if (page === 'system') {
     showPage('system');
   } else if (page === 'operations') {

--
Gitblit v1.3.1