A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
235 lines
9.0 KiB
JavaScript
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.html?=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();
|
|
}
|
|
})();
|