Merge claude/1
This commit is contained in:
commit
b374787486
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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 ---------------------------------------------------------------
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user