Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
b374787486 Merge claude/1 2026-06-01 01:36:25 +01:00
librelad
c508a20605 feat(webui): embed the full backup center in the Overview Backups tab
Instead of a glance + 'Open backup center' button, the Backups tab now mounts
the real BackupPage (dashboard/snapshots/locations/migrate/configuration) inline,
with its sidebar restyled as a nested tab strip and its own header taking over.

- BackupPage gains an embedded mode (opts.embedded): no /backup URL coupling, so
  sub-tabs switch in-page under /overview/backups. Backward compatible.
- OverviewManager lazy-loads the backup bundle + fragment on first open, news a
  BackupPage({embedded:true}), and disposes it on apps-feature unmount. Colliding
  ids (#sidebar/#mobile-overlay) are stripped on inject.
- Revert the Admin backup-config surface — the embedded center (incl.
  Configuration) is now the single home for backup settings.
- The updater needs no equivalent: its sections were already unpacked into the
  Overview/Updates/Improvements tabs + the per-row expander.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:36:25 +01:00
5 changed files with 129 additions and 73 deletions

View File

@ -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';

View File

@ -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;
},
});

View File

@ -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; }

View File

@ -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 `<div class="updater-empty">No backup data yet. <button class="updater-btn" data-overview-action="open-backup">Open backup center</button></div>`;
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) => `
<div class="ov-backup-tile"${app ? ` data-overview-action="open-app" data-app="${esc(name)}" role="button" tabindex="0"` : ''}>
<span class="ov-backup-dot ${snap ? 'ok' : 'warn'}"></span>
<span class="ov-backup-name">${esc(name)}</span>
<span class="ov-backup-time">${snap ? `backed up ${rel(time)}` : 'no backup yet'}</span>
</div>`;
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) =>
`<div class="ov-loc-row"><span>${esc(l.name)}</span><span class="updater-detail-meta">${esc(l.type || '')}</span></div>`).join('');
const protectedCount = apps.filter((a) => a.latest_snapshot).length;
return `
<div class="updater-toolbar ov-toolbar">
<div class="ov-backup-summary">${protectedCount}/${apps.length} apps protected</div>
<div class="ov-toolbar-actions">
<button class="updater-btn" data-overview-action="open-backup">Open backup center</button>
</div>
</div>
<div class="ov-backup-grid">${tiles}</div>
${locRows ? `<div class="ov-loc-list"><h4>Locations</h4>${locRows}</div>` : ''}`;
// 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 = '<div class="updater-empty">Loading backup center…</div>';
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('<div class="mobile-overlay" id="mobile-overlay"></div>', '');
pane.innerHTML = html;
if (typeof BackupPage === 'undefined') {
pane.innerHTML = '<div class="updater-empty">Backup center unavailable.</div>';
return;
}
try { if (window.overviewBackupPage) window.overviewBackupPage.dispose(); } catch (_) {}
window.overviewBackupPage = new BackupPage({ embedded: true });
await window.overviewBackupPage.init();
} catch (_) {
pane.innerHTML = '<div class="updater-empty">Failed to load the backup center.</div>';
} 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 ---------------------------------------------------------------

View File

@ -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/<tab> (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.