Merge claude/1

This commit is contained in:
librelad 2026-05-28 22:06:39 +01:00
commit f6e310998b
6 changed files with 294 additions and 159 deletions

View File

@ -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>

View File

@ -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) {

View File

@ -42,9 +42,34 @@ 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') {
@ -89,7 +114,7 @@ class AppsManager {
// 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;
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);
@ -98,7 +123,7 @@ class AppsManager {
// 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;
const taskId = detail.taskId || detail.id;
if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) {
this._pendingTaskCleanup.delete(taskId);
try {
@ -167,7 +192,6 @@ class AppsManager {
console.error('Post-task handler failed for', action, appName, ':', err);
}
}
});
}
clearCache() {

View File

@ -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

View File

@ -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;

View File

@ -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.