diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html
index 2403f36..1d31063 100755
--- a/containers/libreportal/frontend/index.html
+++ b/containers/libreportal/frontend/index.html
@@ -94,6 +94,7 @@
+
diff --git a/containers/libreportal/frontend/js/components/admin/admin-overview.js b/containers/libreportal/frontend/js/components/admin/admin-overview.js
index 5cdeeb1..f7e5592 100644
--- a/containers/libreportal/frontend/js/components/admin/admin-overview.js
+++ b/containers/libreportal/frontend/js/components/admin/admin-overview.js
@@ -53,16 +53,16 @@ class AdminOverview {
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; }
if (e.target.closest('[data-admin-verify]')) { this.runVerify(); return; }
});
- // When a verify (or update) task finishes, re-read the integrity status
+ // When a verify or update task finishes, re-read the integrity status
// and re-render so the badge reflects reality without a manual reload.
- const onTask = (ev) => {
- const cmd = ev?.detail?.command || ev?.detail?.task?.command || '';
- if (/^libreportal (verify|update)\b/.test(cmd)) {
- setTimeout(() => this.refreshVerify(), 1500);
- }
- };
- window.addEventListener('taskCompleted', onTask);
- window.addEventListener('taskUpdated', onTask);
+ // Registered with the task-refresh coordinator (single source of truth).
+ window.taskRefresh?.register({
+ id: 'admin-overview',
+ match: (d) => ['verify', 'update', 'system_update'].includes(d.action)
+ || /^libreportal (verify|update)\b/.test((d.task && d.task.command) || d.command || ''),
+ run: () => this.refreshVerify(),
+ debounceMs: 1500,
+ });
}
go(where) {
diff --git a/containers/libreportal/frontend/js/components/app/apps-manager.js b/containers/libreportal/frontend/js/components/app/apps-manager.js
index c9d904f..b8833c3 100755
--- a/containers/libreportal/frontend/js/components/app/apps-manager.js
+++ b/containers/libreportal/frontend/js/components/app/apps-manager.js
@@ -42,132 +42,156 @@ class AppsManager {
}
setupTaskCompletionListener() {
- // Listen for task completion events to reload apps data
- window.addEventListener('taskCompleted', async (event) => {
- const { action, appName, status } = event.detail;
+ // App-data refresh is registered with the task-refresh coordinator — the
+ // single source of truth for "task finished → reload this". Two entries:
+ // - lifecycle: install/uninstall/tool/config_update carry their own UX
+ // (welcome modal, post-uninstall task cleanup + tab bounce), so they run
+ // the full handler with no debounce (every task must be handled).
+ // - state: restore/update/rebuild just need the app + service data
+ // repainted so version/config/restored state shows.
+ if (!window.taskRefresh) return;
+ window.taskRefresh.register({
+ id: 'apps-lifecycle',
+ run: (d) => this._onAppTaskCompleted(d),
+ });
+ window.taskRefresh.register({
+ id: 'apps-state',
+ match: (d) => d.status === 'completed'
+ && (['restore', 'update', 'rebuild'].includes(d.action)
+ || /^libreportal\s+(app\s+(update|rebuild)|restore\s+app)\b/.test((d.task && d.task.command) || '')),
+ run: () => this.refreshAppsAndView(),
+ debounceMs: 400,
+ });
+ }
+
+ // Full handler for app lifecycle tasks. Carries UX side-effects (first-install
+ // welcome modal, post-uninstall task cleanup + tab bounce) alongside the data
+ // reload. `detail` is the taskCompleted event detail; self-gates by action so
+ // it's safe to run on every completed task.
+ async _onAppTaskCompleted(detail) {
+ const { action, appName, status } = detail;
+
+ // Tool tasks mutate per-app config — refresh cache silently for next read.
+ if (action === 'tool' && status === 'completed') {
+ this.clearCache();
+ await this.reloadAppsData();
+ // If the user is viewing this app's detail page, re-render the
+ // config section in place so updated CFG_* values (e.g. a freshly
+ // reset password) show without needing a page refresh. Don't
+ // switch tabs — they may be reading the tool's task log.
+ const url = new URL(window.location.href);
+ const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || url.searchParams.get('app') || url.searchParams.get('');
+ const onAppDetail = window.location.pathname === '/app' || window.location.pathname.startsWith('/app/');
+ if (onAppDetail && appName && currentAppFromUrl === appName) {
+ this.displayConfigForm?.((window.apps || []).find(a =>
+ (a.command || '').endsWith(' ' + appName)
+ ));
+ }
+ return;
+ }
+
+ // Config apply re-deploys apps (ports/subdomains/URLs/routing). Reload
+ // app + service data and repaint so the UI reflects the new config
+ // instead of showing stale URLs/routing until a manual refresh.
+ if (action === 'config_update' && status === 'completed') {
+ await this.refreshAppsAndView();
+ return;
+ }
+
+ // First-install welcome modal — only on the very first successful install per app per browser.
+ if (action === 'install' && status === 'completed' && appName) {
+ const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`;
+ try {
+ if (!localStorage.getItem(key)) {
+ setTimeout(() => this.showInstallWelcome(appName), 600);
+ }
+ } catch (_) {}
+ }
+
+ // Only reload on successful install or uninstall
+ if ((action === 'install' || action === 'uninstall') && status === 'completed') {
+ // Skip duplicate events for the same task id — the reconcile
+ // loop can synthesise a 404-fallback completed event after we've
+ // already handled the real one, which would re-trigger the
+ // heavy re-render + tab switch and visually flash the page.
+ const _taskId = detail.taskId || detail.id;
+ this._handledTaskIds = this._handledTaskIds || new Set();
+ if (_taskId && this._handledTaskIds.has(_taskId)) return;
+ if (_taskId) this._handledTaskIds.add(_taskId);
+
+ try {
+ // If this uninstall asked to delete its own task, do it now —
+ // the bash side skipped the in-flight task on purpose to
+ // avoid racing with the processor's final status write.
+ const taskId = detail.taskId || detail.id;
+ if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) {
+ this._pendingTaskCleanup.delete(taskId);
+ try {
+ if (window.tasksManager?.taskManager?.deleteTask) {
+ await window.tasksManager.taskManager.deleteTask(taskId, { force: true });
+ }
+ // Re-render Tasks tab so the empty state ("No tasks found for X") shows.
+ if (window.tasksManager?.loadTasks) await window.tasksManager.loadTasks();
+ if (window.tasksManager?.renderTasks) window.tasksManager.renderTasks();
+ // If the user is parked on the Tasks tab and now there's
+ // nothing to look at, bounce them to Config.
+ if (window.appTabbedManager?.currentTab === 'tasks') {
+ window.appTabbedManager.switchTab('config');
+ }
+ } catch (e) { console.error('post-uninstall task cleanup failed:', e); }
+ }
- // Tool tasks mutate per-app config — refresh cache silently for next read.
- if (action === 'tool' && status === 'completed') {
this.clearCache();
await this.reloadAppsData();
- // If the user is viewing this app's detail page, re-render the
- // config section in place so updated CFG_* values (e.g. a freshly
- // reset password) show without needing a page refresh. Don't
- // switch tabs — they may be reading the tool's task log.
- const url = new URL(window.location.href);
- const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || url.searchParams.get('app') || url.searchParams.get('');
- const onAppDetail = window.location.pathname === '/app' || window.location.pathname.startsWith('/app/');
- if (onAppDetail && appName && currentAppFromUrl === appName) {
- this.displayConfigForm?.((window.apps || []).find(a =>
- (a.command || '').endsWith(' ' + appName)
- ));
+ if (window.serviceButtons) {
+ try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); }
}
- return;
- }
- // Config apply re-deploys apps (ports/subdomains/URLs/routing). Reload
- // app + service data and repaint so the UI reflects the new config
- // instead of showing stale URLs/routing until a manual refresh.
- if (action === 'config_update' && status === 'completed') {
- await this.refreshAppsAndView();
- return;
- }
+ const currentUrl = new URL(window.location.href);
+ const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || currentUrl.searchParams.get('app') || currentUrl.searchParams.get('');
+ const pathname = window.location.pathname;
+ const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/');
+ const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/');
- // First-install welcome modal — only on the very first successful install per app per browser.
- if (action === 'install' && status === 'completed' && appName) {
- const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`;
- try {
- if (!localStorage.getItem(key)) {
- setTimeout(() => this.showInstallWelcome(appName), 600);
- }
- } catch (_) {}
- }
+ if (isAppsPage && !isAppDetailPage) {
+ const category = window.appsCategory || 'all';
+ this.renderApps(category);
+ } else if (isAppDetailPage && currentAppFromUrl === appName) {
+ // Defer + isolate the heavy re-render so a throw inside
+ // displayConfigForm / port-manager init can't lock up the
+ // post-task UI cleanup. Fires on the next tick — gives the
+ // task spinners + button enables a chance to repaint first.
+ setTimeout(() => {
+ // _skipReload flag tells renderAppDetail not to re-fetch
+ // apps.json again (we already just did, line above).
+ this.renderAppDetail(appName, null, true, { skipReload: true })
+ .catch(err => console.error('renderAppDetail failed:', err));
+ }, 0);
+ }
- // Only reload on successful install or uninstall
- if ((action === 'install' || action === 'uninstall') && status === 'completed') {
- // Skip duplicate events for the same task id — the reconcile
- // loop can synthesise a 404-fallback completed event after we've
- // already handled the real one, which would re-trigger the
- // heavy re-render + tab switch and visually flash the page.
- const _taskId = event.detail.taskId || event.detail.id;
- this._handledTaskIds = this._handledTaskIds || new Set();
- if (_taskId && this._handledTaskIds.has(_taskId)) return;
- if (_taskId) this._handledTaskIds.add(_taskId);
-
- try {
- // If this uninstall asked to delete its own task, do it now —
- // the bash side skipped the in-flight task on purpose to
- // avoid racing with the processor's final status write.
- const taskId = event.detail.taskId || event.detail.id;
- if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) {
- this._pendingTaskCleanup.delete(taskId);
- try {
- if (window.tasksManager?.taskManager?.deleteTask) {
- await window.tasksManager.taskManager.deleteTask(taskId, { force: true });
- }
- // Re-render Tasks tab so the empty state ("No tasks found for X") shows.
- if (window.tasksManager?.loadTasks) await window.tasksManager.loadTasks();
- if (window.tasksManager?.renderTasks) window.tasksManager.renderTasks();
- // If the user is parked on the Tasks tab and now there's
- // nothing to look at, bounce them to Config.
+ // After uninstall, bounce off the Tasks tab — there's nothing
+ // to watch any more. Mark the app as "recently uninstalled"
+ // so the 5s watchForTaskCreation poll doesn't bounce back.
+ if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) {
+ window.appTabbedManager = window.appTabbedManager || null;
+ if (window.appTabbedManager) {
+ window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map();
+ window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000);
+ setTimeout(() => {
if (window.appTabbedManager?.currentTab === 'tasks') {
window.appTabbedManager.switchTab('config');
}
- } catch (e) { console.error('post-uninstall task cleanup failed:', e); }
+ }, 50);
}
-
- this.clearCache();
- await this.reloadAppsData();
- if (window.serviceButtons) {
- try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); }
- }
-
- const currentUrl = new URL(window.location.href);
- const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || currentUrl.searchParams.get('app') || currentUrl.searchParams.get('');
- const pathname = window.location.pathname;
- const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/');
- const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/');
-
- if (isAppsPage && !isAppDetailPage) {
- const category = window.appsCategory || 'all';
- this.renderApps(category);
- } else if (isAppDetailPage && currentAppFromUrl === appName) {
- // Defer + isolate the heavy re-render so a throw inside
- // displayConfigForm / port-manager init can't lock up the
- // post-task UI cleanup. Fires on the next tick — gives the
- // task spinners + button enables a chance to repaint first.
- setTimeout(() => {
- // _skipReload flag tells renderAppDetail not to re-fetch
- // apps.json again (we already just did, line above).
- this.renderAppDetail(appName, null, true, { skipReload: true })
- .catch(err => console.error('renderAppDetail failed:', err));
- }, 0);
- }
-
- // After uninstall, bounce off the Tasks tab — there's nothing
- // to watch any more. Mark the app as "recently uninstalled"
- // so the 5s watchForTaskCreation poll doesn't bounce back.
- if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) {
- window.appTabbedManager = window.appTabbedManager || null;
- if (window.appTabbedManager) {
- window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map();
- window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000);
- setTimeout(() => {
- if (window.appTabbedManager?.currentTab === 'tasks') {
- window.appTabbedManager.switchTab('config');
- }
- }, 50);
- }
- }
-
- if (typeof window.renderInstalledApps === 'function') {
- window.renderInstalledApps();
- }
- } catch (err) {
- console.error('Post-task handler failed for', action, appName, ':', err);
}
+
+ if (typeof window.renderInstalledApps === 'function') {
+ window.renderInstalledApps();
+ }
+ } catch (err) {
+ console.error('Post-task handler failed for', action, appName, ':', err);
}
- });
+ }
}
clearCache() {
diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js
index 5b807a6..1662ca3 100644
--- a/containers/libreportal/frontend/js/components/backup/backup-page.js
+++ b/containers/libreportal/frontend/js/components/backup/backup-page.js
@@ -99,7 +99,6 @@ class BackupPage {
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.eventBound = false;
this._taskRefreshTimer = null;
- this._onTaskCompleted = this._onTaskCompleted.bind(this);
}
async init() {
@@ -113,29 +112,6 @@ class BackupPage {
this.updatePrimaryAction();
}
- /* Backups/restores complete asynchronously and the page has no live feed,
- so repaint when one finishes (snapshot lists, last-backup times, sizes).
- The Refresh button stays as a manual pull. Debounced because picking
- several apps queues a task each, which finish in a burst. Only the
- mounted instance reacts; a stale listener removes itself. */
- _onTaskCompleted(e) {
- if (window.backupPage !== this || !document.getElementById('backup-page')) {
- window.removeEventListener('taskCompleted', this._onTaskCompleted);
- return;
- }
- const d = e?.detail || {};
- const cmd = d.task?.command || '';
- const relevant = d.action === 'backup' || d.action === 'restore'
- || /^libreportal\s+(backup|restore)\b/.test(cmd);
- if (!relevant) return;
- clearTimeout(this._taskRefreshTimer);
- this._taskRefreshTimer = setTimeout(() => {
- if (window.backupPage === this && document.getElementById('backup-page')) {
- this.refreshAll().then(() => this.render());
- }
- }, 600);
- }
-
/* Read the active tab slug from window.location, supporting both
/backup?=dashboard (the legacy libreportal ?= form used on /config)
and /backup?backup=dashboard (standard query string) so links from
@@ -171,7 +147,22 @@ class BackupPage {
if (this.eventBound) return;
this.eventBound = true;
- window.addEventListener('taskCompleted', this._onTaskCompleted);
+ // Backups/restores/deletes complete asynchronously with no live feed, so
+ // repaint when one finishes (snapshot lists, last-backup times, sizes).
+ // Registered with the task-refresh coordinator (single source of truth);
+ // debounced because picking several apps queues a task each, finishing
+ // in a burst. The Refresh button stays as a manual pull.
+ window.taskRefresh?.register({
+ id: 'backups',
+ match: (d) => ['backup', 'restore', 'delete', 'delete_all'].includes(d.action)
+ || /^libreportal\s+(backup|restore)\b/.test((d.task && d.task.command) || ''),
+ run: () => {
+ if (window.backupPage === this && document.getElementById('backup-page')) {
+ return this.refreshAll().then(() => this.render());
+ }
+ },
+ debounceMs: 600,
+ });
// Browser back/forward is handled by the SPA's popstate listener —
// pushTabToUrl includes a `route` field in state so the SPA's
diff --git a/containers/libreportal/frontend/js/components/task/task-refresh-coordinator.js b/containers/libreportal/frontend/js/components/task/task-refresh-coordinator.js
new file mode 100644
index 0000000..96a287f
--- /dev/null
+++ b/containers/libreportal/frontend/js/components/task/task-refresh-coordinator.js
@@ -0,0 +1,120 @@
+/**
+ * TaskRefreshCoordinator — one place that maps "a task finished" to "what the
+ * UI should reload" so it reflects the result without a manual refresh.
+ *
+ * Why this exists: refresh-on-completion used to be scattered — every page
+ * added its own `window.addEventListener('taskCompleted', …)` and hard-coded
+ * which `action` strings it cared about. That made it easy to add a task and
+ * forget the refresh (stale UI), with no single place to see the wiring.
+ *
+ * Now components REGISTER a refresh entry here; the coordinator owns the single
+ * listener, the dedupe (the SSE bus + synthetic fallbacks can double-fire the
+ * same task), and the debounce (bursts of tasks coalesce into one refresh).
+ * `window.taskRefresh.table()` is the introspectable "what reloads when" map.
+ *
+ * An entry is DATA-REFRESH only. Interaction side-effects (button enable/
+ * disable, nav locks, welcome modals) stay in their components — they are not
+ * "make the data current" concerns.
+ *
+ * window.taskRefresh.register({
+ * id: 'backups', // unique; re-registering replaces
+ * actions: ['backup', 'restore'], // match on task type, AND/OR…
+ * commands: /^libreportal\s+backup\b/, // …match on the command string, OR…
+ * match: (d) => d.status === 'completed',// …a custom predicate
+ * run: async (detail) => { … }, // do the refresh
+ * debounceMs: 300,
+ * });
+ *
+ * The detail object is the `taskCompleted` event detail:
+ * { taskId, appName, action, status, task, timestamp }
+ */
+class TaskRefreshCoordinator {
+ constructor() {
+ this.entries = [];
+ this._recent = new Map(); // `${taskId}::${entryId}` -> ts, for dedupe
+ this._timers = new Map(); // entryId -> debounce timer
+ this._listening = false;
+ }
+
+ _ensureListening() {
+ if (this._listening) return;
+ this._listening = true;
+ const onEvent = (e) => this._dispatch(e && e.detail);
+ window.addEventListener('taskCompleted', onEvent);
+ // Safety net: a terminal status that the bus classified as "updated"
+ // (not a clean transition to completed) should still trigger refreshes.
+ window.addEventListener('taskUpdated', (e) => {
+ const st = e && e.detail && e.detail.task && e.detail.task.status;
+ if (st === 'completed' || st === 'failed' || st === 'cancelled') onEvent(e);
+ });
+ }
+
+ register(spec) {
+ if (!spec || !spec.id || typeof spec.run !== 'function') {
+ console.warn('taskRefresh.register: ignoring invalid entry', spec);
+ return;
+ }
+ // Idempotent: re-registering the same id (e.g. a page re-mounts) replaces.
+ this.entries = this.entries.filter((e) => e.id !== spec.id);
+ this.entries.push(spec);
+ this._ensureListening();
+ return spec;
+ }
+
+ unregister(id) {
+ this.entries = this.entries.filter((e) => e.id !== id);
+ }
+
+ // Introspectable map of what reloads on which tasks.
+ table() {
+ return this.entries.map((e) => ({
+ id: e.id,
+ actions: e.actions || null,
+ commands: e.commands ? String(e.commands) : null,
+ custom: !!e.match,
+ }));
+ }
+
+ _matches(entry, d) {
+ if (entry.match) { try { if (!entry.match(d)) return false; } catch { return false; } }
+ // If neither actions nor commands is set, a passing `match` (or no match)
+ // means "all tasks".
+ if (!entry.actions && !entry.commands) return true;
+ if (entry.actions && d.action && entry.actions.includes(d.action)) return true;
+ if (entry.commands && entry.commands.test((d.task && d.task.command) || '')) return true;
+ return false;
+ }
+
+ _dispatch(detail) {
+ if (!detail) return;
+ const taskId = detail.taskId || detail.id || '?';
+ const now = Date.now();
+ for (const entry of this.entries) {
+ if (!this._matches(entry, detail)) continue;
+ // Dedupe the same task+entry firing twice in quick succession.
+ const key = `${taskId}::${entry.id}`;
+ if (now - (this._recent.get(key) || 0) < 4000) continue;
+ this._recent.set(key, now);
+ this._runDebounced(entry, detail);
+ }
+ if (this._recent.size > 256) this._recent.clear();
+ }
+
+ _runDebounced(entry, detail) {
+ const run = async () => {
+ try { await entry.run(detail); }
+ catch (err) { console.error(`taskRefresh[${entry.id}] failed:`, err); }
+ };
+ const ms = Number.isFinite(entry.debounceMs) ? entry.debounceMs : 0;
+ // No debounce → run per task. A debounce coalesces a burst into one call,
+ // so only use it for detail-agnostic data reloads, never for entries with
+ // per-task side-effects (the timer is per-entry and would drop all but the
+ // last task's invocation).
+ if (ms <= 0) { Promise.resolve().then(run); return; }
+ clearTimeout(this._timers.get(entry.id));
+ this._timers.set(entry.id, setTimeout(() => { this._timers.delete(entry.id); run(); }, ms));
+ }
+}
+
+window.taskRefresh = window.taskRefresh || new TaskRefreshCoordinator();
+window.TaskRefreshCoordinator = TaskRefreshCoordinator;
diff --git a/containers/libreportal/frontend/js/components/update-notifier.js b/containers/libreportal/frontend/js/components/update-notifier.js
index 08dbdf5..f29ed0c 100644
--- a/containers/libreportal/frontend/js/components/update-notifier.js
+++ b/containers/libreportal/frontend/js/components/update-notifier.js
@@ -72,18 +72,17 @@ class UpdateNotifier {
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => this.refresh(), this.pollMs);
- // Re-read the status as soon as an update/check task finishes so the badge
- // clears (or the version updates) without waiting for the next poll.
- const onTask = (event) => {
- const cmd = event?.detail?.command || event?.detail?.task?.command || '';
- const action = event?.detail?.action;
- if (/^libreportal update\b/.test(cmd) || action === 'update') {
- // Give the host a beat to finish writing update_status.json.
- setTimeout(() => this.refresh(), 1500);
- }
- };
- window.addEventListener('taskCompleted', onTask);
- window.addEventListener('taskUpdated', onTask);
+ // Re-read the status as soon as an update task finishes so the badge clears
+ // (or the version updates) without waiting for the next poll. Registered
+ // with the task-refresh coordinator (single source of truth); the debounce
+ // gives the host a beat to finish writing update_status.json.
+ window.taskRefresh?.register({
+ id: 'update-badge',
+ match: (d) => d.action === 'update' || d.action === 'system_update'
+ || /^libreportal update\b/.test((d.task && d.task.command) || d.command || ''),
+ run: () => this.refresh(),
+ debounceMs: 1500,
+ });
}
// Called by TopbarComponent.init() once the topbar DOM exists.