Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
9a58869899 Merge claude/2 2026-06-17 18:40:40 +01:00
librelad
0bcde854e6 refactor(webui): move fleet Overview under /apps/overview; retire standalone /backup
The fleet Overview area (Overview/Updates/Improvements/Backups/Migrate) now
lives at /apps/overview* instead of /overview*, reflecting that it belongs to
App Center. The Backups tab is therefore /apps/overview/backups, and the old
standalone Backup Center page is removed entirely:

- apps feature owns /apps/overview* (covered by the existing /apps* route); its
  mount() dispatches /apps/overview -> fleet Overview before the grid check.
- _legacyRedirect() rewrites old short URLs so bookmarks/links keep working and
  the address bar shows the canonical path:
    /overview[/tab] -> /apps/overview[/tab]
    /backup[/sub]   -> /apps/overview/backups[/sub]
    /updater, /peers redirects retargeted to /apps/overview*
- Removed the standalone backup feature: components/backup/{index.js,feature.json},
  its manifest entry, the /backup route registrations and the dead handleBackup().
  The BackupPage classes stay — the Overview Backups tab embeds them.
- Repointed every backups/overview link: the admin dashboard's 'Open backup
  center', the app-card 'Open backup center' button + snapshot-overflow link,
  the sidebar Overview entry, the improvements deep-link, and the Migrate
  'go to locations' deep-link.

Also drop the redundant inline Check button from the Security empty state
(same rationale as Improvements: the host auto-scan repopulates it and the
header carries a manual Check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-17 18:40:40 +01:00
13 changed files with 53 additions and 137 deletions

View File

@ -70,7 +70,7 @@ class OverviewPage {
go(where) {
if (where === 'backup') {
window.spaClean?.navigate('/backup', true);
window.spaClean?.navigate('/apps/overview/backups', true);
} else if (where === 'ssh' || where === 'security' || where === 'system') {
const target = where === 'ssh' ? 'ssh-access' : where;
window.history.pushState({}, '', window.adminPath(target));

View File

@ -9,7 +9,7 @@
<!-- Fleet Overview entry — pinned above the search box, opens the
Overview · Updates · Improvements · Backups tabs in the main pane. -->
<div class="sidebar-overview-entry" id="sidebar-overview-entry" role="button" tabindex="0"
onclick="if(window.navigateToRoute){window.navigateToRoute('/overview');}else{window.location.href='/overview';}"
onclick="if(window.navigateToRoute){window.navigateToRoute('/apps/overview');}else{window.location.href='/apps/overview';}"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}">
<svg class="ov-entry-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="9"></rect>
@ -210,7 +210,7 @@
</svg>
Backup now
</button>
<a class="btn btn-secondary" href="/backup" onclick="event.preventDefault(); if(window.navigateToRoute){window.navigateToRoute('/backup');}else{window.location.href='/backup';}">
<a class="btn btn-secondary" href="/apps/overview/backups" onclick="event.preventDefault(); if(window.navigateToRoute){window.navigateToRoute('/apps/overview/backups');}else{window.location.href='/apps/overview/backups';}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>

View File

@ -497,9 +497,9 @@ class AppsManager {
chip.style.cursor = 'pointer';
chip.style.display = '';
chip.onclick = () => {
if (typeof window.navigateToRoute === 'function') window.navigateToRoute('/overview/improvements');
else if (typeof window.spaClean === 'function') window.spaClean('/overview/improvements');
else window.location.href = '/overview/improvements';
if (typeof window.navigateToRoute === 'function') window.navigateToRoute('/apps/overview/improvements');
else if (typeof window.spaClean === 'function') window.spaClean('/apps/overview/improvements');
else window.location.href = '/apps/overview/improvements';
};
} catch (_) { /* best-effort */ }
}

View File

@ -4,9 +4,7 @@
"/apps",
"/apps*",
"/app",
"/app*",
"/overview",
"/overview*"
"/app*"
],
"module": "/components/apps/index.js",
"handler": "handleApps",

View File

@ -6,12 +6,15 @@
// sibling component; it's the same feature, so it lives here.)
LP.features.register({
id: 'apps',
routes: ['/apps', '/apps*', '/app', '/app*', '/overview', '/overview*'],
routes: ['/apps', '/apps*', '/app', '/app*'],
async mount(ctx) {
// /overview* -> fleet Overview; /apps* -> grid; everything else (/app*) ->
// detail. Check '/apps' before '/app' (since '/apps'.startsWith('/app')).
if (window.location.pathname.startsWith('/overview')) {
// /apps/overview* -> fleet Overview; /apps* -> grid; everything else
// (/app*) -> detail. The Overview check must precede the bare '/apps' one
// (since '/apps/overview'.startsWith('/apps')), and '/apps' before '/app'
// (since '/apps'.startsWith('/app')). Legacy /overview* is rewritten to
// /apps/overview* by _legacyRedirect() before this runs.
if (window.location.pathname.startsWith('/apps/overview')) {
return this._mountOverview(ctx);
}
if (window.location.pathname.startsWith('/apps')) {
@ -78,7 +81,7 @@ LP.features.register({
await window.appTabbedManager.initialize();
},
// ---- fleet Overview (/overview[/<tab>]) ----
// ---- fleet Overview (/apps/overview[/<tab>]) ----
async _mountOverview(ctx) {
// Lazy-load the fleet controller + its deps. Guard by typeof so re-entry
// never re-declares the classes (loadScripts dedupes by URL too). The

View File

@ -124,7 +124,7 @@ class OverviewManager {
parseTabFromUrl() {
const allowed = new Set(['overview', 'updates', 'improvements', 'backups', 'migrate']);
const seg = window.location.pathname.replace(/^\/overview\/?/, '').split('/')[0];
const seg = window.location.pathname.replace(/^\/apps\/overview\/?/, '').split('/')[0];
return (seg && allowed.has(seg)) ? seg : null;
}
@ -134,7 +134,7 @@ class OverviewManager {
switchTab(id) {
if (!id || id === this.current) return;
this._applyTab(id);
const url = id === 'overview' ? '/overview' : `/overview/${id}`;
const url = id === 'overview' ? '/apps/overview' : `/apps/overview/${id}`;
this._pushUrl(url, false);
}
@ -212,7 +212,7 @@ class OverviewManager {
if (pane && !pane.querySelector(':scope > .ov-tab-header')) {
pane.insertAdjacentHTML('afterbegin', this.renderHeader('migrate'));
}
const seg = window.location.pathname.replace(/^\/overview\/migrate\/?/, '').split('/')[0];
const seg = window.location.pathname.replace(/^\/apps\/overview\/migrate\/?/, '').split('/')[0];
const sub = (seg === 'peers' || seg === 'restore') ? seg : (this._migrateSub || 'restore');
this.switchMigrateSub(sub, { fromUrl: true });
}
@ -225,7 +225,7 @@ class OverviewManager {
pane.querySelectorAll('.ov-subtabs-content .tab-panel[data-ov-subtab]').forEach((p) => p.classList.toggle('active', p.dataset.ovSubtab === sub));
if (sub === 'restore') this._mountRestore();
else if (sub === 'peers') this._mountPeers();
if (!opts.fromUrl) this._pushUrl(`/overview/migrate/${sub}`, true);
if (!opts.fromUrl) this._pushUrl(`/apps/overview/migrate/${sub}`, true);
}
async _mountRestore() {
@ -601,9 +601,9 @@ class OverviewManager {
// 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) {
// Honor an optional sub-tab deep-link (/overview/backups/<sub>), e.g. the
// Restore empty-state's "Open Locations" button.
const seg = window.location.pathname.replace(/^\/overview\/backups\/?/, '').split('/')[0];
// Honor an optional sub-tab deep-link (/apps/overview/backups/<sub>), e.g.
// the Restore empty-state's "Open Locations" button.
const seg = window.location.pathname.replace(/^\/apps\/overview\/backups\/?/, '').split('/')[0];
const sub = ['dashboard', 'backups', 'locations', 'configuration'].includes(seg) ? seg : null;
// Detect a prior mount by the embedded fragment in THIS pane — not by
// #backup-section, which the per-app Backups tab also defines.

View File

@ -68,7 +68,7 @@ class MigratePage {
}
const locBtn = e.target.closest('[data-action="go-to-locations"]');
if (locBtn && this.root()?.contains(locBtn)) {
if (window.navigateToRoute) window.navigateToRoute('/overview/backups/locations');
if (window.navigateToRoute) window.navigateToRoute('/apps/overview/backups/locations');
return;
}
if (e.target.closest('#ov-migrate-confirm')) { this.confirmMigrate(); return; }

View File

@ -1,8 +0,0 @@
{
"id": "backup",
"routes": ["/backup", "/backup*"],
"module": "/components/backup/index.js",
"handler": "handleBackup",
"navId": "nav-backup",
"order": 50
}

View File

@ -1,56 +0,0 @@
// components/backup/index.js — Backup Center as a self-contained feature module.
//
// FIRST page migrated to the feature-module contract (docs/architecture/webui-architecture.md).
// The kernel drives mount()/unmount() for the /backup route instead of
// spa.js's handleBackup(). The heavy controller (backup-page.js, ~129KB) is
// still lazy-loaded on first mount, so cold-load cost is unchanged.
//
// Snap-out demo: deleting this folder removes the backup route's module
// registration; the manifest entry then falls back to the legacy handler
// (and, once handleBackup is retired, to the kernel's not-found route). The
// full decomposition of backup-page.js into per-tab modules is Phase 5.
LP.features.register({
id: 'backup',
routes: ['/backup', '/backup*'],
// Controllers the feature needs; lazy-loaded on first mount (idempotent).
// Controllers, organised by sub-system (tabs). core/ first: schema + base
// class + the shared data/cron, then each tab's prototype-augment clusters.
scripts: [
'/components/backup/core/js/backup-schema.js',
'/components/backup/core/js/backup-page.js', // base: class + constructor + init/switchTab/render
'/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/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',
],
async mount(ctx) {
await ctx.loadScripts(this.scripts);
const html = await ctx.loadFragment('/components/backup/core/html/backup-content.html');
ctx.setContent(html, 'Backups');
if (typeof BackupPage === 'undefined') {
throw new Error('BackupPage controller failed to load');
}
window.backupPage = new BackupPage();
await window.backupPage.init();
},
async unmount(ctx) {
// Release the page's document listeners + task-refresh registration so a
// navigation away doesn't leave stale BackupPage listeners firing on the
// live DOM — the backup sidebar "content stacks on revisit" bug. dispose()
// aborts the click/input/change listeners and drops the coordinator reg.
try { window.backupPage && window.backupPage.dispose(); } catch (_) {}
try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('backups'); } catch (_) {}
window.backupPage = null;
},
});

View File

@ -23,9 +23,7 @@
"/apps",
"/apps*",
"/app",
"/app*",
"/overview",
"/overview*"
"/app*"
],
"module": "/components/apps/index.js",
"handler": "handleApps",
@ -75,17 +73,6 @@
"module": "/components/updater/index.js",
"navId": "nav-updater",
"order": 30
},
{
"id": "backup",
"routes": [
"/backup",
"/backup*"
],
"module": "/components/backup/index.js",
"handler": "handleBackup",
"navId": "nav-backup",
"order": 50
}
]
}

View File

@ -379,7 +379,10 @@ class UpdaterPage {
renderSecurity() {
const withCves = this.apps.filter(a => (a.cves || []).length);
if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.', true);
// No inline Check button: the host auto-scan runs the vulnerability scan on
// its own within a couple of minutes (and the embedding header carries a
// manual Check), so the message alone is the right button-free empty UI.
if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.');
if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉');
const blocks = withCves.map(a => {
const items = (a.cves || []).map(c => `

View File

@ -80,7 +80,7 @@ class BackupAppCard {
<div class="backup-snapshot-rows">
${allSnaps.slice(0, 50).map(s => this._renderRow(s, iconUrl)).join('')}
</div>
${allSnaps.length > 50 ? `<div class="backup-snapshot-overflow">Showing the most recent 50 of ${allSnaps.length} backups. Use the <a href="/backup">backup center</a> for the full list.</div>` : ''}
${allSnaps.length > 50 ? `<div class="backup-snapshot-overflow">Showing the most recent 50 of ${allSnaps.length} backups. Use the <a href="/apps/overview/backups">backup center</a> for the full list.</div>` : ''}
`;
// Deep-link: /app/<name>/backups?snapshot=<id> auto-expands that row

View File

@ -69,10 +69,9 @@ class LibrePortalSPAClean {
this.routes.set('/admin*', () => this.handleAdmin());
this.routes.set('/tasks', () => this.handleTasks()); // Handle /tasks without query
this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query
this.routes.set('/backup', () => this.handleBackup());
this.routes.set('/backup*', () => this.handleBackup());
// Legacy /config, /peers, /ssh are handled by _legacyRedirect() at the top
// of navigate() (rewrites to the canonical /admin/* path).
// Legacy /config, /peers, /ssh, /backup, /overview are handled by
// _legacyRedirect() at the top of navigate() (rewritten to their canonical
// /admin/* or /apps/overview* paths before routing).
}
@ -333,28 +332,6 @@ class LibrePortalSPAClean {
}
}
async handleBackup() {
try {
// backup-page.js + backup-app-card.js are loaded on first navigation.
// loadScript is idempotent — subsequent /backup navigations are no-ops.
await Promise.all([
this.loadScript('/components/backup/core/js/backup-page.js'),
this.loadScript('/core/backup-card/js/backup-app-card.js')
]);
const html = await this.fetchContent('/components/backup/core/html/backup-content.html');
this.loadContent(html, 'Backups');
if (typeof BackupPage !== 'undefined') {
window.backupPage = new BackupPage();
await window.backupPage.init();
} else {
console.error('BackupPage class not loaded');
}
} catch (error) {
console.error('❌ Backup page load error:', error);
this.showError('Failed to load backup page');
}
}
// Map a legacy short URL (/ssh, /peers, /config[?=cat]) to its canonical
// /admin/* path, or return null if it isn't a legacy redirect. Used at the top
// of navigate(). Replaces the old config-redirect/peers/ssh handlers.
@ -362,8 +339,8 @@ class LibrePortalSPAClean {
const full = path || '';
const p = full.split('?')[0];
if (p === '/ssh' || p.startsWith('/ssh/')) return '/admin/tools/ssh-access';
// Peers now lives under Overview Migrate Peers (moved out of Admin).
if (p === '/peers' || p.startsWith('/peers/') || p === '/admin/tools/peers' || p.startsWith('/admin/tools/peers/')) return '/overview/migrate/peers';
// Peers now lives under App Center Overview Migrate Peers (moved out of Admin).
if (p === '/peers' || p.startsWith('/peers/') || p === '/admin/tools/peers' || p.startsWith('/admin/tools/peers/')) return '/apps/overview/migrate/peers';
if (p === '/config' || p.startsWith('/config/') || full.startsWith('/config?')) {
let cat = 'overview';
if (full.includes('?=')) cat = full.split('?=')[1] || 'overview';
@ -371,15 +348,27 @@ class LibrePortalSPAClean {
else { const seg = p.replace(/^\/config\/?/, ''); if (seg) cat = seg; }
return (typeof window.adminPath === 'function') ? window.adminPath(cat) : '/admin';
}
// The standalone Updater page is now the fleet Overview area. /updater[/tab]
// -> /overview[/tab]; security/recovery/history folded into the Updates
// expander, so they land on /overview/updates. (/backup is NOT redirected —
// it remains the operational backup center, reached from Overview Backups.)
// The fleet Overview area lives under App Center: /apps/overview[/tab].
// Rewrite the legacy short forms so old bookmarks/links keep working and the
// address bar always shows the canonical path:
// /overview[/tab] -> /apps/overview[/tab]
// /backup[/sub] -> /apps/overview/backups[/sub] (the backup center is
// now the Overview Backups tab, not a standalone page)
// /updater[/tab] -> /apps/overview[/tab]; security/recovery/history were
// folded into the Updates expander, so they land on
// /apps/overview/updates.
if (p === '/overview' || p.startsWith('/overview/')) {
return '/apps' + p;
}
if (p === '/backup' || p.startsWith('/backup/')) {
const sub = p.replace(/^\/backup\/?/, '');
return sub ? '/apps/overview/backups/' + sub : '/apps/overview/backups';
}
if (p === '/updater' || p.startsWith('/updater/')) {
const seg = p.replace(/^\/updater\/?/, '');
if (seg === 'updates' || seg === 'improvements') return '/overview/' + seg;
if (!seg || seg === 'overview') return '/overview';
return '/overview/updates';
if (seg === 'updates' || seg === 'improvements') return '/apps/overview/' + seg;
if (!seg || seg === 'overview') return '/apps/overview';
return '/apps/overview/updates';
}
return null;
}