diff --git a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
index 6297ff6..0248ee0 100755
--- a/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
+++ b/containers/libreportal/frontend/components/apps/core/js/app-tabbed-manager.js
@@ -292,6 +292,10 @@ class AppTabbedManager {
// IMPORTANT: Re-apply button state if there are running tasks
this.restoreButtonState();
break;
+ case 'updater':
+ await this.loadAppUpdater();
+ this.restoreButtonState();
+ break;
case 'services':
if (window.servicesManager) {
await window.servicesManager.load(this.currentApp);
@@ -553,6 +557,62 @@ class AppTabbedManager {
await this.backupAppCard.render();
}
+ // Load the per-app Updates tab — version state + CVEs + recovery + history for
+ // the current app. Reuses the headless UpdaterPage: the same data layer the
+ // fleet Overview uses, and renderAppDetail() for the body. Actions dispatch
+ // through the task system, identical to the fleet view.
+ async loadAppUpdater() {
+ const section = document.getElementById('app-updater-section');
+ if (!section) return;
+ if (typeof UpdaterPage === 'undefined') {
+ section.innerHTML = '
Updater unavailable.
';
+ return;
+ }
+ if (!this.appUpdater) this.appUpdater = new UpdaterPage((window.LP && window.LP.services) || {});
+
+ // One delegated listener scoped to this section (the pane lives in the
+ // shared layout, replaced on full navigation, so it GCs with the node).
+ if (section.dataset.updBound !== '1') {
+ section.dataset.updBound = '1';
+ section.addEventListener('click', (e) => {
+ const a = e.target.closest('[data-updater-action]');
+ if (!a) return;
+ const app = a.dataset.app || this.currentApp;
+ switch (a.dataset.updaterAction) {
+ case 'check': this.appUpdater.checkForUpdates(); break;
+ case 'update': this.appUpdater.applyUpdate(app); break;
+ case 'rollback': this.appUpdater.rollback(app); break;
+ }
+ });
+ }
+
+ section.innerHTML = '
Loading…
';
+ await this.appUpdater.refreshAll();
+ const app = (this.appUpdater.apps || []).find((x) => x.name === this.currentApp);
+ if (!app) {
+ section.innerHTML = `
No update data for this app yet.
`;
+ return;
+ }
+ section.innerHTML = this.renderAppUpdaterHead(app) + this.appUpdater.renderAppDetail(app);
+ }
+
+ 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
+ ? `
update available`
+ : (a.scanned ? `
up to date` : `
unscanned`);
+ const updBtn = a.update_available
+ ? `
`
+ : '';
+ return `
+
${badge} ${cur}${avail ? ` → ${avail}` : ''}
+
${updBtn}
+
`;
+ }
+
// Initialize the tabbed manager
async initialize() {
// Prevent double initialization
@@ -849,7 +909,7 @@ class AppTabbedManager {
// Disable config, services and backup tabs when task is running
disableTabs() {
- const tabs = ['config', 'services', 'tools', 'backups']
+ const tabs = ['config', 'services', 'tools', 'backups', 'updater']
.map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`))
.filter(Boolean);
@@ -864,7 +924,7 @@ class AppTabbedManager {
// Enable config, services and backup tabs when task completes
enableTabs() {
- const tabs = ['config', 'services', 'tools', 'backups']
+ const tabs = ['config', 'services', 'tools', 'backups', 'updater']
.map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`))
.filter(Boolean);
diff --git a/containers/libreportal/frontend/components/apps/overview/css/overview.css b/containers/libreportal/frontend/components/apps/overview/css/overview.css
index 110ca07..07fbc4f 100644
--- a/containers/libreportal/frontend/components/apps/overview/css/overview.css
+++ b/containers/libreportal/frontend/components/apps/overview/css/overview.css
@@ -172,3 +172,20 @@
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, .08));
}
.ov-loc-row span:first-child { font-weight: 500; }
+
+/* ---- per-app Updates tab header ----------------------------------------- */
+.app-updater-section { padding: 4px 0; }
+.app-updater-head {
+ 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;
+}
+.app-updater-head-main { display: flex; align-items: center; gap: 10px; }
+.app-updater-head-actions { display: flex; gap: 8px; }
diff --git a/containers/libreportal/frontend/core/boot/js/system-loader.js b/containers/libreportal/frontend/core/boot/js/system-loader.js
index 1b794c9..2d13dfa 100755
--- a/containers/libreportal/frontend/core/boot/js/system-loader.js
+++ b/containers/libreportal/frontend/core/boot/js/system-loader.js
@@ -195,6 +195,7 @@ class SystemLoader {
'/components/apps/port-manager/js/port-manager.js',
'/core/tasks/js/task-manager.js', // Add TaskManager for backup functionality
'/core/backup-card/js/backup-app-card.js',
+ '/components/updater/js/updater-page.js', // headless reuse: per-app Updates tab + fleet Overview
'/components/apps/services/js/services-manager.js',
'/components/apps/tools/js/tools-manager.js',
'/components/apps/routing/js/routing-manager.js',