Tap the Advanced card 10 times and a full-width "Dev mode activated" strip slides in beneath the two cards — the same 10-tap pattern as the topbar logo and services-manager unlocks, now at install time. The choice rides the setup payload (dev_mode) so setup_apply.sh persists CFG_DEV_MODE=true, and it's mirrored in-process via LpUi.dev so the next surface already reflects it. 10 more taps toggles it back off. Counting the Advanced radio's click (not the label's) sidesteps the label->input double-fire; the radio is pointer-events:none, so each tap reaches it exactly once. The strip is [hidden] by default (no phantom gap in the flex column) and replays its entrance keyframes each reveal. Signed-off-by: librelad <librelad@digitalangels.vip>
874 lines
36 KiB
JavaScript
Executable File
874 lines
36 KiB
JavaScript
Executable File
// 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">🛠️</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.<domain> 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;
|