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,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() {

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.