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:
parent
5106425b3c
commit
8acf2d02c3
@ -6,6 +6,18 @@
|
||||
<!-- Sidebar Container -->
|
||||
<div class="sidebar-container">
|
||||
<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">
|
||||
<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>
|
||||
@ -251,6 +263,46 @@
|
||||
</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>
|
||||
|
||||
|
||||
@ -449,19 +449,17 @@ class AppsManager {
|
||||
}
|
||||
|
||||
showView(viewType) {
|
||||
// Get both view containers
|
||||
const appsView = document.getElementById('apps-view');
|
||||
const appDetailView = document.getElementById('app-detail-view');
|
||||
|
||||
if (viewType === 'apps') {
|
||||
// Show apps view, hide app detail view
|
||||
if (appsView) appsView.style.display = 'block';
|
||||
if (appDetailView) appDetailView.style.display = 'none';
|
||||
} else if (viewType === 'app-detail') {
|
||||
// Show app detail view, hide apps view
|
||||
if (appsView) appsView.style.display = 'none';
|
||||
if (appDetailView) appDetailView.style.display = 'block';
|
||||
}
|
||||
// Three sibling content-views share the apps shell: the grid, the per-app
|
||||
// detail, and the fleet Overview. Exactly one is visible at a time.
|
||||
const views = {
|
||||
'apps': document.getElementById('apps-view'),
|
||||
'app-detail': document.getElementById('app-detail-view'),
|
||||
'overview': document.getElementById('overview-view'),
|
||||
};
|
||||
Object.keys(views).forEach((key) => {
|
||||
const el = views[key];
|
||||
if (el) el.style.display = (key === viewType) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
"/apps",
|
||||
"/apps*",
|
||||
"/app",
|
||||
"/app*"
|
||||
"/app*",
|
||||
"/overview",
|
||||
"/overview*"
|
||||
],
|
||||
"module": "/components/apps/index.js",
|
||||
"handler": "handleApps",
|
||||
|
||||
@ -6,11 +6,14 @@
|
||||
// sibling component; it's the same feature, so it lives here.)
|
||||
LP.features.register({
|
||||
id: 'apps',
|
||||
routes: ['/apps', '/apps*', '/app', '/app*'],
|
||||
routes: ['/apps', '/apps*', '/app', '/app*', '/overview', '/overview*'],
|
||||
|
||||
async mount(ctx) {
|
||||
// /apps* -> grid; everything else (/app*) -> detail. Check '/apps' FIRST so
|
||||
// it wins over '/app' (since '/apps'.startsWith('/app')).
|
||||
// /overview* -> fleet Overview; /apps* -> grid; everything else (/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')) {
|
||||
return this._mountGrid(ctx);
|
||||
}
|
||||
@ -75,12 +78,50 @@ LP.features.register({
|
||||
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
|
||||
// the detail view's per-mount resources DO need releasing: the reconcile loop,
|
||||
// the watchdog window/document listeners, and the active tab's Services
|
||||
// intervals + log SSE. dispose() handles all of it (re-armed on next mount).
|
||||
// The dirty-config nav guard still fires in navigate() before unmount.
|
||||
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 (_) {}
|
||||
},
|
||||
});
|
||||
|
||||
@ -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; }
|
||||
@ -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 & 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
}
|
||||
|
||||
window.OverviewManager = OverviewManager;
|
||||
@ -23,7 +23,9 @@
|
||||
"/apps",
|
||||
"/apps*",
|
||||
"/app",
|
||||
"/app*"
|
||||
"/app*",
|
||||
"/overview",
|
||||
"/overview*"
|
||||
],
|
||||
"module": "/components/apps/index.js",
|
||||
"handler": "handleApps",
|
||||
|
||||
@ -381,6 +381,42 @@ class UpdaterPage {
|
||||
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) {
|
||||
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>`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -31,6 +31,7 @@
|
||||
<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.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/config.css">
|
||||
<link rel="stylesheet" href="/components/apps/core/css/service-buttons.css">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user