// 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; this.stepNames = ['Identity', 'Domains', 'Recommended', 'Metrics']; this.stepIcons = ['πŸͺ', 'πŸ›°οΈ', 'πŸ›‘οΈ', 'πŸ“Š']; this.totalSteps = this.stepNames.length; this.domainCount = 0; // tracked dynamically as the user adds rows } 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 = `

Tuning your private universe before takeoff...

Step 1 of ${this.totalSteps} 0%

No domain? Skip this step β€” apps will be reachable by IP and Port on your LAN.

Recommended Apps

Pre-selected to give you a working install out of the box.

Metrics Apps

Optional. Install these to enable per-app "Export metrics to Grafana" later.

`; 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()); $('#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); if (idx === 0) { 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 === 1) { // 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 === 2) { 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