// 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 = `
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());
$('#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