// Setup Wizard - First-time install configuration UI.
//
// Multi-step slide-right form. Each step is a sibling div inside a track
// that translates horizontally as the user advances. Submission POSTs to
// /api/setup/save which fans out into separate tasks per app, then this UI
// hands off to the tasks page focused on the first task.
class SetupWizard {
constructor() {
this.container = null;
this.suggestedName = '';
this.dnsCheckTimer = null;
this.dnsCheckController = null;
this.onComplete = null;
this.currentStep = 0;
// Step 0 (Experience) chooses Beginner or Advanced. Beginner shrinks
// the wizard β the Metrics step is hidden and never asked β so the
// visible step count is dynamic (4 for beginner, 5 for advanced).
// installLevel defaults to 'beginner' so a user who races through
// step 0 without touching anything gets the safe-default UX.
this.stepNames = ['Experience', 'Identity', 'Domains', 'Recommended', 'Metrics'];
this.stepIcons = ['π±', 'πͺ', 'π°οΈ', 'π‘οΈ', 'π'];
this.installLevel = 'beginner';
this.totalSteps = this._effectiveTotalSteps();
this.domainCount = 0; // tracked dynamically as the user adds rows
this.devMode = false; // unlocked by the Advanced-card 10-tap easter egg
}
_effectiveTotalSteps() {
// Metrics (last step) is advanced-only; beginner skips it entirely.
return this.installLevel === 'advanced' ? this.stepNames.length : this.stepNames.length - 1;
}
initialize(setupDetector, onComplete = null) {
this.onComplete = onComplete;
this.create();
this.attach();
this.suggestName();
this.renderAppTiles();
this.preselectTimezone();
this.showStep(0);
}
getWizardApps() {
return [
{ slug: 'traefik', recommended: true, defaultChecked: true,
fallback: { name: 'Traefik', description: 'Reverse proxy + automatic SSL via LetsEncrypt' } },
{ slug: 'crowdsec', recommended: true, defaultChecked: true,
fallback: { name: 'CrowdSec', description: 'Host-installed intrusion prevention' } }
];
}
getMetricsApps() {
return [
{ slug: 'prometheus', defaultChecked: false,
fallback: { name: 'Prometheus', description: 'Scrapes and stores time-series metrics from your apps' } },
{ slug: 'grafana', defaultChecked: false,
fallback: { name: 'Grafana', description: 'Dashboards and visualisations on top of Prometheus metrics' } }
];
}
create() {
this.container = document.createElement('div');
this.container.className = 'setup-wizard aurora-bg aurora-static';
this.container.innerHTML = `
Step 1 of ${this.totalSteps}
0%
`;
document.body.appendChild(this.container);
document.body.classList.add('setup-wizard-open');
}
attach() {
const $ = (id) => this.container.querySelector(id);
$('#sw-reroll').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const btn = $('#sw-reroll');
btn.classList.add('manifesting');
setTimeout(() => btn.classList.remove('manifesting'), 700);
this.suggestName(true);
});
$('#sw-domain-add').addEventListener('click', () => this.addDomainRow());
// Seed with one empty row so the user has somewhere to type. They can
// remove it if they truly want a local-only install.
this.addDomainRow();
this.attachLiveValidation();
$('#sw-back').addEventListener('click', () => this.prev());
$('#sw-next').addEventListener('click', () => this.next());
// Experience radio β updates installLevel and the visible-step count
// immediately so the progress bar reflects the choice (4 vs 5 steps).
// Selection cards visually toggle via the wrapping 's :has().
this.container.querySelectorAll('input[name="sw-level"]').forEach((r) => {
r.addEventListener('change', () => {
if (!r.checked) return;
this.installLevel = (r.value === 'advanced') ? 'advanced' : 'beginner';
this.totalSteps = this._effectiveTotalSteps();
// Re-paint progress so "Step 1 of N" updates immediately.
this.showStep(this.currentStep);
});
});
// Easter egg: 10 taps on the Advanced card toggles Developer mode and
// reveals a strip beneath the cards β same 10-tap pattern as the topbar
// logo and services-manager unlocks (CFG_DEV_MODE), persisted on install
// via the setup payload's dev_mode field. Counting the radio's click
// (not the label's) avoids the labelβinput double-fire: the radio is
// pointer-events:none, so each tap reaches it exactly once via the label.
// A 3s idle gap resets the streak.
const advRadio = this.container.querySelector('input[name="sw-level"][value="advanced"]');
const devStrip = this.container.querySelector('#sw-dev-strip');
if (advRadio && devStrip) {
const TARGET = 10;
let taps = 0;
let resetTimer = null;
advRadio.addEventListener('click', () => {
taps++;
if (resetTimer) clearTimeout(resetTimer);
resetTimer = setTimeout(() => { taps = 0; }, 3000);
if (taps >= TARGET) {
taps = 0;
clearTimeout(resetTimer);
this.devMode = !this.devMode;
// Toggling [hidden] (display:none β flex) replays the entrance
// animation each time it's revealed.
devStrip.hidden = !this.devMode;
}
});
}
$('#setup-form').addEventListener('submit', (e) => {
e.preventDefault();
console.log('[setup] form submit fired');
this.submit();
});
$('#sw-submit').addEventListener('click', (e) => {
e.preventDefault();
console.log('[setup] launch button clicked');
this.submit();
});
$('#setup-form').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA' && this.currentStep < this.totalSteps - 1) {
e.preventDefault();
this.next();
}
});
}
showStep(n) {
this.currentStep = Math.max(0, Math.min(this.totalSteps - 1, n));
this.container.querySelectorAll('.setup-step').forEach((el, idx) => {
el.classList.toggle('active', idx === this.currentStep);
});
const pct = Math.round(((this.currentStep + 1) / this.totalSteps) * 100);
const name = this.stepNames[this.currentStep];
const icon = this.stepIcons[this.currentStep];
this.container.querySelector('#sw-progress-fill').style.width = `${pct}%`;
this.container.querySelector('#sw-progress-step').innerHTML =
`Step ${this.currentStep + 1} of ${this.totalSteps} β ${icon} ${name} `;
this.container.querySelector('#sw-progress-pct').textContent = `${pct}%`;
this.container.querySelector('#sw-back').disabled = this.currentStep === 0;
const isLast = this.currentStep === this.totalSteps - 1;
this.container.querySelector('#sw-next').style.display = isLast ? 'none' : '';
this.container.querySelector('#sw-submit').style.display = isLast ? '' : 'none';
setTimeout(() => {
const focusable = this.container.querySelector(`.setup-step.active input, .setup-step.active select`);
if (focusable) focusable.focus();
}, 350);
}
validateStep(idx) {
const $ = (id) => this.container.querySelector(id);
// Step 0 (Experience): always valid β radios are pre-checked.
if (idx === 1) {
const name = $('#sw-name').value.trim();
if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.';
if (!$('#sw-timezone').value) return 'Please select a timezone.';
}
if (idx === 2) {
// Domains are optional, but any non-empty input must be valid.
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
for (const input of inputs) {
const v = input.value.trim();
if (v && !/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(v)) {
return 'One of your domains doesn\'t look valid.';
}
}
}
if (idx === 3) {
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
if (traefikBox && traefikBox.checked) {
const tEmail = $('#sw-traefik-email').value.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tEmail)) {
return 'Traefik needs a valid LetsEncrypt email.';
}
}
}
return null;
}
next() {
const err = this.validateStep(this.currentStep);
if (err) { this.showError(err); return; }
this.clearError();
this.showStep(this.currentStep + 1);
}
prev() {
this.clearError();
this.showStep(this.currentStep - 1);
}
async loadAppsManifest() {
if (this._appsManifest) return this._appsManifest;
try {
const res = await fetch('/data/apps/generated/apps.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const list = Array.isArray(data) ? data : (data.apps || []);
this._appsManifest = new Map();
for (const app of list) {
const key = (app.slug || app.name || app.id || '').toLowerCase();
if (key) this._appsManifest.set(key, app);
}
} catch (err) {
this._appsManifest = new Map();
}
return this._appsManifest;
}
async renderAppTiles() {
const manifest = await this.loadAppsManifest();
const recBox = this.container.querySelector('#sw-apps-recommended');
if (!recBox) return;
const buildTile = ({ slug, defaultChecked, fallback, subOption }) => {
const app = manifest.get(slug) || {};
const name = app.title || app.name || fallback.name;
const desc = app.description || fallback.description;
const icon = app.icon || `/icons/apps/${slug}.svg`;
// Sub-option lives OUTSIDE the parent label (nested behaviour
// is messy β clicks would toggle both checkboxes). Both labels are
// siblings inside a wrapper div; CSS connects them visually.
const subHtml = subOption ? `
${subOption.label}
` : '';
const tile = `
`;
return subOption
? `${tile}${subHtml}
`
: tile;
};
const apps = this.getWizardApps();
recBox.innerHTML = apps.map(buildTile).join('');
apps.filter(a => a.defaultChecked).forEach(a => {
const cb = this.container.querySelector(`input[data-app="${a.slug}"]`);
if (cb) cb.checked = true;
});
const metricsBox = this.container.querySelector('#sw-apps-metrics');
if (metricsBox) {
const metricsApps = this.getMetricsApps();
metricsBox.innerHTML = metricsApps.map(buildTile).join('');
// Grafana's datasource is hardcoded to prometheus-service β ticking Grafana
// auto-ticks and locks Prometheus.
const gBox = this.container.querySelector('input[data-app="grafana"]');
const pBox = this.container.querySelector('input[data-app="prometheus"]');
if (gBox && pBox) {
const sync = () => {
if (gBox.checked) {
pBox.checked = true;
pBox.disabled = true;
pBox.title = 'Required by Grafana';
} else {
pBox.disabled = false;
pBox.title = '';
}
};
gBox.addEventListener('change', sync);
sync();
}
}
// Once tiles are in the DOM, wire the Traefik checkbox to the conditional
// LetsEncrypt email field, and pre-tick it if any domains are configured.
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
const emailWrap = this.container.querySelector('#sw-traefik-email-wrap');
if (traefikBox && emailWrap) {
const sync = () => { emailWrap.style.display = traefikBox.checked ? '' : 'none'; };
traefikBox.addEventListener('change', sync);
sync();
}
// Sub-options: when a parent app is unchecked, disable + uncheck its
// sub-option (it's meaningless without the parent installed).
this.container.querySelectorAll('.setup-app-suboption').forEach((subLabel) => {
const slug = subLabel.dataset.parent;
const parent = this.container.querySelector(`input[data-app="${slug}"]`);
const sub = subLabel.querySelector('input[type=checkbox]');
if (!parent || !sub) return;
const sync = () => {
sub.disabled = !parent.checked;
subLabel.classList.toggle('disabled', !parent.checked);
};
parent.addEventListener('change', sync);
sync();
});
// Initial pass: if domains were already entered before tiles rendered,
// reflect that on the Traefik recommendation now.
this.refreshTraefikRecommendationFromDomains();
}
preselectTimezone() {
let tz;
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { tz = null; }
if (!tz) return;
const sel = this.container.querySelector('#sw-timezone');
if (!sel) return;
const exists = Array.from(sel.options).some(o => o.value === tz);
if (!exists) {
const opt = document.createElement('option');
opt.value = tz;
opt.textContent = `${tz} (detected)`;
sel.insertBefore(opt, sel.options[1] || null);
}
sel.value = tz;
}
generateName() {
const ADJECTIVES = [
'Quantum','Neutrino','Photon','Plasma','Quasar','Pulsar','Tachyon','Boson','Fermion','Hadron',
'Gluon','Muon','Higgs','Entangled','Singular','Warped','Tunneling','Coherent','Superposed','Spectral',
'Orbital','Cosmic','Stellar','Nebular','Astral','Gravitic','Inertial','Relativistic','Helical','Toroidal',
'Holographic','Cryogenic','Crystalline','Resonant','Harmonic','Phasic','Drifting','Spinning','Pulsing','Hyper'
];
const NOUNS = [
'Frog','Fox','Otter','Raven','Wolf','Yak','Lynx','Owl','Hawk','Crow','Newt','Wren','Eel','Crab','Squid',
'Octopus','Mantis','Cobra','Viper','Ferret','Badger','Penguin','Panda','Lemur','Quark','Nebula','Comet',
'Nova','Eclipse','Aurora','Vortex','Helix','Halo','Phoenix','Hydra','Kraken','Sphinx','Specter','Phantom','Glyph'
];
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
return `${adj}${noun}`;
}
suggestName(forceDifferent = false) {
const input = this.container.querySelector('#sw-name');
const previous = input ? input.value : '';
let name = this.generateName();
if (forceDifferent) {
for (let i = 0; i < 8 && name === previous; i++) name = this.generateName();
}
this.suggestedName = name;
if (!input) return;
input.value = name;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.classList.remove('setup-name-pulse');
void input.offsetWidth;
input.classList.add('setup-name-pulse');
setTimeout(() => input.classList.remove('setup-name-pulse'), 600);
}
// Multi-domain editor β appends a new row up to the CFG_DOMAIN_1..9 cap.
// Each row tracks its own DNS check timer/abort controller so multiple
// simultaneous checks don't cancel each other.
addDomainRow() {
const list = this.container.querySelector('#sw-domain-list');
if (!list) return;
if (list.children.length >= 9) return; // CFG_DOMAIN_1..9
const idx = this.domainCount++;
const row = document.createElement('div');
row.className = 'setup-domain-row';
row.dataset.idx = idx;
row.innerHTML = `
π
β
`;
list.appendChild(row);
const input = row.querySelector('.setup-domain-input');
const status = row.querySelector('.setup-dns-status');
const remove = row.querySelector('.setup-domain-remove');
let dnsTimer = null;
let dnsCtrl = null;
input.addEventListener('input', (e) => {
this.runDnsCheckForRow(e.target.value, status, input, () => dnsTimer, (t) => dnsTimer = t, () => dnsCtrl, (c) => dnsCtrl = c);
});
remove.addEventListener('click', () => {
if (dnsCtrl) dnsCtrl.abort();
if (dnsTimer) clearTimeout(dnsTimer);
row.remove();
this.refreshTraefikRecommendationFromDomains();
});
setTimeout(() => input.focus(), 30);
}
runDnsCheckForRow(domain, status, input, getTimer, setTimer, getCtrl, setCtrl) {
const t = getTimer(); if (t) clearTimeout(t);
const c = getCtrl(); if (c) c.abort();
domain = domain.trim().toLowerCase();
input.classList.remove('is-valid', 'is-invalid');
input.removeAttribute('data-error');
const row = input.closest('.setup-input-row');
if (row) row.removeAttribute('data-error');
if (!domain) {
status.textContent = '';
status.className = 'setup-dns-status';
this.refreshTraefikRecommendationFromDomains();
return;
}
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(domain)) {
status.textContent = '';
status.className = 'setup-dns-status';
input.classList.add('is-invalid');
input.setAttribute('data-error', 'Doesn\'t look like a valid domain');
if (row) row.setAttribute('data-error', 'Doesn\'t look like a valid domain');
return;
}
status.textContent = 'Checking DNSβ¦';
status.className = 'setup-dns-status checking';
setTimer(setTimeout(async () => {
setCtrl(new AbortController());
try {
const res = await fetch(`/api/setup/dns-check?domain=${encodeURIComponent(domain)}`, {
signal: getCtrl().signal
});
const data = await res.json();
if (data.matches) {
status.textContent = `β ${domain} β ${data.server_ip} (this server)`;
status.className = 'setup-dns-status ok';
input.classList.add('is-valid');
} else {
const detail = data.domain_ip
? `points to ${data.domain_ip}, this server is ${data.server_ip}`
: `does not resolve (server: ${data.server_ip || 'unknown'})`;
status.textContent = `β ${domain} ${detail}. Traefik may not route this yet.`;
status.className = 'setup-dns-status warn';
}
this.refreshTraefikRecommendationFromDomains();
} catch (err) {
if (err.name !== 'AbortError') {
status.textContent = 'DNS check failed.';
status.className = 'setup-dns-status warn';
}
}
}, 500));
}
// Auto-untick Traefik when no valid domains exist (it has nothing to route).
// Auto-tick when at least one domain is present and looks valid.
refreshTraefikRecommendationFromDomains() {
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
if (!traefikBox) return;
const domains = this.collectDomains();
traefikBox.checked = domains.length > 0;
// Re-sync the LetsEncrypt email visibility
const emailWrap = this.container.querySelector('#sw-traefik-email-wrap');
if (emailWrap) emailWrap.style.display = traefikBox.checked ? '' : 'none';
}
collectDomains() {
return Array.from(this.container.querySelectorAll('.setup-domain-input'))
.map(i => i.value.trim().toLowerCase())
.filter(d => d && /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(d));
}
async submit() {
console.log('[setup] submit() entered');
if (this._submitting) { console.log('[setup] already submitting, ignoring'); return; }
this._submitting = true;
const $ = (id) => this.container.querySelector(id);
this.clearError();
for (let i = 0; i < this.totalSteps; i++) {
const err = this.validateStep(i);
if (err) {
console.log('[setup] step', i, 'validation failed:', err);
this.showStep(i);
this.showError(err);
this._submitting = false;
return;
}
}
console.log('[setup] all steps valid, posting payload');
const apps = Array.from(this.container.querySelectorAll('.setup-app input[type=checkbox][data-app]:checked'))
.map(cb => cb.dataset.app);
// Sub-options live under their parent app and only matter if the parent
// is installed. Shape: { crowdsec: { dashboard: true }, ... }
const appOptions = {};
this.container.querySelectorAll('input[data-app-suboption]').forEach((cb) => {
const [slug, optId] = cb.dataset.appSuboption.split(':');
if (!apps.includes(slug)) return;
appOptions[slug] = appOptions[slug] || {};
appOptions[slug][optId] = cb.checked;
});
const domains = this.collectDomains();
const payload = {
install_name: $('#sw-name').value.trim(),
timezone: $('#sw-timezone').value,
install_level: this.installLevel,
dev_mode: this.devMode,
domains,
apps,
appOptions,
traefik_email: apps.includes('traefik') ? $('#sw-traefik-email').value.trim() : ''
};
// Apply the experience choice to the WebUI immediately so the next
// surface the user sees (the running-tasks page) is already in the
// right mode. The bash applier writes the CFG_* in the background
// for future sessions; this is just the in-process mirror.
if (window.LpUi?.advanced) {
try {
window.LpUi.advanced.set(this.installLevel === 'advanced');
try { localStorage.setItem('lp.ui.seeded', '1'); } catch {}
} catch {}
}
// Same in-process mirror for the dev-mode easter egg (LpUi.dev enabling
// dev also enables advanced); the bash applier persists CFG_DEV_MODE.
if (this.devMode && window.LpUi?.dev) {
try { window.LpUi.dev.set(true); } catch {}
}
const submitBtn = $('#sw-submit');
submitBtn.disabled = true;
submitBtn.querySelector('.setup-launch-text').textContent = 'Launchingβ¦';
try {
const res = await fetch('/api/setup/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log('[setup] /api/setup/save status', res.status);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
const data = await res.json();
console.log('[setup] handoff data', data);
this.handoffToTasks(data);
} catch (err) {
console.error('[setup] submit error:', err);
this.showError(err.message || 'Failed to launch install.');
submitBtn.disabled = false;
submitBtn.querySelector('.setup-launch-text').textContent = 'Launch Install';
} finally {
this._submitting = false;
}
}
handoffToTasks({ firstTaskId, finalizeTaskId, setupGroup, installName, taskIds }) {
try {
sessionStorage.setItem('libreportal_setup_handoff', JSON.stringify({
finalizeTaskId,
setupGroup,
installName,
totalTaskCount: Array.isArray(taskIds) ? taskIds.length : 0,
startedAt: Date.now()
}));
} catch { /* sessionStorage may be unavailable in private mode */ }
if (typeof this.onComplete === 'function') {
try { this.onComplete(); } catch (err) { console.error('[setup] onComplete threw:', err); }
}
this.container.classList.add('setup-launched');
setTimeout(() => {
// Path-based route (the app uses /β¦ URLs); the specific task is still
// selected via ?task=. Navigate via the SPA helper, with an absolute-path
// full-load fallback.
const target = `/tasks/all?task=${encodeURIComponent(firstTaskId)}&from=setup`;
if (typeof window.navigateToRoute === 'function' && window.spaClean) {
window.navigateToRoute(target);
this.hide();
} else {
window.location.href = target;
}
}, 600);
}
// Live per-field validation. Each watched field has a validator that
// returns null on valid input or an error message string. We mirror that
// state onto the input as is-valid / is-invalid classes, and the message
// gets pushed into a data-error attribute that the CSS tooltip reads.
attachLiveValidation() {
const validators = {
'sw-name': (v) => {
if (!v.trim()) return 'Required';
if (!/^[a-zA-Z0-9-]+$/.test(v.trim())) return 'Letters, numbers, and hyphens only';
return null;
},
'sw-traefik-email': (v) => {
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
if (!traefikBox || !traefikBox.checked) return null; // Only required when Traefik is ticked
if (!v.trim()) return 'Required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())) return 'Doesn\'t look like a valid email';
return null;
},
'sw-timezone': (v) => v ? null : 'Pick a timezone'
};
Object.entries(validators).forEach(([id, fn]) => {
const el = this.container.querySelector(`#${id}`);
if (!el) return;
// Mirror error state onto the parent .setup-input-row so the CSS
// tooltip-over-input (which lives on a row pseudo-element) can read
// it via attr(data-error). Pseudo-elements can only read attrs from
// their own host, so we have to bubble it up here.
const row = el.closest('.setup-input-row');
const run = () => {
const err = fn(el.value);
const hasContent = el.value.length > 0;
el.classList.toggle('is-invalid', !!err && hasContent);
el.classList.toggle('is-valid', !err && hasContent);
if (err && hasContent) {
el.setAttribute('data-error', err);
row && row.setAttribute('data-error', err);
} else {
el.removeAttribute('data-error');
row && row.removeAttribute('data-error');
}
};
el.addEventListener('input', run);
el.addEventListener('change', run);
el.addEventListener('blur', run);
});
}
showError(msg) {
const errBox = this.container.querySelector('#sw-error');
errBox.textContent = msg;
errBox.style.display = '';
}
clearError() {
const errBox = this.container.querySelector('#sw-error');
errBox.style.display = 'none';
errBox.textContent = '';
}
show() {
if (this.container) this.container.classList.add('active');
}
hide() {
if (this.container) {
this.container.classList.add('hiding');
document.body.classList.remove('setup-wizard-open');
setTimeout(() => {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}, 500);
}
}
}
window.SetupWizard = SetupWizard;