refactor(webui/backups): backup center sub-tabs use canonical tabs-wrapper

The embedded backup center's fragment kept its standalone-page skeleton
(.container + .sidebar restyled by a pile of overview.css overrides).
.container's fixed viewport height inflated the Backups tab pane to
~100vh, stretching the pane surface far past the content and under the
footer buttons, and the restyled strip never matched the app-detail
Config sub-tabs.

Rebuild backup-content.html on the canonical sub-tab idiom instead —
.tabs-wrapper > .tabs-list (emoji tab-buttons) + .tabs-content card,
with a .backup-actions footer below the card mirroring .config-actions.
The bespoke overview.css restyle block, the nebula special-cases, the
embed's id-stripping and BackupPage's dead page-header updater all fall
away; the export menu now opens upward from the footer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-07-04 21:08:03 +01:00
parent 9830382a4a
commit aef0c15726
6 changed files with 85 additions and 280 deletions

View File

@ -221,114 +221,10 @@
.updater-detail-empty { color: var(--text-secondary); margin: 0; }
/* ---- Backups tab: embedded backup center -------------------------------- */
/* 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;
max-width: none;
height: auto;
min-height: 0;
margin: 0; /* no gap — the strip joins the content card below */
padding: 0;
background: transparent;
border: none;
}
/* Match the per-app Config .tabs-list/.tab-button segmented look (full-width
bar, evenly-flexed items, 2px accent underline on the active one). */
#overview-view #ov-pane-backups .backup-layout > .sidebar #backup-sidebar-list,
#overview-view #ov-pane-backups .backup-layout > .sidebar .sidebar-section {
display: flex;
width: 100%;
background: var(--hover-bg);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
#overview-view #ov-pane-backups .backup-layout > .sidebar .category {
display: flex;
flex: 1 1 0;
min-width: 0;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0;
padding: 12px 14px;
white-space: nowrap;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
cursor: pointer;
/* Type matches .main-tab-button so the nested strip reads like the outer
overview tab strip, not like the backup page's vertical sidebar. */
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #ccc);
}
#overview-view #ov-pane-backups .backup-layout > .sidebar .category:hover {
color: var(--accent);
background: rgba(var(--accent-rgb), 0.1);
}
#overview-view #ov-pane-backups .backup-layout > .sidebar .category.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: rgba(var(--accent-rgb), 0.1);
}
#overview-view #ov-pane-backups .backup-layout > .sidebar .category .category-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
/* Round only the outer top corners to follow the strip's chrome (the global
.tab-button rule does this for app/Migrate sub-tabs; .category needs its own). */
#overview-view #ov-pane-backups .backup-layout > .sidebar .category:first-child { border-top-left-radius: 12px; }
#overview-view #ov-pane-backups .backup-layout > .sidebar .category:last-child { border-top-right-radius: 12px; }
/* .main is just the layout column now the visible card surface lives on the
body below (so it stops above the footer buttons), exactly like the app
Config tab where .tabs-content is the card and .config-actions sits outside. */
#overview-view #ov-pane-backups .backup-layout > .main {
width: 100%;
min-width: 0;
background: transparent;
}
/* The body is the connected .tabs-content card: card surface + rounded bottom
corners, joining the tab strip above so the strip + body read as one unit. */
#overview-view #ov-pane-backups .backup-page-body {
background: var(--card-bg);
border-radius: 0 0 12px 12px;
}
/* Embedded, the nested strip already names the section BackupPage's big
page header (icon + h1 + subtitle) is redundant chrome. Hide the title
block and flip the header below the body via flex order, so its action
buttons (Refresh + the per-section primary) become a bottom-LEFT footer
row the same place the app-detail tabs put theirs (.config-actions). */
#overview-view #ov-pane-backups .backup-page-section {
display: flex;
flex-direction: column;
}
#overview-view #ov-pane-backups .backup-page-section > .page-header {
order: 2;
border-bottom: none;
border-top: none;
background: transparent;
margin-bottom: 0;
/* Flush-left under the card, matching .config-actions (left padding 0). */
padding: 16px 22px 8px 0;
}
#overview-view #ov-pane-backups .backup-page-section > .page-header .page-header-icon-slot,
#overview-view #ov-pane-backups .backup-page-section > .page-header .page-header-title {
display: none;
}
#overview-view #ov-pane-backups .backup-page-body { order: 1; }
/* The footer row sits low on the page, so the export dropdown opens upward
(and leftward) to stay on-screen rather than clipping past the viewport. */
#overview-view #ov-pane-backups .backup-page-section > .page-header .backup-export-menu {
top: auto;
bottom: calc(100% + 6px);
right: auto;
left: 0;
}
/* The Backups tab mounts the real BackupPage. Its fragment now uses the
canonical .tabs-wrapper (.tabs-list strip + .tabs-content card) with a
.backup-actions footer all styled globally (base.css / backup.css), so
no embed-specific restyling is needed here. */
/* ---- Migrate tab: segmented sub-tabs (per-app Config-tab design) -------- */
/* .tabs-wrapper/.tabs-list/.tabs-content/.tab-button come from base.css; the

View File

@ -625,11 +625,7 @@ class OverviewManager {
this._backupLoading = true;
try {
await this._ensureBackupAssets();
let html = await fetch('/components/backup/core/html/backup-content.html', { cache: 'no-store' }).then((r) => r.text());
// Strip ids that would collide with the apps layout (it also has #sidebar
// and #mobile-overlay). BackupPage selects its own nodes by class, so this
// is safe; it just keeps the document free of duplicate ids.
html = html.replace('id="sidebar"', '').replace('<div class="mobile-overlay" id="mobile-overlay"></div>', '');
const html = await fetch('/components/backup/core/html/backup-content.html', { cache: 'no-store' }).then((r) => r.text());
// Lead with the shared in-content header (BackupPage supplies only its own
// per-section headers inside the layout), so Backups matches every other
// fleet tab. It persists across refreshes since revisits don't reset innerHTML.

View File

@ -1,37 +1,22 @@
/* Backup Page — restic-engine UI */
.backup-layout {
display: flex;
min-height: calc(100vh - var(--topbar-height, 60px));
}
.backup-layout .main {
flex: 1;
min-width: 0;
overflow-y: auto;
}
/* Backup Page restic-engine UI.
Mounted inside the fleet Overview's Backups tab as a canonical
.tabs-wrapper (strip + .tabs-content card) with a .backup-actions footer
below the card, mirroring the app-detail Config tab's layout. */
.backup-page {
color: var(--text-primary);
width: 100%;
padding-bottom: 48px;
}
/* The whole backup page is one .config-section card containing both the
.page-header and the body. Remove the card's inner padding so the
.page-header sits flush at the top and its border-bottom acts as a
full-width divider; the body gets its own padding. */
.backup-page-section {
padding: 0;
overflow: hidden;
}
.backup-page-section > .page-header {
margin-bottom: 0;
}
.backup-page-body {
padding: 22px;
/* Actions footer under the tab card (Refresh + per-section primary),
bottom-left like the app-detail tabs' .config-actions row. */
.backup-actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
padding: 20px 20px 20px 0;
background: transparent;
}
/* Configuration tab embeds /config's renderConfig, which emits its own
@ -103,6 +88,9 @@
.backup-tabpanel {
display: none;
/* Content inset matching the canonical .tab-panel (5px 24px), so panels
sit at the same depth inside .tabs-content as the Config tab's. */
padding: 5px 24px;
}
.backup-tabpanel.active {
@ -940,15 +928,21 @@
color: var(--text-primary);
}
/* Export dropdown in the backup page header (Configuration tab). */
/* Export dropdown in the backup actions footer (Configuration tab). */
#backup-page-header .page-header-actions {
position: relative;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
/* The footer sits low on the page, so the menu opens upward (and leftward)
to stay on-screen rather than clipping past the viewport. */
.backup-export-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
bottom: calc(100% + 6px);
left: 0;
z-index: 50;
min-width: 210px;
padding: 6px;

View File

@ -1,74 +1,26 @@
<div class="container backup-layout">
<div class="mobile-overlay" id="mobile-overlay"></div>
<div class="sidebar" id="sidebar">
<div class="sidebar-section" id="backup-sidebar-list">
<div class="category active" data-backup-tab="dashboard">
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
Dashboard
</div>
<div class="category" data-backup-tab="backups">
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
Backups
</div>
<div class="category" data-backup-tab="locations">
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
Locations
</div>
<div class="category" data-backup-tab="configuration">
<svg class="category-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
Configuration
</div>
<div class="backup-layout">
<!-- Canonical sub-tab idiom (.tabs-wrapper > .tabs-list + .tabs-content),
identical to the per-app Config tab and the Migrate tab, so the backup
center reads as tabs-within-tabs with no bespoke chrome. -->
<div class="tabs-wrapper">
<div class="tabs-list">
<button type="button" class="tab-button active" data-backup-tab="dashboard">
<span class="tab-emoji">📊</span><span class="tab-name">Dashboard</span>
</button>
<button type="button" class="tab-button" data-backup-tab="backups">
<span class="tab-emoji">💾</span><span class="tab-name">Backups</span>
</button>
<button type="button" class="tab-button" data-backup-tab="locations">
<span class="tab-emoji">📍</span><span class="tab-name">Locations</span>
</button>
<button type="button" class="tab-button" data-backup-tab="configuration">
<span class="tab-emoji">⚙️</span><span class="tab-name">Configuration</span>
</button>
</div>
</div>
<div class="main">
<div class="backup-page" id="backup-page">
<div class="config-section backup-page-section">
<div class="page-header" id="backup-page-header">
<div class="page-header-icon-slot" id="backup-page-header-icon"></div>
<div class="page-header-title">
<h1 id="backup-section-title">Dashboard</h1>
<p id="backup-section-subtitle"></p>
</div>
<div class="page-header-actions">
<button class="backup-refresh-btn" id="backup-refresh-btn" title="Refresh">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Refresh
</button>
<button class="backup-primary-btn" id="backup-primary-action"></button>
<div class="backup-export-menu" id="backup-export-menu" role="menu" hidden>
<button type="button" class="backup-export-menu-item" data-action="export-passwords" role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Repository Passwords
</button>
</div>
</div>
</div>
<div class="backup-page-body">
<span class="backup-engine-badge" id="backup-engine-badge" hidden>restic</span>
<div class="tabs-content">
<div class="backup-page" id="backup-page">
<span class="backup-engine-badge" id="backup-engine-badge" hidden>restic</span>
<section class="backup-tabpanel active" id="backup-panel-dashboard">
<div class="backup-summary-row" id="backup-summary-row"></div>
@ -116,7 +68,32 @@
<section class="backup-tabpanel" id="backup-panel-configuration">
<div id="backup-configuration-body"></div>
</section>
</div>
</div>
</div>
</div>
<!-- Actions footer: sits below the tab card at bottom-left, exactly where
the app-detail tabs put .config-actions. The per-section primary button
label is driven by updatePrimaryAction(). -->
<div class="backup-actions" id="backup-page-header">
<div class="page-header-actions">
<button class="backup-refresh-btn" id="backup-refresh-btn" title="Refresh">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Refresh
</button>
<button class="backup-primary-btn" id="backup-primary-action"></button>
<div class="backup-export-menu" id="backup-export-menu" role="menu" hidden>
<button type="button" class="backup-export-menu-item" data-action="export-passwords" role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Repository Passwords
</button>
</div>
</div>
</div>

View File

@ -29,7 +29,6 @@ class BackupPage {
await this.refreshAll();
await window.Dismissible?.load();
this.render();
this.updatePageHeader();
this.updatePrimaryAction();
}
@ -53,11 +52,11 @@ class BackupPage {
return null;
}
/* Toggle the sidebar .active class + panel visibility without going
/* Toggle the tab strip's .active class + panel visibility without going
through switchTab's URL-update path (used on initial render and
browser back/forward). */
applyActiveTabUi(tab) {
document.querySelectorAll('.backup-layout .sidebar .category[data-backup-tab]').forEach(b => {
document.querySelectorAll('.backup-layout .tabs-list .tab-button[data-backup-tab]').forEach(b => {
b.classList.toggle('active', b.dataset.backupTab === tab);
});
document.querySelectorAll('.backup-tabpanel').forEach(p => {
@ -104,7 +103,7 @@ class BackupPage {
this.toggleExportMenu(false);
}
const tabBtn = e.target.closest('.backup-layout .sidebar .category[data-backup-tab]');
const tabBtn = e.target.closest('.backup-layout .tabs-list .tab-button[data-backup-tab]');
if (tabBtn) {
this.switchTab(tabBtn.dataset.backupTab);
return;
@ -339,7 +338,6 @@ class BackupPage {
if (!tab || tab === this.currentTab) return;
this.currentTab = tab;
this.applyActiveTabUi(tab);
this.updatePageHeader();
this.updatePrimaryAction();
if (!opts.fromPopstate) this.pushTabToUrl(tab);
}
@ -357,58 +355,6 @@ class BackupPage {
}
}
updatePageHeader() {
const titleEl = document.getElementById('backup-section-title');
const subEl = document.getElementById('backup-section-subtitle');
const iconEl = document.getElementById('backup-page-header-icon');
if (titleEl) titleEl.textContent = this.titleFor(this.currentTab);
if (subEl) subEl.textContent = this.subtitleFor(this.currentTab);
if (iconEl) iconEl.innerHTML = this.iconFor(this.currentTab);
}
titleFor(tab) {
return {
dashboard: 'Dashboard',
backups: 'Backups',
locations: 'Locations',
configuration: 'Configuration'
}[tab] || 'Backups';
}
subtitleFor(tab) {
return {
dashboard: "Check what's protected — and when it last ran.",
backups: 'Every backup across every enabled location.',
locations: 'Where backups are stored. Add, edit, or remove destinations.',
configuration: 'Schedule, retention, and engine settings.'
}[tab] || '';
}
iconFor(tab) {
const icons = {
dashboard:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<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>',
backups:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>' +
'<polyline points="17 8 12 3 7 8"></polyline>' +
'<line x1="12" y1="3" x2="12" y2="15"></line></svg>',
locations:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>' +
'<circle cx="12" cy="10" r="3"></circle></svg>',
configuration:
'<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<circle cx="12" cy="12" r="3"></circle>' +
'<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>'
};
return icons[tab] || icons.backups;
}
updatePrimaryAction() {
const btn = document.getElementById('backup-primary-action');
if (!btn) return;

View File

@ -105,19 +105,15 @@
/* App-page tab strips: anchor every tab bar to the same surface as
the sidebar so the chrome reads as one coherent navy surface.
Using var(--sidebar-bg) directly so any future sidebar tweak
carries through. Covers all three tab places on the apps page:
carries through. Covers both tab idioms on the apps page:
1. .tab-navigation main app tabs (Config / Services / ) and
the Backup-location sub-tabs (Local / Remote 1 / Remote 2).
2. .tabs-list inside .tabs-wrapper config sub-tabs that the
app-config form renders inside the Config tab.
3. The fleet Overview's Backups tab, where the embedded BackupPage
sidebar is restyled into a nested horizontal strip + joined
content card (overview.css) same surface, same anchor. */
2. .tabs-list inside .tabs-wrapper config sub-tabs from the
app-config form, plus the fleet Overview's Migrate and Backups
sub-tabs (all share the canonical .tabs-wrapper markup). */
[data-theme="nebula"] .tab-navigation,
[data-theme="nebula"] .tabs-wrapper .tabs-list,
[data-theme="nebula"] .tabs-content,
[data-theme="nebula"] #overview-view #ov-pane-backups .backup-layout > .sidebar #backup-sidebar-list,
[data-theme="nebula"] #overview-view #ov-pane-backups .backup-layout > .main {
[data-theme="nebula"] .tabs-content {
background: var(--sidebar-bg);
}