librelad 7ff21621d9 ux(setup): dedicated dev icon + richer reveal for the dev-mode strip
Swap the strip's shared 🛠️ emoji for the inline "tool" SVG used by the
topbar Developer-mode banner — a real, dedicated icon that ties the two
dev-mode surfaces together and no longer doubles the Advanced card's
glyph.

Enrich the entrance: the box grows in and settles, a one-shot accent
glow pulses for the "unlocked" beat, a subtle shine sweeps across, the
icon pops with a slight overshoot/wiggle, and the text slides in just
behind it. All gated behind prefers-reduced-motion.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 14:19:03 +01:00

878 lines
37 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<div class="aurora-stars" aria-hidden="true"></div>
<div class="setup-content">
<div class="aurora-header">
<div class="aurora-logo">
<img src="/icons/libreportal.svg" alt="LibrePortal" onload="this.classList.add('loaded')" onerror="this.style.display='none'">
<h1>LibrePortal</h1>
</div>
<p class="aurora-subtitle">Tuning your private universe before takeoff...</p>
</div>
<div class="setup-card">
<div class="setup-progress">
<div class="setup-progress-bar">
<div class="setup-progress-fill" id="sw-progress-fill"></div>
</div>
<div class="setup-progress-text">
<span id="sw-progress-step">Step 1 of ${this.totalSteps}</span>
<span id="sw-progress-pct">0%</span>
</div>
</div>
<form class="setup-form" id="setup-form" autocomplete="off">
<div class="setup-track-wrap">
<div class="setup-track" id="sw-track">
<!-- Step 1: Experience — Beginner vs Advanced. Seeds the WebUI's
Advanced UI mode default (CFG_INSTALL_LEVEL) so a newcomer
doesn't get a wall of operator detail and an experienced
user sees everything by default. Either choice is reversible
from the Advanced toggle in any page that exposes it. -->
<section class="setup-step" data-step="0">
<div class="setup-field setup-level-field">
<label>Choose your experience</label>
<p class="setup-step-note setup-level-note">
You can switch any time from the Advanced toggle inside the WebUI.
</p>
<div class="setup-level-cards">
<label class="setup-level-card" data-level="beginner">
<input type="radio" name="sw-level" value="beginner" checked>
<div class="setup-level-card-body">
<div class="setup-level-card-icon">🌱</div>
<div class="setup-level-card-title">Beginner</div>
<div class="setup-level-card-desc">Easy mode. Just the essentials, nothing technical.</div>
</div>
</label>
<label class="setup-level-card" data-level="advanced">
<input type="radio" name="sw-level" value="advanced">
<div class="setup-level-card-body">
<div class="setup-level-card-icon">🛠️</div>
<div class="setup-level-card-title">Advanced</div>
<div class="setup-level-card-desc">Full control. Everything unlocked, metrics and all.</div>
</div>
</label>
</div>
<div class="setup-dev-strip" id="sw-dev-strip" role="status" hidden>
<span class="setup-dev-strip-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
</svg>
</span>
<div class="setup-dev-strip-text">
<strong>Dev mode activated</strong>
<span>Developer fields will be unlocked after install.</span>
</div>
</div>
</div>
</section>
<!-- Step 2: Identity -->
<section class="setup-step" data-step="1">
<div class="setup-field">
<label for="sw-name">
Install Name
<span class="setup-tooltip" tabindex="0" data-tip="A unique name for this install — used in backups, SSH keys, and VPN identity.">?</span>
</label>
<div class="setup-input-row">
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">🧑‍🚀</span>
<input type="text" id="sw-name" class="setup-input-with-icon" pattern="[a-zA-Z0-9\\-]+" required>
<button type="button" class="setup-manifest" id="sw-reroll" title="Manifest a new cosmic name">
<svg class="setup-manifest-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
<span>Manifest</span>
</button>
</div>
</div>
<div class="setup-field">
<label for="sw-timezone">
Timezone
<span class="setup-tooltip" tabindex="0" data-tip="Used by scheduled tasks and log timestamps. We'll preselect the OS detected zone.">?</span>
</label>
<div class="setup-input-row">
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">🕒</span>
<select id="sw-timezone" class="setup-input-with-icon form-control" required>
<option value="">Select…</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Phoenix">America/Phoenix</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Australia/Sydney">Australia/Sydney</option>
<option value="Etc/UTC">Etc/UTC</option>
</select>
</div>
</div>
</section>
<!-- Step 3: Domains (optional, multi). Each domain enables HTTPS
routing for apps via Traefik. Skipping leaves the install
local-only (apps reachable by IP and Port, no SSL). -->
<section class="setup-step" data-step="2">
<div class="setup-field">
<label>
Domains
<span class="setup-tooltip" tabindex="0" data-tip="Optional. Each domain you add unlocks https://app.&lt;domain&gt; routing (via Traefik) and a self-signed SSL cert. Skip entirely for a local-only install reachable by IP.">?</span>
</label>
<div id="sw-domain-list" class="setup-domain-list"></div>
<button type="button" class="setup-domain-add" id="sw-domain-add">
<span>+</span> Add domain
</button>
<p class="setup-step-note">No domain? Skip this step — apps will be reachable by IP and Port on your LAN.</p>
</div>
</section>
<!-- Step 4: Recommended apps (Traefik + Fail2ban) -->
<section class="setup-step" data-step="3">
<div class="setup-section">
<div class="setup-section-title">Recommended Apps</div>
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
<div id="sw-apps-recommended"></div>
<!-- Conditional: only shown when Traefik is ticked. -->
<div class="setup-field setup-traefik-email" id="sw-traefik-email-wrap" style="display:none; margin-top: 10px;">
<label for="sw-traefik-email">
LetsEncrypt Email
<span class="setup-tooltip" tabindex="0" data-tip="Used by LetsEncrypt for ACME registration and cert-expiry warnings. Required because you ticked Traefik.">?</span>
</label>
<div class="setup-input-row">
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">✉️</span>
<input type="email" id="sw-traefik-email" class="setup-input-with-icon" placeholder="you@example.com">
</div>
</div>
</div>
</section>
<!-- Step 5: Optional metrics apps (Prometheus + Grafana). Off by
default — they're only useful if the user wants the MONITORING
toggle on apps to do anything. Advanced-only: this whole step
is skipped when the user chose Beginner on step 1. -->
<section class="setup-step" data-step="4">
<div class="setup-section">
<div class="setup-section-title">Metrics Apps</div>
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
<div id="sw-apps-metrics"></div>
</div>
</section>
</div>
</div>
<div class="setup-error" id="sw-error" style="display:none;"></div>
<div class="setup-nav">
<button type="button" class="setup-btn-back" id="sw-back" disabled>← Back</button>
<button type="button" class="setup-btn-next" id="sw-next">Next →</button>
<button type="submit" class="setup-launch" id="sw-submit" style="display:none;">
<span class="setup-launch-text">Launch Install</span>
<span class="setup-launch-arrow">→</span>
</button>
</div>
</form>
</div>
</div>
`;
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 <label>'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} <span class="setup-progress-sep">—</span> <span class="setup-progress-icon">${icon}</span> <span class="setup-progress-name">${name}</span>`;
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 <label> behaviour
// is messy — clicks would toggle both checkboxes). Both labels are
// siblings inside a wrapper div; CSS connects them visually.
const subHtml = subOption ? `
<label class="setup-app-suboption" data-parent="${slug}">
<input type="checkbox" data-app-suboption="${slug}:${subOption.id}" ${subOption.defaultChecked ? 'checked' : ''}>
<span class="setup-app-suboption-label">${subOption.label}</span>
</label>
` : '';
const tile = `
<label class="setup-app">
<input type="checkbox" data-app="${slug}" ${defaultChecked ? 'checked' : ''}>
<div class="setup-app-icon-wrap">
<img src="${icon}" alt="${name}" class="setup-app-icon"
onerror="this.onerror=null; this.src='/icons/apps/default.svg'">
</div>
<div class="setup-app-info">
<div class="setup-app-name">${name}</div>
<div class="setup-app-desc">${desc}</div>
</div>
</label>
`;
return subOption
? `<div class="setup-app-group">${tile}${subHtml}</div>`
: 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 = `
<div class="setup-input-row">
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">🌍</span>
<input type="text" class="setup-input-with-icon setup-domain-input" placeholder="example.org">
<button type="button" class="setup-domain-remove" title="Remove this domain" aria-label="Remove"></button>
</div>
<div class="setup-dns-status"></div>
`;
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;