fix(backup/webui): render the Backups Configuration form (dup-id collision) + refresh feedback

The Backups tab's Configuration sub-tab showed only the warning banner and
an orphaned divider -- the actual config form (retention presets, engine,
keep-policies, Save/Reset) never appeared.

Root cause: a duplicate id. The Fleet Overview, the app grid and per-app
detail all render the shared apps-unified-layout, which keeps a hidden
#config-tab > #config-section in the DOM. The Backups Configuration sub-tab
created its OWN #config-section, and configManager.renderConfig() resolved
the target with getElementById('config-section') -- which returns the FIRST
(hidden) node in document order. So the 21KB form painted into the invisible
per-app container while the backup tab's container stayed empty. Because the
/backup routes now redirect into /apps/overview/backups, this fired every
time -- the form had effectively never been visible.

Fix:
- renderConfig(category, target) now accepts an explicit container (element
  or id) and falls back to #config-section; the error-retry button re-targets
  via configSection.id.
- The Backups Configuration sub-tab uses a unique id (#backup-config-section)
  and passes it to renderConfig; retention-preset and engine-details
  selectors are scoped to it.

Also: the Refresh button now spins + locks while refreshAll()+render() run,
so the click has visible feedback instead of appearing to do nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
librelad 2026-07-07 21:13:06 +01:00
parent b33764f259
commit 6912c36aaf
6 changed files with 45 additions and 11 deletions

View File

@ -16,9 +16,17 @@ if (typeof window.ConfigManager === 'undefined') {
window.IPWhitelistManager = this.whitelistManager; window.IPWhitelistManager = this.whitelistManager;
} }
async renderConfig(category) { async renderConfig(category, target) {
const configSection = document.getElementById('config-section'); // `target` (an element or an element id) lets an embedder render into its
// OWN container instead of the page-global #config-section. The shared
// apps-unified-layout keeps a hidden #config-tab > #config-section in the
// DOM at all times, so on the Fleet Overview a plain
// getElementById('config-section') resolves to THAT stale element (two
// nodes share the id) and the form paints where nobody can see it. The
// Backups tab's Configuration sub-tab passes its own container id here.
const configSection = (typeof target === 'string' ? document.getElementById(target) : target)
|| document.getElementById('config-section');
if (!configSection) { if (!configSection) {
console.error('ConfigManager: config-section element not found'); console.error('ConfigManager: config-section element not found');
return; return;
@ -238,7 +246,7 @@ if (typeof window.ConfigManager === 'undefined') {
} catch (error) { } catch (error) {
console.error('ConfigManager: Error rendering ' + category + ' config: ', error); console.error('ConfigManager: Error rendering ' + category + ' config: ', error);
configSection.innerHTML = '<div class="error-container"><h3>Error Loading Configuration</h3><p>Failed to load configuration: ' + error.message + '</p><button type="button" class="btn btn-primary" onclick="window.configManager.renderConfig(\'' + category + '\')">Retry</button></div>'; configSection.innerHTML = '<div class="error-container"><h3>Error Loading Configuration</h3><p>Failed to load configuration: ' + error.message + '</p><button type="button" class="btn btn-primary" onclick="window.configManager.renderConfig(\'' + category + '\', \'' + configSection.id + '\')">Retry</button></div>';
} }
} }

View File

@ -29,9 +29,14 @@ Object.assign(BackupPage.prototype, {
<div class="config-divider"></div> <div class="config-divider"></div>
`; `;
// Unique id (NOT the page-global "config-section"): the shared
// apps-unified-layout keeps a hidden #config-tab > #config-section in the
// DOM, so reusing that id would collide and the config form would render
// into the wrong (invisible) node. renderConfig() is told to target this
// container explicitly.
body.innerHTML = ` body.innerHTML = `
${warningHTML} ${warningHTML}
<div id="config-section" class="backup-embedded-config"></div> <div id="backup-config-section" class="backup-embedded-config"></div>
`; `;
this.invokeConfigManager(); this.invokeConfigManager();
@ -39,7 +44,7 @@ Object.assign(BackupPage.prototype, {
async invokeConfigManager(attempt = 0) { async invokeConfigManager(attempt = 0) {
if (window.configManager && typeof window.configManager.renderConfig === 'function') { if (window.configManager && typeof window.configManager.renderConfig === 'function') {
try { try {
await window.configManager.renderConfig('backup'); await window.configManager.renderConfig('backup', 'backup-config-section');
this.enhanceConfigurationWithPresets(); this.enhanceConfigurationWithPresets();
} catch (err) { } catch (err) {
console.error('Backup configuration render failed:', err); console.error('Backup configuration render failed:', err);
@ -47,7 +52,7 @@ Object.assign(BackupPage.prototype, {
return; return;
} }
if (attempt >= 20) { if (attempt >= 20) {
const sec = document.getElementById('config-section'); const sec = document.getElementById('backup-config-section');
if (sec) sec.innerHTML = `<div class="backup-empty-state">Configuration system not loaded. Try refreshing the page.</div>`; if (sec) sec.innerHTML = `<div class="backup-empty-state">Configuration system not loaded. Try refreshing the page.</div>`;
return; return;
} }

View File

@ -2,7 +2,7 @@
Object.assign(BackupPage.prototype, { Object.assign(BackupPage.prototype, {
enhanceEngineDetailsButton() { enhanceEngineDetailsButton() {
const selector = '[name="CFG_BACKUP_ENGINE"], [name^="CFG_BACKUP_LOC_"][name$="_ENGINE"]'; const selector = '[name="CFG_BACKUP_ENGINE"], [name^="CFG_BACKUP_LOC_"][name$="_ENGINE"]';
document.querySelectorAll(`#config-section ${selector}, .backup-location-details ${selector}`).forEach((engineInput) => { document.querySelectorAll(`#backup-config-section ${selector}, .backup-location-details ${selector}`).forEach((engineInput) => {
const customSelect = engineInput.closest('.custom-select'); const customSelect = engineInput.closest('.custom-select');
const wrapTarget = customSelect || engineInput; const wrapTarget = customSelect || engineInput;
const group = wrapTarget.closest('.field-group') || wrapTarget.parentElement; const group = wrapTarget.closest('.field-group') || wrapTarget.parentElement;

View File

@ -2,7 +2,7 @@
Object.assign(BackupPage.prototype, { Object.assign(BackupPage.prototype, {
enhanceConfigurationWithPresets() { enhanceConfigurationWithPresets() {
this.enhanceEngineDetailsButton(); this.enhanceEngineDetailsButton();
const lastInput = document.querySelector('#config-section [name="CFG_BACKUP_KEEP_LAST"]'); const lastInput = document.querySelector('#backup-config-section [name="CFG_BACKUP_KEEP_LAST"]');
if (!lastInput) return; if (!lastInput) return;
const section = lastInput.closest('.config-category'); const section = lastInput.closest('.config-category');

View File

@ -86,6 +86,19 @@
background: rgba(var(--text-rgb), 0.1); background: rgba(var(--text-rgb), 0.1);
} }
/* Busy state while a manual refresh is in flight spin the arrow icon and
lock the button so the click gives visible feedback (refreshAll() + re-render
is otherwise silent, which read as "the button does nothing"). `spin` is the
global keyframe from core/theme/css/base.css. */
.backup-refresh-btn.is-refreshing {
pointer-events: none;
opacity: 0.75;
}
.backup-refresh-btn.is-refreshing svg {
animation: spin 0.8s linear infinite;
transform-origin: center;
}
.backup-tabpanel { .backup-tabpanel {
display: none; display: none;
/* Content inset matching the canonical .tab-panel (5px 24px), so panels /* Content inset matching the canonical .tab-panel (5px 24px), so panels

View File

@ -118,8 +118,16 @@ class BackupPage {
return; return;
} }
if (e.target.closest('#backup-refresh-btn')) { const refreshBtn = e.target.closest('#backup-refresh-btn');
this.refreshAll().then(() => this.render()); if (refreshBtn) {
// Give the click visible feedback: spin + lock the button until
// the refetch + re-render settle. Without this the button looks
// dead because refreshAll()/render() paint nothing new on a tab
// whose data hasn't changed.
if (refreshBtn.classList.contains('is-refreshing')) return;
refreshBtn.classList.add('is-refreshing');
const done = () => refreshBtn.classList.remove('is-refreshing');
this.refreshAll().then(() => this.render()).then(done, done);
return; return;
} }