diff --git a/containers/libreportal/frontend/components/apps/overview/css/overview.css b/containers/libreportal/frontend/components/apps/overview/css/overview.css
index a9dbc51..a7f810a 100644
--- a/containers/libreportal/frontend/components/apps/overview/css/overview.css
+++ b/containers/libreportal/frontend/components/apps/overview/css/overview.css
@@ -36,18 +36,11 @@
}
.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; }
+/* ---- layout ------------------------------------------------------------- */
+/* No floating header โ each tab renders its own in-content .config-title header
+ under the tab strip (OverviewManager.renderHeader). The tabbed interface owns
+ the area padding the old header used to provide at the top. */
+.overview-tabbed { margin-top: 0; padding: 22px 24px 24px; }
/* ---- Updates: filter chips + toolbar ------------------------------------ */
.ov-toolbar {
@@ -132,12 +125,9 @@
.updater-detail-empty { color: var(--text-muted, rgba(255, 255, 255, .6)); margin: 0; }
/* ---- Backups tab: embedded backup center -------------------------------- */
-/* The Backups tab mounts the real BackupPage. Its own page-header replaces the
- generic fleet header, and its left sidebar is restyled into a horizontal
- nested tab strip so the whole thing reads as tabs-within-tabs. */
-#overview-view.ov-backups-active .overview-header,
-#overview-view.ov-migrate-active .overview-header { display: none; }
-
+/* The Backups tab mounts the real BackupPage, which supplies its own header, and
+ its left sidebar is restyled into a horizontal nested tab strip so the whole
+ thing reads as tabs-within-tabs. */
#overview-view #ov-pane-backups .backup-layout { display: block; }
#overview-view #ov-pane-backups .backup-layout > .sidebar {
width: auto;
diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
index 0a76f7f..21f340f 100644
--- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
+++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
@@ -12,6 +12,24 @@
// 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).
+
+// 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 {
constructor(services) {
this.services = services || (window.LP && window.LP.services) || {};
@@ -117,52 +135,39 @@ class OverviewManager {
_applyTab(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.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.'],
- migrate: ['Migrate', 'Move apps in from another LibrePortal, and manage the peers you share backup locations with.'],
- };
- 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];
+ // The shared in-content header. Sourced only from OV_TAB_META so every fleet
+ // tab matches the per-app detail tabs' .config-title (emoji + title + blurb,
+ // inside the pane, above the body). renderTab() prepends this for the string
+ // tabs; mountMigrate() injects it once for the static Migrate pane.
+ renderHeader(id) {
+ const m = OV_TAB_META[id];
+ if (!m) return '';
+ return ``;
}
renderTab(id) {
const pane = document.querySelector(`#overview-view .tab-pane[data-tab="${id}"]`);
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) {
- case 'overview': pane.innerHTML = this.renderOverview(); break;
+ case 'overview': pane.innerHTML = this.renderHeader(id) + this.renderOverview(); break;
case 'updates': {
// Preserve which rows are expanded across a rebuild โ a background
// task-refresh repaints the whole table, so restore ALL open rows, not
// just the single ?app= deep-link.
const open = Array.from(document.querySelectorAll('#overview-view .ov-row-details:not([hidden])'))
.map((d) => d.id.replace(/^ov-detail-/, ''));
- pane.innerHTML = this.renderUpdates();
+ pane.innerHTML = this.renderHeader(id) + this.renderUpdates();
open.forEach((app) => this._openDetail(app));
this._honorAppDeepLink();
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 'migrate': this.mountMigrate(); break;
}
@@ -171,6 +176,12 @@ class OverviewManager {
// ---- Migrate tab: Restore (standalone MigratePage) + Peers (PeersPage) ----
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 sub = (seg === 'peers' || seg === 'restore') ? seg : (this._migrateSub || 'restore');
this.switchMigrateSub(sub, { fromUrl: true });