fix(webui): give the app-detail Updates tab the standard tab chrome

The per-app Updates tab rendered a bare version/badge bar + detail with no
title, no dividers and no recessed container — unlike the Config / Backups /
Tasks tabs.

- Add a .updater-title block (⬆️ Updates + description, Check/Update actions,
  bottom-border divider) mirroring .backup-title.
- Wrap the body in a .updater-detail-container recessed dark panel (same recipe
  as .backup-snapshots-container / .tasks-container).
- Separate the Version/Security/Recovery/History sections with divider lines
  (scoped to the container; fleet row-details keep their gap-only spacing).
- renderAppDetail() gains an opt-in Version section so the version/badge reads
  as a section in the panel; fleet rows omit it (the row head shows it already).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-06-03 00:56:01 +01:00
parent 6ced7c4c71
commit 8006ddba75
3 changed files with 88 additions and 32 deletions

View File

@ -589,27 +589,36 @@ class AppTabbedManager {
section.innerHTML = '<div class="updater-empty">Loading…</div>';
await this.appUpdater.refreshAll();
const app = (this.appUpdater.apps || []).find((x) => x.name === this.currentApp);
if (!app) {
section.innerHTML = `<div class="updater-empty">No update data for this app yet. <button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>`;
return;
}
section.innerHTML = this.renderAppUpdaterHead(app) + this.appUpdater.renderAppDetail(app);
// Always lead with the title block + recessed dark container, so the tab
// matches the Config / Backups / Tasks tabs whether or not there's data.
const body = app
? this.appUpdater.renderAppDetail(app, { includeVersion: true })
: `<div class="updater-empty">No update data for this app yet. <button class="updater-btn updater-btn-primary" data-updater-action="check">Check now</button></div>`;
section.innerHTML = this.renderAppUpdaterHead(app) +
`<div class="updater-detail-container">${body}</div>`;
}
// Title block for the per-app Updates tab — emoji + title + description on the
// left, Check/Update actions on the right. Mirrors .backup-title (.config-title
// family) so every app-detail tab shares one header idiom. `a` may be null
// (no data yet); the Update button only appears when an update is available.
renderAppUpdaterHead(a) {
const up = this.appUpdater;
const esc = (s) => up.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 available</span>`
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
const updBtn = a.update_available
const esc = (s) => (this.appUpdater ? this.appUpdater.escape(s) : String(s == null ? '' : s));
const name = this.currentApp
? (window.getAppDisplayName ? window.getAppDisplayName(this.currentApp) : this.currentApp)
: 'this app';
const updBtn = (a && a.update_available)
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${esc(a.name)}">Update</button>`
: '';
return `<div class="app-updater-head">
<div class="app-updater-head-main">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div>
<div class="app-updater-head-actions"><button class="updater-btn" data-updater-action="check"> Check</button> ${updBtn}</div>
return `<div class="updater-title">
<div class="updater-title-main">
<h3> Updates</h3>
<p>Version, security and recovery for ${esc(name)}.</p>
</div>
<div class="updater-title-actions">
<button class="updater-btn" data-updater-action="check"> Check</button>
${updBtn}
</div>
</div>`;
}

View File

@ -199,19 +199,53 @@
#overview-view .ov-subtabs-content .tab-panel { display: none; }
#overview-view .ov-subtabs-content .tab-panel.active { display: block; }
/* ---- per-app Updates tab header ----------------------------------------- */
.app-updater-section { padding: 4px 0; }
.app-updater-head {
/* ---- per-app Updates tab (app-detail) ----------------------------------- */
/* Same idiom as the Config / Backups / Tasks tabs: a title block (icon + h3 +
description, with a bottom-border divider) followed by a recessed dark
container holding the body. Mirrors .backup-section / .backup-title /
.backup-snapshots-container. */
.app-updater-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid var(--border-color, rgba(255, 255, 255, .12));
border-radius: 10px;
background: var(--input-bg, rgba(255, 255, 255, .03));
margin-bottom: 4px;
flex-direction: column;
padding: 0;
}
.updater-title {
padding: 20px;
background: transparent;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.updater-title-main { flex: 1; min-width: 0; }
.updater-title h3 {
margin: 0 0 8px 0;
color: var(--text-primary, #fff);
font-size: 18px;
font-weight: 600;
}
.updater-title p {
margin: 0;
color: var(--text-secondary, #ccc);
font-size: 13px;
}
.updater-title-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
/* Recessed dark panel wrapping the body same recipe as .backup-snapshots-
container / .tasks-container. */
.updater-detail-container {
padding: 4px 16px;
margin: 16px;
background: rgba(var(--bg-rgb), 0.2);
border-radius: 8px;
}
/* Inside the container the Version / Security / Recovery / History sections are
separated by divider lines (the fleet row-details, not in this container, keep
their gap-only spacing). */
.updater-detail-container .updater-detail { gap: 0; padding-top: 0; }
.updater-detail-container .updater-detail-section { padding: 16px 0; }
.updater-detail-container .updater-detail-section:first-child { padding-top: 0; }
.updater-detail-container .updater-detail-section + .updater-detail-section {
border-top: 1px solid var(--border-color);
}
.app-updater-head-main { display: flex; align-items: center; gap: 10px; }
.app-updater-head-actions { display: flex; gap: 8px; }

View File

@ -386,8 +386,21 @@ class UpdaterPage {
// 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) {
renderAppDetail(app, opts = {}) {
const a = app || {};
// Optional leading "Version" section — the per-app Updates tab shows the
// current/available version + status badge as a section in the panel. The
// fleet rows omit it (the row head already shows the version).
let versionSection = '';
if (opts.includeVersion) {
const cur = this.escape(a.current_version || a.current_image || '—');
const avail = a.update_available ? this.escape(a.available_version || a.available_image || 'newer') : null;
const badge = a.update_available
? `<span class="updater-badge updater-badge-update">update available</span>`
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
versionSection = `<div class="updater-detail-section"><h4>Version</h4>
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div></div>`;
}
const cves = a.cves || [];
const cveItems = cves.map((c) => `
<div class="updater-cve sev-${(c.severity || 'low').toLowerCase()}">
@ -423,7 +436,7 @@ class UpdaterPage {
<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>`;
return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
}
empty(msg, withCheck) {