diff --git a/containers/libreportal/frontend/components/admin/config/js/config-sidebar.js b/containers/libreportal/frontend/components/admin/config/js/config-sidebar.js index fcd8439..726295d 100755 --- a/containers/libreportal/frontend/components/admin/config/js/config-sidebar.js +++ b/containers/libreportal/frontend/components/admin/config/js/config-sidebar.js @@ -76,10 +76,10 @@ class ConfigSidebar { var self = this; // Preserve 'this' context categoriesArray.forEach(function(category) { - // The backup config category (engine/schedule/retention) surfaces here in - // Admin. The operational backup center (/backup, reached from Overview › - // Backups) keeps locations, migrate and snapshots. Both bind the same - // generated category, so edits stay in sync. + // Backup config (engine/schedule/retention) now lives in the Backups tab's + // embedded center (Overview › Backups › Configuration), so it's hidden from + // the Admin config sidebar to avoid a second surface for the same data. + if (category.id === 'backup') return; const categoryItem = document.createElement('div'); categoryItem.className = 'category'; diff --git a/containers/libreportal/frontend/components/apps/index.js b/containers/libreportal/frontend/components/apps/index.js index caa4739..2dd20e6 100644 --- a/containers/libreportal/frontend/components/apps/index.js +++ b/containers/libreportal/frontend/components/apps/index.js @@ -123,5 +123,9 @@ LP.features.register({ // overviewManager singleton + its DOM persist with the layout; its run() // self-guards, but unregistering is the clean release. try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {} + // The Backups tab embeds a BackupPage; release its document listeners + + // task-refresh registration on the way out (the "stacks on revisit" bug). + try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {} + window.overviewBackupPage = null; }, }); diff --git a/containers/libreportal/frontend/components/apps/overview/css/overview.css b/containers/libreportal/frontend/components/apps/overview/css/overview.css index 07fbc4f..64c5144 100644 --- a/containers/libreportal/frontend/components/apps/overview/css/overview.css +++ b/containers/libreportal/frontend/components/apps/overview/css/overview.css @@ -131,47 +131,57 @@ .updater-detail-meta { color: var(--text-muted, rgba(255, 255, 255, .6)); font-size: .85rem; } .updater-detail-empty { color: var(--text-muted, rgba(255, 255, 255, .6)); margin: 0; } -/* ---- Backups tab -------------------------------------------------------- */ -.ov-backup-summary { font-weight: 600; } -.ov-backup-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 12px; - margin-top: 14px; -} -.ov-backup-tile { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 14px; - border-radius: 10px; - border: 1px solid var(--border-color, rgba(255, 255, 255, .12)); - background: var(--input-bg, rgba(255, 255, 255, .03)); -} -.ov-backup-tile[role="button"] { cursor: pointer; transition: border-color .15s ease, background .15s ease; } -.ov-backup-tile[role="button"]:hover { border-color: rgba(var(--page-backups-rgb), .5); background: rgba(var(--page-backups-rgb), .08); } -.ov-backup-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; } -.ov-backup-dot.ok { background: var(--page-verify, #1fb88a); } -.ov-backup-dot.warn { background: var(--page-system, #f0883e); } -.ov-backup-name { font-weight: 600; } -.ov-backup-time { margin-left: auto; color: var(--text-muted, rgba(255, 255, 255, .6)); font-size: .82rem; } +/* ---- Backups tab: embedded backup center -------------------------------- */ +/* The Backups tab mounts the real BackupPage. Its own page-header replaces the + generic fleet header, and its left sidebar is restyled into a horizontal + nested tab strip so the whole thing reads as tabs-within-tabs. */ +#overview-view.ov-backups-active .overview-header { display: none; } -.ov-loc-list { margin-top: 20px; } -.ov-loc-list h4 { - margin: 0 0 8px; - font-size: .8rem; - text-transform: uppercase; - letter-spacing: .04em; - color: var(--text-muted, rgba(255, 255, 255, .6)); +#overview-view #ov-pane-backups .backup-layout { display: block; } +#overview-view #ov-pane-backups .backup-layout > .sidebar { + width: auto; + max-width: none; + height: auto; + min-height: 0; + margin: 0 0 16px; + padding: 0; + background: transparent; + border: none; } -.ov-loc-row { +#overview-view #ov-pane-backups .backup-layout > .sidebar #backup-sidebar-list, +#overview-view #ov-pane-backups .backup-layout > .sidebar .sidebar-section { display: flex; - align-items: center; - gap: 12px; - padding: 8px 0; - border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, .08)); + flex-wrap: wrap; + gap: 8px; +} +#overview-view #ov-pane-backups .backup-layout > .sidebar .category { + display: inline-flex; + align-items: center; + gap: 8px; + width: auto; + margin: 0; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid var(--border-color, rgba(255, 255, 255, .14)); + cursor: pointer; +} +#overview-view #ov-pane-backups .backup-layout > .sidebar .category:hover { + background: rgba(255, 255, 255, .06); +} +#overview-view #ov-pane-backups .backup-layout > .sidebar .category.active { + background: rgba(var(--page-backups-rgb), .18); + border-color: rgba(var(--page-backups-rgb), .5); + color: #fff; +} +#overview-view #ov-pane-backups .backup-layout > .sidebar .category .category-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; +} +#overview-view #ov-pane-backups .backup-layout > .main { + width: 100%; + min-width: 0; } -.ov-loc-row span:first-child { font-weight: 500; } /* ---- per-app Updates tab header ----------------------------------------- */ .app-updater-section { padding: 4px 0; } diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index 58197de..6901295 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -117,6 +117,10 @@ class OverviewManager { _applyTab(id) { if (this.tabs) this.tabs.switch(id); + // The Backups tab embeds the full backup center, which brings its own header + // and a nested sub-tab strip — hide the generic fleet header for it. + const root = document.getElementById('overview-view'); + if (root) root.classList.toggle('ov-backups-active', id === 'backups'); this.updateHeader(id); this.renderTab(id); this.current = id; @@ -153,7 +157,7 @@ class OverviewManager { break; } case 'improvements': pane.innerHTML = this.renderImprovements(); break; - case 'backups': pane.innerHTML = this.renderBackups(); break; + case 'backups': this.mountBackupCenter(pane); break; } } @@ -190,8 +194,6 @@ class OverviewManager { case 'goto': this.switchTab(oa.dataset.tab); break; case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break; case 'toggle': this.toggleAppDetails(oa.dataset.app); break; - case 'open-app': window.navigateToRoute && window.navigateToRoute(`/app/${oa.dataset.app}/backups`); break; - case 'open-backup': window.navigateToRoute && window.navigateToRoute('/backup'); break; } } } @@ -352,34 +354,69 @@ class OverviewManager { // ---- Backups tab (fleet health glance; actions deep-link per app) --------- - renderBackups() { - const d = this.backup; - if (!d) return `
No backup data yet.
`; - const apps = Array.isArray(d.apps) ? d.apps : []; - const sys = d.system || {}; - const locs = Array.isArray(d.locations) ? d.locations : []; - const esc = (s) => this.escape(s); - const rel = (t) => (t && this.updater) ? this.updater.fmtRel(t) : 'never'; - const tile = (name, snap, time, app) => ` -
- - ${esc(name)} - ${snap ? `backed up ${rel(time)}` : 'no backup yet'} -
`; - const sysTile = tile('System', sys.latest_snapshot, sys.latest_time, false); - const tiles = sysTile + apps.map((a) => tile(a.app, a.latest_snapshot, a.latest_time, true)).join(''); - const locRows = locs.map((l) => - `
${esc(l.name)}${esc(l.type || '')}
`).join(''); - const protectedCount = apps.filter((a) => a.latest_snapshot).length; - return ` -
-
${protectedCount}/${apps.length} apps protected
-
- -
-
-
${tiles}
- ${locRows ? `

Locations

${locRows}
` : ''}`; + // The Backups tab hosts the real backup center — the BackupPage controller + // embedded in the pane, with its own dashboard/snapshots/locations/migrate/ + // configuration sections (its sidebar restyled as a nested tab strip). On a + // revisit we refresh rather than re-mount, to keep its sub-tab + expand state. + mountBackupCenter(pane) { + if (document.getElementById('backup-section') && window.overviewBackupPage) { + try { window.overviewBackupPage.refreshAll().then(() => window.overviewBackupPage.render()).catch(() => {}); } catch (_) {} + return; + } + pane.innerHTML = '
Loading backup center…
'; + this._loadBackupCenter(pane); + } + + async _loadBackupCenter(pane) { + if (this._backupLoading) return; + this._backupLoading = true; + try { + await this._ensureBackupAssets(); + let html = await fetch('/components/backup/core/html/backup-content.html', { cache: 'no-store' }).then((r) => r.text()); + // Strip ids that would collide with the apps layout (it also has #sidebar + // and #mobile-overlay). BackupPage selects its own nodes by class, so this + // is safe; it just keeps the document free of duplicate ids. + html = html.replace('id="sidebar"', '').replace('
', ''); + pane.innerHTML = html; + if (typeof BackupPage === 'undefined') { + pane.innerHTML = '
Backup center unavailable.
'; + return; + } + try { if (window.overviewBackupPage) window.overviewBackupPage.dispose(); } catch (_) {} + window.overviewBackupPage = new BackupPage({ embedded: true }); + await window.overviewBackupPage.init(); + } catch (_) { + pane.innerHTML = '
Failed to load the backup center.
'; + } finally { + this._backupLoading = false; + } + } + + // Lazy-load the backup controller bundle once (idempotent: spaClean.loadScript + // dedupes by URL, and several of these are already in the apps bundle). + _ensureBackupAssets() { + if (this._backupAssets) return this._backupAssets; + const scripts = [ + '/components/backup/core/js/backup-schema.js', + '/components/backup/core/js/backup-page.js', + '/components/backup/core/js/backup-fetch-client.js', + '/components/backup/core/js/backup-cron-schedule.js', + '/components/backup/dashboard/js/backup-dashboard.js', + '/components/backup/snapshots/js/backup-snapshots.js', + '/components/backup/snapshots/js/backup-snapshot-actions.js', + '/components/backup/locations/js/backup-locations.js', + '/components/backup/locations/js/backup-location-fields.js', + '/components/backup/locations/js/backup-location-modal.js', + '/components/backup/locations/js/backup-ssh-key.js', + '/components/backup/migrate/js/backup-migrate.js', + '/components/backup/configuration/js/backup-configuration.js', + '/components/backup/configuration/js/backup-retention-presets.js', + '/components/backup/configuration/js/backup-engine-details.js', + '/core/backup-card/js/backup-app-card.js', + ]; + const load = (url) => (window.spaClean && window.spaClean.loadScript) ? window.spaClean.loadScript(url) : Promise.resolve(); + this._backupAssets = scripts.reduce((p, url) => p.then(() => load(url)), Promise.resolve()); + return this._backupAssets; } // ---- utils --------------------------------------------------------------- diff --git a/containers/libreportal/frontend/components/backup/core/js/backup-page.js b/containers/libreportal/frontend/components/backup/core/js/backup-page.js index 1d54e0c..7fa9427 100644 --- a/containers/libreportal/frontend/components/backup/core/js/backup-page.js +++ b/containers/libreportal/frontend/components/backup/core/js/backup-page.js @@ -6,7 +6,10 @@ // Module-level schema/retention data moved to backup-schema.js (loaded first). class BackupPage { - constructor() { + constructor(opts = {}) { + // Embedded in the fleet Overview's Backups tab: the same controller, but + // it must not couple to the /backup URL (the fleet owns /overview/backups). + this.embedded = !!opts.embedded; this.currentTab = 'dashboard'; this.dashboard = null; this.locations = null; @@ -35,6 +38,7 @@ class BackupPage { and /backup?backup=dashboard (standard query string) so links from either source resolve correctly. */ parseTabFromUrl() { + if (this.embedded) return null; // embedded: always open on Dashboard; sub-tabs are in-page only const allowed = new Set(['dashboard', 'backups', 'locations', 'migrate', 'configuration']); // Path-based: /backup/ (bare /backup → default tab). const seg = window.location.pathname.replace(/^\/backup\/?/, '').split('/')[0]; @@ -357,6 +361,7 @@ class BackupPage { } pushTabToUrl(tab) { + if (this.embedded) return; // embedded: keep the URL at /overview/backups (no sub-tab coupling) const url = `/backup/${tab}`; // Use replaceState for the *first* push (initial tab inferred from // URL); otherwise pushState so back/forward navigates between tabs.