feat(webui): central task-refresh registry + close stale-UI gaps
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 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
bfdfb3298c
commit
bae9a79158
@ -94,6 +94,7 @@
|
|||||||
<script src="/js/utils/system-live.js"></script>
|
<script src="/js/utils/system-live.js"></script>
|
||||||
<script src="/js/utils/dismissible.js"></script>
|
<script src="/js/utils/dismissible.js"></script>
|
||||||
<script src="/js/components/eo-modal.js"></script>
|
<script src="/js/components/eo-modal.js"></script>
|
||||||
|
<script src="/js/components/task/task-refresh-coordinator.js"></script>
|
||||||
<script src="/js/components/dashboard.js"></script>
|
<script src="/js/components/dashboard.js"></script>
|
||||||
<script src="/js/system/system-loader.js"></script>
|
<script src="/js/system/system-loader.js"></script>
|
||||||
<script src="/js/system/loading-ui.js"></script>
|
<script src="/js/system/loading-ui.js"></script>
|
||||||
|
|||||||
@ -53,16 +53,16 @@ class AdminOverview {
|
|||||||
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; }
|
if (e.target.closest('[data-admin-update]')) { this.runUpdate(); return; }
|
||||||
if (e.target.closest('[data-admin-verify]')) { this.runVerify(); 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.
|
// and re-render so the badge reflects reality without a manual reload.
|
||||||
const onTask = (ev) => {
|
// Registered with the task-refresh coordinator (single source of truth).
|
||||||
const cmd = ev?.detail?.command || ev?.detail?.task?.command || '';
|
window.taskRefresh?.register({
|
||||||
if (/^libreportal (verify|update)\b/.test(cmd)) {
|
id: 'admin-overview',
|
||||||
setTimeout(() => this.refreshVerify(), 1500);
|
match: (d) => ['verify', 'update', 'system_update'].includes(d.action)
|
||||||
}
|
|| /^libreportal (verify|update)\b/.test((d.task && d.task.command) || d.command || ''),
|
||||||
};
|
run: () => this.refreshVerify(),
|
||||||
window.addEventListener('taskCompleted', onTask);
|
debounceMs: 1500,
|
||||||
window.addEventListener('taskUpdated', onTask);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
go(where) {
|
go(where) {
|
||||||
|
|||||||
@ -42,132 +42,156 @@ class AppsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupTaskCompletionListener() {
|
setupTaskCompletionListener() {
|
||||||
// Listen for task completion events to reload apps data
|
// App-data refresh is registered with the task-refresh coordinator — the
|
||||||
window.addEventListener('taskCompleted', async (event) => {
|
// single source of truth for "task finished → reload this". Two entries:
|
||||||
const { action, appName, status } = event.detail;
|
// - 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();
|
this.clearCache();
|
||||||
await this.reloadAppsData();
|
await this.reloadAppsData();
|
||||||
// If the user is viewing this app's detail page, re-render the
|
if (window.serviceButtons) {
|
||||||
// config section in place so updated CFG_* values (e.g. a freshly
|
try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); }
|
||||||
// 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
|
const currentUrl = new URL(window.location.href);
|
||||||
// app + service data and repaint so the UI reflects the new config
|
const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || currentUrl.searchParams.get('app') || currentUrl.searchParams.get('');
|
||||||
// instead of showing stale URLs/routing until a manual refresh.
|
const pathname = window.location.pathname;
|
||||||
if (action === 'config_update' && status === 'completed') {
|
const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/');
|
||||||
await this.refreshAppsAndView();
|
const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-install welcome modal — only on the very first successful install per app per browser.
|
if (isAppsPage && !isAppDetailPage) {
|
||||||
if (action === 'install' && status === 'completed' && appName) {
|
const category = window.appsCategory || 'all';
|
||||||
const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`;
|
this.renderApps(category);
|
||||||
try {
|
} else if (isAppDetailPage && currentAppFromUrl === appName) {
|
||||||
if (!localStorage.getItem(key)) {
|
// Defer + isolate the heavy re-render so a throw inside
|
||||||
setTimeout(() => this.showInstallWelcome(appName), 600);
|
// displayConfigForm / port-manager init can't lock up the
|
||||||
}
|
// post-task UI cleanup. Fires on the next tick — gives the
|
||||||
} catch (_) {}
|
// 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
|
// After uninstall, bounce off the Tasks tab — there's nothing
|
||||||
if ((action === 'install' || action === 'uninstall') && status === 'completed') {
|
// to watch any more. Mark the app as "recently uninstalled"
|
||||||
// Skip duplicate events for the same task id — the reconcile
|
// so the 5s watchForTaskCreation poll doesn't bounce back.
|
||||||
// loop can synthesise a 404-fallback completed event after we've
|
if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) {
|
||||||
// already handled the real one, which would re-trigger the
|
window.appTabbedManager = window.appTabbedManager || null;
|
||||||
// heavy re-render + tab switch and visually flash the page.
|
if (window.appTabbedManager) {
|
||||||
const _taskId = event.detail.taskId || event.detail.id;
|
window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map();
|
||||||
this._handledTaskIds = this._handledTaskIds || new Set();
|
window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000);
|
||||||
if (_taskId && this._handledTaskIds.has(_taskId)) return;
|
setTimeout(() => {
|
||||||
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.
|
|
||||||
if (window.appTabbedManager?.currentTab === 'tasks') {
|
if (window.appTabbedManager?.currentTab === 'tasks') {
|
||||||
window.appTabbedManager.switchTab('config');
|
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() {
|
clearCache() {
|
||||||
|
|||||||
@ -99,7 +99,6 @@ class BackupPage {
|
|||||||
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
|
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
|
||||||
this.eventBound = false;
|
this.eventBound = false;
|
||||||
this._taskRefreshTimer = null;
|
this._taskRefreshTimer = null;
|
||||||
this._onTaskCompleted = this._onTaskCompleted.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@ -113,29 +112,6 @@ class BackupPage {
|
|||||||
this.updatePrimaryAction();
|
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
|
/* Read the active tab slug from window.location, supporting both
|
||||||
/backup?=dashboard (the legacy libreportal ?= form used on /config)
|
/backup?=dashboard (the legacy libreportal ?= form used on /config)
|
||||||
and /backup?backup=dashboard (standard query string) so links from
|
and /backup?backup=dashboard (standard query string) so links from
|
||||||
@ -171,7 +147,22 @@ class BackupPage {
|
|||||||
if (this.eventBound) return;
|
if (this.eventBound) return;
|
||||||
this.eventBound = true;
|
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 —
|
// Browser back/forward is handled by the SPA's popstate listener —
|
||||||
// pushTabToUrl includes a `route` field in state so the SPA's
|
// pushTabToUrl includes a `route` field in state so the SPA's
|
||||||
|
|||||||
@ -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;
|
||||||
@ -72,18 +72,17 @@ class UpdateNotifier {
|
|||||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||||
this.pollTimer = setInterval(() => this.refresh(), this.pollMs);
|
this.pollTimer = setInterval(() => this.refresh(), this.pollMs);
|
||||||
|
|
||||||
// Re-read the status as soon as an update/check task finishes so the badge
|
// Re-read the status as soon as an update task finishes so the badge clears
|
||||||
// clears (or the version updates) without waiting for the next poll.
|
// (or the version updates) without waiting for the next poll. Registered
|
||||||
const onTask = (event) => {
|
// with the task-refresh coordinator (single source of truth); the debounce
|
||||||
const cmd = event?.detail?.command || event?.detail?.task?.command || '';
|
// gives the host a beat to finish writing update_status.json.
|
||||||
const action = event?.detail?.action;
|
window.taskRefresh?.register({
|
||||||
if (/^libreportal update\b/.test(cmd) || action === 'update') {
|
id: 'update-badge',
|
||||||
// Give the host a beat to finish writing update_status.json.
|
match: (d) => d.action === 'update' || d.action === 'system_update'
|
||||||
setTimeout(() => this.refresh(), 1500);
|
|| /^libreportal update\b/.test((d.task && d.task.command) || d.command || ''),
|
||||||
}
|
run: () => this.refresh(),
|
||||||
};
|
debounceMs: 1500,
|
||||||
window.addEventListener('taskCompleted', onTask);
|
});
|
||||||
window.addEventListener('taskUpdated', onTask);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by TopbarComponent.init() once the topbar DOM exists.
|
// Called by TopbarComponent.init() once the topbar DOM exists.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user