librelad 9a92805bdb feat(ui): Beginner/Advanced experience level + linked dev mode + setup-wizard step
Adds the install-time Beginner/Advanced choice the user described, with
the linked dev-mode escape hatch and global body-class machinery that
any surface can hang advanced/dev-only DOM off.

Three-tier mental model, two flags in the data model:

  Beginner            default. nothing extra shown.
  Advanced            .lp-advanced DOM revealed; advanced wizard steps shown
  Adv+Dev             .lp-dev DOM also revealed; dev-only fields visible

Linking rule (enforced inside LpUi):
  - enabling dev auto-enables advanced (dev w/o advanced is incoherent)
  - disabling advanced auto-disables dev

Wire shape:
  CFG_INSTALL_LEVEL                  beginner | advanced (general_basic)
  CFG_DEV_MODE                       existing, unchanged behaviour
  window.LpUi.{advanced,dev}         {get(), set(), apply()}
  localStorage keys                  lp.ui.advanced, lp.ui.dev, lp.ui.seeded
  body classes                       lp-ui--advanced, lp-ui--dev
  events                             lp-ui-advanced-changed, lp-ui-dev-changed
  global CSS gates                   body:not(.lp-ui--advanced) .lp-advanced { hide }
                                     body:not(.lp-ui--dev) .lp-dev { hide }

Setup wizard:
  - New step 1 "Choose your experience" with Beginner/Advanced cards.
    Beginner is preselected so race-through gets the safe default.
  - Picking a level updates totalSteps live (4 for beginner, 5 for
    advanced) so the progress bar reflects the choice.
  - Metrics step (Prometheus + Grafana) is gated to Advanced — beginner
    never sees it, never gets asked, never installs them by accident.
  - Submit payload now carries install_level; setup-routes.js validates
    it against the enum (beginner|advanced).
  - scripts/setup/setup_apply.sh writes it to CFG_INSTALL_LEVEL via
    updateConfigOption.
  - On submit, LpUi.advanced.set is called immediately so the next
    surface (running-tasks page) is already in the right mode — no
    refresh needed.

WebUI bootstrap:
  - js/utils/lp-ui.js loads first thing in index.html (before any other
    bootstrap) so body.lp-ui--advanced is applied pre-paint — no FOUC
    of advanced content on a fresh tab.
  - On first run, seeds lp.ui.advanced from CFG_INSTALL_LEVEL.
    Subsequent loads honour the user's per-browser override.
  - Mirrors CFG_DEV_MODE → lp.ui.dev on the seed pass.

Dev-mode unlock:
  - Existing 10-click LibrePortal-logo easter egg unchanged.
  - NEW: same 10-click unlock on the Advanced toggle (in services-manager).
    Reuses the countdown-toast pattern; on the 10th click delegates to
    the topbar's _setDevMode so there's one canonical setter and the
    config_update task path stays singular.
  - TopbarComponent now exposes its instance as window.topbar so the
    toggle's tap handler can reach _setDevMode.
  - topbar._setDevMode also calls LpUi.dev.set(enabled) so the body
    class flips immediately (no reload needed to see dev-only DOM).

Convention rolled out:
  - Services tab's .service-rich panel was already gated on
    body.lp-ui--advanced.
  - .lp-advanced / .lp-dev are now first-class hide classes any
    component can tag DOM with — see style.css globals.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 23:31:50 +01:00

832 lines
35 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
}
_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">A simple, friendly view. Advanced settings and developer fields are hidden until you ask for them. We'll skip the metrics setup.</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">Show everything. Container internals, advanced config fields, and the optional metrics stack (Prometheus + Grafana) all on by default.</div>
</div>
</label>
</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);
});
});
$('#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,
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 {}
}
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;