| .. | .. |
|---|
| 1 | 1 | 'use strict'; |
|---|
| 2 | | -const APP_VERSION = 'v4-20260222'; |
|---|
| 2 | +const APP_VERSION = 'v13-20260222'; |
|---|
| 3 | 3 | |
|---|
| 4 | 4 | // ============================================================ |
|---|
| 5 | | -// OPS Dashboard — Vanilla JS Application (v4) |
|---|
| 5 | +// OPS Dashboard — Vanilla JS Application (v6) |
|---|
| 6 | 6 | // ============================================================ |
|---|
| 7 | 7 | |
|---|
| 8 | 8 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 19 | 19 | let refreshTimer = null; |
|---|
| 20 | 20 | const REFRESH_INTERVAL = 30000; |
|---|
| 21 | 21 | |
|---|
| 22 | | -// Backup filter state |
|---|
| 23 | | -let backupFilterProject = null; // null = all |
|---|
| 24 | | -let backupFilterEnv = null; // null = all |
|---|
| 22 | +// Backup drill-down state |
|---|
| 23 | +let backupDrillLevel = 0; // 0=projects, 1=environments, 2=backup list |
|---|
| 24 | +let backupDrillProject = null; |
|---|
| 25 | +let backupDrillEnv = null; |
|---|
| 26 | +let cachedBackups = null; // merged array, fetched once per page visit |
|---|
| 25 | 27 | |
|---|
| 26 | 28 | // Log modal state |
|---|
| 27 | 29 | let logCtx = { project: null, env: null, service: null }; |
|---|
| 30 | + |
|---|
| 31 | +// Restore modal state |
|---|
| 32 | +let restoreCtx = { project: null, env: null, source: null }; |
|---|
| 33 | +let restoreEventSource = null; |
|---|
| 34 | + |
|---|
| 35 | +// Backup multi-select state |
|---|
| 36 | +let selectedBackups = new Set(); |
|---|
| 37 | +// Operations state |
|---|
| 38 | +let opsEventSource = null; |
|---|
| 39 | +let opsCtx = { type: null, project: null, fromEnv: null, toEnv: null }; |
|---|
| 40 | +let cachedRegistry = null; |
|---|
| 28 | 41 | |
|---|
| 29 | 42 | // --------------------------------------------------------------------------- |
|---|
| 30 | 43 | // Helpers |
|---|
| .. | .. |
|---|
| 109 | 122 | document.getElementById('login-overlay').style.display = 'none'; |
|---|
| 110 | 123 | document.getElementById('app').style.display = 'flex'; |
|---|
| 111 | 124 | const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION; |
|---|
| 112 | | - showPage('dashboard'); |
|---|
| 125 | + navigateToHash(); |
|---|
| 113 | 126 | startAutoRefresh(); |
|---|
| 114 | 127 | }) |
|---|
| 115 | 128 | .catch(() => { err.textContent = 'Invalid token.'; err.style.display = 'block'; }); |
|---|
| .. | .. |
|---|
| 161 | 174 | function showPage(page) { |
|---|
| 162 | 175 | currentPage = page; |
|---|
| 163 | 176 | drillLevel = 0; drillProject = null; drillEnv = null; |
|---|
| 177 | + backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; |
|---|
| 178 | + cachedBackups = null; |
|---|
| 164 | 179 | if (page !== 'dashboard') { viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; } |
|---|
| 165 | 180 | |
|---|
| 166 | 181 | document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => |
|---|
| .. | .. |
|---|
| 169 | 184 | document.getElementById('mobile-overlay').classList.remove('open'); |
|---|
| 170 | 185 | |
|---|
| 171 | 186 | renderPage(); |
|---|
| 187 | + pushHash(); |
|---|
| 172 | 188 | } |
|---|
| 173 | 189 | |
|---|
| 174 | 190 | function renderPage() { |
|---|
| .. | .. |
|---|
| 180 | 196 | case 'dashboard': renderDashboard(); break; |
|---|
| 181 | 197 | case 'backups': renderBackups(); break; |
|---|
| 182 | 198 | case 'system': renderSystem(); break; |
|---|
| 183 | | - case 'restore': renderRestore(); break; |
|---|
| 199 | + case 'operations': renderOperations(); break; |
|---|
| 184 | 200 | default: renderDashboard(); |
|---|
| 185 | 201 | } |
|---|
| 186 | 202 | } |
|---|
| 187 | 203 | |
|---|
| 188 | 204 | function refreshCurrentPage() { |
|---|
| 189 | 205 | showSpin(); |
|---|
| 206 | + cachedBackups = null; |
|---|
| 190 | 207 | fetchStatus().then(() => renderPage()).catch(e => toast('Refresh failed: ' + e.message, 'error')).finally(hideSpin); |
|---|
| 191 | 208 | } |
|---|
| 192 | 209 | |
|---|
| .. | .. |
|---|
| 198 | 215 | if (mode === 'cards') { tableFilter = null; tableFilterLabel = ''; } |
|---|
| 199 | 216 | updateViewToggle(); |
|---|
| 200 | 217 | renderDashboard(); |
|---|
| 218 | + pushHash(); |
|---|
| 201 | 219 | } |
|---|
| 202 | 220 | |
|---|
| 203 | 221 | function setTableFilter(filter, label) { |
|---|
| .. | .. |
|---|
| 206 | 224 | viewMode = 'table'; |
|---|
| 207 | 225 | updateViewToggle(); |
|---|
| 208 | 226 | renderDashboard(); |
|---|
| 227 | + pushHash(); |
|---|
| 209 | 228 | } |
|---|
| 210 | 229 | |
|---|
| 211 | 230 | function clearFilter() { |
|---|
| .. | .. |
|---|
| 261 | 280 | } else if (drillLevel === 2) { |
|---|
| 262 | 281 | h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + esc(drillProject) + '</a><span class="sep">/</span><span class="current">' + esc(drillEnv) + '</span>'; |
|---|
| 263 | 282 | } |
|---|
| 264 | | - } else { |
|---|
| 265 | | - const names = { backups: 'Backups', system: 'System', restore: 'Restore' }; |
|---|
| 266 | | - h = '<span class="current">' + (names[currentPage] || currentPage) + '</span>'; |
|---|
| 283 | + } else if (currentPage === 'backups') { |
|---|
| 284 | + if (backupDrillLevel === 0) { |
|---|
| 285 | + h = '<span class="current">Backups</span>'; |
|---|
| 286 | + } else if (backupDrillLevel === 1) { |
|---|
| 287 | + h = '<a onclick="backupDrillBack(0)">Backups</a><span class="sep">/</span><span class="current">' + esc(backupDrillProject) + '</span>'; |
|---|
| 288 | + } else if (backupDrillLevel === 2) { |
|---|
| 289 | + 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 | + } |
|---|
| 291 | + } else if (currentPage === 'system') { |
|---|
| 292 | + h = '<span class="current">System</span>'; |
|---|
| 293 | + } else if (currentPage === 'operations') { |
|---|
| 294 | + h = '<span class="current">Operations</span>'; |
|---|
| 267 | 295 | } |
|---|
| 268 | 296 | bc.innerHTML = h; |
|---|
| 269 | 297 | } |
|---|
| .. | .. |
|---|
| 272 | 300 | if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; } |
|---|
| 273 | 301 | else if (level === 1) { drillLevel = 1; drillEnv = null; } |
|---|
| 274 | 302 | renderDashboard(); |
|---|
| 303 | + pushHash(); |
|---|
| 275 | 304 | } |
|---|
| 276 | 305 | |
|---|
| 277 | 306 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 357 | 386 | c.innerHTML = h; |
|---|
| 358 | 387 | } |
|---|
| 359 | 388 | |
|---|
| 360 | | -function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); } |
|---|
| 361 | | -function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); } |
|---|
| 389 | +function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); pushHash(); } |
|---|
| 390 | +function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); pushHash(); } |
|---|
| 362 | 391 | |
|---|
| 363 | 392 | // --------------------------------------------------------------------------- |
|---|
| 364 | 393 | // Dashboard — Table View |
|---|
| .. | .. |
|---|
| 463 | 492 | } |
|---|
| 464 | 493 | |
|---|
| 465 | 494 | // --------------------------------------------------------------------------- |
|---|
| 466 | | -// Backups |
|---|
| 495 | +// Backups — helpers |
|---|
| 467 | 496 | // --------------------------------------------------------------------------- |
|---|
| 468 | 497 | function fmtBackupDate(raw) { |
|---|
| 469 | 498 | if (!raw) return '\u2014'; |
|---|
| .. | .. |
|---|
| 474 | 503 | return raw; |
|---|
| 475 | 504 | } |
|---|
| 476 | 505 | |
|---|
| 506 | +// Parse YYYYMMDD_HHMMSS -> { dateKey: 'YYYY-MM-DD', timeStr: 'HH:MM' } |
|---|
| 507 | +function parseBackupDate(raw) { |
|---|
| 508 | + if (!raw) return { dateKey: '', timeStr: '' }; |
|---|
| 509 | + const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})/); |
|---|
| 510 | + if (m) return { dateKey: `${m[1]}-${m[2]}-${m[3]}`, timeStr: `${m[4]}:${m[5]}` }; |
|---|
| 511 | + return { dateKey: raw, timeStr: '' }; |
|---|
| 512 | +} |
|---|
| 513 | + |
|---|
| 514 | +// Format a YYYY-MM-DD key into a friendly group header label |
|---|
| 515 | +function fmtGroupHeader(dateKey) { |
|---|
| 516 | + if (!dateKey) return 'Unknown Date'; |
|---|
| 517 | + const d = new Date(dateKey + 'T00:00:00'); |
|---|
| 518 | + const today = new Date(); today.setHours(0, 0, 0, 0); |
|---|
| 519 | + const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); |
|---|
| 520 | + const targetDay = new Date(dateKey + 'T00:00:00'); targetDay.setHours(0, 0, 0, 0); |
|---|
| 521 | + |
|---|
| 522 | + const longFmt = d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' }); |
|---|
| 523 | + |
|---|
| 524 | + if (targetDay.getTime() === today.getTime()) return 'Today \u2014 ' + longFmt; |
|---|
| 525 | + if (targetDay.getTime() === yesterday.getTime()) return 'Yesterday \u2014 ' + longFmt; |
|---|
| 526 | + return longFmt; |
|---|
| 527 | +} |
|---|
| 528 | + |
|---|
| 529 | +// Toggle a date group open/closed |
|---|
| 530 | +function toggleDateGroup(dateKey) { |
|---|
| 531 | + const body = document.getElementById('dg-body-' + dateKey); |
|---|
| 532 | + const chevron = document.getElementById('dg-chevron-' + dateKey); |
|---|
| 533 | + if (!body) return; |
|---|
| 534 | + const isOpen = body.classList.contains('open'); |
|---|
| 535 | + body.classList.toggle('open', !isOpen); |
|---|
| 536 | + if (chevron) chevron.classList.toggle('open', !isOpen); |
|---|
| 537 | +} |
|---|
| 538 | + |
|---|
| 539 | +// --------------------------------------------------------------------------- |
|---|
| 540 | +// Backups — merge helper (dedup local+offsite by filename) |
|---|
| 541 | +// --------------------------------------------------------------------------- |
|---|
| 542 | +function mergeBackups(local, offsite) { |
|---|
| 543 | + const byName = new Map(); |
|---|
| 544 | + |
|---|
| 545 | + for (const b of local) { |
|---|
| 546 | + const name = b.name || b.file || ''; |
|---|
| 547 | + const key = name || (b.project + '/' + b.env + '/' + (b.date || b.timestamp)); |
|---|
| 548 | + byName.set(key, { |
|---|
| 549 | + project: b.project || '', |
|---|
| 550 | + env: b.env || b.environment || '', |
|---|
| 551 | + name: name, |
|---|
| 552 | + date: b.date || b.timestamp || '', |
|---|
| 553 | + size_human: b.size_human || b.size || '', |
|---|
| 554 | + size_bytes: Number(b.size || 0), |
|---|
| 555 | + hasLocal: true, |
|---|
| 556 | + hasOffsite: false, |
|---|
| 557 | + }); |
|---|
| 558 | + } |
|---|
| 559 | + |
|---|
| 560 | + for (const b of offsite) { |
|---|
| 561 | + const name = b.name || ''; |
|---|
| 562 | + const key = name || (b.project + '/' + b.env + '/' + (b.date || '')); |
|---|
| 563 | + if (byName.has(key)) { |
|---|
| 564 | + byName.get(key).hasOffsite = true; |
|---|
| 565 | + } else { |
|---|
| 566 | + byName.set(key, { |
|---|
| 567 | + project: b.project || '', |
|---|
| 568 | + env: b.env || b.environment || '', |
|---|
| 569 | + name: name, |
|---|
| 570 | + date: b.date || '', |
|---|
| 571 | + size_human: b.size || '', |
|---|
| 572 | + size_bytes: Number(b.size_bytes || 0), |
|---|
| 573 | + hasLocal: false, |
|---|
| 574 | + hasOffsite: true, |
|---|
| 575 | + }); |
|---|
| 576 | + } |
|---|
| 577 | + } |
|---|
| 578 | + |
|---|
| 579 | + return Array.from(byName.values()); |
|---|
| 580 | +} |
|---|
| 581 | + |
|---|
| 582 | +// --------------------------------------------------------------------------- |
|---|
| 583 | +// Backups — main render (v7: drill-down) |
|---|
| 584 | +// --------------------------------------------------------------------------- |
|---|
| 477 | 585 | async function renderBackups() { |
|---|
| 478 | 586 | updateBreadcrumbs(); |
|---|
| 479 | 587 | const c = document.getElementById('page-content'); |
|---|
| 480 | 588 | try { |
|---|
| 481 | | - const [local, offsite] = await Promise.all([ |
|---|
| 482 | | - api('/api/backups/'), |
|---|
| 483 | | - api('/api/backups/offsite').catch(() => []), |
|---|
| 484 | | - ]); |
|---|
| 485 | | - |
|---|
| 486 | | - // Apply filters |
|---|
| 487 | | - const filteredLocal = local.filter(b => { |
|---|
| 488 | | - if (backupFilterProject && b.project !== backupFilterProject) return false; |
|---|
| 489 | | - if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false; |
|---|
| 490 | | - return true; |
|---|
| 491 | | - }); |
|---|
| 492 | | - const filteredOffsite = offsite.filter(b => { |
|---|
| 493 | | - if (backupFilterProject && b.project !== backupFilterProject) return false; |
|---|
| 494 | | - if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false; |
|---|
| 495 | | - return true; |
|---|
| 496 | | - }); |
|---|
| 497 | | - |
|---|
| 498 | | - let h = '<div class="page-enter">'; |
|---|
| 499 | | - |
|---|
| 500 | | - // Quick backup buttons |
|---|
| 501 | | - h += '<div style="margin-bottom:1.5rem;">'; |
|---|
| 502 | | - h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>'; |
|---|
| 503 | | - h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">'; |
|---|
| 504 | | - for (const p of ['mdf', 'seriousletter']) { |
|---|
| 505 | | - for (const e of ['dev', 'int', 'prod']) { |
|---|
| 506 | | - h += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${p}','${e}')">${p}/${e}</button>`; |
|---|
| 507 | | - } |
|---|
| 508 | | - } |
|---|
| 509 | | - h += '</div></div>'; |
|---|
| 510 | | - |
|---|
| 511 | | - // Filter bar |
|---|
| 512 | | - const activeStyle = 'background:rgba(59,130,246,0.2);color:#60a5fa;'; |
|---|
| 513 | | - h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;margin-bottom:1.5rem;padding:0.75rem 1rem;background:#1f2937;border-radius:0.5rem;">'; |
|---|
| 514 | | - h += '<span style="color:#9ca3af;font-size:0.875rem;margin-right:0.25rem;">Project:</span>'; |
|---|
| 515 | | - h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === null ? activeStyle : ''}" onclick="setBackupFilter('project',null)">All</button>`; |
|---|
| 516 | | - h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === 'mdf' ? activeStyle : ''}" onclick="setBackupFilter('project','mdf')">mdf</button>`; |
|---|
| 517 | | - h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === 'seriousletter' ? activeStyle : ''}" onclick="setBackupFilter('project','seriousletter')">seriousletter</button>`; |
|---|
| 518 | | - h += '<span style="color:#374151;margin:0 0.25rem;">|</span>'; |
|---|
| 519 | | - h += '<span style="color:#9ca3af;font-size:0.875rem;margin-right:0.25rem;">Env:</span>'; |
|---|
| 520 | | - h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === null ? activeStyle : ''}" onclick="setBackupFilter('env',null)">All</button>`; |
|---|
| 521 | | - h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'dev' ? activeStyle : ''}" onclick="setBackupFilter('env','dev')">dev</button>`; |
|---|
| 522 | | - h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'int' ? activeStyle : ''}" onclick="setBackupFilter('env','int')">int</button>`; |
|---|
| 523 | | - h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'prod' ? activeStyle : ''}" onclick="setBackupFilter('env','prod')">prod</button>`; |
|---|
| 524 | | - h += '</div>'; |
|---|
| 525 | | - |
|---|
| 526 | | - // Local |
|---|
| 527 | | - h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>'; |
|---|
| 528 | | - if (filteredLocal.length === 0) { |
|---|
| 529 | | - h += '<div class="card" style="color:#6b7280;">No local backups match the current filter.</div>'; |
|---|
| 530 | | - } else { |
|---|
| 531 | | - h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>File</th><th>Date</th><th>Size</th></tr></thead><tbody>'; |
|---|
| 532 | | - for (const b of filteredLocal) { |
|---|
| 533 | | - h += `<tr> |
|---|
| 534 | | - <td>${esc(b.project||'')}</td> |
|---|
| 535 | | - <td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td> |
|---|
| 536 | | - <td class="mono" style="font-size:0.8125rem;">${esc(b.name||b.file||'')}</td> |
|---|
| 537 | | - <td>${esc(fmtBackupDate(b.date||b.timestamp||''))}</td> |
|---|
| 538 | | - <td>${esc(b.size_human||b.size||'')}</td> |
|---|
| 539 | | - </tr>`; |
|---|
| 540 | | - } |
|---|
| 541 | | - h += '</tbody></table></div>'; |
|---|
| 589 | + if (!cachedBackups) { |
|---|
| 590 | + const [local, offsite] = await Promise.all([ |
|---|
| 591 | + api('/api/backups/'), |
|---|
| 592 | + api('/api/backups/offsite').catch(() => []), |
|---|
| 593 | + ]); |
|---|
| 594 | + cachedBackups = mergeBackups(local, offsite); |
|---|
| 542 | 595 | } |
|---|
| 543 | 596 | |
|---|
| 544 | | - // Offsite |
|---|
| 545 | | - h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>'; |
|---|
| 546 | | - if (filteredOffsite.length === 0) { |
|---|
| 547 | | - h += '<div class="card" style="color:#6b7280;">No offsite backups match the current filter.</div>'; |
|---|
| 548 | | - } else { |
|---|
| 549 | | - h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>File</th><th>Date</th><th>Size</th></tr></thead><tbody>'; |
|---|
| 550 | | - for (const b of filteredOffsite) { |
|---|
| 551 | | - h += `<tr> |
|---|
| 552 | | - <td>${esc(b.project||'')}</td> |
|---|
| 553 | | - <td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td> |
|---|
| 554 | | - <td class="mono" style="font-size:0.8125rem;">${esc(b.name||'')}</td> |
|---|
| 555 | | - <td>${esc(fmtBackupDate(b.date||''))}</td> |
|---|
| 556 | | - <td>${esc(b.size||'')}</td> |
|---|
| 557 | | - </tr>`; |
|---|
| 558 | | - } |
|---|
| 559 | | - h += '</tbody></table></div>'; |
|---|
| 560 | | - } |
|---|
| 561 | | - |
|---|
| 562 | | - h += '</div>'; |
|---|
| 563 | | - c.innerHTML = h; |
|---|
| 597 | + if (backupDrillLevel === 0) renderBackupProjects(c); |
|---|
| 598 | + else if (backupDrillLevel === 1) renderBackupEnvironments(c); |
|---|
| 599 | + else renderBackupList(c); |
|---|
| 564 | 600 | } catch (e) { |
|---|
| 565 | 601 | c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + esc(e.message) + '</div>'; |
|---|
| 566 | 602 | } |
|---|
| 603 | +} |
|---|
| 604 | + |
|---|
| 605 | +// --------------------------------------------------------------------------- |
|---|
| 606 | +// Backups — Level 0: Project cards |
|---|
| 607 | +// --------------------------------------------------------------------------- |
|---|
| 608 | +function renderBackupProjects(c) { |
|---|
| 609 | + const all = cachedBackups; |
|---|
| 610 | + const localCount = all.filter(b => b.hasLocal).length; |
|---|
| 611 | + const offsiteCount = all.filter(b => b.hasOffsite).length; |
|---|
| 612 | + const syncedCount = all.filter(b => b.hasLocal && b.hasOffsite).length; |
|---|
| 613 | + let latestTs = ''; |
|---|
| 614 | + for (const b of all) { if (b.date > latestTs) latestTs = b.date; } |
|---|
| 615 | + const latestDisplay = latestTs ? fmtBackupDate(latestTs) : '\u2014'; |
|---|
| 616 | + |
|---|
| 617 | + let h = '<div class="page-enter">'; |
|---|
| 618 | + |
|---|
| 619 | + // Create Backup buttons |
|---|
| 620 | + h += '<div style="margin-bottom:1.5rem;">'; |
|---|
| 621 | + h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>'; |
|---|
| 622 | + h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">'; |
|---|
| 623 | + for (const p of ['mdf', 'seriousletter']) { |
|---|
| 624 | + for (const e of ['dev', 'int', 'prod']) { |
|---|
| 625 | + h += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${p}','${e}')">${p}/${e}</button>`; |
|---|
| 626 | + } |
|---|
| 627 | + } |
|---|
| 628 | + h += '</div></div>'; |
|---|
| 629 | + |
|---|
| 630 | + // Global stat tiles |
|---|
| 631 | + h += '<div class="grid-stats" style="margin-bottom:1.5rem;">'; |
|---|
| 632 | + h += statTile('Local', localCount, '#3b82f6'); |
|---|
| 633 | + h += statTile('Offsite', offsiteCount, '#8b5cf6'); |
|---|
| 634 | + h += statTile('Synced', syncedCount, '#10b981'); |
|---|
| 635 | + h += statTile('Latest', latestDisplay, '#f59e0b'); |
|---|
| 636 | + h += '</div>'; |
|---|
| 637 | + |
|---|
| 638 | + // Project cards |
|---|
| 639 | + const projects = groupBy(all, 'project'); |
|---|
| 640 | + h += '<div class="grid-auto">'; |
|---|
| 641 | + for (const [name, backups] of Object.entries(projects)) { |
|---|
| 642 | + const envs = [...new Set(backups.map(b => b.env))].sort(); |
|---|
| 643 | + let projLatest = ''; |
|---|
| 644 | + for (const b of backups) { if (b.date > projLatest) projLatest = b.date; } |
|---|
| 645 | + const projSize = backups.reduce((acc, b) => acc + (b.size_bytes || 0), 0); |
|---|
| 646 | + |
|---|
| 647 | + h += `<div class="card card-clickable" onclick="backupDrillToProject('${esc(name)}')"> |
|---|
| 648 | + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;"> |
|---|
| 649 | + <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(name)}</span> |
|---|
| 650 | + <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${backups.length} backup${backups.length !== 1 ? 's' : ''}</span> |
|---|
| 651 | + </div> |
|---|
| 652 | + <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;"> |
|---|
| 653 | + ${envs.map(e => `<span class="badge badge-blue">${esc(e)}</span>`).join('')} |
|---|
| 654 | + </div> |
|---|
| 655 | + <div style="font-size:0.8125rem;color:#9ca3af;"> |
|---|
| 656 | + Latest: ${projLatest ? fmtBackupDate(projLatest) : '\u2014'} |
|---|
| 657 | + ${projSize > 0 ? ' · ' + fmtBytes(projSize) : ''} |
|---|
| 658 | + </div> |
|---|
| 659 | + </div>`; |
|---|
| 660 | + } |
|---|
| 661 | + h += '</div></div>'; |
|---|
| 662 | + c.innerHTML = h; |
|---|
| 663 | +} |
|---|
| 664 | + |
|---|
| 665 | +// --------------------------------------------------------------------------- |
|---|
| 666 | +// Backups — Level 1: Environment cards for a project |
|---|
| 667 | +// --------------------------------------------------------------------------- |
|---|
| 668 | +function renderBackupEnvironments(c) { |
|---|
| 669 | + const projBackups = cachedBackups.filter(b => b.project === backupDrillProject); |
|---|
| 670 | + const envGroups = groupBy(projBackups, 'env'); |
|---|
| 671 | + const envOrder = ['dev', 'int', 'prod']; |
|---|
| 672 | + const sortedEnvs = Object.keys(envGroups).sort((a, b) => { |
|---|
| 673 | + const ai = envOrder.indexOf(a), bi = envOrder.indexOf(b); |
|---|
| 674 | + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); |
|---|
| 675 | + }); |
|---|
| 676 | + |
|---|
| 677 | + let h = '<div class="page-enter"><div class="grid-auto">'; |
|---|
| 678 | + for (const envName of sortedEnvs) { |
|---|
| 679 | + const backups = envGroups[envName]; |
|---|
| 680 | + const count = backups.length; |
|---|
| 681 | + let envLatest = ''; |
|---|
| 682 | + for (const b of backups) { if (b.date > envLatest) envLatest = b.date; } |
|---|
| 683 | + const envSize = backups.reduce((acc, b) => acc + (b.size_bytes || 0), 0); |
|---|
| 684 | + const ep = esc(backupDrillProject), ee = esc(envName); |
|---|
| 685 | + |
|---|
| 686 | + // Restore button logic |
|---|
| 687 | + let restoreBtn = ''; |
|---|
| 688 | + if (count === 0) { |
|---|
| 689 | + restoreBtn = `<button class="btn btn-danger btn-xs" disabled>Restore</button>`; |
|---|
| 690 | + } else if (count === 1) { |
|---|
| 691 | + const b = backups[0]; |
|---|
| 692 | + const src = b.hasLocal ? 'local' : 'offsite'; |
|---|
| 693 | + restoreBtn = `<button class="btn btn-danger btn-xs" onclick="event.stopPropagation();openRestoreModal('${ep}','${ee}','${src}','${esc(b.name)}')">Restore</button>`; |
|---|
| 694 | + } else { |
|---|
| 695 | + restoreBtn = `<button class="btn btn-danger btn-xs" onclick="event.stopPropagation();backupDrillToEnv('${ee}')">Restore (${count})</button>`; |
|---|
| 696 | + } |
|---|
| 697 | + |
|---|
| 698 | + h += `<div class="card card-clickable" onclick="backupDrillToEnv('${ee}')"> |
|---|
| 699 | + <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;"> |
|---|
| 700 | + <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${ee.toUpperCase()}</span> |
|---|
| 701 | + <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${count} backup${count !== 1 ? 's' : ''}</span> |
|---|
| 702 | + </div> |
|---|
| 703 | + <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;"> |
|---|
| 704 | + Latest: ${envLatest ? fmtBackupDate(envLatest) : '\u2014'} |
|---|
| 705 | + ${envSize > 0 ? ' · ' + fmtBytes(envSize) : ''} |
|---|
| 706 | + </div> |
|---|
| 707 | + <div style="display:flex;gap:0.5rem;"> |
|---|
| 708 | + <button class="btn btn-ghost btn-xs" onclick="event.stopPropagation();createBackup('${ep}','${ee}')">Create Backup</button> |
|---|
| 709 | + ${restoreBtn} |
|---|
| 710 | + </div> |
|---|
| 711 | + </div>`; |
|---|
| 712 | + } |
|---|
| 713 | + h += '</div></div>'; |
|---|
| 714 | + c.innerHTML = h; |
|---|
| 715 | +} |
|---|
| 716 | + |
|---|
| 717 | +// --------------------------------------------------------------------------- |
|---|
| 718 | +// Backups — Level 2: Individual backups for project/env |
|---|
| 719 | +// --------------------------------------------------------------------------- |
|---|
| 720 | +function renderBackupList(c) { |
|---|
| 721 | + const filtered = cachedBackups.filter(b => b.project === backupDrillProject && b.env === backupDrillEnv); |
|---|
| 722 | + |
|---|
| 723 | + let h = '<div class="page-enter">'; |
|---|
| 724 | + |
|---|
| 725 | + // Selection action bar |
|---|
| 726 | + h += `<div id="backup-selection-bar" class="selection-bar" style="display:${selectedBackups.size > 0 ? 'flex' : 'none'};">`; |
|---|
| 727 | + h += `<span id="selection-count">${selectedBackups.size} selected</span>`; |
|---|
| 728 | + h += `<button class="btn btn-danger btn-xs" onclick="deleteSelected()">Delete selected</button>`; |
|---|
| 729 | + h += `<button class="btn btn-ghost btn-xs" onclick="clearSelection()">Clear</button>`; |
|---|
| 730 | + h += `</div>`; |
|---|
| 731 | + |
|---|
| 732 | + if (filtered.length === 0) { |
|---|
| 733 | + h += '<div class="card" style="color:#6b7280;">No backups for ' + esc(backupDrillProject) + '/' + esc(backupDrillEnv) + '.</div>'; |
|---|
| 734 | + } else { |
|---|
| 735 | + // Group by date key (YYYY-MM-DD), sort descending |
|---|
| 736 | + const groups = {}; |
|---|
| 737 | + for (const b of filtered) { |
|---|
| 738 | + const { dateKey, timeStr } = parseBackupDate(b.date); |
|---|
| 739 | + b._dateKey = dateKey; |
|---|
| 740 | + b._timeStr = timeStr; |
|---|
| 741 | + if (!groups[dateKey]) groups[dateKey] = []; |
|---|
| 742 | + groups[dateKey].push(b); |
|---|
| 743 | + } |
|---|
| 744 | + |
|---|
| 745 | + const sortedKeys = Object.keys(groups).sort().reverse(); |
|---|
| 746 | + const today = new Date(); today.setHours(0, 0, 0, 0); |
|---|
| 747 | + const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); |
|---|
| 748 | + |
|---|
| 749 | + for (const dateKey of sortedKeys) { |
|---|
| 750 | + const items = groups[dateKey].sort((a, b) => b.date.localeCompare(a.date)); |
|---|
| 751 | + const groupSizeBytes = items.reduce((acc, b) => acc + (b.size_bytes || 0), 0); |
|---|
| 752 | + const headerLabel = fmtGroupHeader(dateKey); |
|---|
| 753 | + const safeKey = backupDrillProject + backupDrillEnv + dateKey.replace(/-/g, ''); |
|---|
| 754 | + |
|---|
| 755 | + const targetDay = new Date(dateKey + 'T00:00:00'); targetDay.setHours(0, 0, 0, 0); |
|---|
| 756 | + const isRecent = targetDay.getTime() >= yesterday.getTime(); |
|---|
| 757 | + |
|---|
| 758 | + h += `<div class="date-group">`; |
|---|
| 759 | + h += `<div class="date-group-header" onclick="toggleDateGroup('${safeKey}')">`; |
|---|
| 760 | + h += `<span class="chevron${isRecent ? ' open' : ''}" id="dg-chevron-${safeKey}">▶</span>`; |
|---|
| 761 | + h += `<span class="date-group-title">${esc(headerLabel)}</span>`; |
|---|
| 762 | + h += `<span class="date-group-meta">${items.length} backup${items.length !== 1 ? 's' : ''}</span>`; |
|---|
| 763 | + if (groupSizeBytes > 0) { |
|---|
| 764 | + h += `<span class="date-group-size">${fmtBytes(groupSizeBytes)}</span>`; |
|---|
| 765 | + } |
|---|
| 766 | + h += `</div>`; |
|---|
| 767 | + |
|---|
| 768 | + h += `<div class="date-group-body${isRecent ? ' open' : ''}" id="dg-body-${safeKey}">`; |
|---|
| 769 | + h += `<div class="table-wrapper"><table class="ops-table">`; |
|---|
| 770 | + h += `<thead><tr><th style="width:2rem;padding-left:0.75rem;"><input type="checkbox" onclick="toggleSelectAll(this)" style="accent-color:#3b82f6;cursor:pointer;"></th><th>Location</th><th>Time</th><th>Size</th><th>Actions</th></tr></thead><tbody>`; |
|---|
| 771 | + for (const b of items) { |
|---|
| 772 | + let locationBadge; |
|---|
| 773 | + if (b.hasLocal && b.hasOffsite) { |
|---|
| 774 | + locationBadge = '<span class="badge badge-synced">local + offsite</span>'; |
|---|
| 775 | + } else if (b.hasLocal) { |
|---|
| 776 | + locationBadge = '<span class="badge badge-local">local</span>'; |
|---|
| 777 | + } else { |
|---|
| 778 | + locationBadge = '<span class="badge badge-offsite">offsite</span>'; |
|---|
| 779 | + } |
|---|
| 780 | + |
|---|
| 781 | + const restoreSource = b.hasLocal ? 'local' : 'offsite'; |
|---|
| 782 | + const checked = selectedBackups.has(b.name) ? ' checked' : ''; |
|---|
| 783 | + 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 | + 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>` |
|---|
| 786 | + : ''; |
|---|
| 787 | + h += `<tr> |
|---|
| 788 | + <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> |
|---|
| 789 | + <td>${locationBadge}</td> |
|---|
| 790 | + <td class="mono">${esc(b._timeStr || '\u2014')}</td> |
|---|
| 791 | + <td>${esc(b.size_human || '\u2014')}</td> |
|---|
| 792 | + <td style="white-space:nowrap;"> |
|---|
| 793 | + <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 | + ${uploadBtn} |
|---|
| 795 | + ${deleteBtn} |
|---|
| 796 | + </td> |
|---|
| 797 | + </tr>`; |
|---|
| 798 | + } |
|---|
| 799 | + h += `</tbody></table></div>`; |
|---|
| 800 | + h += `</div>`; |
|---|
| 801 | + h += `</div>`; |
|---|
| 802 | + } |
|---|
| 803 | + } |
|---|
| 804 | + |
|---|
| 805 | + h += '</div>'; |
|---|
| 806 | + c.innerHTML = h; |
|---|
| 807 | +} |
|---|
| 808 | + |
|---|
| 809 | +// --------------------------------------------------------------------------- |
|---|
| 810 | +// Backups — Drill-down navigation |
|---|
| 811 | +// --------------------------------------------------------------------------- |
|---|
| 812 | +function backupDrillToProject(name) { backupDrillProject = name; backupDrillLevel = 1; selectedBackups.clear(); renderBackups(); pushHash(); } |
|---|
| 813 | +function backupDrillToEnv(name) { backupDrillEnv = name; backupDrillLevel = 2; selectedBackups.clear(); renderBackups(); pushHash(); } |
|---|
| 814 | +function backupDrillBack(level) { |
|---|
| 815 | + if (level === 0) { backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; } |
|---|
| 816 | + else if (level === 1) { backupDrillLevel = 1; backupDrillEnv = null; } |
|---|
| 817 | + selectedBackups.clear(); |
|---|
| 818 | + renderBackups(); |
|---|
| 819 | + pushHash(); |
|---|
| 820 | +} |
|---|
| 821 | + |
|---|
| 822 | +// --------------------------------------------------------------------------- |
|---|
| 823 | +// Backup Multi-Select |
|---|
| 824 | +// --------------------------------------------------------------------------- |
|---|
| 825 | +function toggleBackupSelect(name) { |
|---|
| 826 | + if (selectedBackups.has(name)) selectedBackups.delete(name); |
|---|
| 827 | + else selectedBackups.add(name); |
|---|
| 828 | + updateSelectionBar(); |
|---|
| 829 | +} |
|---|
| 830 | + |
|---|
| 831 | +function toggleSelectAll(masterCb) { |
|---|
| 832 | + const table = masterCb.closest('table'); |
|---|
| 833 | + const cbs = table.querySelectorAll('.backup-cb'); |
|---|
| 834 | + if (masterCb.checked) { |
|---|
| 835 | + cbs.forEach(cb => { cb.checked = true; selectedBackups.add(cb.value); }); |
|---|
| 836 | + } else { |
|---|
| 837 | + cbs.forEach(cb => { cb.checked = false; selectedBackups.delete(cb.value); }); |
|---|
| 838 | + } |
|---|
| 839 | + updateSelectionBar(); |
|---|
| 840 | +} |
|---|
| 841 | + |
|---|
| 842 | +function clearSelection() { |
|---|
| 843 | + selectedBackups.clear(); |
|---|
| 844 | + document.querySelectorAll('.backup-cb').forEach(cb => { cb.checked = false; }); |
|---|
| 845 | + document.querySelectorAll('th input[type="checkbox"]').forEach(cb => { cb.checked = false; }); |
|---|
| 846 | + updateSelectionBar(); |
|---|
| 847 | +} |
|---|
| 848 | + |
|---|
| 849 | +function updateSelectionBar() { |
|---|
| 850 | + const bar = document.getElementById('backup-selection-bar'); |
|---|
| 851 | + const count = document.getElementById('selection-count'); |
|---|
| 852 | + if (bar) { |
|---|
| 853 | + bar.style.display = selectedBackups.size > 0 ? 'flex' : 'none'; |
|---|
| 854 | + if (count) count.textContent = selectedBackups.size + ' selected'; |
|---|
| 855 | + } |
|---|
| 856 | +} |
|---|
| 857 | + |
|---|
| 858 | +async function deleteSelected() { |
|---|
| 859 | + const names = [...selectedBackups]; |
|---|
| 860 | + if (names.length === 0) return; |
|---|
| 861 | + // Check if any selected backups have both locations |
|---|
| 862 | + const anyBoth = cachedBackups && cachedBackups.some(b => names.includes(b.name) && b.hasLocal && b.hasOffsite); |
|---|
| 863 | + let target = 'local'; |
|---|
| 864 | + if (anyBoth) { |
|---|
| 865 | + target = await showDeleteTargetDialog(names.length + ' selected backup(s)'); |
|---|
| 866 | + if (!target) return; |
|---|
| 867 | + } else { |
|---|
| 868 | + // Determine if all are offsite-only |
|---|
| 869 | + const allOffsite = cachedBackups && names.every(n => { const b = cachedBackups.find(x => x.name === n); return b && !b.hasLocal && b.hasOffsite; }); |
|---|
| 870 | + if (allOffsite) target = 'offsite'; |
|---|
| 871 | + } |
|---|
| 872 | + 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; |
|---|
| 874 | + toast(`Deleting ${names.length} backups (${label})...`, 'info'); |
|---|
| 875 | + let ok = 0, fail = 0; |
|---|
| 876 | + for (const name of names) { |
|---|
| 877 | + try { |
|---|
| 878 | + await api(`/api/backups/${encodeURIComponent(backupDrillProject)}/${encodeURIComponent(backupDrillEnv)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' }); |
|---|
| 879 | + ok++; |
|---|
| 880 | + } catch (_) { fail++; } |
|---|
| 881 | + } |
|---|
| 882 | + selectedBackups.clear(); |
|---|
| 883 | + cachedBackups = null; |
|---|
| 884 | + toast(`Deleted ${ok}${fail > 0 ? ', ' + fail + ' failed' : ''}`, fail > 0 ? 'warning' : 'success'); |
|---|
| 885 | + if (currentPage === 'backups') renderBackups(); |
|---|
| 886 | +} |
|---|
| 887 | + |
|---|
| 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'); } |
|---|
| 897 | +} |
|---|
| 898 | + |
|---|
| 899 | +// --------------------------------------------------------------------------- |
|---|
| 900 | +// Restore Modal |
|---|
| 901 | +// --------------------------------------------------------------------------- |
|---|
| 902 | +function openRestoreModal(project, env, source, name, hasLocal, hasOffsite) { |
|---|
| 903 | + restoreCtx = { project, env, source, name, hasLocal: !!hasLocal, hasOffsite: !!hasOffsite }; |
|---|
| 904 | + |
|---|
| 905 | + // Close any running event source |
|---|
| 906 | + if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; } |
|---|
| 907 | + |
|---|
| 908 | + // Populate modal info |
|---|
| 909 | + document.getElementById('restore-modal-project').textContent = project + '/' + env; |
|---|
| 910 | + document.getElementById('restore-modal-name').textContent = name || '(latest)'; |
|---|
| 911 | + document.getElementById('restore-dry-run').checked = false; |
|---|
| 912 | + |
|---|
| 913 | + // Source selector: show radios when both local+offsite, static text otherwise |
|---|
| 914 | + const sourceRow = document.getElementById('restore-source-row'); |
|---|
| 915 | + const sourceSelector = document.getElementById('restore-source-selector'); |
|---|
| 916 | + if (hasLocal && hasOffsite) { |
|---|
| 917 | + sourceRow.style.display = 'none'; |
|---|
| 918 | + sourceSelector.style.display = 'block'; |
|---|
| 919 | + document.querySelectorAll('input[name="restore-source"]').forEach(r => { |
|---|
| 920 | + r.checked = r.value === source; |
|---|
| 921 | + }); |
|---|
| 922 | + } else { |
|---|
| 923 | + sourceRow.style.display = 'flex'; |
|---|
| 924 | + sourceSelector.style.display = 'none'; |
|---|
| 925 | + document.getElementById('restore-modal-source').textContent = source; |
|---|
| 926 | + } |
|---|
| 927 | + |
|---|
| 928 | + // Reset mode to "full" |
|---|
| 929 | + const modeRadios = document.querySelectorAll('input[name="restore-mode"]'); |
|---|
| 930 | + modeRadios.forEach(r => { r.checked = r.value === 'full'; }); |
|---|
| 931 | + |
|---|
| 932 | + // Reset terminal |
|---|
| 933 | + const term = document.getElementById('restore-modal-terminal'); |
|---|
| 934 | + term.textContent = ''; |
|---|
| 935 | + document.getElementById('restore-modal-output').style.display = 'none'; |
|---|
| 936 | + |
|---|
| 937 | + // Enable start button |
|---|
| 938 | + const startBtn = document.getElementById('restore-start-btn'); |
|---|
| 939 | + startBtn.disabled = false; |
|---|
| 940 | + startBtn.textContent = 'Start Restore'; |
|---|
| 941 | + |
|---|
| 942 | + document.getElementById('restore-modal').style.display = 'flex'; |
|---|
| 943 | +} |
|---|
| 944 | + |
|---|
| 945 | +function closeRestoreModal() { |
|---|
| 946 | + if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; } |
|---|
| 947 | + document.getElementById('restore-modal').style.display = 'none'; |
|---|
| 948 | + restoreCtx = { project: null, env: null, source: null, name: null }; |
|---|
| 949 | +} |
|---|
| 950 | + |
|---|
| 951 | +function startRestore() { |
|---|
| 952 | + const { project, env, hasLocal, hasOffsite } = restoreCtx; |
|---|
| 953 | + if (!project || !env) return; |
|---|
| 954 | + |
|---|
| 955 | + // Determine source: from radio if both available, otherwise from context |
|---|
| 956 | + let source = restoreCtx.source; |
|---|
| 957 | + if (hasLocal && hasOffsite) { |
|---|
| 958 | + const srcEl = document.querySelector('input[name="restore-source"]:checked'); |
|---|
| 959 | + if (srcEl) source = srcEl.value; |
|---|
| 960 | + } |
|---|
| 961 | + |
|---|
| 962 | + const dryRun = document.getElementById('restore-dry-run').checked; |
|---|
| 963 | + const startBtn = document.getElementById('restore-start-btn'); |
|---|
| 964 | + |
|---|
| 965 | + // Show terminal |
|---|
| 966 | + const outputDiv = document.getElementById('restore-modal-output'); |
|---|
| 967 | + const term = document.getElementById('restore-modal-terminal'); |
|---|
| 968 | + outputDiv.style.display = 'block'; |
|---|
| 969 | + term.textContent = 'Starting restore...\n'; |
|---|
| 970 | + |
|---|
| 971 | + startBtn.disabled = true; |
|---|
| 972 | + startBtn.textContent = dryRun ? 'Running preview...' : 'Restoring...'; |
|---|
| 973 | + |
|---|
| 974 | + const name = restoreCtx.name || ''; |
|---|
| 975 | + const modeEl = document.querySelector('input[name="restore-mode"]:checked'); |
|---|
| 976 | + const mode = modeEl ? modeEl.value : 'full'; |
|---|
| 977 | + 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)}`; |
|---|
| 978 | + const es = new EventSource(url); |
|---|
| 979 | + restoreEventSource = es; |
|---|
| 980 | + |
|---|
| 981 | + es.onmessage = function(e) { |
|---|
| 982 | + try { |
|---|
| 983 | + const d = JSON.parse(e.data); |
|---|
| 984 | + if (d.done) { |
|---|
| 985 | + es.close(); |
|---|
| 986 | + restoreEventSource = null; |
|---|
| 987 | + const msg = d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; |
|---|
| 988 | + term.textContent += msg; |
|---|
| 989 | + term.scrollTop = term.scrollHeight; |
|---|
| 990 | + toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error'); |
|---|
| 991 | + startBtn.disabled = false; |
|---|
| 992 | + startBtn.textContent = 'Start Restore'; |
|---|
| 993 | + return; |
|---|
| 994 | + } |
|---|
| 995 | + if (d.line) { |
|---|
| 996 | + term.textContent += d.line + '\n'; |
|---|
| 997 | + term.scrollTop = term.scrollHeight; |
|---|
| 998 | + } |
|---|
| 999 | + } catch (_) {} |
|---|
| 1000 | + }; |
|---|
| 1001 | + |
|---|
| 1002 | + es.onerror = function() { |
|---|
| 1003 | + es.close(); |
|---|
| 1004 | + restoreEventSource = null; |
|---|
| 1005 | + term.textContent += '\n--- Connection lost ---\n'; |
|---|
| 1006 | + toast('Connection lost', 'error'); |
|---|
| 1007 | + startBtn.disabled = false; |
|---|
| 1008 | + startBtn.textContent = 'Start Restore'; |
|---|
| 1009 | + }; |
|---|
| 567 | 1010 | } |
|---|
| 568 | 1011 | |
|---|
| 569 | 1012 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 577 | 1020 | api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })), |
|---|
| 578 | 1021 | api('/api/system/health').catch(e => ({ checks: [], raw: e.message })), |
|---|
| 579 | 1022 | api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })), |
|---|
| 580 | | - api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })), |
|---|
| 1023 | + api('/api/system/info').catch(e => ({ uptime: 'error' })), |
|---|
| 581 | 1024 | ]); |
|---|
| 582 | 1025 | |
|---|
| 583 | 1026 | let h = '<div class="page-enter">'; |
|---|
| .. | .. |
|---|
| 614 | 1057 | // Quick stats row |
|---|
| 615 | 1058 | h += '<div class="grid-stats" style="margin-bottom:1.5rem;">'; |
|---|
| 616 | 1059 | h += statTile('Uptime', info.uptime || 'n/a', '#3b82f6'); |
|---|
| 617 | | - h += statTile('Load', info.load || 'n/a', '#8b5cf6'); |
|---|
| 1060 | + h += statTile('Containers', info.containers || 'n/a', '#8b5cf6'); |
|---|
| 1061 | + h += statTile('Processes', info.processes || '0', '#f59e0b'); |
|---|
| 618 | 1062 | h += '</div>'; |
|---|
| 619 | 1063 | |
|---|
| 620 | 1064 | // Disk usage — only real filesystems |
|---|
| .. | .. |
|---|
| 676 | 1120 | } |
|---|
| 677 | 1121 | |
|---|
| 678 | 1122 | // --------------------------------------------------------------------------- |
|---|
| 679 | | -// Restore |
|---|
| 1123 | +// Operations Page |
|---|
| 680 | 1124 | // --------------------------------------------------------------------------- |
|---|
| 681 | | -function renderRestore() { |
|---|
| 1125 | +async function renderOperations() { |
|---|
| 682 | 1126 | updateBreadcrumbs(); |
|---|
| 683 | 1127 | const c = document.getElementById('page-content'); |
|---|
| 684 | | - let h = '<div class="page-enter">'; |
|---|
| 685 | | - h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>'; |
|---|
| 686 | | - h += '<div class="card" style="max-width:480px;">'; |
|---|
| 687 | | - h += '<div style="margin-bottom:1rem;"><label class="form-label">Project</label><select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select></div>'; |
|---|
| 688 | | - h += '<div style="margin-bottom:1rem;"><label class="form-label">Environment</label><select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select></div>'; |
|---|
| 689 | | - h += '<div style="margin-bottom:1rem;"><label class="form-label">Source</label><select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select></div>'; |
|---|
| 690 | | - h += '<div style="margin-bottom:1rem;"><label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;"><input type="checkbox" id="restore-dry" checked> Dry run (preview only)</label></div>'; |
|---|
| 691 | | - h += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>'; |
|---|
| 1128 | + |
|---|
| 1129 | + // Fetch registry if not cached |
|---|
| 1130 | + if (!cachedRegistry) { |
|---|
| 1131 | + try { |
|---|
| 1132 | + cachedRegistry = await api('/api/registry/'); |
|---|
| 1133 | + } catch (e) { |
|---|
| 1134 | + c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load registry: ' + esc(e.message) + '</div>'; |
|---|
| 1135 | + return; |
|---|
| 1136 | + } |
|---|
| 1137 | + } |
|---|
| 1138 | + |
|---|
| 1139 | + const projects = cachedRegistry.projects || {}; |
|---|
| 1140 | + |
|---|
| 1141 | + let h = '<div style="max-width:900px;">'; |
|---|
| 1142 | + |
|---|
| 1143 | + // Section: Promote Code (Forward) |
|---|
| 1144 | + h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Promote Code</h2>'; |
|---|
| 1145 | + h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Push code forward: dev → int → prod. Each project defines its own promotion type (git pull or rsync).</p>'; |
|---|
| 1146 | + h += '<div class="grid-auto" style="margin-bottom:2rem;">'; |
|---|
| 1147 | + |
|---|
| 1148 | + for (const [name, cfg] of Object.entries(projects)) { |
|---|
| 1149 | + if (!cfg.promote || cfg.static || cfg.infrastructure) continue; |
|---|
| 1150 | + const pType = cfg.promote.type || 'unknown'; |
|---|
| 1151 | + const envs = cfg.environments || []; |
|---|
| 1152 | + const typeBadge = pType === 'git' |
|---|
| 1153 | + ? '<span class="badge badge-blue" style="font-size:0.6875rem;">git</span>' |
|---|
| 1154 | + : '<span class="badge badge-purple" style="font-size:0.6875rem;">rsync</span>'; |
|---|
| 1155 | + |
|---|
| 1156 | + h += '<div class="card">'; |
|---|
| 1157 | + h += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">'; |
|---|
| 1158 | + h += '<span style="font-weight:600;color:#f3f4f6;">' + esc(name) + '</span>'; |
|---|
| 1159 | + h += typeBadge; |
|---|
| 1160 | + h += '</div>'; |
|---|
| 1161 | + |
|---|
| 1162 | + const promotions = []; |
|---|
| 1163 | + if (envs.includes('dev') && envs.includes('int')) promotions.push(['dev', 'int']); |
|---|
| 1164 | + if (envs.includes('int') && envs.includes('prod')) promotions.push(['int', 'prod']); |
|---|
| 1165 | + |
|---|
| 1166 | + if (promotions.length === 0) { |
|---|
| 1167 | + h += '<div style="font-size:0.8125rem;color:#6b7280;">No promotion paths available</div>'; |
|---|
| 1168 | + } else { |
|---|
| 1169 | + h += '<div style="display:flex;flex-direction:column;gap:0.5rem;">'; |
|---|
| 1170 | + for (const [from, to] of promotions) { |
|---|
| 1171 | + h += '<button class="btn btn-ghost btn-sm" style="justify-content:flex-start;" onclick="openOpsModal('promote','' + esc(name) + '','' + esc(from) + '','' + esc(to) + '')">'; |
|---|
| 1172 | + h += '<span style="color:#60a5fa;">' + esc(from) + '</span>'; |
|---|
| 1173 | + h += ' <span style="color:#6b7280;">→</span> '; |
|---|
| 1174 | + h += '<span style="color:#fbbf24;">' + esc(to) + '</span>'; |
|---|
| 1175 | + h += '</button>'; |
|---|
| 1176 | + } |
|---|
| 1177 | + h += '</div>'; |
|---|
| 1178 | + } |
|---|
| 1179 | + h += '</div>'; |
|---|
| 1180 | + } |
|---|
| 692 | 1181 | h += '</div>'; |
|---|
| 693 | | - h += '<div id="restore-output" style="display:none;margin-top:1rem;"><h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3><div id="restore-terminal" class="terminal" style="max-height:400px;"></div></div>'; |
|---|
| 1182 | + |
|---|
| 1183 | + // Section: Sync Data (Backward) |
|---|
| 1184 | + h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Sync Data</h2>'; |
|---|
| 1185 | + h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Sync content between environments. Choose the direction when syncing.</p>'; |
|---|
| 1186 | + h += '<div class="grid-auto" style="margin-bottom:2rem;">'; |
|---|
| 1187 | + |
|---|
| 1188 | + for (const [name, cfg] of Object.entries(projects)) { |
|---|
| 1189 | + if (!cfg.has_cli || cfg.static || cfg.infrastructure) continue; |
|---|
| 1190 | + const envs = cfg.environments || []; |
|---|
| 1191 | + |
|---|
| 1192 | + h += '<div class="card">'; |
|---|
| 1193 | + h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>'; |
|---|
| 1194 | + |
|---|
| 1195 | + const syncPairs = []; |
|---|
| 1196 | + if (envs.includes('prod') && envs.includes('int')) syncPairs.push(['prod', 'int']); |
|---|
| 1197 | + if (envs.includes('int') && envs.includes('dev')) syncPairs.push(['int', 'dev']); |
|---|
| 1198 | + |
|---|
| 1199 | + if (syncPairs.length === 0) { |
|---|
| 1200 | + h += '<div style="font-size:0.8125rem;color:#6b7280;">No sync paths available</div>'; |
|---|
| 1201 | + } else { |
|---|
| 1202 | + h += '<div style="display:flex;flex-direction:column;gap:0.5rem;">'; |
|---|
| 1203 | + for (const [a, b] of syncPairs) { |
|---|
| 1204 | + h += '<button class="btn btn-ghost btn-sm" style="justify-content:flex-start;" onclick="openSyncModal('' + esc(name) + '','' + esc(a) + '','' + esc(b) + '')">'; |
|---|
| 1205 | + h += '<span style="color:#60a5fa;">' + esc(a) + '</span>'; |
|---|
| 1206 | + h += ' <span style="color:#6b7280;">↔</span> '; |
|---|
| 1207 | + h += '<span style="color:#fbbf24;">' + esc(b) + '</span>'; |
|---|
| 1208 | + h += '</button>'; |
|---|
| 1209 | + } |
|---|
| 1210 | + h += '</div>'; |
|---|
| 1211 | + } |
|---|
| 1212 | + h += '</div>'; |
|---|
| 1213 | + } |
|---|
| 694 | 1214 | h += '</div>'; |
|---|
| 1215 | + |
|---|
| 1216 | + // Section: Container Lifecycle |
|---|
| 1217 | + 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. ' |
|---|
| 1219 | + + '<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;">'; |
|---|
| 1223 | + |
|---|
| 1224 | + 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'); |
|---|
| 1227 | + if (!envs.length) continue; |
|---|
| 1228 | + |
|---|
| 1229 | + h += '<div class="card">'; |
|---|
| 1230 | + h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>'; |
|---|
| 1231 | + h += '<div style="display:flex;flex-direction:column;gap:0.625rem;">'; |
|---|
| 1232 | + |
|---|
| 1233 | + for (const env of envs) { |
|---|
| 1234 | + h += '<div style="display:flex;align-items:center;gap:0.5rem;">'; |
|---|
| 1235 | + // Environment label |
|---|
| 1236 | + h += '<span style="min-width:2.5rem;font-size:0.75rem;color:#9ca3af;font-weight:500;">' + esc(env) + '</span>'; |
|---|
| 1237 | + // Restart (green) |
|---|
| 1238 | + h += '<button class="btn btn-ghost btn-xs" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);" ' |
|---|
| 1239 | + + 'onclick="openLifecycleModal('restart','' + esc(name) + '','' + esc(env) + '')">' |
|---|
| 1240 | + + 'Restart</button>'; |
|---|
| 1241 | + // Rebuild (yellow) |
|---|
| 1242 | + h += '<button class="btn btn-ghost btn-xs" style="color:#fbbf24;border-color:rgba(251,191,36,0.3);" ' |
|---|
| 1243 | + + 'onclick="openLifecycleModal('rebuild','' + esc(name) + '','' + esc(env) + '')">' |
|---|
| 1244 | + + '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>'; |
|---|
| 1249 | + h += '</div>'; |
|---|
| 1250 | + } |
|---|
| 1251 | + |
|---|
| 1252 | + h += '</div></div>'; |
|---|
| 1253 | + } |
|---|
| 1254 | + h += '</div></div>'; |
|---|
| 1255 | + |
|---|
| 695 | 1256 | c.innerHTML = h; |
|---|
| 696 | 1257 | } |
|---|
| 697 | 1258 | |
|---|
| 698 | | -async function startRestore() { |
|---|
| 699 | | - const project = document.getElementById('restore-project').value; |
|---|
| 700 | | - const env = document.getElementById('restore-env').value; |
|---|
| 701 | | - const source = document.getElementById('restore-source').value; |
|---|
| 702 | | - const dryRun = document.getElementById('restore-dry').checked; |
|---|
| 703 | | - if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}?`)) return; |
|---|
| 1259 | +// --------------------------------------------------------------------------- |
|---|
| 1260 | +// Operations Modal |
|---|
| 1261 | +// --------------------------------------------------------------------------- |
|---|
| 1262 | +function openSyncModal(project, envA, envB) { |
|---|
| 1263 | + // Show direction picker in the ops modal |
|---|
| 1264 | + opsCtx = { type: 'sync', project: project, fromEnv: envA, toEnv: envB }; |
|---|
| 704 | 1265 | |
|---|
| 705 | | - const out = document.getElementById('restore-output'); |
|---|
| 706 | | - const term = document.getElementById('restore-terminal'); |
|---|
| 707 | | - out.style.display = 'block'; |
|---|
| 708 | | - term.textContent = 'Starting restore...\n'; |
|---|
| 1266 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 709 | 1267 | |
|---|
| 710 | | - const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`; |
|---|
| 1268 | + const title = document.getElementById('ops-modal-title'); |
|---|
| 1269 | + const info = document.getElementById('ops-modal-info'); |
|---|
| 1270 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1271 | + |
|---|
| 1272 | + title.textContent = 'Sync Data'; |
|---|
| 1273 | + |
|---|
| 1274 | + let ih = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'; |
|---|
| 1275 | + ih += '<div style="margin-top:0.75rem;margin-bottom:0.25rem;font-size:0.8125rem;color:#9ca3af;">Direction</div>'; |
|---|
| 1276 | + ih += '<div style="display:flex;flex-direction:column;gap:0.5rem;">'; |
|---|
| 1277 | + ih += '<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid #374151;' |
|---|
| 1278 | + + 'background:rgba(96,165,250,0.1);" id="sync-dir-down">'; |
|---|
| 1279 | + ih += '<input type="radio" name="sync-dir" value="down" checked onchange="updateSyncDir()" style="accent-color:#60a5fa;">'; |
|---|
| 1280 | + ih += '<span style="color:#60a5fa;font-weight:600;">' + esc(envA) + '</span>'; |
|---|
| 1281 | + ih += '<span style="color:#6b7280;">→</span>'; |
|---|
| 1282 | + ih += '<span style="color:#fbbf24;font-weight:600;">' + esc(envB) + '</span>'; |
|---|
| 1283 | + ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows down</span>'; |
|---|
| 1284 | + ih += '</label>'; |
|---|
| 1285 | + ih += '<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid #374151;" id="sync-dir-up">'; |
|---|
| 1286 | + ih += '<input type="radio" name="sync-dir" value="up" onchange="updateSyncDir()" style="accent-color:#fbbf24;">'; |
|---|
| 1287 | + ih += '<span style="color:#fbbf24;font-weight:600;">' + esc(envB) + '</span>'; |
|---|
| 1288 | + ih += '<span style="color:#6b7280;">→</span>'; |
|---|
| 1289 | + ih += '<span style="color:#60a5fa;font-weight:600;">' + esc(envA) + '</span>'; |
|---|
| 1290 | + ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows up</span>'; |
|---|
| 1291 | + ih += '</label>'; |
|---|
| 1292 | + ih += '</div>'; |
|---|
| 1293 | + |
|---|
| 1294 | + info.innerHTML = ih; |
|---|
| 1295 | + startBtn.className = 'btn btn-primary btn-sm'; |
|---|
| 1296 | + startBtn.textContent = 'Sync'; |
|---|
| 1297 | + |
|---|
| 1298 | + document.getElementById('ops-dry-run').checked = true; |
|---|
| 1299 | + document.getElementById('ops-modal-output').style.display = 'none'; |
|---|
| 1300 | + document.getElementById('ops-modal-terminal').textContent = ''; |
|---|
| 1301 | + startBtn.disabled = false; |
|---|
| 1302 | + document.getElementById('ops-modal').style.display = 'flex'; |
|---|
| 1303 | +} |
|---|
| 1304 | + |
|---|
| 1305 | +function updateSyncDir() { |
|---|
| 1306 | + const dir = document.querySelector('input[name="sync-dir"]:checked').value; |
|---|
| 1307 | + const downLabel = document.getElementById('sync-dir-down'); |
|---|
| 1308 | + const upLabel = document.getElementById('sync-dir-up'); |
|---|
| 1309 | + if (dir === 'down') { |
|---|
| 1310 | + downLabel.style.background = 'rgba(96,165,250,0.1)'; |
|---|
| 1311 | + upLabel.style.background = 'transparent'; |
|---|
| 1312 | + // envA -> envB (default / downward) |
|---|
| 1313 | + opsCtx.fromEnv = downLabel.querySelector('span[style*="color:#60a5fa"]').textContent; |
|---|
| 1314 | + opsCtx.toEnv = downLabel.querySelector('span[style*="color:#fbbf24"]').textContent; |
|---|
| 1315 | + } else { |
|---|
| 1316 | + downLabel.style.background = 'transparent'; |
|---|
| 1317 | + upLabel.style.background = 'rgba(251,191,36,0.1)'; |
|---|
| 1318 | + // envB -> envA (upward) |
|---|
| 1319 | + opsCtx.fromEnv = upLabel.querySelector('span[style*="color:#fbbf24"]').textContent; |
|---|
| 1320 | + opsCtx.toEnv = upLabel.querySelector('span[style*="color:#60a5fa"]').textContent; |
|---|
| 1321 | + } |
|---|
| 1322 | +} |
|---|
| 1323 | + |
|---|
| 1324 | +function openOpsModal(type, project, fromEnv, toEnv) { |
|---|
| 1325 | + opsCtx = { type, project, fromEnv, toEnv }; |
|---|
| 1326 | + |
|---|
| 1327 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 1328 | + |
|---|
| 1329 | + const title = document.getElementById('ops-modal-title'); |
|---|
| 1330 | + const info = document.getElementById('ops-modal-info'); |
|---|
| 1331 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1332 | + |
|---|
| 1333 | + if (type === 'promote') { |
|---|
| 1334 | + title.textContent = 'Promote Code'; |
|---|
| 1335 | + info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1336 | + + '<div class="restore-info-row"><span class="restore-info-label">Direction</span><span class="restore-info-value">' + esc(fromEnv) + ' → ' + esc(toEnv) + '</span></div>'; |
|---|
| 1337 | + startBtn.className = 'btn btn-primary btn-sm'; |
|---|
| 1338 | + startBtn.textContent = 'Promote'; |
|---|
| 1339 | + } else if (type === 'sync') { |
|---|
| 1340 | + title.textContent = 'Sync Data'; |
|---|
| 1341 | + info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1342 | + + '<div class="restore-info-row"><span class="restore-info-label">Direction</span><span class="restore-info-value">' + esc(fromEnv) + ' → ' + esc(toEnv) + '</span></div>'; |
|---|
| 1343 | + startBtn.className = 'btn btn-primary btn-sm'; |
|---|
| 1344 | + startBtn.textContent = 'Sync'; |
|---|
| 1345 | + } |
|---|
| 1346 | + |
|---|
| 1347 | + document.getElementById('ops-dry-run').checked = true; |
|---|
| 1348 | + document.getElementById('ops-modal-output').style.display = 'none'; |
|---|
| 1349 | + document.getElementById('ops-modal-terminal').textContent = ''; |
|---|
| 1350 | + startBtn.disabled = false; |
|---|
| 1351 | + |
|---|
| 1352 | + document.getElementById('ops-modal').style.display = 'flex'; |
|---|
| 1353 | +} |
|---|
| 1354 | + |
|---|
| 1355 | +// --------------------------------------------------------------------------- |
|---|
| 1356 | +// Lifecycle Modal (Restart / Rebuild / Recreate) |
|---|
| 1357 | +// --------------------------------------------------------------------------- |
|---|
| 1358 | +function openLifecycleModal(action, project, env) { |
|---|
| 1359 | + opsCtx = { type: action, project, fromEnv: env, toEnv: null }; |
|---|
| 1360 | + |
|---|
| 1361 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 1362 | + |
|---|
| 1363 | + const title = document.getElementById('ops-modal-title'); |
|---|
| 1364 | + const info = document.getElementById('ops-modal-info'); |
|---|
| 1365 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1366 | + const dryRunRow = document.getElementById('ops-dry-run-row'); |
|---|
| 1367 | + |
|---|
| 1368 | + // Hide the dry-run checkbox — lifecycle ops don't use it |
|---|
| 1369 | + if (dryRunRow) dryRunRow.style.display = 'none'; |
|---|
| 1370 | + |
|---|
| 1371 | + if (action === 'restart') { |
|---|
| 1372 | + title.textContent = 'Restart Containers'; |
|---|
| 1373 | + info.innerHTML = '' |
|---|
| 1374 | + + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1375 | + + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>' |
|---|
| 1376 | + + '<div style="background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.25);border-radius:0.5rem;padding:0.625rem 0.875rem;font-size:0.8125rem;color:#6ee7b7;margin-top:0.75rem;">' |
|---|
| 1377 | + + 'Safe operation. Runs <code>docker restart</code> on each container. No image changes, no data loss.</div>'; |
|---|
| 1378 | + startBtn.className = 'btn btn-sm'; |
|---|
| 1379 | + startBtn.style.cssText = 'background:#065f46;color:#6ee7b7;border:1px solid rgba(110,231,179,0.3);'; |
|---|
| 1380 | + startBtn.textContent = 'Restart'; |
|---|
| 1381 | + |
|---|
| 1382 | + } else if (action === 'rebuild') { |
|---|
| 1383 | + title.textContent = 'Rebuild Environment'; |
|---|
| 1384 | + info.innerHTML = '' |
|---|
| 1385 | + + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1386 | + + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>' |
|---|
| 1387 | + + '<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>'; |
|---|
| 1389 | + startBtn.className = 'btn btn-sm'; |
|---|
| 1390 | + startBtn.style.cssText = 'background:#78350f;color:#fde68a;border:1px solid rgba(251,191,36,0.3);'; |
|---|
| 1391 | + startBtn.textContent = 'Rebuild'; |
|---|
| 1392 | + |
|---|
| 1393 | + } else if (action === 'recreate') { |
|---|
| 1394 | + title.textContent = 'Recreate Environment'; |
|---|
| 1395 | + info.innerHTML = '' |
|---|
| 1396 | + + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>' |
|---|
| 1397 | + + '<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'; |
|---|
| 1409 | + startBtn.style.cssText = ''; |
|---|
| 1410 | + startBtn.textContent = 'Recreate'; |
|---|
| 1411 | + startBtn.disabled = true; // enabled after typing env name |
|---|
| 1412 | + } |
|---|
| 1413 | + |
|---|
| 1414 | + document.getElementById('ops-modal-output').style.display = 'none'; |
|---|
| 1415 | + document.getElementById('ops-modal-terminal').textContent = ''; |
|---|
| 1416 | + |
|---|
| 1417 | + 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 | +} |
|---|
| 1432 | + |
|---|
| 1433 | +function closeOpsModal() { |
|---|
| 1434 | + if (opsEventSource) { opsEventSource.close(); opsEventSource = null; } |
|---|
| 1435 | + document.getElementById('ops-modal').style.display = 'none'; |
|---|
| 1436 | + opsCtx = { type: null, project: null, fromEnv: null, toEnv: null }; |
|---|
| 1437 | + // Restore dry-run row visibility for promote/sync operations |
|---|
| 1438 | + const dryRunRow = document.getElementById('ops-dry-run-row'); |
|---|
| 1439 | + if (dryRunRow) dryRunRow.style.display = ''; |
|---|
| 1440 | + // Reset start button style |
|---|
| 1441 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1442 | + if (startBtn) { startBtn.style.cssText = ''; startBtn.disabled = false; } |
|---|
| 1443 | +} |
|---|
| 1444 | + |
|---|
| 1445 | +function _btnLabelForType(type) { |
|---|
| 1446 | + if (type === 'promote') return 'Promote'; |
|---|
| 1447 | + if (type === 'sync') return 'Sync'; |
|---|
| 1448 | + if (type === 'restart') return 'Restart'; |
|---|
| 1449 | + if (type === 'rebuild') return 'Rebuild'; |
|---|
| 1450 | + if (type === 'recreate') return 'Recreate'; |
|---|
| 1451 | + return 'Run'; |
|---|
| 1452 | +} |
|---|
| 1453 | + |
|---|
| 1454 | +function startOperation() { |
|---|
| 1455 | + const { type, project, fromEnv, toEnv } = opsCtx; |
|---|
| 1456 | + if (!type || !project) return; |
|---|
| 1457 | + |
|---|
| 1458 | + const dryRun = document.getElementById('ops-dry-run').checked; |
|---|
| 1459 | + const startBtn = document.getElementById('ops-start-btn'); |
|---|
| 1460 | + const outputDiv = document.getElementById('ops-modal-output'); |
|---|
| 1461 | + const term = document.getElementById('ops-modal-terminal'); |
|---|
| 1462 | + |
|---|
| 1463 | + outputDiv.style.display = 'block'; |
|---|
| 1464 | + term.textContent = 'Starting...\n'; |
|---|
| 1465 | + startBtn.disabled = true; |
|---|
| 1466 | + startBtn.textContent = 'Running...'; |
|---|
| 1467 | + |
|---|
| 1468 | + let url; |
|---|
| 1469 | + if (type === 'promote') { |
|---|
| 1470 | + url = '/api/promote/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '/' + encodeURIComponent(toEnv) + '?dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken()); |
|---|
| 1471 | + } else if (type === 'sync') { |
|---|
| 1472 | + 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=... |
|---|
| 1475 | + url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) |
|---|
| 1476 | + + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken()); |
|---|
| 1477 | + } |
|---|
| 1478 | + |
|---|
| 711 | 1479 | const es = new EventSource(url); |
|---|
| 1480 | + opsEventSource = es; |
|---|
| 1481 | + let opDone = false; |
|---|
| 1482 | + |
|---|
| 712 | 1483 | es.onmessage = function(e) { |
|---|
| 713 | | - const d = JSON.parse(e.data); |
|---|
| 714 | | - if (d.done) { |
|---|
| 715 | | - es.close(); |
|---|
| 716 | | - term.textContent += d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n'; |
|---|
| 717 | | - toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error'); |
|---|
| 718 | | - return; |
|---|
| 719 | | - } |
|---|
| 720 | | - if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; } |
|---|
| 1484 | + try { |
|---|
| 1485 | + const d = JSON.parse(e.data); |
|---|
| 1486 | + if (d.done) { |
|---|
| 1487 | + opDone = true; |
|---|
| 1488 | + es.close(); |
|---|
| 1489 | + opsEventSource = null; |
|---|
| 1490 | + const msg = d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n'; |
|---|
| 1491 | + term.textContent += msg; |
|---|
| 1492 | + term.scrollTop = term.scrollHeight; |
|---|
| 1493 | + toast(d.success ? 'Operation completed' : 'Operation failed', d.success ? 'success' : 'error'); |
|---|
| 1494 | + startBtn.disabled = false; |
|---|
| 1495 | + startBtn.textContent = _btnLabelForType(type); |
|---|
| 1496 | + |
|---|
| 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); |
|---|
| 1509 | + } |
|---|
| 1510 | + |
|---|
| 1511 | + return; |
|---|
| 1512 | + } |
|---|
| 1513 | + if (d.line) { |
|---|
| 1514 | + term.textContent += d.line + '\n'; |
|---|
| 1515 | + term.scrollTop = term.scrollHeight; |
|---|
| 1516 | + } |
|---|
| 1517 | + } catch (_) {} |
|---|
| 721 | 1518 | }; |
|---|
| 722 | | - es.onerror = function() { es.close(); term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); }; |
|---|
| 1519 | + |
|---|
| 1520 | + es.onerror = function() { |
|---|
| 1521 | + es.close(); |
|---|
| 1522 | + opsEventSource = null; |
|---|
| 1523 | + if (opDone) return; |
|---|
| 1524 | + term.textContent += '\n--- Connection lost ---\n'; |
|---|
| 1525 | + toast('Connection lost', 'error'); |
|---|
| 1526 | + startBtn.disabled = false; |
|---|
| 1527 | + startBtn.textContent = _btnLabelForType(type); |
|---|
| 1528 | + }; |
|---|
| 723 | 1529 | } |
|---|
| 724 | 1530 | |
|---|
| 725 | 1531 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 758 | 1564 | logCtx = { project: null, env: null, service: null }; |
|---|
| 759 | 1565 | } |
|---|
| 760 | 1566 | |
|---|
| 761 | | -function setBackupFilter(type, value) { |
|---|
| 762 | | - if (type === 'project') backupFilterProject = value; |
|---|
| 763 | | - if (type === 'env') backupFilterEnv = value; |
|---|
| 764 | | - renderBackups(); |
|---|
| 765 | | -} |
|---|
| 766 | | - |
|---|
| 767 | 1567 | async function createBackup(project, env) { |
|---|
| 768 | 1568 | if (!confirm(`Create backup for ${project}/${env}?`)) return; |
|---|
| 769 | 1569 | toast('Creating backup...', 'info'); |
|---|
| 770 | 1570 | try { |
|---|
| 771 | 1571 | await api(`/api/backups/${project}/${env}`, { method: 'POST' }); |
|---|
| 772 | 1572 | toast('Backup created for ' + project + '/' + env, 'success'); |
|---|
| 1573 | + cachedBackups = null; |
|---|
| 773 | 1574 | if (currentPage === 'backups') renderBackups(); |
|---|
| 774 | 1575 | } catch (e) { toast('Backup failed: ' + e.message, 'error'); } |
|---|
| 1576 | +} |
|---|
| 1577 | + |
|---|
| 1578 | +async function deleteBackup(project, env, name, hasLocal, hasOffsite) { |
|---|
| 1579 | + let target; |
|---|
| 1580 | + if (hasLocal && hasOffsite) { |
|---|
| 1581 | + target = await showDeleteTargetDialog(name); |
|---|
| 1582 | + if (!target) return; |
|---|
| 1583 | + } else if (hasLocal) { |
|---|
| 1584 | + target = 'local'; |
|---|
| 1585 | + } else { |
|---|
| 1586 | + target = 'offsite'; |
|---|
| 1587 | + } |
|---|
| 1588 | + const label = target === 'both' ? 'local + offsite' : target; |
|---|
| 1589 | + if (!confirm(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`)) return; |
|---|
| 1590 | + toast('Deleting backup (' + label + ')...', 'info'); |
|---|
| 1591 | + try { |
|---|
| 1592 | + await api(`/api/backups/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' }); |
|---|
| 1593 | + toast('Backup deleted: ' + name + ' (' + label + ')', 'success'); |
|---|
| 1594 | + cachedBackups = null; |
|---|
| 1595 | + if (currentPage === 'backups') renderBackups(); |
|---|
| 1596 | + } catch (e) { toast('Delete failed: ' + e.message, 'error'); } |
|---|
| 1597 | +} |
|---|
| 1598 | + |
|---|
| 1599 | +function showDeleteTargetDialog(name) { |
|---|
| 1600 | + return new Promise(resolve => { |
|---|
| 1601 | + const overlay = document.createElement('div'); |
|---|
| 1602 | + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:9999;'; |
|---|
| 1603 | + const box = document.createElement('div'); |
|---|
| 1604 | + box.style.cssText = 'background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;min-width:320px;max-width:420px;color:#e2e8f0;'; |
|---|
| 1605 | + box.innerHTML = ` |
|---|
| 1606 | + <h3 style="margin:0 0 0.5rem;font-size:1rem;color:#f1f5f9;">Delete from where?</h3> |
|---|
| 1607 | + <p style="margin:0 0 1.25rem;font-size:0.85rem;color:#94a3b8;">This backup exists in both local and offsite storage.</p> |
|---|
| 1608 | + <div style="display:flex;flex-direction:column;gap:0.5rem;"> |
|---|
| 1609 | + <button class="btn btn-ghost" style="justify-content:flex-start;color:#f87171;border-color:#7f1d1d;" data-target="local">Local only</button> |
|---|
| 1610 | + <button class="btn btn-ghost" style="justify-content:flex-start;color:#a78bfa;border-color:rgba(167,139,250,0.25);" data-target="offsite">Offsite only</button> |
|---|
| 1611 | + <button class="btn btn-danger" style="justify-content:flex-start;" data-target="both">Both (local + offsite)</button> |
|---|
| 1612 | + <button class="btn btn-ghost" style="justify-content:flex-start;margin-top:0.25rem;" data-target="">Cancel</button> |
|---|
| 1613 | + </div>`; |
|---|
| 1614 | + overlay.appendChild(box); |
|---|
| 1615 | + document.body.appendChild(overlay); |
|---|
| 1616 | + box.addEventListener('click', e => { |
|---|
| 1617 | + const btn = e.target.closest('[data-target]'); |
|---|
| 1618 | + if (!btn) return; |
|---|
| 1619 | + document.body.removeChild(overlay); |
|---|
| 1620 | + resolve(btn.dataset.target || null); |
|---|
| 1621 | + }); |
|---|
| 1622 | + overlay.addEventListener('click', e => { |
|---|
| 1623 | + if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); } |
|---|
| 1624 | + }); |
|---|
| 1625 | + }); |
|---|
| 775 | 1626 | } |
|---|
| 776 | 1627 | |
|---|
| 777 | 1628 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 781 | 1632 | const m = {}; |
|---|
| 782 | 1633 | for (const item of arr) { const k = item[key] || 'other'; (m[k] = m[k] || []).push(item); } |
|---|
| 783 | 1634 | return m; |
|---|
| 1635 | +} |
|---|
| 1636 | + |
|---|
| 1637 | +// --------------------------------------------------------------------------- |
|---|
| 1638 | +// URL Hash Routing |
|---|
| 1639 | +// --------------------------------------------------------------------------- |
|---|
| 1640 | +function pushHash() { |
|---|
| 1641 | + let hash = ''; |
|---|
| 1642 | + if (currentPage === 'dashboard') { |
|---|
| 1643 | + if (viewMode === 'table') { |
|---|
| 1644 | + hash = '/dashboard/table'; |
|---|
| 1645 | + if (tableFilter) hash += '/' + encodeURIComponent(tableFilter); |
|---|
| 1646 | + } else if (drillLevel === 2) { |
|---|
| 1647 | + hash = '/dashboard/' + encodeURIComponent(drillProject) + '/' + encodeURIComponent(drillEnv); |
|---|
| 1648 | + } else if (drillLevel === 1) { |
|---|
| 1649 | + hash = '/dashboard/' + encodeURIComponent(drillProject); |
|---|
| 1650 | + } else { |
|---|
| 1651 | + hash = '/dashboard'; |
|---|
| 1652 | + } |
|---|
| 1653 | + } else if (currentPage === 'backups') { |
|---|
| 1654 | + if (backupDrillLevel === 2) { |
|---|
| 1655 | + hash = '/backups/' + encodeURIComponent(backupDrillProject) + '/' + encodeURIComponent(backupDrillEnv); |
|---|
| 1656 | + } else if (backupDrillLevel === 1) { |
|---|
| 1657 | + hash = '/backups/' + encodeURIComponent(backupDrillProject); |
|---|
| 1658 | + } else { |
|---|
| 1659 | + hash = '/backups'; |
|---|
| 1660 | + } |
|---|
| 1661 | + } else if (currentPage === 'system') { |
|---|
| 1662 | + hash = '/system'; |
|---|
| 1663 | + } else if (currentPage === 'operations') { |
|---|
| 1664 | + hash = '/operations'; |
|---|
| 1665 | + } |
|---|
| 1666 | + const newHash = '#' + hash; |
|---|
| 1667 | + if (window.location.hash !== newHash) { |
|---|
| 1668 | + history.replaceState(null, '', newHash); |
|---|
| 1669 | + } |
|---|
| 1670 | +} |
|---|
| 1671 | + |
|---|
| 1672 | +function navigateToHash() { |
|---|
| 1673 | + const raw = (window.location.hash || '').replace(/^#\/?/, ''); |
|---|
| 1674 | + const parts = raw.split('/').map(decodeURIComponent).filter(Boolean); |
|---|
| 1675 | + |
|---|
| 1676 | + if (!parts.length) { showPage('dashboard'); return; } |
|---|
| 1677 | + |
|---|
| 1678 | + const page = parts[0]; |
|---|
| 1679 | + if (page === 'dashboard') { |
|---|
| 1680 | + currentPage = 'dashboard'; |
|---|
| 1681 | + drillLevel = 0; drillProject = null; drillEnv = null; |
|---|
| 1682 | + viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; |
|---|
| 1683 | + cachedBackups = null; |
|---|
| 1684 | + backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; |
|---|
| 1685 | + |
|---|
| 1686 | + if (parts[1] === 'table') { |
|---|
| 1687 | + viewMode = 'table'; |
|---|
| 1688 | + if (parts[2]) { tableFilter = parts[2]; tableFilterLabel = parts[2]; } |
|---|
| 1689 | + } else if (parts[1]) { |
|---|
| 1690 | + drillProject = parts[1]; drillLevel = 1; |
|---|
| 1691 | + if (parts[2]) { drillEnv = parts[2]; drillLevel = 2; } |
|---|
| 1692 | + } |
|---|
| 1693 | + document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => |
|---|
| 1694 | + el.classList.toggle('active', el.dataset.page === 'dashboard')); |
|---|
| 1695 | + renderPage(); |
|---|
| 1696 | + } else if (page === 'backups') { |
|---|
| 1697 | + currentPage = 'backups'; |
|---|
| 1698 | + drillLevel = 0; drillProject = null; drillEnv = null; |
|---|
| 1699 | + viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; |
|---|
| 1700 | + cachedBackups = null; |
|---|
| 1701 | + backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; |
|---|
| 1702 | + selectedBackups.clear(); |
|---|
| 1703 | + |
|---|
| 1704 | + if (parts[1]) { |
|---|
| 1705 | + backupDrillProject = parts[1]; backupDrillLevel = 1; |
|---|
| 1706 | + if (parts[2]) { backupDrillEnv = parts[2]; backupDrillLevel = 2; } |
|---|
| 1707 | + } |
|---|
| 1708 | + document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => |
|---|
| 1709 | + el.classList.toggle('active', el.dataset.page === 'backups')); |
|---|
| 1710 | + renderPage(); |
|---|
| 1711 | + } else if (page === 'system') { |
|---|
| 1712 | + showPage('system'); |
|---|
| 1713 | + } else if (page === 'operations') { |
|---|
| 1714 | + showPage('operations'); |
|---|
| 1715 | + } else { |
|---|
| 1716 | + showPage('dashboard'); |
|---|
| 1717 | + } |
|---|
| 784 | 1718 | } |
|---|
| 785 | 1719 | |
|---|
| 786 | 1720 | // --------------------------------------------------------------------------- |
|---|
| .. | .. |
|---|
| 795 | 1729 | allServices = data; |
|---|
| 796 | 1730 | document.getElementById('login-overlay').style.display = 'none'; |
|---|
| 797 | 1731 | document.getElementById('app').style.display = 'flex'; |
|---|
| 798 | | - const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION; |
|---|
| 799 | | - showPage('dashboard'); |
|---|
| 1732 | + const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION; |
|---|
| 1733 | + navigateToHash(); |
|---|
| 800 | 1734 | startAutoRefresh(); |
|---|
| 801 | 1735 | }) |
|---|
| 802 | 1736 | .catch(() => { localStorage.removeItem('ops_token'); }); |
|---|
| 803 | 1737 | } |
|---|
| 804 | | - document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLogModal(); }); |
|---|
| 1738 | + document.addEventListener('keydown', e => { |
|---|
| 1739 | + if (e.key === 'Escape') { |
|---|
| 1740 | + closeLogModal(); |
|---|
| 1741 | + closeRestoreModal(); |
|---|
| 1742 | + closeOpsModal(); |
|---|
| 1743 | + } |
|---|
| 1744 | + }); |
|---|
| 1745 | + window.addEventListener('hashchange', () => { |
|---|
| 1746 | + if (getToken()) navigateToHash(); |
|---|
| 1747 | + }); |
|---|
| 805 | 1748 | })(); |
|---|