From bae9a791584e5b3a5785961f532425d3585f7252 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 22:06:39 +0100 Subject: [PATCH] feat(webui): central task-refresh registry + close stale-UI gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-task UI refresh was scattered: every page added its own taskCompleted listener and hard-coded which actions it cared about, so it was easy to add a task and forget the refresh (stale UI), with no single place to see the wiring. Adds TaskRefreshCoordinator (window.taskRefresh): one listener, with dedupe (the SSE bus + synthetic fallbacks double-fire) and opt-in debounce (bursts coalesce; per-task handlers run every time). Components now register a refresh entry; window.taskRefresh.table() is the introspectable "what reloads when" map. Migrated onto it: apps (install/uninstall/tool/config_update lifecycle + restore/update/rebuild state), backups (backup/restore/delete), the update badge, and the admin overview integrity badge. Gaps closed: restore/update/ rebuild now repaint app+service data. (start/stop/restart intentionally omitted — no live status surface to refresh today; revisit if a running/stopped badge is added. Storage reclaim/image-rm keep their own in-page refresh.) Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- containers/libreportal/frontend/index.html | 1 + .../js/components/admin/admin-overview.js | 18 +- .../js/components/app/apps-manager.js | 250 ++++++++++-------- .../js/components/backup/backup-page.js | 41 ++- .../task/task-refresh-coordinator.js | 120 +++++++++ .../frontend/js/components/update-notifier.js | 23 +- 6 files changed, 294 insertions(+), 159 deletions(-) create mode 100644 containers/libreportal/frontend/js/components/task/task-refresh-coordinator.js 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.