LibrePortal/containers/libreportal/frontend/js/system/setup-completion-watcher.js
librelad a103aa6864 refactor(webui): path-based URLs for apps, app, tasks, backup
Convert the remaining sections off the legacy ?= query form to clean paths,
matching the Admin area:
  /apps/<category>           (was /apps?=<category>)
  /app/<name>?tab=&task=     (was /app?=<name>&tab=&task=)
  /tasks/<category>?task=    (was /tasks?=<category>&task=)
  /backup/<tab>              (was /backup?=<tab>)

Builders updated everywhere (sidebar, dashboard, notifications, tasks, apps,
app tabs, task-actions, setup watcher); parsers now read the resource from the
path with the legacy ?= kept as a fallback so old links/bookmarks still work
(server already serves index.html at any depth). Route table gains /apps* and
orders it before /app* (since '/apps' startsWith '/app'); active-nav and
config/apps data-loading recognise the new paths.

Tab/task remain ordinary query params (modifiers, not the primary resource).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 19:03:54 +01:00

235 lines
9.0 KiB
JavaScript

// Setup Completion Watcher
//
// Listens for the `taskCompleted` event the TaskEventBus dispatches when any
// task hits a terminal state. If the completed task is the finalize task
// recorded by the Setup Wizard handoff (stashed in sessionStorage), trigger
// the celebratory exit: toast notification, re-enable the topbar nav, then
// navigate to the dashboard.
(function setupCompletionWatcher() {
function readHandoff() {
try {
const raw = sessionStorage.getItem('libreportal_setup_handoff');
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function clearHandoff() {
try { sessionStorage.removeItem('libreportal_setup_handoff'); } catch { /* noop */ }
}
// Floating banner shown across the top of any page while setup tasks are
// running. Surfaces the X-of-Y completion count from the live TaskEventBus
// snapshot so the user always knows how far through they are. Vanishes
// automatically when the finalize event fires (the same handler that runs
// the toast + dashboard redirect down below).
let bannerEl = null;
function isOnTasksPage() {
const p = window.location.pathname;
return p === '/tasks' || p.startsWith('/tasks') || p === '/tasks.html';
}
function ensureBanner() {
if (bannerEl && document.body.contains(bannerEl)) return bannerEl;
bannerEl = document.createElement('div');
bannerEl.className = 'setup-progress-banner';
bannerEl.innerHTML = `
<div class="setup-progress-banner-inner">
<div class="setup-progress-banner-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<div class="setup-progress-banner-text">
<strong>Setting up your install</strong>
<span class="setup-progress-banner-count">— starting…</span>
</div>
<div class="setup-progress-banner-bar"><div class="setup-progress-banner-fill"></div></div>
</div>
`;
document.body.appendChild(bannerEl);
return bannerEl;
}
function removeBanner() {
if (bannerEl) {
bannerEl.classList.add('leaving');
setTimeout(() => { bannerEl?.remove(); bannerEl = null; }, 350);
}
}
// Local cache keyed by task id. SSE bus only registers tasks once they fire
// an event AFTER it connects, so tasks that already finished before the page
// loaded are invisible to it. We seed this from /api/tasks on start and
// refresh from each SSE event so the banner reflects the true count.
const groupTasks = new Map();
async function seedGroupTasks() {
const handoff = readHandoff();
if (!handoff || !handoff.setupGroup) return;
try {
const res = await fetch('/api/tasks', { cache: 'no-store' });
if (!res.ok) return;
const all = await res.json();
for (const t of all) {
if (t && t.setupGroup === handoff.setupGroup) groupTasks.set(t.id, t);
}
refreshBanner();
// Reload-mid-setup case: if the finalize task already terminated
// before the page came back up, no SSE event will fire for it. The
// /api/tasks fetch above is the only signal — kick off the completion
// handler from here too so the toast/redirect still runs.
checkAlreadyCompleted();
} catch { /* network blip — SSE events will fill in the gaps */ }
}
function ingestEventTask(detail) {
const t = detail && detail.task;
if (!t || !t.id) return;
const handoff = readHandoff();
if (!handoff || !handoff.setupGroup) return;
if (t.setupGroup !== handoff.setupGroup) return;
groupTasks.set(t.id, t);
}
function refreshBanner() {
if (!isOnTasksPage()) { removeBanner(); return; }
const handoff = readHandoff();
if (!handoff || !handoff.setupGroup) { removeBanner(); return; }
const tasksInGroup = Array.from(groupTasks.values());
// Trust the handoff for the denominator — see seedGroupTasks for why
// the SSE bus alone undercounts.
const total = handoff.totalTaskCount || tasksInGroup.length;
if (total === 0) return;
const done = tasksInGroup.filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled').length;
const failed = tasksInGroup.some(t => t.status === 'failed');
const banner = ensureBanner();
banner.classList.toggle('failed', failed);
banner.querySelector('.setup-progress-banner-count').textContent = `${done} of ${total} tasks complete`;
const pct = total ? Math.round((done / total) * 100) : 0;
banner.querySelector('.setup-progress-banner-fill').style.width = `${pct}%`;
}
function showWelcomeToast(installName, success) {
const sys = (typeof window.ensureNotificationSystem === 'function')
? window.ensureNotificationSystem()
: window.notificationSystem;
if (!sys || typeof sys.show !== 'function') return;
if (success) {
sys.show(
`Setup complete — welcome aboard, <strong>${installName || 'Quantum Traveler'}</strong>. Your install is ready.`,
'success'
);
} else {
sys.show(
`Setup ran into an issue. Check the failed task above for details.`,
'error'
);
}
}
function reEnableNav() {
document.querySelectorAll('.topbar-nav.setup-needed').forEach((nav) => {
nav.classList.remove('setup-needed');
});
}
function navigateToRecommendedApps() {
const route = '/apps/recommended';
if (window.spaClean && typeof window.spaClean.navigate === 'function') {
window.spaClean.navigate(route);
} else if (typeof window.navigateToRoute === 'function') {
window.navigateToRoute(route);
} else {
window.location.href = '/apps/recommended';
}
}
// Refresh the banner on every task event so the count stays live.
const onTaskEvent = (e) => { ingestEventTask(e.detail || {}); refreshBanner(); };
window.addEventListener('taskCreated', onTaskEvent);
window.addEventListener('taskUpdated', onTaskEvent);
window.addEventListener('taskCompleted', onTaskEvent);
// Re-evaluate on navigation: covers back/forward (popstate) and SPA
// pushState/replaceState (monkey-patched once). Without this the banner
// would linger after leaving /tasks (or never appear when arriving there)
// because nothing else triggers refreshBanner.
window.addEventListener('popstate', refreshBanner);
['pushState', 'replaceState'].forEach((m) => {
const orig = history[m];
history[m] = function (...args) {
const r = orig.apply(this, args);
refreshBanner();
return r;
};
});
// Seed once now and again every 3s as a safety net — covers the window
// between the page loading and the SSE bus connecting, plus any FS-watch
// hiccups that swallow a task.upsert event.
seedGroupTasks();
setInterval(() => {
if (readHandoff()) seedGroupTasks();
}, 3000);
window.addEventListener('taskCompleted', (event) => {
const detail = event.detail || {};
const handoff = readHandoff();
if (!handoff || !handoff.finalizeTaskId) return;
if (detail.taskId !== handoff.finalizeTaskId) return;
const success = detail.status === 'completed';
clearHandoff();
// Defer to the next frame so the tasks-manager listener (registered on the
// same `taskCompleted` event) gets to flip the row to "completed" first —
// otherwise the welcome toast appears while the finalize row still shows
// "running", which reads as out-of-order.
requestAnimationFrame(() => {
removeBanner();
reEnableNav();
showWelcomeToast(handoff.installName, success);
if (success) setTimeout(navigateToRecommendedApps, 1800);
});
});
// Also handle the case where the user reloads the tasks page after the
// finalize task has already completed — we won't get a fresh `taskCompleted`
// event, so check the bus snapshot once it's ready.
function checkAlreadyCompleted() {
const handoff = readHandoff();
if (!handoff || !handoff.finalizeTaskId) return;
// Prefer the /api/tasks-seeded cache (groupTasks) — the SSE bus only
// knows tasks that emitted events after it connected, so reload-mid-
// setup leaves it blind to anything that already finished.
const task = groupTasks.get(handoff.finalizeTaskId)
|| (window.taskEventBus && window.taskEventBus.getTask(handoff.finalizeTaskId));
if (!task) return;
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
const success = task.status === 'completed';
clearHandoff();
removeBanner();
reEnableNav();
showWelcomeToast(handoff.installName, success);
if (success) setTimeout(navigateToRecommendedApps, 1800);
}
}
window.addEventListener('taskBusReady', () => {
checkAlreadyCompleted();
refreshBanner();
});
if (window.taskEventBus && window.taskEventBus.connected) {
checkAlreadyCompleted();
refreshBanner();
}
})();