Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
58b76af311 Merge claude/1 2026-06-02 23:29:16 +01:00
librelad
4d54d6a9b0 feat(webui): unify Overview tab headers as in-content, app-detail style
Drop the floating fleet header; every Overview tab now renders its heading
inside the pane, under the tab strip, in the per-app detail .config-title
format (emoji + title + description) — matching the Migrate tab.

Introduces a small modular system so the area can't drift:
- OV_TAB_META is the single source of truth for each tab's icon/title/blurb.
- renderHeader(id) is the only thing that turns it into markup; renderTab()
  prepends it for the string tabs and mountMigrate() injects it once for the
  static Migrate pane. Body renderers now only ever produce the body.

Retires the now-dead floating-header plumbing: updateHeader(), the
ov-backups-active/ov-migrate-active hide toggles + CSS, and the .overview-header
rules. The tabbed interface owns the top padding the header used to provide.

Backups is the documented exception — it embeds the full BackupPage, which
supplies its own header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 23:29:16 +01:00
3 changed files with 50 additions and 59 deletions

View File

@ -279,14 +279,9 @@
<!-- Fleet Overview View (Overview · Updates · Improvements · Backups). <!-- Fleet Overview View (Overview · Updates · Improvements · Backups).
Driven by OverviewManager; shares the persistent apps sidebar. --> Driven by OverviewManager; shares the persistent apps sidebar. -->
<div id="overview-view" class="content-view"> <div id="overview-view" class="content-view">
<div class="overview-header"> <!-- No floating header: every tab renders its own in-content .config-title
<div class="overview-header-text"> header (icon + title + description) under the tab strip, just like the
<h2 id="overview-title">Overview</h2> per-app detail tabs. See OverviewManager.renderHeader / OV_TAB_META. -->
<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="tabbed-interface overview-tabbed">
<div class="tab-navigation"> <div class="tab-navigation">
<button class="main-tab-button active" data-tab="overview" onclick="if(window.overviewManager) window.overviewManager.switchTab('overview')"> <button class="main-tab-button active" data-tab="overview" onclick="if(window.overviewManager) window.overviewManager.switchTab('overview')">
@ -317,13 +312,8 @@
<div class="tab-pane" data-tab="improvements" id="ov-pane-improvements"></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 class="tab-pane" data-tab="backups" id="ov-pane-backups"></div>
<div class="tab-pane" data-tab="migrate" id="ov-pane-migrate"> <div class="tab-pane" data-tab="migrate" id="ov-pane-migrate">
<!-- In-content header (mirrors the per-app Config tab's .config-title: <!-- The in-content header (.ov-tab-header) is injected here by
icon + title + description sit inside the pane, above the sub-tabs). mountMigrate() from OV_TAB_META, above the sub-tabs. -->
The generic fleet header is hidden for this tab (.ov-migrate-active). -->
<div class="config-title">
<h3>🔀 Migrate</h3>
<p>Move apps in from another LibrePortal, and manage the peers you share backup locations with.</p>
</div>
<!-- Cross-host area: nested segmented sub-tabs (Config-tab design). --> <!-- Cross-host area: nested segmented sub-tabs (Config-tab design). -->
<div class="tabs-wrapper ov-subtabs"> <div class="tabs-wrapper ov-subtabs">
<div class="tabs-list"> <div class="tabs-list">

View File

@ -36,18 +36,11 @@
} }
.sidebar-overview-entry .ov-entry-icon { width: 18px; height: 18px; flex: 0 0 auto; } .sidebar-overview-entry .ov-entry-icon { width: 18px; height: 18px; flex: 0 0 auto; }
/* ---- header ------------------------------------------------------------- */ /* ---- layout ------------------------------------------------------------- */
.overview-header { /* No floating header each tab renders its own in-content .config-title header
display: flex; under the tab strip (OverviewManager.renderHeader). The tabbed interface owns
align-items: flex-end; the area padding the old header used to provide at the top. */
justify-content: space-between; .overview-tabbed { margin-top: 0; padding: 22px 24px 24px; }
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 ------------------------------------ */ /* ---- Updates: filter chips + toolbar ------------------------------------ */
.ov-toolbar { .ov-toolbar {
@ -132,12 +125,9 @@
.updater-detail-empty { color: var(--text-muted, rgba(255, 255, 255, .6)); margin: 0; } .updater-detail-empty { color: var(--text-muted, rgba(255, 255, 255, .6)); margin: 0; }
/* ---- Backups tab: embedded backup center -------------------------------- */ /* ---- Backups tab: embedded backup center -------------------------------- */
/* The Backups tab mounts the real BackupPage. Its own page-header replaces the /* The Backups tab mounts the real BackupPage, which supplies its own header, and
generic fleet header, and its left sidebar is restyled into a horizontal its left sidebar is restyled into a horizontal nested tab strip so the whole
nested tab strip so the whole thing reads as tabs-within-tabs. */ thing reads as tabs-within-tabs. */
#overview-view.ov-backups-active .overview-header,
#overview-view.ov-migrate-active .overview-header { display: none; }
#overview-view #ov-pane-backups .backup-layout { display: block; } #overview-view #ov-pane-backups .backup-layout { display: block; }
#overview-view #ov-pane-backups .backup-layout > .sidebar { #overview-view #ov-pane-backups .backup-layout > .sidebar {
width: auto; width: auto;

View File

@ -12,6 +12,24 @@
// health is read directly from the same generated JSON the backup page uses. // 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 // System config edits are never inline here — they bounce to Admin / the per-app
// tabs (operate/view here; change-the-system there). // tabs (operate/view here; change-the-system there).
// Single source of truth for every fleet tab's in-content header. Each tab
// renders the SAME .config-title block the per-app detail tabs use — emoji +
// title + description, sitting inside the pane, directly above the body and
// under the tab strip. renderHeader() is the only thing that turns this into
// markup, and renderTab()/mountMigrate() are the only callers, so the area
// cannot drift from the shared app-detail header format: to change a heading,
// edit it HERE and nowhere else. (Backups is the one exception — it embeds the
// full BackupPage, which supplies its own header — so it has no entry to render
// but is listed for completeness.)
const OV_TAB_META = {
overview: { icon: '🛰️', title: 'Overview', desc: 'Updates, improvements, and backups across all your apps.' },
updates: { icon: '⬆️', title: 'Updates', desc: 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.' },
improvements: { icon: '✨', title: 'Improvements', desc: 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.' },
backups: { icon: '💾', title: 'Backups', desc: 'Backup health across your apps. Open an app for snapshots and restore.' },
migrate: { icon: '🔀', title: 'Migrate', desc: 'Move apps in from another LibrePortal, and manage the peers you share backup locations with.' },
};
class OverviewManager { class OverviewManager {
constructor(services) { constructor(services) {
this.services = services || (window.LP && window.LP.services) || {}; this.services = services || (window.LP && window.LP.services) || {};
@ -117,52 +135,39 @@ class OverviewManager {
_applyTab(id) { _applyTab(id) {
if (this.tabs) this.tabs.switch(id); if (this.tabs) this.tabs.switch(id);
// Backups embeds the full backup center (its own header + nested sub-tab
// strip) and Migrate carries its own in-content .config-title header above
// its sub-tabs — both supply their own heading, so hide the generic fleet
// header for them.
const root = document.getElementById('overview-view');
if (root) {
root.classList.toggle('ov-backups-active', id === 'backups');
root.classList.toggle('ov-migrate-active', id === 'migrate');
}
this.updateHeader(id);
this.renderTab(id); this.renderTab(id);
this.current = id; this.current = id;
} }
updateHeader(id) { // The shared in-content header. Sourced only from OV_TAB_META so every fleet
const titles = { // tab matches the per-app detail tabs' .config-title (emoji + title + blurb,
overview: ['Overview', 'Updates, improvements, and backups across all your apps.'], // inside the pane, above the body). renderTab() prepends this for the string
updates: ['Updates', 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.'], // tabs; mountMigrate() injects it once for the static Migrate pane.
improvements: ['Improvements', 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.'], renderHeader(id) {
backups: ['Backups', 'Backup health across your apps. Open an app for snapshots and restore.'], const m = OV_TAB_META[id];
migrate: ['Migrate', 'Move apps in from another LibrePortal, and manage the peers you share backup locations with.'], if (!m) return '';
}; return `<div class="config-title ov-tab-header"><h3>${m.icon} ${this.escape(m.title)}</h3><p>${this.escape(m.desc)}</p></div>`;
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) { renderTab(id) {
const pane = document.querySelector(`#overview-view .tab-pane[data-tab="${id}"]`); const pane = document.querySelector(`#overview-view .tab-pane[data-tab="${id}"]`);
if (!pane) return; if (!pane) return;
// Every string-rendered tab leads with the shared in-content header, so the
// body renderers below only ever produce the body (never a heading).
switch (id) { switch (id) {
case 'overview': pane.innerHTML = this.renderOverview(); break; case 'overview': pane.innerHTML = this.renderHeader(id) + this.renderOverview(); break;
case 'updates': { case 'updates': {
// Preserve which rows are expanded across a rebuild — a background // Preserve which rows are expanded across a rebuild — a background
// task-refresh repaints the whole table, so restore ALL open rows, not // task-refresh repaints the whole table, so restore ALL open rows, not
// just the single ?app= deep-link. // just the single ?app= deep-link.
const open = Array.from(document.querySelectorAll('#overview-view .ov-row-details:not([hidden])')) const open = Array.from(document.querySelectorAll('#overview-view .ov-row-details:not([hidden])'))
.map((d) => d.id.replace(/^ov-detail-/, '')); .map((d) => d.id.replace(/^ov-detail-/, ''));
pane.innerHTML = this.renderUpdates(); pane.innerHTML = this.renderHeader(id) + this.renderUpdates();
open.forEach((app) => this._openDetail(app)); open.forEach((app) => this._openDetail(app));
this._honorAppDeepLink(); this._honorAppDeepLink();
break; break;
} }
case 'improvements': pane.innerHTML = this.renderImprovements(); break; case 'improvements': pane.innerHTML = this.renderHeader(id) + this.renderImprovements(); break;
case 'backups': this.mountBackupCenter(pane); break; case 'backups': this.mountBackupCenter(pane); break;
case 'migrate': this.mountMigrate(); break; case 'migrate': this.mountMigrate(); break;
} }
@ -171,6 +176,12 @@ class OverviewManager {
// ---- Migrate tab: Restore (standalone MigratePage) + Peers (PeersPage) ---- // ---- Migrate tab: Restore (standalone MigratePage) + Peers (PeersPage) ----
mountMigrate() { mountMigrate() {
// The Migrate pane is static layout (its sub-tabs live in the HTML, not an
// innerHTML rebuild), so inject the shared header once, above the sub-tabs.
const pane = document.getElementById('ov-pane-migrate');
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(/^\/overview\/migrate\/?/, '').split('/')[0];
const sub = (seg === 'peers' || seg === 'restore') ? seg : (this._migrateSub || 'restore'); const sub = (seg === 'peers' || seg === 'restore') ? seg : (this._migrateSub || 'restore');
this.switchMigrateSub(sub, { fromUrl: true }); this.switchMigrateSub(sub, { fromUrl: true });