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/dismissible.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/system/system-loader.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-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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user