| .. | .. |
|---|
| 1 | 1 | 'use strict'; |
|---|
| 2 | | -const APP_VERSION = 'v13-20260222'; |
|---|
| 2 | +const APP_VERSION = 'v14-20260222'; |
|---|
| 3 | 3 | |
|---|
| 4 | 4 | // ============================================================ |
|---|
| 5 | 5 | // OPS Dashboard — Vanilla JS Application (v6) |
|---|
| .. | .. |
|---|
| 38 | 38 | let opsEventSource = null; |
|---|
| 39 | 39 | let opsCtx = { type: null, project: null, fromEnv: null, toEnv: null }; |
|---|
| 40 | 40 | let cachedRegistry = null; |
|---|
| 41 | +let currentOpId = null; |
|---|
| 41 | 42 | |
|---|
| 42 | 43 | // --------------------------------------------------------------------------- |
|---|
| 43 | 44 | // Helpers |
|---|
| .. | .. |
|---|
| 104 | 105 | } |
|---|
| 105 | 106 | |
|---|
| 106 | 107 | // --------------------------------------------------------------------------- |
|---|
| 108 | +// Progress Bar |
|---|
| 109 | +// --------------------------------------------------------------------------- |
|---|
| 110 | +function _setProgressState(barId, state) { |
|---|
| 111 | + const bar = document.getElementById(barId); |
|---|
| 112 | + if (!bar) return; |
|---|
| 113 | + bar.className = 'op-progress ' + (state === 'running' ? 'running' : state === 'ok' ? 'done-ok' : state === 'fail' ? 'done-fail' : 'hidden'); |
|---|
| 114 | +} |
|---|
| 115 | + |
|---|
| 116 | +// --------------------------------------------------------------------------- |
|---|
| 107 | 117 | // Auth |
|---|
| 108 | 118 | // --------------------------------------------------------------------------- |
|---|
| 109 | 119 | function getToken() { return localStorage.getItem('ops_token'); } |
|---|
| .. | .. |
|---|
| 142 | 152 | async function api(path, opts = {}) { |
|---|
| 143 | 153 | const token = getToken(); |
|---|
| 144 | 154 | const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token }; |
|---|
| 145 | | - const resp = await fetch(path, { ...opts, headers }); |
|---|
| 155 | + const resp = await fetch(path, { ...opts, headers, cache: 'no-store' }); |
|---|
| 146 | 156 | if (resp.status === 401) { doLogout(); throw new Error('Session expired'); } |
|---|
| 147 | 157 | if (!resp.ok) { const b = await resp.text(); throw new Error(b || 'HTTP ' + resp.status); } |
|---|
| 148 | 158 | const ct = resp.headers.get('content-type') || ''; |
|---|
| .. | .. |
|---|
| 197 | 207 | case 'backups': renderBackups(); break; |
|---|
| 198 | 208 | case 'system': renderSystem(); break; |
|---|
| 199 | 209 | case 'operations': renderOperations(); break; |
|---|
| 210 | + case 'schedules': renderSchedules(); break; |
|---|
| 200 | 211 | default: renderDashboard(); |
|---|
| 201 | 212 | } |
|---|
| 202 | 213 | } |
|---|
| .. | .. |
|---|
| 288 | 299 | } else if (backupDrillLevel === 2) { |
|---|
| 289 | 300 | 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>'; |
|---|
| 290 | 301 | } |
|---|
| 302 | + } else if (currentPage === 'schedules') { |
|---|
| 303 | + h = '<span class="current">Schedules</span>'; |
|---|
| 291 | 304 | } else if (currentPage === 'system') { |
|---|
| 292 | 305 | h = '<span class="current">System</span>'; |
|---|
| 293 | 306 | } else if (currentPage === 'operations') { |
|---|
| .. | .. |
|---|
| 499 | 512 | // YYYYMMDD_HHMMSS -> YYYY-MM-DD HH:MM |
|---|
| 500 | 513 | const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})/); |
|---|
| 501 | 514 | if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`; |
|---|
| 502 | | - // YYYY-MM-DD passthrough |
|---|
| 515 | + // ISO 8601: YYYY-MM-DDTHH:MM:SS |
|---|
| 516 | + const iso = String(raw).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); |
|---|
| 517 | + if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}`; |
|---|
| 503 | 518 | return raw; |
|---|
| 504 | 519 | } |
|---|
| 505 | 520 | |
|---|
| 506 | | -// Parse YYYYMMDD_HHMMSS -> { dateKey: 'YYYY-MM-DD', timeStr: 'HH:MM' } |
|---|
| 521 | +// Parse backup date -> { dateKey: 'YYYY-MM-DD', timeStr: 'HH:MM' } |
|---|
| 507 | 522 | function parseBackupDate(raw) { |
|---|
| 508 | 523 | if (!raw) return { dateKey: '', timeStr: '' }; |
|---|
| 524 | + // YYYYMMDD_HHMMSS |
|---|
| 509 | 525 | const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})/); |
|---|
| 510 | 526 | if (m) return { dateKey: `${m[1]}-${m[2]}-${m[3]}`, timeStr: `${m[4]}:${m[5]}` }; |
|---|
| 527 | + // ISO 8601: YYYY-MM-DDTHH:MM:SS |
|---|
| 528 | + const iso = String(raw).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); |
|---|
| 529 | + if (iso) return { dateKey: `${iso[1]}-${iso[2]}-${iso[3]}`, timeStr: `${iso[4]}:${iso[5]}` }; |
|---|
| 511 | 530 | return { dateKey: raw, timeStr: '' }; |
|---|
| 512 | 531 | } |
|---|
| 513 | 532 | |
|---|
| .. | .. |
|---|
| 536 | 555 | if (chevron) chevron.classList.toggle('open', !isOpen); |
|---|
| 537 | 556 | } |
|---|
| 538 | 557 | |
|---|
| 558 | +// Normalize any backup date to ISO-sortable format (YYYY-MM-DDTHH:MM:SS) |
|---|
| 559 | +function normalizeBackupDate(raw) { |
|---|
| 560 | + if (!raw) return ''; |
|---|
| 561 | + // Compact: YYYYMMDD_HHMMSS -> YYYY-MM-DDTHH:MM:SS |
|---|
| 562 | + const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})(\d{2})?/); |
|---|
| 563 | + if (m) return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] || '00'}`; |
|---|
| 564 | + // Already ISO-ish: pass through |
|---|
| 565 | + return String(raw); |
|---|
| 566 | +} |
|---|
| 567 | + |
|---|
| 539 | 568 | // --------------------------------------------------------------------------- |
|---|
| 540 | 569 | // Backups — merge helper (dedup local+offsite by filename) |
|---|
| 541 | 570 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 544 | 573 | |
|---|
| 545 | 574 | for (const b of local) { |
|---|
| 546 | 575 | const name = b.name || b.file || ''; |
|---|
| 547 | | - const key = name || (b.project + '/' + b.env + '/' + (b.date || b.timestamp)); |
|---|
| 576 | + const key = name || (b.project + '/' + b.env + '/' + (b.date || b.mtime || b.timestamp)); |
|---|
| 548 | 577 | byName.set(key, { |
|---|
| 549 | 578 | project: b.project || '', |
|---|
| 550 | 579 | env: b.env || b.environment || '', |
|---|
| 551 | 580 | name: name, |
|---|
| 552 | | - date: b.date || b.timestamp || '', |
|---|
| 581 | + date: normalizeBackupDate(b.date || b.mtime || b.timestamp || ''), |
|---|
| 553 | 582 | size_human: b.size_human || b.size || '', |
|---|
| 554 | 583 | size_bytes: Number(b.size || 0), |
|---|
| 555 | 584 | hasLocal: true, |
|---|
| .. | .. |
|---|
| 561 | 590 | const name = b.name || ''; |
|---|
| 562 | 591 | const key = name || (b.project + '/' + b.env + '/' + (b.date || '')); |
|---|
| 563 | 592 | if (byName.has(key)) { |
|---|
| 564 | | - byName.get(key).hasOffsite = true; |
|---|
| 593 | + const existing = byName.get(key); |
|---|
| 594 | + existing.hasOffsite = true; |
|---|
| 595 | + if (!existing.date && b.date) existing.date = normalizeBackupDate(b.date); |
|---|
| 565 | 596 | } else { |
|---|
| 566 | 597 | byName.set(key, { |
|---|
| 567 | 598 | project: b.project || '', |
|---|
| 568 | 599 | env: b.env || b.environment || '', |
|---|
| 569 | 600 | name: name, |
|---|
| 570 | | - date: b.date || '', |
|---|
| 601 | + date: normalizeBackupDate(b.date || ''), |
|---|
| 571 | 602 | size_human: b.size || '', |
|---|
| 572 | 603 | size_bytes: Number(b.size_bytes || 0), |
|---|
| 573 | 604 | hasLocal: false, |
|---|
| .. | .. |
|---|
| 628 | 659 | h += '</div></div>'; |
|---|
| 629 | 660 | |
|---|
| 630 | 661 | // Global stat tiles |
|---|
| 631 | | - h += '<div class="grid-stats" style="margin-bottom:1.5rem;">'; |
|---|
| 662 | + h += '<div class="grid-stats" style="margin-bottom:0.5rem;">'; |
|---|
| 632 | 663 | h += statTile('Local', localCount, '#3b82f6'); |
|---|
| 633 | 664 | h += statTile('Offsite', offsiteCount, '#8b5cf6'); |
|---|
| 634 | 665 | h += statTile('Synced', syncedCount, '#10b981'); |
|---|
| 635 | | - h += statTile('Latest', latestDisplay, '#f59e0b'); |
|---|
| 636 | 666 | h += '</div>'; |
|---|
| 667 | + h += `<div style="margin-bottom:1.5rem;font-size:0.8125rem;color:#9ca3af;">Latest backup: <span style="color:#f59e0b;">${esc(latestDisplay)}</span></div>`; |
|---|
| 637 | 668 | |
|---|
| 638 | 669 | // Project cards |
|---|
| 639 | 670 | const projects = groupBy(all, 'project'); |
|---|
| .. | .. |
|---|
| 722 | 753 | |
|---|
| 723 | 754 | let h = '<div class="page-enter">'; |
|---|
| 724 | 755 | |
|---|
| 756 | + // Action bar: Create Backup + Upload |
|---|
| 757 | + h += `<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">`; |
|---|
| 758 | + h += `<button class="btn btn-primary btn-sm" onclick="createBackup('${esc(backupDrillProject)}','${esc(backupDrillEnv)}')">Create Backup</button>`; |
|---|
| 759 | + 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>`; |
|---|
| 760 | + if (filtered.some(b => b.hasOffsite && !b.hasLocal)) { |
|---|
| 761 | + 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>`; |
|---|
| 762 | + } |
|---|
| 763 | + h += `</div>`; |
|---|
| 764 | + |
|---|
| 725 | 765 | // Selection action bar |
|---|
| 726 | 766 | h += `<div id="backup-selection-bar" class="selection-bar" style="display:${selectedBackups.size > 0 ? 'flex' : 'none'};">`; |
|---|
| 727 | 767 | h += `<span id="selection-count">${selectedBackups.size} selected</span>`; |
|---|
| .. | .. |
|---|
| 782 | 822 | const checked = selectedBackups.has(b.name) ? ' checked' : ''; |
|---|
| 783 | 823 | 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>`; |
|---|
| 784 | 824 | const uploadBtn = (b.hasLocal && !b.hasOffsite) |
|---|
| 785 | | - ? `<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>` |
|---|
| 825 | + ? `<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>` |
|---|
| 826 | + : ''; |
|---|
| 827 | + const downloadBtn = (!b.hasLocal && b.hasOffsite) |
|---|
| 828 | + ? `<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>` |
|---|
| 786 | 829 | : ''; |
|---|
| 787 | 830 | h += `<tr> |
|---|
| 788 | 831 | <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 | 835 | <td style="white-space:nowrap;"> |
|---|
| 793 | 836 | <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> |
|---|
| 794 | 837 | ${uploadBtn} |
|---|
| 838 | + ${downloadBtn} |
|---|
| 795 | 839 | ${deleteBtn} |
|---|
| 796 | 840 | </td> |
|---|
| 797 | 841 | </tr>`; |
|---|
| .. | .. |
|---|
| 870 | 914 | if (allOffsite) target = 'offsite'; |
|---|
| 871 | 915 | } |
|---|
| 872 | 916 | const label = target === 'both' ? 'local + offsite' : target; |
|---|
| 873 | | - if (!confirm(`Delete ${names.length} backup${names.length > 1 ? 's' : ''} (${label})?\n\nThis cannot be undone.`)) return; |
|---|
| 917 | + if (!await showConfirmDialog(`Delete ${names.length} backup${names.length > 1 ? 's' : ''} (${label})?\n\nThis cannot be undone.`, 'Delete', true)) return; |
|---|
| 874 | 918 | toast(`Deleting ${names.length} backups (${label})...`, 'info'); |
|---|
| 875 | 919 | let ok = 0, fail = 0; |
|---|
| 876 | 920 | for (const name of names) { |
|---|
| .. | .. |
|---|
| 885 | 929 | if (currentPage === 'backups') renderBackups(); |
|---|
| 886 | 930 | } |
|---|
| 887 | 931 | |
|---|
| 888 | | -async function uploadOffsiteBackup(project, env) { |
|---|
| 889 | | - if (!confirm(`Upload latest ${project}/${env} backup to offsite storage?`)) return; |
|---|
| 890 | | - toast('Uploading to offsite...', 'info'); |
|---|
| 891 | | - try { |
|---|
| 892 | | - await api(`/api/backups/offsite/upload/${encodeURIComponent(project)}/${encodeURIComponent(env)}`, { method: 'POST' }); |
|---|
| 893 | | - toast('Offsite upload complete for ' + project + '/' + env, 'success'); |
|---|
| 894 | | - cachedBackups = null; |
|---|
| 895 | | - if (currentPage === 'backups') renderBackups(); |
|---|
| 896 | | - } catch (e) { toast('Upload failed: ' + e.message, 'error'); } |
|---|
| 932 | +async function uploadOffsiteBackup(project, env, name) { |
|---|
| 933 | + const label = name ? name : `latest ${project}/${env}`; |
|---|
| 934 | + if (!await showConfirmDialog(`Upload ${label} to offsite storage?`, 'Upload')) return; |
|---|
| 935 | + |
|---|
| 936 | + // Open the ops modal with streaming output |
|---|
| 937 | + opsCtx = { type: 'upload', project, fromEnv: env, toEnv: null }; |
|---|
| 938 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 939 | + |
|---|
| 940 | + const title = document.getElementById('ops-modal-title'); |
|---|
| 941 | + const info = document.getElementById('ops-modal-info'); |
|---|
| 942 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 943 | + const dryRunRow = document.getElementById('ops-dry-run-row'); |
|---|
| 944 | + const outputDiv = document.getElementById('ops-modal-output'); |
|---|
| 945 | + const term = document.getElementById('ops-modal-terminal'); |
|---|
| 946 | + |
|---|
| 947 | + title.textContent = 'Upload to Offsite'; |
|---|
| 948 | + let infoHtml = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 949 | + + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'; |
|---|
| 950 | + 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>'; |
|---|
| 951 | + info.innerHTML = infoHtml; |
|---|
| 952 | + if (dryRunRow) dryRunRow.style.display = 'none'; |
|---|
| 953 | + startBtn.style.display = 'none'; |
|---|
| 954 | + |
|---|
| 955 | + outputDiv.style.display = 'block'; |
|---|
| 956 | + term.textContent = 'Starting upload...\n'; |
|---|
| 957 | + currentOpId = null; |
|---|
| 958 | + _setProgressState('ops-progress-bar', 'running'); |
|---|
| 959 | + |
|---|
| 960 | + document.getElementById('ops-modal').style.display = 'flex'; |
|---|
| 961 | + |
|---|
| 962 | + let url = '/api/backups/offsite/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?token=' + encodeURIComponent(getToken()); |
|---|
| 963 | + if (name) url += '&name=' + encodeURIComponent(name); |
|---|
| 964 | + const es = new EventSource(url); |
|---|
| 965 | + opsEventSource = es; |
|---|
| 966 | + |
|---|
| 967 | + es.onmessage = function(e) { |
|---|
| 968 | + try { |
|---|
| 969 | + const d = JSON.parse(e.data); |
|---|
| 970 | + if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } |
|---|
| 971 | + if (d.done) { |
|---|
| 972 | + es.close(); |
|---|
| 973 | + opsEventSource = null; |
|---|
| 974 | + currentOpId = null; |
|---|
| 975 | + const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Upload complete ---\n' : '\n--- Upload FAILED ---\n'; |
|---|
| 976 | + term.textContent += msg; |
|---|
| 977 | + term.scrollTop = term.scrollHeight; |
|---|
| 978 | + toast(d.cancelled ? 'Upload cancelled' : d.success ? 'Offsite upload complete for ' + project + '/' + env : 'Upload failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error'); |
|---|
| 979 | + _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); |
|---|
| 980 | + cachedBackups = null; |
|---|
| 981 | + if (d.success && currentPage === 'backups') renderBackups(); |
|---|
| 982 | + return; |
|---|
| 983 | + } |
|---|
| 984 | + if (d.line) { |
|---|
| 985 | + term.textContent += d.line + '\n'; |
|---|
| 986 | + term.scrollTop = term.scrollHeight; |
|---|
| 987 | + } |
|---|
| 988 | + } catch (_) {} |
|---|
| 989 | + }; |
|---|
| 990 | + |
|---|
| 991 | + es.onerror = function() { |
|---|
| 992 | + es.close(); |
|---|
| 993 | + opsEventSource = null; |
|---|
| 994 | + currentOpId = null; |
|---|
| 995 | + term.textContent += '\n--- Connection lost ---\n'; |
|---|
| 996 | + toast('Connection lost', 'error'); |
|---|
| 997 | + _setProgressState('ops-progress-bar', 'fail'); |
|---|
| 998 | + }; |
|---|
| 999 | +} |
|---|
| 1000 | + |
|---|
| 1001 | +// --------------------------------------------------------------------------- |
|---|
| 1002 | +// Offsite Download (download to local storage, no restore) |
|---|
| 1003 | +// --------------------------------------------------------------------------- |
|---|
| 1004 | +async function downloadOffsiteBackup(project, env, name) { |
|---|
| 1005 | + const label = name ? name : `latest offsite backup for ${project}/${env}`; |
|---|
| 1006 | + if (!name) { |
|---|
| 1007 | + // No specific name: find the latest offsite-only backup for this env |
|---|
| 1008 | + const latest = cachedBackups && cachedBackups.find(b => b.project === project && b.env === env && b.hasOffsite && !b.hasLocal); |
|---|
| 1009 | + if (!latest) { |
|---|
| 1010 | + toast('No offsite-only backup found for ' + project + '/' + env, 'warning'); |
|---|
| 1011 | + return; |
|---|
| 1012 | + } |
|---|
| 1013 | + name = latest.name; |
|---|
| 1014 | + } |
|---|
| 1015 | + if (!await showConfirmDialog(`Download "${name}" from offsite to local storage?`, 'Download')) return; |
|---|
| 1016 | + |
|---|
| 1017 | + // Open the ops modal with streaming output |
|---|
| 1018 | + opsCtx = { type: 'download', project, fromEnv: env, toEnv: null }; |
|---|
| 1019 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 1020 | + |
|---|
| 1021 | + const title = document.getElementById('ops-modal-title'); |
|---|
| 1022 | + const info = document.getElementById('ops-modal-info'); |
|---|
| 1023 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1024 | + const dryRunRow = document.getElementById('ops-dry-run-row'); |
|---|
| 1025 | + const outputDiv = document.getElementById('ops-modal-output'); |
|---|
| 1026 | + const term = document.getElementById('ops-modal-terminal'); |
|---|
| 1027 | + |
|---|
| 1028 | + title.textContent = 'Download from Offsite'; |
|---|
| 1029 | + let infoHtml = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1030 | + + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>' |
|---|
| 1031 | + + '<div class="restore-info-row"><span class="restore-info-label">File</span><span class="restore-info-value mono">' + esc(name) + '</span></div>'; |
|---|
| 1032 | + info.innerHTML = infoHtml; |
|---|
| 1033 | + if (dryRunRow) dryRunRow.style.display = 'none'; |
|---|
| 1034 | + startBtn.style.display = 'none'; |
|---|
| 1035 | + |
|---|
| 1036 | + outputDiv.style.display = 'block'; |
|---|
| 1037 | + term.textContent = 'Starting download...\n'; |
|---|
| 1038 | + currentOpId = null; |
|---|
| 1039 | + _setProgressState('ops-progress-bar', 'running'); |
|---|
| 1040 | + |
|---|
| 1041 | + document.getElementById('ops-modal').style.display = 'flex'; |
|---|
| 1042 | + |
|---|
| 1043 | + const url = '/api/backups/offsite/download/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?name=' + encodeURIComponent(name) + '&token=' + encodeURIComponent(getToken()); |
|---|
| 1044 | + const es = new EventSource(url); |
|---|
| 1045 | + opsEventSource = es; |
|---|
| 1046 | + |
|---|
| 1047 | + es.onmessage = function(e) { |
|---|
| 1048 | + try { |
|---|
| 1049 | + const d = JSON.parse(e.data); |
|---|
| 1050 | + if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } |
|---|
| 1051 | + if (d.done) { |
|---|
| 1052 | + es.close(); |
|---|
| 1053 | + opsEventSource = null; |
|---|
| 1054 | + currentOpId = null; |
|---|
| 1055 | + const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Download complete ---\n' : '\n--- Download FAILED ---\n'; |
|---|
| 1056 | + term.textContent += msg; |
|---|
| 1057 | + term.scrollTop = term.scrollHeight; |
|---|
| 1058 | + toast(d.cancelled ? 'Download cancelled' : d.success ? 'Downloaded ' + (d.name || name) + ' to local storage' : 'Download failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error'); |
|---|
| 1059 | + _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); |
|---|
| 1060 | + cachedBackups = null; |
|---|
| 1061 | + if (d.success && currentPage === 'backups') renderBackups(); |
|---|
| 1062 | + return; |
|---|
| 1063 | + } |
|---|
| 1064 | + if (d.line) { |
|---|
| 1065 | + term.textContent += d.line + '\n'; |
|---|
| 1066 | + term.scrollTop = term.scrollHeight; |
|---|
| 1067 | + } |
|---|
| 1068 | + } catch (_) {} |
|---|
| 1069 | + }; |
|---|
| 1070 | + |
|---|
| 1071 | + es.onerror = function() { |
|---|
| 1072 | + es.close(); |
|---|
| 1073 | + opsEventSource = null; |
|---|
| 1074 | + currentOpId = null; |
|---|
| 1075 | + term.textContent += '\n--- Connection lost ---\n'; |
|---|
| 1076 | + toast('Connection lost', 'error'); |
|---|
| 1077 | + _setProgressState('ops-progress-bar', 'fail'); |
|---|
| 1078 | + }; |
|---|
| 897 | 1079 | } |
|---|
| 898 | 1080 | |
|---|
| 899 | 1081 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 943 | 1125 | } |
|---|
| 944 | 1126 | |
|---|
| 945 | 1127 | function closeRestoreModal() { |
|---|
| 1128 | + if (currentOpId && restoreEventSource) { |
|---|
| 1129 | + fetch('/api/operations/' + currentOpId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + getToken() } }).catch(() => {}); |
|---|
| 1130 | + } |
|---|
| 946 | 1131 | if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; } |
|---|
| 1132 | + currentOpId = null; |
|---|
| 1133 | + _setProgressState('restore-progress-bar', 'hidden'); |
|---|
| 947 | 1134 | document.getElementById('restore-modal').style.display = 'none'; |
|---|
| 948 | 1135 | restoreCtx = { project: null, env: null, source: null, name: null }; |
|---|
| 949 | 1136 | } |
|---|
| .. | .. |
|---|
| 975 | 1162 | const modeEl = document.querySelector('input[name="restore-mode"]:checked'); |
|---|
| 976 | 1163 | const mode = modeEl ? modeEl.value : 'full'; |
|---|
| 977 | 1164 | 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)}`; |
|---|
| 1165 | + currentOpId = null; |
|---|
| 1166 | + _setProgressState('restore-progress-bar', 'running'); |
|---|
| 978 | 1167 | const es = new EventSource(url); |
|---|
| 979 | 1168 | restoreEventSource = es; |
|---|
| 980 | 1169 | |
|---|
| 981 | 1170 | es.onmessage = function(e) { |
|---|
| 982 | 1171 | try { |
|---|
| 983 | 1172 | const d = JSON.parse(e.data); |
|---|
| 1173 | + if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } |
|---|
| 984 | 1174 | if (d.done) { |
|---|
| 985 | 1175 | es.close(); |
|---|
| 986 | 1176 | restoreEventSource = null; |
|---|
| 987 | | - const msg = d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; |
|---|
| 1177 | + currentOpId = null; |
|---|
| 1178 | + const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; |
|---|
| 988 | 1179 | term.textContent += msg; |
|---|
| 989 | 1180 | term.scrollTop = term.scrollHeight; |
|---|
| 990 | | - toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error'); |
|---|
| 1181 | + const toastMsg = d.cancelled ? 'Restore cancelled' : d.success ? 'Restore completed' : 'Restore failed'; |
|---|
| 1182 | + toast(toastMsg, d.success ? 'success' : d.cancelled ? 'warning' : 'error'); |
|---|
| 1183 | + _setProgressState('restore-progress-bar', d.success ? 'ok' : 'fail'); |
|---|
| 991 | 1184 | startBtn.disabled = false; |
|---|
| 992 | 1185 | startBtn.textContent = 'Start Restore'; |
|---|
| 993 | 1186 | return; |
|---|
| .. | .. |
|---|
| 1002 | 1195 | es.onerror = function() { |
|---|
| 1003 | 1196 | es.close(); |
|---|
| 1004 | 1197 | restoreEventSource = null; |
|---|
| 1198 | + currentOpId = null; |
|---|
| 1005 | 1199 | term.textContent += '\n--- Connection lost ---\n'; |
|---|
| 1006 | 1200 | toast('Connection lost', 'error'); |
|---|
| 1201 | + _setProgressState('restore-progress-bar', 'fail'); |
|---|
| 1007 | 1202 | startBtn.disabled = false; |
|---|
| 1008 | 1203 | startBtn.textContent = 'Start Restore'; |
|---|
| 1009 | 1204 | }; |
|---|
| .. | .. |
|---|
| 1120 | 1315 | } |
|---|
| 1121 | 1316 | |
|---|
| 1122 | 1317 | // --------------------------------------------------------------------------- |
|---|
| 1318 | +// Schedules Page |
|---|
| 1319 | +// --------------------------------------------------------------------------- |
|---|
| 1320 | +let cachedSchedules = null; |
|---|
| 1321 | + |
|---|
| 1322 | +async function renderSchedules() { |
|---|
| 1323 | + updateBreadcrumbs(); |
|---|
| 1324 | + const c = document.getElementById('page-content'); |
|---|
| 1325 | + try { |
|---|
| 1326 | + const schedules = await api('/api/schedule/'); |
|---|
| 1327 | + cachedSchedules = schedules; |
|---|
| 1328 | + |
|---|
| 1329 | + let h = '<div class="page-enter">'; |
|---|
| 1330 | + h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Backup Schedules</h2>'; |
|---|
| 1331 | + h += '<p style="font-size:0.8125rem;color:#6b7280;margin-bottom:1rem;">Managed via registry.yaml. Changes regenerate systemd timers on the server.</p>'; |
|---|
| 1332 | + |
|---|
| 1333 | + h += '<div class="table-wrapper"><table class="ops-table"><thead><tr>' |
|---|
| 1334 | + + '<th>Project</th><th>Enabled</th><th>Schedule</th><th>Environments</th>' |
|---|
| 1335 | + + '<th>Offsite</th><th>Retention</th><th></th>' |
|---|
| 1336 | + + '</tr></thead><tbody>'; |
|---|
| 1337 | + |
|---|
| 1338 | + for (const s of schedules) { |
|---|
| 1339 | + if (s.static) continue; // skip static sites |
|---|
| 1340 | + |
|---|
| 1341 | + const enabled = s.enabled; |
|---|
| 1342 | + const enabledBadge = enabled |
|---|
| 1343 | + ? '<span class="badge badge-green">On</span>' |
|---|
| 1344 | + : '<span class="badge badge-gray">Off</span>'; |
|---|
| 1345 | + const schedule = s.schedule || '\u2014'; |
|---|
| 1346 | + const envs = (s.backup_environments || s.environments || []).join(', ') || '\u2014'; |
|---|
| 1347 | + const offsiteBadge = s.offsite |
|---|
| 1348 | + ? '<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>' |
|---|
| 1349 | + : '<span class="badge badge-gray">No</span>'; |
|---|
| 1350 | + const retLocal = s.retention_local_days != null ? s.retention_local_days + 'd local' : ''; |
|---|
| 1351 | + const retOffsite = s.retention_offsite_days != null ? s.retention_offsite_days + 'd offsite' : ''; |
|---|
| 1352 | + const retention = [retLocal, retOffsite].filter(Boolean).join(', ') || '\u2014'; |
|---|
| 1353 | + |
|---|
| 1354 | + const canEdit = s.has_backup_dir || s.has_cli; |
|---|
| 1355 | + const editBtn = canEdit |
|---|
| 1356 | + ? `<button class="btn btn-ghost btn-xs" onclick="openScheduleEdit('${esc(s.project)}')">Edit</button>` |
|---|
| 1357 | + : '<span style="color:#4b5563;font-size:0.75rem;">n/a</span>'; |
|---|
| 1358 | + const runBtn = canEdit |
|---|
| 1359 | + ? `<button class="btn btn-ghost btn-xs" onclick="runBackupNow('${esc(s.project)}')">Run Now</button>` |
|---|
| 1360 | + : ''; |
|---|
| 1361 | + |
|---|
| 1362 | + h += `<tr> |
|---|
| 1363 | + <td style="font-weight:500;">${esc(s.project)}</td> |
|---|
| 1364 | + <td>${enabledBadge}</td> |
|---|
| 1365 | + <td class="mono">${esc(schedule)}</td> |
|---|
| 1366 | + <td>${esc(envs)}</td> |
|---|
| 1367 | + <td>${offsiteBadge}</td> |
|---|
| 1368 | + <td style="font-size:0.8125rem;color:#9ca3af;">${esc(retention)}</td> |
|---|
| 1369 | + <td style="display:flex;gap:0.25rem;">${editBtn} ${runBtn}</td> |
|---|
| 1370 | + </tr>`; |
|---|
| 1371 | + } |
|---|
| 1372 | + h += '</tbody></table></div>'; |
|---|
| 1373 | + h += '</div>'; |
|---|
| 1374 | + c.innerHTML = h; |
|---|
| 1375 | + } catch (e) { |
|---|
| 1376 | + c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load schedules: ' + esc(e.message) + '</div>'; |
|---|
| 1377 | + } |
|---|
| 1378 | +} |
|---|
| 1379 | + |
|---|
| 1380 | +let _schedClockInterval = null; |
|---|
| 1381 | +function _startScheduleClock() { |
|---|
| 1382 | + _stopScheduleClock(); |
|---|
| 1383 | + const el = document.getElementById('sched-server-clock'); |
|---|
| 1384 | + const tick = () => { |
|---|
| 1385 | + const now = new Date(); |
|---|
| 1386 | + el.textContent = 'Server now: ' + now.toISOString().slice(11, 19) + ' UTC'; |
|---|
| 1387 | + }; |
|---|
| 1388 | + tick(); |
|---|
| 1389 | + _schedClockInterval = setInterval(tick, 1000); |
|---|
| 1390 | +} |
|---|
| 1391 | +function _stopScheduleClock() { |
|---|
| 1392 | + if (_schedClockInterval) { clearInterval(_schedClockInterval); _schedClockInterval = null; } |
|---|
| 1393 | +} |
|---|
| 1394 | + |
|---|
| 1395 | +function openScheduleEdit(project) { |
|---|
| 1396 | + const s = (cachedSchedules || []).find(x => x.project === project); |
|---|
| 1397 | + if (!s) return; |
|---|
| 1398 | + |
|---|
| 1399 | + const envOptions = (s.environments || []).map(e => { |
|---|
| 1400 | + const checked = (s.backup_environments || s.environments || []).includes(e) ? 'checked' : ''; |
|---|
| 1401 | + return `<label style="display:flex;align-items:center;gap:0.375rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;"> |
|---|
| 1402 | + <input type="checkbox" name="sched-env" value="${esc(e)}" ${checked} style="accent-color:#3b82f6;"> ${esc(e)} |
|---|
| 1403 | + </label>`; |
|---|
| 1404 | + }).join(''); |
|---|
| 1405 | + |
|---|
| 1406 | + const offsiteEnvOptions = (s.environments || []).map(e => { |
|---|
| 1407 | + const checked = (s.offsite_envs || ['prod']).includes(e) ? 'checked' : ''; |
|---|
| 1408 | + return `<label style="display:flex;align-items:center;gap:0.375rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;"> |
|---|
| 1409 | + <input type="checkbox" name="sched-offsite-env" value="${esc(e)}" ${checked} style="accent-color:#3b82f6;"> ${esc(e)} |
|---|
| 1410 | + </label>`; |
|---|
| 1411 | + }).join(''); |
|---|
| 1412 | + |
|---|
| 1413 | + const modal = document.getElementById('schedule-modal'); |
|---|
| 1414 | + document.getElementById('schedule-modal-title').textContent = 'Edit Schedule: ' + project; |
|---|
| 1415 | + document.getElementById('sched-project').value = project; |
|---|
| 1416 | + document.getElementById('sched-enabled').checked = s.enabled; |
|---|
| 1417 | + document.getElementById('sched-time').value = s.schedule || '03:00'; |
|---|
| 1418 | + document.getElementById('sched-envs').innerHTML = envOptions; |
|---|
| 1419 | + document.getElementById('sched-command').value = s.command || ''; |
|---|
| 1420 | + document.getElementById('sched-offsite').checked = s.offsite; |
|---|
| 1421 | + document.getElementById('sched-offsite-envs').innerHTML = offsiteEnvOptions; |
|---|
| 1422 | + document.getElementById('sched-offsite-section').style.display = s.offsite ? '' : 'none'; |
|---|
| 1423 | + document.getElementById('sched-retention-local').value = s.retention_local_days != null ? s.retention_local_days : 7; |
|---|
| 1424 | + document.getElementById('sched-retention-offsite').value = s.retention_offsite_days != null ? s.retention_offsite_days : 30; |
|---|
| 1425 | + document.getElementById('sched-save-btn').disabled = false; |
|---|
| 1426 | + document.getElementById('sched-save-btn').textContent = 'Save'; |
|---|
| 1427 | + _startScheduleClock(); |
|---|
| 1428 | + modal.style.display = 'flex'; |
|---|
| 1429 | +} |
|---|
| 1430 | + |
|---|
| 1431 | +function closeScheduleModal() { |
|---|
| 1432 | + _stopScheduleClock(); |
|---|
| 1433 | + document.getElementById('schedule-modal').style.display = 'none'; |
|---|
| 1434 | +} |
|---|
| 1435 | + |
|---|
| 1436 | +function toggleOffsiteSection() { |
|---|
| 1437 | + const show = document.getElementById('sched-offsite').checked; |
|---|
| 1438 | + document.getElementById('sched-offsite-section').style.display = show ? '' : 'none'; |
|---|
| 1439 | +} |
|---|
| 1440 | + |
|---|
| 1441 | +async function saveSchedule() { |
|---|
| 1442 | + const project = document.getElementById('sched-project').value; |
|---|
| 1443 | + const btn = document.getElementById('sched-save-btn'); |
|---|
| 1444 | + btn.disabled = true; |
|---|
| 1445 | + btn.textContent = 'Saving...'; |
|---|
| 1446 | + |
|---|
| 1447 | + const envCheckboxes = document.querySelectorAll('input[name="sched-env"]:checked'); |
|---|
| 1448 | + const environments = Array.from(envCheckboxes).map(cb => cb.value); |
|---|
| 1449 | + const offsiteEnvCheckboxes = document.querySelectorAll('input[name="sched-offsite-env"]:checked'); |
|---|
| 1450 | + const offsite_envs = Array.from(offsiteEnvCheckboxes).map(cb => cb.value); |
|---|
| 1451 | + |
|---|
| 1452 | + const body = { |
|---|
| 1453 | + enabled: document.getElementById('sched-enabled').checked, |
|---|
| 1454 | + schedule: document.getElementById('sched-time').value, |
|---|
| 1455 | + environments: environments.length ? environments : null, |
|---|
| 1456 | + command: document.getElementById('sched-command').value || null, |
|---|
| 1457 | + offsite: document.getElementById('sched-offsite').checked, |
|---|
| 1458 | + offsite_envs: offsite_envs.length ? offsite_envs : null, |
|---|
| 1459 | + retention_local_days: parseInt(document.getElementById('sched-retention-local').value) || null, |
|---|
| 1460 | + retention_offsite_days: parseInt(document.getElementById('sched-retention-offsite').value) || null, |
|---|
| 1461 | + }; |
|---|
| 1462 | + |
|---|
| 1463 | + try { |
|---|
| 1464 | + await api('/api/schedule/' + encodeURIComponent(project), { |
|---|
| 1465 | + method: 'PUT', |
|---|
| 1466 | + headers: { 'Content-Type': 'application/json' }, |
|---|
| 1467 | + body: JSON.stringify(body), |
|---|
| 1468 | + }); |
|---|
| 1469 | + toast('Schedule updated for ' + project, 'success'); |
|---|
| 1470 | + closeScheduleModal(); |
|---|
| 1471 | + cachedSchedules = null; |
|---|
| 1472 | + renderSchedules(); |
|---|
| 1473 | + } catch (e) { |
|---|
| 1474 | + toast('Failed to save schedule: ' + e.message, 'error'); |
|---|
| 1475 | + btn.disabled = false; |
|---|
| 1476 | + btn.textContent = 'Save'; |
|---|
| 1477 | + } |
|---|
| 1478 | +} |
|---|
| 1479 | + |
|---|
| 1480 | +async function runBackupNow(project) { |
|---|
| 1481 | + if (!await showConfirmDialog(`Run backup now for ${project}?`, 'Run Backup')) return; |
|---|
| 1482 | + |
|---|
| 1483 | + opsCtx = { type: 'backup', project, fromEnv: null, toEnv: null }; |
|---|
| 1484 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 1485 | + |
|---|
| 1486 | + const title = document.getElementById('ops-modal-title'); |
|---|
| 1487 | + const info = document.getElementById('ops-modal-info'); |
|---|
| 1488 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1489 | + const dryRunRow = document.getElementById('ops-dry-run-row'); |
|---|
| 1490 | + const outputDiv = document.getElementById('ops-modal-output'); |
|---|
| 1491 | + const term = document.getElementById('ops-modal-terminal'); |
|---|
| 1492 | + |
|---|
| 1493 | + title.textContent = 'Backup: ' + project; |
|---|
| 1494 | + info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'; |
|---|
| 1495 | + if (dryRunRow) dryRunRow.style.display = 'none'; |
|---|
| 1496 | + startBtn.style.display = 'none'; |
|---|
| 1497 | + |
|---|
| 1498 | + outputDiv.style.display = 'block'; |
|---|
| 1499 | + term.textContent = 'Starting backup...\n'; |
|---|
| 1500 | + currentOpId = null; |
|---|
| 1501 | + _setProgressState('ops-progress-bar', 'running'); |
|---|
| 1502 | + |
|---|
| 1503 | + document.getElementById('ops-modal').style.display = 'flex'; |
|---|
| 1504 | + |
|---|
| 1505 | + const url = '/api/schedule/' + encodeURIComponent(project) + '/run?token=' + encodeURIComponent(getToken()); |
|---|
| 1506 | + const es = new EventSource(url); |
|---|
| 1507 | + opsEventSource = es; |
|---|
| 1508 | + |
|---|
| 1509 | + es.onmessage = function(e) { |
|---|
| 1510 | + try { |
|---|
| 1511 | + const d = JSON.parse(e.data); |
|---|
| 1512 | + if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } |
|---|
| 1513 | + if (d.done) { |
|---|
| 1514 | + es.close(); |
|---|
| 1515 | + opsEventSource = null; |
|---|
| 1516 | + _setProgressState('ops-progress-bar', d.success ? 'done' : 'error'); |
|---|
| 1517 | + if (d.cancelled) term.textContent += '\n--- Cancelled ---\n'; |
|---|
| 1518 | + else if (d.success) term.textContent += '\n--- Done ---\n'; |
|---|
| 1519 | + else term.textContent += '\n--- Failed ---\n'; |
|---|
| 1520 | + return; |
|---|
| 1521 | + } |
|---|
| 1522 | + if (d.line != null) { |
|---|
| 1523 | + term.textContent += d.line + '\n'; |
|---|
| 1524 | + term.scrollTop = term.scrollHeight; |
|---|
| 1525 | + } |
|---|
| 1526 | + } catch {} |
|---|
| 1527 | + }; |
|---|
| 1528 | + es.onerror = function() { es.close(); opsEventSource = null; _setProgressState('ops-progress-bar', 'error'); }; |
|---|
| 1529 | +} |
|---|
| 1530 | + |
|---|
| 1531 | +// --------------------------------------------------------------------------- |
|---|
| 1123 | 1532 | // Operations Page |
|---|
| 1124 | 1533 | // --------------------------------------------------------------------------- |
|---|
| 1125 | 1534 | async function renderOperations() { |
|---|
| .. | .. |
|---|
| 1148 | 1557 | for (const [name, cfg] of Object.entries(projects)) { |
|---|
| 1149 | 1558 | if (!cfg.promote || cfg.static || cfg.infrastructure) continue; |
|---|
| 1150 | 1559 | const pType = cfg.promote.type || 'unknown'; |
|---|
| 1151 | | - const envs = cfg.environments || []; |
|---|
| 1560 | + const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name); |
|---|
| 1152 | 1561 | const typeBadge = pType === 'git' |
|---|
| 1153 | 1562 | ? '<span class="badge badge-blue" style="font-size:0.6875rem;">git</span>' |
|---|
| 1154 | 1563 | : '<span class="badge badge-purple" style="font-size:0.6875rem;">rsync</span>'; |
|---|
| .. | .. |
|---|
| 1187 | 1596 | |
|---|
| 1188 | 1597 | for (const [name, cfg] of Object.entries(projects)) { |
|---|
| 1189 | 1598 | if (!cfg.has_cli || cfg.static || cfg.infrastructure) continue; |
|---|
| 1190 | | - const envs = cfg.environments || []; |
|---|
| 1599 | + const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name); |
|---|
| 1191 | 1600 | |
|---|
| 1192 | 1601 | h += '<div class="card">'; |
|---|
| 1193 | 1602 | h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>'; |
|---|
| .. | .. |
|---|
| 1215 | 1624 | |
|---|
| 1216 | 1625 | // Section: Container Lifecycle |
|---|
| 1217 | 1626 | h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.375rem;">Container Lifecycle</h2>'; |
|---|
| 1218 | | - h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Manage container state via Coolify API. ' |
|---|
| 1627 | + h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Manage container state via docker compose. ' |
|---|
| 1219 | 1628 | + '<span style="color:#6ee7b7;">Restart</span> is safe. ' |
|---|
| 1220 | | - + '<span style="color:#fbbf24;">Rebuild</span> refreshes the image. ' |
|---|
| 1221 | | - + '<span style="color:#f87171;">Recreate</span> wipes data (disaster recovery only).</p>'; |
|---|
| 1222 | | - h += '<div class="grid-auto" style="margin-bottom:2rem;">'; |
|---|
| 1629 | + + '<span style="color:#fbbf24;">Rebuild</span> refreshes the image.</p>'; |
|---|
| 1630 | + h += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;margin-bottom:2rem;">'; |
|---|
| 1223 | 1631 | |
|---|
| 1224 | 1632 | for (const [name, cfg] of Object.entries(projects)) { |
|---|
| 1225 | | - if (cfg.static || cfg.infrastructure || !cfg.has_coolify) continue; |
|---|
| 1226 | | - const envs = (cfg.environments || []).filter(e => e !== 'infra'); |
|---|
| 1633 | + if (cfg.type === 'static' || cfg.type === 'infrastructure') continue; |
|---|
| 1634 | + const envs = (cfg.environments || []).map(e => typeof e === 'string' ? e : e.name).filter(e => e !== 'infra'); |
|---|
| 1227 | 1635 | if (!envs.length) continue; |
|---|
| 1228 | 1636 | |
|---|
| 1229 | 1637 | h += '<div class="card">'; |
|---|
| .. | .. |
|---|
| 1231 | 1639 | h += '<div style="display:flex;flex-direction:column;gap:0.625rem;">'; |
|---|
| 1232 | 1640 | |
|---|
| 1233 | 1641 | for (const env of envs) { |
|---|
| 1234 | | - h += '<div style="display:flex;align-items:center;gap:0.5rem;">'; |
|---|
| 1642 | + h += '<div style="display:flex;align-items:center;gap:0.375rem;">'; |
|---|
| 1235 | 1643 | // Environment label |
|---|
| 1236 | | - h += '<span style="min-width:2.5rem;font-size:0.75rem;color:#9ca3af;font-weight:500;">' + esc(env) + '</span>'; |
|---|
| 1644 | + h += '<span style="min-width:2.25rem;font-size:0.75rem;color:#9ca3af;font-weight:500;">' + esc(env) + '</span>'; |
|---|
| 1237 | 1645 | // Restart (green) |
|---|
| 1238 | | - h += '<button class="btn btn-ghost btn-xs" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);" ' |
|---|
| 1646 | + 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;" ' |
|---|
| 1239 | 1647 | + 'onclick="openLifecycleModal('restart','' + esc(name) + '','' + esc(env) + '')">' |
|---|
| 1240 | 1648 | + 'Restart</button>'; |
|---|
| 1241 | 1649 | // Rebuild (yellow) |
|---|
| 1242 | | - h += '<button class="btn btn-ghost btn-xs" style="color:#fbbf24;border-color:rgba(251,191,36,0.3);" ' |
|---|
| 1650 | + 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;" ' |
|---|
| 1243 | 1651 | + 'onclick="openLifecycleModal('rebuild','' + esc(name) + '','' + esc(env) + '')">' |
|---|
| 1244 | 1652 | + 'Rebuild</button>'; |
|---|
| 1245 | | - // Recreate (red) |
|---|
| 1246 | | - h += '<button class="btn btn-ghost btn-xs" style="color:#f87171;border-color:rgba(248,113,113,0.3);" ' |
|---|
| 1247 | | - + 'onclick="openLifecycleModal('recreate','' + esc(name) + '','' + esc(env) + '')">' |
|---|
| 1248 | | - + 'Recreate</button>'; |
|---|
| 1653 | + // Backup (blue) |
|---|
| 1654 | + 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;" ' |
|---|
| 1655 | + + 'onclick="openLifecycleModal('backup','' + esc(name) + '','' + esc(env) + '')">' |
|---|
| 1656 | + + 'Backup</button>'; |
|---|
| 1657 | + // Restore (navigate to backups page) |
|---|
| 1658 | + 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;" ' |
|---|
| 1659 | + + 'onclick="currentPage='backups';backupDrillLevel=2;backupDrillProject='' + esc(name) + '';backupDrillEnv='' + esc(env) + '';cachedBackups=null;selectedBackups.clear();document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el=>el.classList.toggle('active',el.dataset.page==='backups'));renderPage();pushHash();">' |
|---|
| 1660 | + + 'Restore</button>'; |
|---|
| 1249 | 1661 | h += '</div>'; |
|---|
| 1250 | 1662 | } |
|---|
| 1251 | 1663 | |
|---|
| .. | .. |
|---|
| 1353 | 1765 | } |
|---|
| 1354 | 1766 | |
|---|
| 1355 | 1767 | // --------------------------------------------------------------------------- |
|---|
| 1356 | | -// Lifecycle Modal (Restart / Rebuild / Recreate) |
|---|
| 1768 | +// Lifecycle Modal (Restart / Rebuild / Backup) |
|---|
| 1357 | 1769 | // --------------------------------------------------------------------------- |
|---|
| 1358 | 1770 | function openLifecycleModal(action, project, env) { |
|---|
| 1359 | 1771 | opsCtx = { type: action, project, fromEnv: env, toEnv: null }; |
|---|
| .. | .. |
|---|
| 1385 | 1797 | + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1386 | 1798 | + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>' |
|---|
| 1387 | 1799 | + '<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;">' |
|---|
| 1388 | | - + 'Stops containers via Coolify, rebuilds the Docker image, then starts again. No data loss.</div>'; |
|---|
| 1800 | + + 'Runs <code>docker compose down</code>, rebuilds the image, then starts again. No data loss.</div>'; |
|---|
| 1389 | 1801 | startBtn.className = 'btn btn-sm'; |
|---|
| 1390 | 1802 | startBtn.style.cssText = 'background:#78350f;color:#fde68a;border:1px solid rgba(251,191,36,0.3);'; |
|---|
| 1391 | 1803 | startBtn.textContent = 'Rebuild'; |
|---|
| 1392 | 1804 | |
|---|
| 1393 | | - } else if (action === 'recreate') { |
|---|
| 1394 | | - title.textContent = 'Recreate Environment'; |
|---|
| 1805 | + } else if (action === 'backup') { |
|---|
| 1806 | + title.textContent = 'Create Backup'; |
|---|
| 1395 | 1807 | info.innerHTML = '' |
|---|
| 1396 | 1808 | + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1397 | 1809 | + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>' |
|---|
| 1398 | | - + '<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;">' |
|---|
| 1399 | | - + '<strong style="display:block;margin-bottom:0.375rem;">DESTRUCTIVE — Disaster Recovery Only</strong>' |
|---|
| 1400 | | - + 'Stops containers, wipes all data volumes, rebuilds image, starts fresh. ' |
|---|
| 1401 | | - + 'You must restore a backup afterwards.</div>' |
|---|
| 1402 | | - + '<div style="margin-top:0.875rem;">' |
|---|
| 1403 | | - + '<label style="font-size:0.8125rem;color:#9ca3af;display:block;margin-bottom:0.375rem;">Type the environment name to confirm:</label>' |
|---|
| 1404 | | - + '<input id="recreate-confirm-input" type="text" placeholder="' + esc(env) + '" ' |
|---|
| 1405 | | - + '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;" ' |
|---|
| 1406 | | - + 'oninput="checkRecreateConfirm(\'' + esc(env) + '\')">' |
|---|
| 1407 | | - + '</div>'; |
|---|
| 1408 | | - startBtn.className = 'btn btn-danger btn-sm'; |
|---|
| 1810 | + + '<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;">' |
|---|
| 1811 | + + 'Creates a backup of the database and uploads for this environment.</div>'; |
|---|
| 1812 | + startBtn.className = 'btn btn-primary btn-sm'; |
|---|
| 1409 | 1813 | startBtn.style.cssText = ''; |
|---|
| 1410 | | - startBtn.textContent = 'Recreate'; |
|---|
| 1411 | | - startBtn.disabled = true; // enabled after typing env name |
|---|
| 1814 | + startBtn.textContent = 'Create Backup'; |
|---|
| 1412 | 1815 | } |
|---|
| 1413 | 1816 | |
|---|
| 1414 | 1817 | document.getElementById('ops-modal-output').style.display = 'none'; |
|---|
| 1415 | 1818 | document.getElementById('ops-modal-terminal').textContent = ''; |
|---|
| 1416 | 1819 | |
|---|
| 1417 | 1820 | document.getElementById('ops-modal').style.display = 'flex'; |
|---|
| 1418 | | - if (action === 'recreate') { |
|---|
| 1419 | | - setTimeout(() => { |
|---|
| 1420 | | - const inp = document.getElementById('recreate-confirm-input'); |
|---|
| 1421 | | - if (inp) inp.focus(); |
|---|
| 1422 | | - }, 100); |
|---|
| 1423 | | - } |
|---|
| 1424 | | -} |
|---|
| 1425 | | - |
|---|
| 1426 | | -function checkRecreateConfirm(expectedEnv) { |
|---|
| 1427 | | - const inp = document.getElementById('recreate-confirm-input'); |
|---|
| 1428 | | - const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1429 | | - if (!inp || !startBtn) return; |
|---|
| 1430 | | - startBtn.disabled = inp.value.trim() !== expectedEnv; |
|---|
| 1431 | 1821 | } |
|---|
| 1432 | 1822 | |
|---|
| 1433 | 1823 | function closeOpsModal() { |
|---|
| 1824 | + if (currentOpId && opsEventSource) { |
|---|
| 1825 | + fetch('/api/operations/' + currentOpId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + getToken() } }).catch(() => {}); |
|---|
| 1826 | + } |
|---|
| 1434 | 1827 | if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 1828 | + currentOpId = null; |
|---|
| 1829 | + _setProgressState('ops-progress-bar', 'hidden'); |
|---|
| 1435 | 1830 | document.getElementById('ops-modal').style.display = 'none'; |
|---|
| 1831 | + // Refresh backup list if we just ran a backup or upload |
|---|
| 1832 | + if ((opsCtx.type === 'backup' || opsCtx.type === 'upload') && currentPage === 'backups') { |
|---|
| 1833 | + cachedBackups = null; |
|---|
| 1834 | + renderBackups(); |
|---|
| 1835 | + } |
|---|
| 1436 | 1836 | opsCtx = { type: null, project: null, fromEnv: null, toEnv: null }; |
|---|
| 1437 | 1837 | // Restore dry-run row visibility for promote/sync operations |
|---|
| 1438 | 1838 | const dryRunRow = document.getElementById('ops-dry-run-row'); |
|---|
| 1439 | 1839 | if (dryRunRow) dryRunRow.style.display = ''; |
|---|
| 1440 | | - // Reset start button style |
|---|
| 1840 | + // Reset start button style and visibility |
|---|
| 1441 | 1841 | const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1442 | | - if (startBtn) { startBtn.style.cssText = ''; startBtn.disabled = false; } |
|---|
| 1842 | + if (startBtn) { startBtn.style.cssText = ''; startBtn.style.display = ''; startBtn.disabled = false; } |
|---|
| 1443 | 1843 | } |
|---|
| 1444 | 1844 | |
|---|
| 1445 | 1845 | function _btnLabelForType(type) { |
|---|
| .. | .. |
|---|
| 1447 | 1847 | if (type === 'sync') return 'Sync'; |
|---|
| 1448 | 1848 | if (type === 'restart') return 'Restart'; |
|---|
| 1449 | 1849 | if (type === 'rebuild') return 'Rebuild'; |
|---|
| 1450 | | - if (type === 'recreate') return 'Recreate'; |
|---|
| 1850 | + if (type === 'backup') return 'Create Backup'; |
|---|
| 1451 | 1851 | return 'Run'; |
|---|
| 1452 | 1852 | } |
|---|
| 1453 | 1853 | |
|---|
| .. | .. |
|---|
| 1461 | 1861 | const term = document.getElementById('ops-modal-terminal'); |
|---|
| 1462 | 1862 | |
|---|
| 1463 | 1863 | outputDiv.style.display = 'block'; |
|---|
| 1864 | + // Remove leftover banners from previous operations |
|---|
| 1865 | + outputDiv.querySelectorAll('div').forEach(el => { if (el !== term) el.remove(); }); |
|---|
| 1464 | 1866 | term.textContent = 'Starting...\n'; |
|---|
| 1465 | 1867 | startBtn.disabled = true; |
|---|
| 1466 | 1868 | startBtn.textContent = 'Running...'; |
|---|
| .. | .. |
|---|
| 1470 | 1872 | url = '/api/promote/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '/' + encodeURIComponent(toEnv) + '?dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken()); |
|---|
| 1471 | 1873 | } else if (type === 'sync') { |
|---|
| 1472 | 1874 | url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken()); |
|---|
| 1473 | | - } else if (type === 'restart' || type === 'rebuild' || type === 'recreate') { |
|---|
| 1474 | | - // All three lifecycle ops go through /api/rebuild/{project}/{env}?action=... |
|---|
| 1875 | + } else if (type === 'restart' || type === 'rebuild') { |
|---|
| 1475 | 1876 | url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) |
|---|
| 1476 | 1877 | + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken()); |
|---|
| 1878 | + } else if (type === 'backup') { |
|---|
| 1879 | + url = '/api/backups/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) |
|---|
| 1880 | + + '?token=' + encodeURIComponent(getToken()); |
|---|
| 1477 | 1881 | } |
|---|
| 1478 | 1882 | |
|---|
| 1883 | + currentOpId = null; |
|---|
| 1884 | + _setProgressState('ops-progress-bar', 'running'); |
|---|
| 1479 | 1885 | const es = new EventSource(url); |
|---|
| 1480 | 1886 | opsEventSource = es; |
|---|
| 1481 | 1887 | let opDone = false; |
|---|
| .. | .. |
|---|
| 1483 | 1889 | es.onmessage = function(e) { |
|---|
| 1484 | 1890 | try { |
|---|
| 1485 | 1891 | const d = JSON.parse(e.data); |
|---|
| 1892 | + if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } |
|---|
| 1486 | 1893 | if (d.done) { |
|---|
| 1487 | 1894 | opDone = true; |
|---|
| 1488 | 1895 | es.close(); |
|---|
| 1489 | 1896 | opsEventSource = null; |
|---|
| 1490 | | - const msg = d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n'; |
|---|
| 1897 | + currentOpId = null; |
|---|
| 1898 | + const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n'; |
|---|
| 1491 | 1899 | term.textContent += msg; |
|---|
| 1492 | 1900 | term.scrollTop = term.scrollHeight; |
|---|
| 1493 | | - toast(d.success ? 'Operation completed' : 'Operation failed', d.success ? 'success' : 'error'); |
|---|
| 1901 | + const toastMsg = d.cancelled ? 'Operation cancelled' : d.success ? 'Operation completed' : 'Operation failed'; |
|---|
| 1902 | + toast(toastMsg, d.success ? 'success' : d.cancelled ? 'warning' : 'error'); |
|---|
| 1903 | + _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); |
|---|
| 1494 | 1904 | startBtn.disabled = false; |
|---|
| 1495 | 1905 | startBtn.textContent = _btnLabelForType(type); |
|---|
| 1496 | 1906 | |
|---|
| 1497 | | - // Show "Go to Backups" banner after recreate (or legacy rebuild) |
|---|
| 1498 | | - const showBackupBanner = (type === 'recreate') && d.success && d.project && d.env; |
|---|
| 1499 | | - if (showBackupBanner) { |
|---|
| 1500 | | - const restoreProject = d.project; |
|---|
| 1501 | | - const restoreEnv = d.env; |
|---|
| 1502 | | - const banner = document.createElement('div'); |
|---|
| 1503 | | - 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;'; |
|---|
| 1504 | | - banner.innerHTML = '<span style="color:#6ee7b7;font-size:0.8125rem;flex:1;">Environment recreated. Next step: restore a backup.</span>' |
|---|
| 1505 | | - + '<button class="btn btn-ghost btn-sm" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);white-space:nowrap;" ' |
|---|
| 1506 | | - + '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();">' |
|---|
| 1507 | | - + 'Go to Backups →</button>'; |
|---|
| 1508 | | - outputDiv.appendChild(banner); |
|---|
| 1907 | + // After a successful backup, invalidate cache so backups page refreshes |
|---|
| 1908 | + if (type === 'backup' && d.success) { |
|---|
| 1909 | + cachedBackups = null; |
|---|
| 1509 | 1910 | } |
|---|
| 1510 | 1911 | |
|---|
| 1511 | 1912 | return; |
|---|
| .. | .. |
|---|
| 1520 | 1921 | es.onerror = function() { |
|---|
| 1521 | 1922 | es.close(); |
|---|
| 1522 | 1923 | opsEventSource = null; |
|---|
| 1924 | + currentOpId = null; |
|---|
| 1523 | 1925 | if (opDone) return; |
|---|
| 1524 | 1926 | term.textContent += '\n--- Connection lost ---\n'; |
|---|
| 1525 | 1927 | toast('Connection lost', 'error'); |
|---|
| 1928 | + _setProgressState('ops-progress-bar', 'fail'); |
|---|
| 1526 | 1929 | startBtn.disabled = false; |
|---|
| 1527 | 1930 | startBtn.textContent = _btnLabelForType(type); |
|---|
| 1528 | 1931 | }; |
|---|
| .. | .. |
|---|
| 1532 | 1935 | // Service Actions |
|---|
| 1533 | 1936 | // --------------------------------------------------------------------------- |
|---|
| 1534 | 1937 | async function restartService(project, env, service) { |
|---|
| 1535 | | - if (!confirm(`Restart ${service} in ${project}/${env}?`)) return; |
|---|
| 1938 | + if (!await showConfirmDialog(`Restart ${service} in ${project}/${env}?`, 'Restart')) return; |
|---|
| 1536 | 1939 | toast('Restarting ' + service + '...', 'info'); |
|---|
| 1537 | 1940 | try { |
|---|
| 1538 | 1941 | const r = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' }); |
|---|
| .. | .. |
|---|
| 1565 | 1968 | } |
|---|
| 1566 | 1969 | |
|---|
| 1567 | 1970 | async function createBackup(project, env) { |
|---|
| 1568 | | - if (!confirm(`Create backup for ${project}/${env}?`)) return; |
|---|
| 1569 | | - toast('Creating backup...', 'info'); |
|---|
| 1570 | | - try { |
|---|
| 1571 | | - await api(`/api/backups/${project}/${env}`, { method: 'POST' }); |
|---|
| 1572 | | - toast('Backup created for ' + project + '/' + env, 'success'); |
|---|
| 1573 | | - cachedBackups = null; |
|---|
| 1574 | | - if (currentPage === 'backups') renderBackups(); |
|---|
| 1575 | | - } catch (e) { toast('Backup failed: ' + e.message, 'error'); } |
|---|
| 1971 | + if (!await showConfirmDialog(`Create backup for ${project}/${env}?`, 'Create Backup')) return; |
|---|
| 1972 | + |
|---|
| 1973 | + // Open the ops modal with streaming output |
|---|
| 1974 | + opsCtx = { type: 'backup', project, fromEnv: env, toEnv: null }; |
|---|
| 1975 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 1976 | + |
|---|
| 1977 | + const title = document.getElementById('ops-modal-title'); |
|---|
| 1978 | + const info = document.getElementById('ops-modal-info'); |
|---|
| 1979 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1980 | + const dryRunRow = document.getElementById('ops-dry-run-row'); |
|---|
| 1981 | + const outputDiv = document.getElementById('ops-modal-output'); |
|---|
| 1982 | + const term = document.getElementById('ops-modal-terminal'); |
|---|
| 1983 | + |
|---|
| 1984 | + title.textContent = 'Create Backup'; |
|---|
| 1985 | + info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1986 | + + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'; |
|---|
| 1987 | + if (dryRunRow) dryRunRow.style.display = 'none'; |
|---|
| 1988 | + startBtn.style.display = 'none'; |
|---|
| 1989 | + |
|---|
| 1990 | + outputDiv.style.display = 'block'; |
|---|
| 1991 | + term.textContent = 'Starting backup...\n'; |
|---|
| 1992 | + currentOpId = null; |
|---|
| 1993 | + _setProgressState('ops-progress-bar', 'running'); |
|---|
| 1994 | + |
|---|
| 1995 | + document.getElementById('ops-modal').style.display = 'flex'; |
|---|
| 1996 | + |
|---|
| 1997 | + const url = '/api/backups/stream/' + encodeURIComponent(project) + '/' + encodeURIComponent(env) + '?token=' + encodeURIComponent(getToken()); |
|---|
| 1998 | + const es = new EventSource(url); |
|---|
| 1999 | + opsEventSource = es; |
|---|
| 2000 | + |
|---|
| 2001 | + es.onmessage = function(e) { |
|---|
| 2002 | + try { |
|---|
| 2003 | + const d = JSON.parse(e.data); |
|---|
| 2004 | + if (d.op_id && !currentOpId) { currentOpId = d.op_id; return; } |
|---|
| 2005 | + if (d.done) { |
|---|
| 2006 | + es.close(); |
|---|
| 2007 | + opsEventSource = null; |
|---|
| 2008 | + currentOpId = null; |
|---|
| 2009 | + const msg = d.cancelled ? '\n--- Cancelled ---\n' : d.success ? '\n--- Backup complete ---\n' : '\n--- Backup FAILED ---\n'; |
|---|
| 2010 | + term.textContent += msg; |
|---|
| 2011 | + term.scrollTop = term.scrollHeight; |
|---|
| 2012 | + toast(d.cancelled ? 'Backup cancelled' : d.success ? 'Backup created for ' + project + '/' + env : 'Backup failed', d.success ? 'success' : d.cancelled ? 'warning' : 'error'); |
|---|
| 2013 | + _setProgressState('ops-progress-bar', d.success ? 'ok' : 'fail'); |
|---|
| 2014 | + cachedBackups = null; |
|---|
| 2015 | + return; |
|---|
| 2016 | + } |
|---|
| 2017 | + if (d.line) { |
|---|
| 2018 | + term.textContent += d.line + '\n'; |
|---|
| 2019 | + term.scrollTop = term.scrollHeight; |
|---|
| 2020 | + } |
|---|
| 2021 | + } catch (_) {} |
|---|
| 2022 | + }; |
|---|
| 2023 | + |
|---|
| 2024 | + es.onerror = function() { |
|---|
| 2025 | + es.close(); |
|---|
| 2026 | + opsEventSource = null; |
|---|
| 2027 | + currentOpId = null; |
|---|
| 2028 | + term.textContent += '\n--- Connection lost ---\n'; |
|---|
| 2029 | + toast('Connection lost', 'error'); |
|---|
| 2030 | + _setProgressState('ops-progress-bar', 'fail'); |
|---|
| 2031 | + }; |
|---|
| 1576 | 2032 | } |
|---|
| 1577 | 2033 | |
|---|
| 1578 | 2034 | async function deleteBackup(project, env, name, hasLocal, hasOffsite) { |
|---|
| .. | .. |
|---|
| 1586 | 2042 | target = 'offsite'; |
|---|
| 1587 | 2043 | } |
|---|
| 1588 | 2044 | const label = target === 'both' ? 'local + offsite' : target; |
|---|
| 1589 | | - if (!confirm(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`)) return; |
|---|
| 2045 | + if (!await showConfirmDialog(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`, 'Delete', true)) return; |
|---|
| 1590 | 2046 | toast('Deleting backup (' + label + ')...', 'info'); |
|---|
| 1591 | 2047 | try { |
|---|
| 1592 | 2048 | await api(`/api/backups/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' }); |
|---|
| .. | .. |
|---|
| 1594 | 2050 | cachedBackups = null; |
|---|
| 1595 | 2051 | if (currentPage === 'backups') renderBackups(); |
|---|
| 1596 | 2052 | } catch (e) { toast('Delete failed: ' + e.message, 'error'); } |
|---|
| 2053 | +} |
|---|
| 2054 | + |
|---|
| 2055 | +function showConfirmDialog(message, confirmLabel = 'Confirm', isDanger = false) { |
|---|
| 2056 | + return new Promise(resolve => { |
|---|
| 2057 | + const overlay = document.createElement('div'); |
|---|
| 2058 | + 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;'; |
|---|
| 2059 | + const box = document.createElement('div'); |
|---|
| 2060 | + 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;'; |
|---|
| 2061 | + const btnClass = isDanger ? 'btn btn-danger' : 'btn btn-primary'; |
|---|
| 2062 | + box.innerHTML = ` |
|---|
| 2063 | + <p style="margin:0 0 1.25rem;font-size:0.9rem;color:#d1d5db;white-space:pre-line;">${esc(message)}</p> |
|---|
| 2064 | + <div style="display:flex;gap:0.75rem;justify-content:flex-end;"> |
|---|
| 2065 | + <button class="btn btn-ghost" data-action="cancel">Cancel</button> |
|---|
| 2066 | + <button class="${btnClass}" data-action="confirm">${esc(confirmLabel)}</button> |
|---|
| 2067 | + </div>`; |
|---|
| 2068 | + overlay.appendChild(box); |
|---|
| 2069 | + document.body.appendChild(overlay); |
|---|
| 2070 | + box.addEventListener('click', e => { |
|---|
| 2071 | + const btn = e.target.closest('[data-action]'); |
|---|
| 2072 | + if (!btn) return; |
|---|
| 2073 | + document.body.removeChild(overlay); |
|---|
| 2074 | + resolve(btn.dataset.action === 'confirm'); |
|---|
| 2075 | + }); |
|---|
| 2076 | + overlay.addEventListener('click', e => { |
|---|
| 2077 | + if (e.target === overlay) { document.body.removeChild(overlay); resolve(false); } |
|---|
| 2078 | + }); |
|---|
| 2079 | + const onKey = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); document.body.removeChild(overlay); resolve(false); } }; |
|---|
| 2080 | + document.addEventListener('keydown', onKey); |
|---|
| 2081 | + }); |
|---|
| 1597 | 2082 | } |
|---|
| 1598 | 2083 | |
|---|
| 1599 | 2084 | function showDeleteTargetDialog(name) { |
|---|
| .. | .. |
|---|
| 1658 | 2143 | } else { |
|---|
| 1659 | 2144 | hash = '/backups'; |
|---|
| 1660 | 2145 | } |
|---|
| 2146 | + } else if (currentPage === 'schedules') { |
|---|
| 2147 | + hash = '/schedules'; |
|---|
| 1661 | 2148 | } else if (currentPage === 'system') { |
|---|
| 1662 | 2149 | hash = '/system'; |
|---|
| 1663 | 2150 | } else if (currentPage === 'operations') { |
|---|
| .. | .. |
|---|
| 1708 | 2195 | document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => |
|---|
| 1709 | 2196 | el.classList.toggle('active', el.dataset.page === 'backups')); |
|---|
| 1710 | 2197 | renderPage(); |
|---|
| 2198 | + } else if (page === 'schedules') { |
|---|
| 2199 | + showPage('schedules'); |
|---|
| 1711 | 2200 | } else if (page === 'system') { |
|---|
| 1712 | 2201 | showPage('system'); |
|---|
| 1713 | 2202 | } else if (page === 'operations') { |
|---|