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 var self = this; // Preserve 'this' context
categoriesArray.forEach(function(category) { categoriesArray.forEach(function(category) {
// The backup config category (engine/schedule/retention) surfaces here in // Backup config (engine/schedule/retention) now lives in the Backups tab's
// Admin. The operational backup center (/backup, reached from Overview // embedded center (Overview Backups Configuration), so it's hidden from
// Backups) keeps locations, migrate and snapshots. Both bind the same // the Admin config sidebar to avoid a second surface for the same data.
// generated category, so edits stay in sync. if (category.id === 'backup') return;
const categoryItem = document.createElement('div'); const categoryItem = document.createElement('div');
categoryItem.className = 'category'; categoryItem.className = 'category';

View File

@ -123,5 +123,9 @@ LP.features.register({
// overviewManager singleton + its DOM persist with the layout; its run() // overviewManager singleton + its DOM persist with the layout; its run()
// self-guards, but unregistering is the clean release. // self-guards, but unregistering is the clean release.
try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {} 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-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; } .updater-detail-empty { color: var(--text-muted, rgba(255, 255, 255, .6)); margin: 0; }
/* ---- Backups tab -------------------------------------------------------- */ /* ---- Backups tab: embedded backup center -------------------------------- */
.ov-backup-summary { font-weight: 600; } /* The Backups tab mounts the real BackupPage. Its own page-header replaces the
.ov-backup-grid { generic fleet header, and its left sidebar is restyled into a horizontal
display: grid; nested tab strip so the whole thing reads as tabs-within-tabs. */
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); #overview-view.ov-backups-active .overview-header { display: none; }
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; }
.ov-loc-list { margin-top: 20px; } #overview-view #ov-pane-backups .backup-layout { display: block; }
.ov-loc-list h4 { #overview-view #ov-pane-backups .backup-layout > .sidebar {
margin: 0 0 8px; width: auto;
font-size: .8rem; max-width: none;
text-transform: uppercase; height: auto;
letter-spacing: .04em; min-height: 0;
color: var(--text-muted, rgba(255, 255, 255, .6)); 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; display: flex;
align-items: center; flex-wrap: wrap;
gap: 12px; gap: 8px;
padding: 8px 0; }
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, .08)); #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 ----------------------------------------- */ /* ---- per-app Updates tab header ----------------------------------------- */
.app-updater-section { padding: 4px 0; } .app-updater-section { padding: 4px 0; }

View File

@ -117,6 +117,10 @@ class OverviewManager {
_applyTab(id) { _applyTab(id) {
if (this.tabs) this.tabs.switch(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.updateHeader(id);
this.renderTab(id); this.renderTab(id);
this.current = id; this.current = id;
@ -153,7 +157,7 @@ class OverviewManager {
break; break;
} }
case 'improvements': pane.innerHTML = this.renderImprovements(); 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 'goto': this.switchTab(oa.dataset.tab); break;
case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break; case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break;
case 'toggle': this.toggleAppDetails(oa.dataset.app); 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) --------- // ---- Backups tab (fleet health glance; actions deep-link per app) ---------
renderBackups() { // The Backups tab hosts the real backup center — the BackupPage controller
const d = this.backup; // embedded in the pane, with its own dashboard/snapshots/locations/migrate/
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>`; // configuration sections (its sidebar restyled as a nested tab strip). On a
const apps = Array.isArray(d.apps) ? d.apps : []; // revisit we refresh rather than re-mount, to keep its sub-tab + expand state.
const sys = d.system || {}; mountBackupCenter(pane) {
const locs = Array.isArray(d.locations) ? d.locations : []; if (document.getElementById('backup-section') && window.overviewBackupPage) {
const esc = (s) => this.escape(s); try { window.overviewBackupPage.refreshAll().then(() => window.overviewBackupPage.render()).catch(() => {}); } catch (_) {}
const rel = (t) => (t && this.updater) ? this.updater.fmtRel(t) : 'never'; return;
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"` : ''}> pane.innerHTML = '<div class="updater-empty">Loading backup center…</div>';
<span class="ov-backup-dot ${snap ? 'ok' : 'warn'}"></span> this._loadBackupCenter(pane);
<span class="ov-backup-name">${esc(name)}</span> }
<span class="ov-backup-time">${snap ? `backed up ${rel(time)}` : 'no backup yet'}</span>
</div>`; async _loadBackupCenter(pane) {
const sysTile = tile('System', sys.latest_snapshot, sys.latest_time, false); if (this._backupLoading) return;
const tiles = sysTile + apps.map((a) => tile(a.app, a.latest_snapshot, a.latest_time, true)).join(''); this._backupLoading = true;
const locRows = locs.map((l) => try {
`<div class="ov-loc-row"><span>${esc(l.name)}</span><span class="updater-detail-meta">${esc(l.type || '')}</span></div>`).join(''); await this._ensureBackupAssets();
const protectedCount = apps.filter((a) => a.latest_snapshot).length; let html = await fetch('/components/backup/core/html/backup-content.html', { cache: 'no-store' }).then((r) => r.text());
return ` // Strip ids that would collide with the apps layout (it also has #sidebar
<div class="updater-toolbar ov-toolbar"> // and #mobile-overlay). BackupPage selects its own nodes by class, so this
<div class="ov-backup-summary">${protectedCount}/${apps.length} apps protected</div> // is safe; it just keeps the document free of duplicate ids.
<div class="ov-toolbar-actions"> html = html.replace('id="sidebar"', '').replace('<div class="mobile-overlay" id="mobile-overlay"></div>', '');
<button class="updater-btn" data-overview-action="open-backup">Open backup center</button> pane.innerHTML = html;
</div> if (typeof BackupPage === 'undefined') {
</div> pane.innerHTML = '<div class="updater-empty">Backup center unavailable.</div>';
<div class="ov-backup-grid">${tiles}</div> return;
${locRows ? `<div class="ov-loc-list"><h4>Locations</h4>${locRows}</div>` : ''}`; }
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 --------------------------------------------------------------- // ---- utils ---------------------------------------------------------------

View File

@ -6,7 +6,10 @@
// Module-level schema/retention data moved to backup-schema.js (loaded first). // Module-level schema/retention data moved to backup-schema.js (loaded first).
class BackupPage { 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.currentTab = 'dashboard';
this.dashboard = null; this.dashboard = null;
this.locations = null; this.locations = null;
@ -35,6 +38,7 @@ class BackupPage {
and /backup?backup=dashboard (standard query string) so links from and /backup?backup=dashboard (standard query string) so links from
either source resolve correctly. */ either source resolve correctly. */
parseTabFromUrl() { 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']); const allowed = new Set(['dashboard', 'backups', 'locations', 'migrate', 'configuration']);
// Path-based: /backup/<tab> (bare /backup → default tab). // Path-based: /backup/<tab> (bare /backup → default tab).
const seg = window.location.pathname.replace(/^\/backup\/?/, '').split('/')[0]; const seg = window.location.pathname.replace(/^\/backup\/?/, '').split('/')[0];
@ -357,6 +361,7 @@ class BackupPage {
} }
pushTabToUrl(tab) { pushTabToUrl(tab) {
if (this.embedded) return; // embedded: keep the URL at /overview/backups (no sub-tab coupling)
const url = `/backup/${tab}`; const url = `/backup/${tab}`;
// Use replaceState for the *first* push (initial tab inferred from // Use replaceState for the *first* push (initial tab inferred from
// URL); otherwise pushState so back/forward navigates between tabs. // URL); otherwise pushState so back/forward navigates between tabs.