feat(webui): add fleet Overview area (Overview/Updates/Improvements/Backups tabs)

Introduce a per-fleet Overview area inside the apps shell, reachable from a new
'Overview' entry pinned above the apps sidebar search. Selecting it renders a
top-tabbed view in the main pane — Overview · Updates · Improvements · Backups —
mirroring the per-app tabbed layout, with the apps sidebar persistent.

- TabController: generic root-scoped show/hide tab host (core/ui-state).
- OverviewManager: drives the 4 tabs. Reuses a headless UpdaterPage for all
  update/CVE/improvement data + rendering (its renderX() are pure HTML
  producers) and reads backup/dashboard.json directly for backup health.
- Overview tab: combined update + backup health cards.
- Updates tab: per-app expander table (CVEs/recovery/history inline via the new
  UpdaterPage.renderAppDetail) + All/Updates/Security filter chips.
- Improvements tab: reuses the updater's signed-hotfix renderer.
- Backups tab: fleet backup-health tiles; actions deep-link per app.
- Additive only: /overview* routes on the apps feature; old /updater and
  /backup pages untouched. Cleanup (redirects, nav, Admin config move) is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-05-31 23:37:35 +01:00
parent 5106425b3c
commit 8acf2d02c3
10 changed files with 685 additions and 19 deletions

View File

@ -6,6 +6,18 @@
<!-- Sidebar Container --> <!-- Sidebar Container -->
<div class="sidebar-container"> <div class="sidebar-container">
<div class="sidebar" id="sidebar"> <div class="sidebar" id="sidebar">
<!-- 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';}">
<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>
<rect x="14" y="3" width="7" height="5"></rect>
<rect x="14" y="12" width="7" height="9"></rect>
<rect x="3" y="16" width="7" height="5"></rect>
</svg>
<span>Overview</span>
</div>
<div class="apps-search"> <div class="apps-search">
<svg class="apps-search-icon" 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"> <svg class="apps-search-icon" 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">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
@ -251,6 +263,46 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Fleet Overview View (Overview · Updates · Improvements · Backups).
Driven by OverviewManager; shares the persistent apps sidebar. -->
<div id="overview-view" class="content-view">
<div class="overview-header">
<div class="overview-header-text">
<h2 id="overview-title">Overview</h2>
<p id="overview-subtitle">Updates, improvements, and backups across all your apps.</p>
</div>
<button class="updater-btn" data-updater-action="check">↻ Check now</button>
</div>
<div class="tabbed-interface overview-tabbed">
<div class="tab-navigation">
<button class="main-tab-button active" data-tab="overview" onclick="if(window.overviewManager) window.overviewManager.switchTab('overview')">
<span class="tab-emoji">🛰️</span>
<span class="tab-name">Overview</span>
</button>
<button class="main-tab-button" data-tab="updates" onclick="if(window.overviewManager) window.overviewManager.switchTab('updates')">
<span class="tab-emoji">⬆️</span>
<span class="tab-name">Updates</span>
</button>
<button class="main-tab-button" data-tab="improvements" onclick="if(window.overviewManager) window.overviewManager.switchTab('improvements')">
<span class="tab-emoji"></span>
<span class="tab-name">Improvements</span>
</button>
<button class="main-tab-button" data-tab="backups" onclick="if(window.overviewManager) window.overviewManager.switchTab('backups')">
<span class="tab-emoji">💾</span>
<span class="tab-name">Backups</span>
</button>
</div>
<div class="tab-content">
<div class="tab-pane active" data-tab="overview" id="ov-pane-overview"></div>
<div class="tab-pane" data-tab="updates" id="ov-pane-updates"></div>
<div class="tab-pane" data-tab="improvements" id="ov-pane-improvements"></div>
<div class="tab-pane" data-tab="backups" id="ov-pane-backups"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -449,19 +449,17 @@ class AppsManager {
} }
showView(viewType) { showView(viewType) {
// Get both view containers // Three sibling content-views share the apps shell: the grid, the per-app
const appsView = document.getElementById('apps-view'); // detail, and the fleet Overview. Exactly one is visible at a time.
const appDetailView = document.getElementById('app-detail-view'); const views = {
'apps': document.getElementById('apps-view'),
if (viewType === 'apps') { 'app-detail': document.getElementById('app-detail-view'),
// Show apps view, hide app detail view 'overview': document.getElementById('overview-view'),
if (appsView) appsView.style.display = 'block'; };
if (appDetailView) appDetailView.style.display = 'none'; Object.keys(views).forEach((key) => {
} else if (viewType === 'app-detail') { const el = views[key];
// Show app detail view, hide apps view if (el) el.style.display = (key === viewType) ? 'block' : 'none';
if (appsView) appsView.style.display = 'none'; });
if (appDetailView) appDetailView.style.display = 'block';
}
} }

View File

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

View File

@ -6,11 +6,14 @@
// sibling component; it's the same feature, so it lives here.) // sibling component; it's the same feature, so it lives here.)
LP.features.register({ LP.features.register({
id: 'apps', id: 'apps',
routes: ['/apps', '/apps*', '/app', '/app*'], routes: ['/apps', '/apps*', '/app', '/app*', '/overview', '/overview*'],
async mount(ctx) { async mount(ctx) {
// /apps* -> grid; everything else (/app*) -> detail. Check '/apps' FIRST so // /overview* -> fleet Overview; /apps* -> grid; everything else (/app*) ->
// it wins over '/app' (since '/apps'.startsWith('/app')). // detail. Check '/apps' before '/app' (since '/apps'.startsWith('/app')).
if (window.location.pathname.startsWith('/overview')) {
return this._mountOverview(ctx);
}
if (window.location.pathname.startsWith('/apps')) { if (window.location.pathname.startsWith('/apps')) {
return this._mountGrid(ctx); return this._mountGrid(ctx);
} }
@ -75,12 +78,50 @@ LP.features.register({
await window.appTabbedManager.initialize(); await window.appTabbedManager.initialize();
}, },
async unmount() { // ---- fleet Overview (/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
// headless UpdaterPage supplies all update/CVE/improvement data + rendering.
const need = [];
if (typeof TabController === 'undefined') need.push('/core/ui-state/js/tab-controller.js');
if (typeof UpdaterPage === 'undefined') need.push('/components/updater/js/updater-page.js');
if (typeof OverviewManager === 'undefined') need.push('/components/apps/overview/js/overview-manager.js');
if (need.length) await ctx.loadScripts(need);
// Reuse the shared apps layout (keeps the sidebar persistent), same as grid.
if (!document.querySelector('.apps-layout')) {
const html = await ctx.loadFragment('/components/apps/core/html/apps-unified-layout.html');
ctx.setContent(html, 'Overview');
}
// Render the apps sidebar (search + categories) but show the Overview pane
// and highlight the Overview entry instead of any category.
if (window.appsManager) {
window.appsManager.currentView = 'overview';
try { window.appsManager.setupSidebar('__overview__'); } catch (_) {}
window.appsManager.showView('overview');
}
document.querySelectorAll('.apps-layout .category.active').forEach((c) => c.classList.remove('active'));
const entry = document.getElementById('sidebar-overview-entry');
if (entry) entry.classList.add('active');
if (typeof OverviewManager === 'undefined') throw new Error('OverviewManager failed to load');
if (!window.overviewManager) window.overviewManager = new OverviewManager(ctx.services);
await window.overviewManager.initialize();
},
async unmount(ctx) {
// appsManager / appTabbedManager are shared singletons (never null them), but // appsManager / appTabbedManager are shared singletons (never null them), but
// the detail view's per-mount resources DO need releasing: the reconcile loop, // the detail view's per-mount resources DO need releasing: the reconcile loop,
// the watchdog window/document listeners, and the active tab's Services // the watchdog window/document listeners, and the active tab's Services
// intervals + log SSE. dispose() handles all of it (re-armed on next mount). // intervals + log SSE. dispose() handles all of it (re-armed on next mount).
// The dirty-config nav guard still fires in navigate() before unmount. // The dirty-config nav guard still fires in navigate() before unmount.
try { window.appTabbedManager && window.appTabbedManager.dispose && window.appTabbedManager.dispose(); } catch (_) {} try { window.appTabbedManager && window.appTabbedManager.dispose && window.appTabbedManager.dispose(); } catch (_) {}
// Drop the Overview task-refresh registration when leaving the apps feature
// so a finished update/backup task can't repaint a torn-down pane. The
// 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 (_) {}
}, },
}); });

View File

@ -0,0 +1,174 @@
/* components/apps/overview/css/overview.css the fleet Overview area.
*
* Lives inside the apps shell (shares the apps sidebar). Deliberately leans on
* already-global stylesheets: .tabbed-interface/.main-tab-button/.tab-pane from
* apps.css, and .updater-stat/.updater-row/.updater-badge/.sev-* from updater.css.
* Only the Overview-specific bits live here. */
/* Hidden until AppsManager.showView('overview') reveals it. */
#overview-view { display: none; }
#overview-view.content-view { padding: 0; }
/* Pane show/hide is self-contained so it never depends on where the base
.tab-pane rule is scoped. */
#overview-view .tab-pane { display: none; }
#overview-view .tab-pane.active { display: block; }
/* ---- sidebar "Overview" entry (pinned above the search box) ------------- */
.sidebar-overview-entry {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 8px 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
color: var(--sidebar-text, #cdd6e4);
border: 1px solid transparent;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.sidebar-overview-entry:hover { background: rgba(255, 255, 255, .06); }
.sidebar-overview-entry.active {
background: rgba(var(--page-updater-rgb), .16);
border-color: rgba(var(--page-updater-rgb), .45);
color: #fff;
}
.sidebar-overview-entry .ov-entry-icon { width: 18px; height: 18px; flex: 0 0 auto; }
/* ---- header ------------------------------------------------------------- */
.overview-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 12px;
flex-wrap: wrap;
}
.overview-header h2 { margin: 0 0 2px; font-size: 1.5rem; }
.overview-header p { margin: 0; color: var(--text-muted, rgba(255, 255, 255, .65)); font-size: .9rem; }
.overview-tabbed { padding: 0 24px 24px; }
/* ---- Updates: filter chips + toolbar ------------------------------------ */
.ov-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.ov-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.ov-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border-color, rgba(255, 255, 255, .14));
background: transparent;
color: var(--text-color, #e8edf4);
font-size: .82rem;
cursor: pointer;
transition: background .15s ease, border-color .15s ease;
}
.ov-chip:hover { background: rgba(255, 255, 255, .06); }
.ov-chip.active {
background: rgba(var(--page-updater-rgb), .18);
border-color: rgba(var(--page-updater-rgb), .5);
color: #fff;
}
.ov-chip-n {
font-variant-numeric: tabular-nums;
font-size: .72rem;
opacity: .8;
background: rgba(255, 255, 255, .08);
border-radius: 999px;
padding: 0 6px;
}
.ov-toolbar-actions { display: flex; gap: 8px; }
/* ---- Updates: expandable rows ------------------------------------------- */
.ov-row { padding: 0; }
.ov-row-head {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
cursor: pointer;
padding: 12px 14px;
}
.ov-row-head .updater-row-ver { margin-left: auto; }
.ov-row-head .ov-row-actions { margin-left: 12px; }
.ov-chevron {
flex: 0 0 auto;
display: inline-block;
font-size: .8rem;
opacity: .7;
transition: transform .18s ease;
}
.ov-row-head[aria-expanded="true"] .ov-chevron { transform: rotate(90deg); }
.ov-row-head:focus-visible { outline: 2px solid rgba(var(--page-updater-rgb), .7); outline-offset: -2px; }
.ov-row-details { display: none; padding: 4px 16px 16px; border-top: 1px solid var(--border-color, rgba(255, 255, 255, .1)); }
.ov-row-details.ov-open { display: block; }
.updater-detail { display: flex; flex-direction: column; gap: 14px; padding-top: 12px; }
.updater-detail-section h4 {
margin: 0 0 8px;
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted, rgba(255, 255, 255, .6));
}
.updater-detail-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0;
flex-wrap: wrap;
}
.updater-detail-row .updater-btn { margin-left: auto; }
.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; }
.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));
}
.ov-loc-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, .08));
}
.ov-loc-row span:first-child { font-weight: 500; }

View File

@ -0,0 +1,332 @@
// components/apps/overview/js/overview-manager.js — the fleet "Overview" area.
//
// One place for a whole-fleet picture: Overview (combined health), Updates
// (per-app expander table + filter chips), Improvements (signed hotfixes), and
// Backups (per-app backup health). It lives inside the apps feature so it shares
// the persistent apps sidebar — selecting "Overview" in the sidebar renders this
// in the main pane, exactly like selecting an app renders the per-app tabs.
//
// Reuse, not duplication: a *headless* UpdaterPage supplies all update/CVE/
// improvement data + row rendering (its renderX() methods are pure HTML-string
// producers), and we route its action buttons straight to its methods. Backup
// health is read directly from the same generated JSON the backup page uses.
// System config edits are never inline here — they bounce to Admin / the per-app
// tabs (operate/view here; change-the-system there).
class OverviewManager {
constructor(services) {
this.services = services || (window.LP && window.LP.services) || {};
this.updater = null; // headless UpdaterPage (data + renderers)
this.backup = null; // /data/backup/generated/dashboard.json
this.tabs = null; // TabController (show/hide only)
this.filter = 'all'; // Updates-tab chip: all | updates | security
this.current = null;
}
// ---- lifecycle -----------------------------------------------------------
async initialize() {
const root = document.getElementById('overview-view');
if (!root) return;
if (typeof UpdaterPage !== 'undefined' && !this.updater) {
this.updater = new UpdaterPage(this.services);
}
this.bindEvents(root);
this.tabs = new TabController(root);
const initial = this.parseTabFromUrl() || 'overview';
await this.refreshAll();
this._applyTab(initial); // active + header + render, no history push
}
bindEvents(root) {
// One delegated click listener per fresh root. The apps layout fragment
// persists across /apps,/app,/overview, so the same root is reused — the
// dataset guard stops us stacking a second listener on revisit; a fresh
// fragment (after leaving the apps feature) gets a new root + new listener
// while the old one is GC'd with its element. The closure binds the
// singleton `this`, so it never goes stale.
if (root.dataset.ovBound !== '1') {
root.dataset.ovBound = '1';
root.addEventListener('click', (e) => this._handleClick(e));
}
// Repaint when a relevant task finishes — debounced via the shared
// coordinator, idempotent by id (re-registering replaces), self-guarded
// against a torn-down view. Called on every initialize() so it re-arms after
// unmount() unregistered it on the way out of the apps feature.
const tr = this.services.tasks && this.services.tasks.refresh;
if (tr && tr.register) {
tr.register({
id: 'overview',
match: (d) => /^(updater_|artifact_|backup|restore|libreportal\s+(updater|artifact|backup|restore))/
.test((d && (d.action || (d.task && d.task.command))) || ''),
run: () => {
if (window.overviewManager === this && document.getElementById('overview-view')) {
return this.refreshAll().then(() => this._applyTab(this.current || 'overview'));
}
},
debounceMs: 800,
});
}
}
async refreshAll() {
const jobs = [];
if (this.updater) jobs.push(this.updater.refreshAll());
jobs.push(
fetch('/data/backup/generated/dashboard.json', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.catch(() => null)
.then((d) => { this.backup = d; })
);
await Promise.all(jobs);
}
parseTabFromUrl() {
const allowed = new Set(['overview', 'updates', 'improvements', 'backups']);
const seg = window.location.pathname.replace(/^\/overview\/?/, '').split('/')[0];
return (seg && allowed.has(seg)) ? seg : null;
}
// ---- tab switching -------------------------------------------------------
// Called by the tab buttons' inline onclick AND by in-page "goto" buttons.
switchTab(id) {
if (!id || id === this.current) return;
this._applyTab(id);
const url = id === 'overview' ? '/overview' : `/overview/${id}`;
window.history.pushState({ route: url }, '', url);
}
_applyTab(id) {
if (this.tabs) this.tabs.switch(id);
this.updateHeader(id);
this.renderTab(id);
this.current = id;
}
updateHeader(id) {
const titles = {
overview: ['Overview', 'Updates, improvements, and backups across all your apps.'],
updates: ['Updates', 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.'],
improvements: ['Improvements', 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.'],
backups: ['Backups', 'Backup health across your apps. Open an app for snapshots and restore.'],
};
const t = titles[id] || titles.overview;
const titleEl = document.getElementById('overview-title');
const subEl = document.getElementById('overview-subtitle');
if (titleEl) titleEl.textContent = t[0];
if (subEl) subEl.textContent = t[1];
}
renderTab(id) {
const pane = document.querySelector(`#overview-view .tab-pane[data-tab="${id}"]`);
if (!pane) return;
switch (id) {
case 'overview': pane.innerHTML = this.renderOverview(); break;
case 'updates': pane.innerHTML = this.renderUpdates(); break;
case 'improvements': pane.innerHTML = this.renderImprovements(); break;
case 'backups': pane.innerHTML = this.renderBackups(); break;
}
}
// ---- clicks --------------------------------------------------------------
_handleClick(e) {
// Updater actions take precedence over the row-toggle they sit inside, so a
// click on "Update" never also expands the row.
const ua = e.target.closest('[data-updater-action]');
if (ua && this.updater) {
const app = ua.dataset.app || null;
switch (ua.dataset.updaterAction) {
case 'check': this.updater.checkForUpdates(); break;
case 'update': this.updater.applyUpdate(app); break;
case 'update-all': this.updater.applyAll(); break;
case 'rollback': this.updater.rollback(app); break;
case 'apply-artifact': this.updater.applyArtifact(ua.dataset.id); break;
case 'revert-artifact': this.updater.revertArtifact(ua.dataset.id); break;
}
return;
}
const oa = e.target.closest('[data-overview-action]');
if (oa) {
switch (oa.dataset.overviewAction) {
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;
}
}
}
// ---- Overview tab --------------------------------------------------------
renderOverview() {
const u = this.updater
? this.updater.counts()
: { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, apps: 0, lastChecked: null };
const b = this.backupSummary();
const sev = u.cveTotals || {};
const checked = (this.updater && u.lastChecked) ? this.updater.fmtRel(u.lastChecked) : 'never';
const card = (hue, big, label, sub, btn) => `
<div class="updater-stat" style="--page: var(--page-${hue}); --page-rgb: var(--page-${hue}-rgb);">
<div class="updater-stat-big">${big}</div>
<div class="updater-stat-label">${label}</div>
${sub ? `<div class="updater-stat-sub">${sub}</div>` : ''}
${btn || ''}
</div>`;
return `
<div class="updater-stat-grid">
${card('updates', u.updatesAvailable, 'Updates available', u.updatesAvailable ? 'across your apps' : "you're current",
`<button class="updater-btn updater-btn-primary" data-overview-action="goto" data-tab="updates">Review</button>`)}
${card('verify', u.totalCves, 'Known CVEs',
(sev.critical || sev.high) ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues',
`<button class="updater-btn" data-overview-action="goto" data-tab="updates">View</button>`)}
${card('updater', u.improvements, 'Improvements', u.improvements ? 'signed hotfixes to apply' : 'nothing pending',
`<button class="updater-btn" data-overview-action="goto" data-tab="improvements">View</button>`)}
${card('backups', b.protectedLabel, 'Backups protected', b.sub,
`<button class="updater-btn" data-overview-action="goto" data-tab="backups">Backups</button>`)}
${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`,
`<button class="updater-btn" data-updater-action="check">Check now</button>`)}
</div>
${(this.updater && this.updater.updates) ? '' : `<div class="updater-hint">No scan data yet — run <strong>Check now</strong> to fetch versions, CVEs &amp; improvements.</div>`}`;
}
backupSummary() {
const d = this.backup;
if (!d) return { protectedLabel: '—', sub: 'no backup data', stale: 0 };
const apps = Array.isArray(d.apps) ? d.apps : [];
const protectedCount = apps.filter((a) => a.latest_snapshot).length;
const now = Date.now();
const stale = apps.filter((a) => {
if (!a.latest_time) return true;
const t = Date.parse(a.latest_time);
return isNaN(t) ? true : (now - t) > 7 * 86400 * 1000;
}).length;
const latest = (d.system && d.system.latest_time && this.updater) ? this.updater.fmtRel(d.system.latest_time) : 'never';
return {
protectedLabel: `${protectedCount}/${apps.length || 0}`,
sub: stale ? `${stale} need attention · last ${latest}` : `all current · last ${latest}`,
stale,
};
}
// ---- Updates tab (expander table + filter chips) -------------------------
renderUpdates() {
const up = this.updater;
if (!up || !up.apps.length) return `<div class="updater-empty">No installed apps to track.</div>`;
const shown = this.filterApps(up.apps);
const anyUpdate = up.apps.some((a) => a.update_available);
const nUpd = up.apps.filter((a) => a.update_available).length;
const nSec = up.apps.filter((a) => (a.cves || []).length).length;
const chip = (id, label, n) =>
`<button class="ov-chip${this.filter === id ? ' active' : ''}" data-overview-action="filter" data-filter="${id}">${label}${n != null ? ` <span class="ov-chip-n">${n}</span>` : ''}</button>`;
const rows = shown.map((a) => this.updateRow(a)).join('') || `<div class="updater-empty">Nothing matches this filter.</div>`;
return `
<div class="updater-toolbar ov-toolbar">
<div class="ov-chips">${chip('all', 'All', up.apps.length)}${chip('updates', 'Updates', nUpd)}${chip('security', 'Security', nSec)}</div>
<div class="ov-toolbar-actions">
<button class="updater-btn" data-updater-action="check"> Check</button>
${anyUpdate ? `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>` : ''}
</div>
</div>
<div class="updater-list ov-updates-list">${rows}</div>`;
}
filterApps(apps) {
if (this.filter === 'updates') return apps.filter((a) => a.update_available);
if (this.filter === 'security') return apps.filter((a) => (a.cves || []).length);
return apps;
}
updateRow(a) {
const esc = (s) => this.escape(s);
const cur = esc(a.current_version || a.current_image || '—');
const avail = a.update_available ? esc(a.available_version || a.available_image || 'newer') : null;
const badge = a.update_available
? `<span class="updater-badge updater-badge-update">update</span>`
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
const sev = a.worstSeverity ? `<span class="updater-badge sev-${a.worstSeverity}">${a.worstSeverity}</span>` : '';
const updBtn = a.update_available
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${esc(a.name)}">Update</button>`
: '';
return `<div class="updater-row ov-row" data-app="${esc(a.name)}">
<div class="updater-row-head ov-row-head" data-overview-action="toggle" data-app="${esc(a.name)}" role="button" tabindex="0" aria-expanded="false" aria-controls="ov-detail-${esc(a.name)}">
<span class="ov-chevron" aria-hidden="true"></span>
<span class="updater-row-name">${esc(a.displayName)}</span> ${badge} ${sev}
<span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span>
<span class="ov-row-actions">${updBtn}</span>
</div>
<div class="updater-row-details ov-row-details" id="ov-detail-${esc(a.name)}" role="region" aria-label="${esc(a.displayName)} details" hidden></div>
</div>`;
}
toggleAppDetails(app) {
if (!app) return;
const root = document.getElementById('overview-view');
const details = document.getElementById(`ov-detail-${app}`);
const head = root && root.querySelector(`.ov-row[data-app="${(window.CSS && CSS.escape) ? CSS.escape(app) : app}"] .ov-row-head`);
if (!details) return;
const isOpen = !details.hidden;
if (isOpen) {
details.hidden = true;
details.classList.remove('ov-open');
if (head) { head.setAttribute('aria-expanded', 'false'); head.classList.remove('ov-open'); }
} else {
if (!details.dataset.filled && this.updater) {
const appObj = (this.updater.apps || []).find((x) => x.name === app);
details.innerHTML = appObj ? this.updater.renderAppDetail(appObj) : '';
details.dataset.filled = '1';
}
details.hidden = false;
details.classList.add('ov-open');
if (head) { head.setAttribute('aria-expanded', 'true'); head.classList.add('ov-open'); }
}
}
// ---- Improvements tab (reuse the updater's hotfix renderer) ---------------
renderImprovements() {
return this.updater ? this.updater.renderImprovements() : `<div class="updater-empty">Improvements unavailable.</div>`;
}
// ---- 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>` : ''}`;
}
// ---- utils ---------------------------------------------------------------
escape(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
}
window.OverviewManager = OverviewManager;

View File

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

View File

@ -381,6 +381,42 @@ class UpdaterPage {
return `<div class="updater-list">${rows}</div>`; return `<div class="updater-list">${rows}</div>`;
} }
// Per-app detail body — composes one app's security (CVEs), recovery point,
// and recent history. Pure HTML string with no DOM assumptions, so it is reused
// verbatim as the fleet Updates expander body (overview-manager.js) AND the
// per-app Updater tab. Action buttons keep the data-updater-action/data-app
// contract, so whichever delegated handler is in scope drives them.
renderAppDetail(app) {
const a = app || {};
const cves = a.cves || [];
const cveItems = cves.map((c) => `
<div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
<span class="updater-cve-sev">${this.escape((c.severity || '').toUpperCase())}</span>
<a class="updater-cve-id" href="${this.escape(c.url || ('https://nvd.nist.gov/vuln/detail/' + (c.id || '')))}" target="_blank" rel="noopener">${this.escape(c.id || 'CVE')}</a>
<span class="updater-cve-pkg">${this.escape(c.package || '')}</span>
${c.fixed_in ? `<span class="updater-cve-fix">fixed in ${this.escape(c.fixed_in)}</span>` : ''}
</div>`).join('');
const security = `<div class="updater-detail-section"><h4>Security</h4>${
cves.length ? cveItems : '<p class="updater-detail-empty">No known CVEs. 🎉</p>'}</div>`;
const can = !!a.last_snapshot;
const snap = can
? `${this.escape(a.last_snapshot_version || '')} · ${this.fmtRel(a.last_snapshot_at)}`
: 'A recovery snapshot is taken automatically before the next update.';
const recovery = `<div class="updater-detail-section"><h4>Recovery</h4>
<div class="updater-detail-row"><span class="updater-badge ${can ? 'updater-badge-ok' : 'updater-badge-unknown'}">${can ? 'recoverable' : 'protected'}</span>
<span class="updater-detail-meta">${snap}</span>
${can ? `<button class="updater-btn" data-updater-action="rollback" data-app="${this.escape(a.name)}">Roll back</button>` : ''}</div></div>`;
const entries = ((this.history && this.history.entries) || []).filter((e) => e.app === a.name).slice(0, 8);
const history = entries.length ? `<div class="updater-detail-section"><h4>History</h4>${entries.map((e) => `
<div class="updater-detail-row"><span class="updater-badge ${e.result === 'ok' ? 'updater-badge-ok' : (e.result === 'rolled-back' ? 'updater-badge-update' : 'sev-high')}">${this.escape(e.action)}${e.result ? ' · ' + this.escape(e.result) : ''}</span>
<span class="updater-detail-meta">${this.escape(e.from || '')}${e.to ? `${this.escape(e.to)}` : ''}</span>
<span class="updater-detail-meta">${this.fmtRel(e.ts)}</span></div>`).join('')}</div>` : '';
return `<div class="updater-detail">${security}${recovery}${history}</div>`;
}
empty(msg, withCheck) { empty(msg, withCheck) {
return `<div class="updater-empty">${this.escape(msg)}${withCheck ? `<div><button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>` : ''}</div>`; return `<div class="updater-empty">${this.escape(msg)}${withCheck ? `<div><button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>` : ''}</div>`;
} }

View File

@ -0,0 +1,28 @@
// core/ui-state/js/tab-controller.js — generic, root-scoped top-tab host.
//
// Owns ONLY the show/hide contract: toggle `.active` on `.main-tab-button[data-tab]`
// and `.tab-pane[data-tab]` within a single root element. It has no app/feature
// knowledge and binds no listeners of its own — consumers wire their own triggers
// (inline onclick or a delegated listener) and call switch(). Scoping every query
// to `root` lets several hosts coexist on one page (e.g. the per-app detail tabs
// and the fleet Overview tabs) without cross-talk, and matching panes by a
// data-tab attribute (not id) avoids duplicate-id collisions between hosts.
class TabController {
constructor(root) {
this.root = typeof root === 'string' ? document.querySelector(root) : root;
this.current = null;
}
switch(tabId) {
if (!this.root || !tabId) return;
this.root.querySelectorAll('.main-tab-button[data-tab]').forEach((b) => {
b.classList.toggle('active', b.dataset.tab === tabId);
});
this.root.querySelectorAll('.tab-pane[data-tab]').forEach((p) => {
p.classList.toggle('active', p.dataset.tab === tabId);
});
this.current = tabId;
}
}
window.TabController = TabController;

View File

@ -31,6 +31,7 @@
<link rel="stylesheet" href="/core/topbar/css/sidebar.css"> <link rel="stylesheet" href="/core/topbar/css/sidebar.css">
<link rel="stylesheet" href="/components/apps/core/css/apps-layout.css"> <link rel="stylesheet" href="/components/apps/core/css/apps-layout.css">
<link rel="stylesheet" href="/components/apps/core/css/apps.css"> <link rel="stylesheet" href="/components/apps/core/css/apps.css">
<link rel="stylesheet" href="/components/apps/overview/css/overview.css">
<link rel="stylesheet" href="/core/forms/css/forms.css"> <link rel="stylesheet" href="/core/forms/css/forms.css">
<link rel="stylesheet" href="/core/forms/css/config.css"> <link rel="stylesheet" href="/core/forms/css/config.css">
<link rel="stylesheet" href="/components/apps/core/css/service-buttons.css"> <link rel="stylesheet" href="/components/apps/core/css/service-buttons.css">