500 lines
32 KiB
HTML
500 lines
32 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="it">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>IRIDE — Installazione</title>
|
||
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Barlow:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||
|
|
<style>
|
||
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
|
|
:root{
|
||
|
|
--bg:#080a0e;--bg2:#0e1117;--bg3:#161b24;--bg4:#1c2333;
|
||
|
|
--border:#1e2840;--border2:#2a3a58;
|
||
|
|
--text:#e2e8f4;--text2:#8a9ab8;--text3:#445068;
|
||
|
|
--brand:#8A7CFF;--brand2:#6F5FE6;--brand-dim:#8A7CFF18;
|
||
|
|
--ok:#10b981;--ok-dim:#10b98118;--err:#ef4444;--err-dim:#ef444418;--warn:#f59e0b;
|
||
|
|
--mono:'JetBrains Mono',monospace;--sans:'Barlow',sans-serif;--r:10px;
|
||
|
|
}
|
||
|
|
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.6}
|
||
|
|
.shell{min-height:100vh;display:grid;grid-template-rows:auto 1fr auto}
|
||
|
|
.hdr{background:var(--bg2);border-bottom:1px solid var(--border);padding:16px 32px;display:flex;align-items:center;gap:16px}
|
||
|
|
.hdr-logo{font-size:22px;font-weight:800;color:var(--brand);letter-spacing:3px}
|
||
|
|
.hdr-sep{width:1px;height:24px;background:var(--border2)}
|
||
|
|
.hdr-sub{font-size:12px;color:var(--text3);font-family:var(--mono);text-transform:uppercase;letter-spacing:1.5px}
|
||
|
|
.hdr-badge{margin-left:auto;font-size:10px;font-family:var(--mono);background:var(--brand-dim);color:var(--brand);padding:4px 10px;border-radius:20px;border:1px solid #8A7CFF33}
|
||
|
|
.main{display:flex;max-width:1000px;margin:0 auto;width:100%;padding:32px 24px;gap:28px}
|
||
|
|
.sidebar{width:200px;flex-shrink:0}
|
||
|
|
.sidebar-title{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;padding:0 12px 12px;font-family:var(--mono)}
|
||
|
|
.tab-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--r);cursor:pointer;transition:all .15s;color:var(--text2);font-size:13px;font-weight:500;margin-bottom:2px;border:1px solid transparent}
|
||
|
|
.tab-item:hover{background:var(--bg3);color:var(--text)}
|
||
|
|
.tab-item.active{background:var(--bg3);color:var(--brand);border-color:var(--border2)}
|
||
|
|
.tab-item.done .tab-num{background:var(--ok);color:#000}
|
||
|
|
.tab-item.active .tab-num{background:var(--brand);color:#000}
|
||
|
|
.tab-num{width:22px;height:22px;border-radius:50%;background:var(--bg4);color:var(--text3);font-size:10px;font-weight:700;font-family:var(--mono);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .2s}
|
||
|
|
.content{flex:1;min-width:0}
|
||
|
|
.tab-panel{display:none}
|
||
|
|
.tab-panel.active{display:block}
|
||
|
|
.panel-title{font-size:22px;font-weight:700;margin-bottom:4px}
|
||
|
|
.panel-sub{font-size:13px;color:var(--text2);margin-bottom:24px}
|
||
|
|
.progress{display:flex;gap:6px;margin-bottom:28px}
|
||
|
|
.prog-step{flex:1;height:3px;border-radius:2px;background:var(--border2);transition:background .3s}
|
||
|
|
.prog-step.done{background:var(--ok)}
|
||
|
|
.prog-step.active{background:var(--brand)}
|
||
|
|
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||
|
|
.form-full{grid-column:1/-1}
|
||
|
|
.form-label{display:block;font-size:11px;font-weight:700;color:var(--text2);text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
|
||
|
|
.form-label span{color:var(--err);margin-left:2px}
|
||
|
|
.form-input,.form-select{width:100%;background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);padding:10px 14px;color:var(--text);font-family:var(--sans);font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}
|
||
|
|
.form-input:focus,.form-select:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-dim)}
|
||
|
|
.form-input::placeholder{color:var(--text3)}
|
||
|
|
.mono-input{font-family:var(--mono);font-size:12px}
|
||
|
|
.form-hint{font-size:11px;color:var(--text3);margin-top:5px}
|
||
|
|
.sec-div{grid-column:1/-1;font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1.5px;font-family:var(--mono);border-top:1px solid var(--border);padding-top:18px;margin-top:4px}
|
||
|
|
.sec-div:first-child{border-top:none;padding-top:0;margin-top:0}
|
||
|
|
.ssl-toggle{grid-column:1/-1;display:flex;gap:8px}
|
||
|
|
.ssl-btn{flex:1;padding:12px 16px;background:var(--bg3);border:1px solid var(--border2);border-radius:var(--r);cursor:pointer;text-align:center;font-size:13px;font-weight:600;color:var(--text2);transition:all .15s}
|
||
|
|
.ssl-btn.active{background:var(--brand-dim);border-color:var(--brand);color:var(--brand)}
|
||
|
|
.ssl-btn small{display:block;font-weight:400;font-size:11px;opacity:.7;margin-top:2px}
|
||
|
|
.upload-area{grid-column:1/-1;border:2px dashed var(--border2);border-radius:var(--r);padding:20px;text-align:center;cursor:pointer;transition:all .15s}
|
||
|
|
.upload-area:hover,.upload-area.drag{border-color:var(--brand);background:var(--brand-dim)}
|
||
|
|
.upload-preview{display:none;align-items:center;justify-content:center;gap:10px;margin-top:10px}
|
||
|
|
.upload-preview img{max-height:50px;max-width:180px;border-radius:6px}
|
||
|
|
.alert{padding:12px 16px;border-radius:var(--r);font-size:13px;margin-bottom:0}
|
||
|
|
.alert-err{background:var(--err-dim);border:1px solid #ef444433;color:var(--err)}
|
||
|
|
.alert-ok{background:var(--ok-dim);border:1px solid #10b98133;color:var(--ok)}
|
||
|
|
.alert-warn{background:#f59e0b15;border:1px solid #f59e0b33;color:var(--warn)}
|
||
|
|
.nav-row{display:flex;justify-content:space-between;align-items:center;margin-top:28px;padding-top:20px;border-top:1px solid var(--border)}
|
||
|
|
.btn{padding:10px 22px;border-radius:var(--r);border:none;font-family:var(--sans);font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:8px}
|
||
|
|
.btn-brand{background:var(--brand);color:#000}
|
||
|
|
.btn-brand:hover{background:var(--brand2)}
|
||
|
|
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border2)}
|
||
|
|
.btn-ghost:hover{background:var(--bg3);color:var(--text)}
|
||
|
|
.btn-ok{background:var(--ok);color:#000;font-size:15px;padding:14px 36px}
|
||
|
|
.btn-ok:hover{filter:brightness(1.1)}
|
||
|
|
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||
|
|
.summary-table{background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;margin-bottom:16px}
|
||
|
|
.summary-section{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:1px;padding:10px 14px;font-family:var(--mono);background:var(--bg4);border-bottom:1px solid var(--border)}
|
||
|
|
.summary-row{display:flex;border-bottom:1px solid #1e284044}
|
||
|
|
.summary-row:last-child{border-bottom:none}
|
||
|
|
.summary-label{width:160px;padding:8px 14px;font-size:12px;color:var(--text3);flex-shrink:0}
|
||
|
|
.summary-value{padding:8px 14px;font-size:12px;color:var(--text);font-family:var(--mono);flex:1}
|
||
|
|
.summary-value.empty{color:var(--text3);font-style:italic;font-family:var(--sans)}
|
||
|
|
.footer{background:var(--bg2);border-top:1px solid var(--border);padding:12px 32px;text-align:center;font-size:11px;color:var(--text3);font-family:var(--mono)}
|
||
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
|
@keyframes pulse{0%,100%{opacity:.3;transform:scale(.8)}50%{opacity:1;transform:scale(1.2)}}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="shell">
|
||
|
|
<header class="hdr">
|
||
|
|
<div class="hdr-logo">IRIDE</div>
|
||
|
|
<div class="hdr-sep"></div>
|
||
|
|
<div class="hdr-sub">Setup Wizard — Tecnotel Servizi SRL</div>
|
||
|
|
<div class="hdr-badge">v0.2.0</div>
|
||
|
|
</header>
|
||
|
|
<div class="main">
|
||
|
|
<aside class="sidebar">
|
||
|
|
<div class="sidebar-title">Configurazione</div>
|
||
|
|
<div class="tab-item active" onclick="goTab(0)" id="tab-0"><div class="tab-num">1</div> Licenza</div>
|
||
|
|
<div class="tab-item" onclick="goTab(1)" id="tab-1"><div class="tab-num">2</div> Cliente</div>
|
||
|
|
<div class="tab-item" onclick="goTab(2)" id="tab-2"><div class="tab-num">3</div> Rete & SSL</div>
|
||
|
|
<div class="tab-item" onclick="goTab(3)" id="tab-3"><div class="tab-num">4</div> Intelligenza artificiale</div>
|
||
|
|
<div class="tab-item" onclick="goTab(4)" id="tab-4"><div class="tab-num">5</div> Utente admin</div>
|
||
|
|
<div class="tab-item" onclick="goTab(5)" id="tab-5"><div class="tab-num">6</div> Installa</div>
|
||
|
|
</aside>
|
||
|
|
<div class="content">
|
||
|
|
<div class="progress">
|
||
|
|
<div class="prog-step active" id="ps-0"></div>
|
||
|
|
<div class="prog-step" id="ps-1"></div>
|
||
|
|
<div class="prog-step" id="ps-2"></div>
|
||
|
|
<div class="prog-step" id="ps-3"></div>
|
||
|
|
<div class="prog-step" id="ps-4"></div>
|
||
|
|
<div class="prog-step" id="ps-5"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 1: Licenza -->
|
||
|
|
<div class="tab-panel active" id="panel-0">
|
||
|
|
<div class="panel-title">🔑 Licenza IRIDE</div>
|
||
|
|
<div class="panel-sub">IRIDE richiede una licenza valida emessa da Tecnotel Servizi SRL, vincolata
|
||
|
|
all'identificativo hardware di questo server. La licenza contiene anche le credenziali di sola
|
||
|
|
lettura per scaricare il software e i suoi aggiornamenti.</div>
|
||
|
|
<div class="form-grid">
|
||
|
|
<div class="form-full">
|
||
|
|
<label class="form-label">Machine ID di questo server</label>
|
||
|
|
<div style="display:flex;gap:8px">
|
||
|
|
<input class="form-input mono-input" id="machine-id-display" value="Calcolo in corso..." readonly style="flex:1">
|
||
|
|
<button class="btn btn-ghost" onclick="copyMachineId()" id="btn-copy-mid" style="padding:10px 16px">📋 Copia</button>
|
||
|
|
</div>
|
||
|
|
<div class="form-hint">Invia questo identificativo a Tecnotel per ricevere il file <code>license.json</code>.</div>
|
||
|
|
</div>
|
||
|
|
<div class="upload-area form-full" id="lic-drop"
|
||
|
|
ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')"
|
||
|
|
ondrop="handleLicenseDrop(event)" onclick="document.getElementById('lic-input').click()">
|
||
|
|
<input type="file" id="lic-input" accept=".json,application/json" style="display:none" onchange="handleLicenseFile(this)">
|
||
|
|
<div style="font-size:28px;margin-bottom:6px">📄</div>
|
||
|
|
<div style="font-weight:600">Trascina qui <code>license.json</code> oppure clicca per selezionarlo</div>
|
||
|
|
<div class="form-hint">Firma Ed25519 verificata sul server prima di procedere</div>
|
||
|
|
</div>
|
||
|
|
<div id="lic-error" class="form-full alert alert-err" style="display:none"></div>
|
||
|
|
<div id="lic-summary" class="form-full" style="display:none;background:var(--ok-dim);border:1px solid var(--ok);border-radius:var(--r);padding:16px 20px">
|
||
|
|
<div style="font-weight:700;color:var(--ok);margin-bottom:10px">✓ Licenza valida</div>
|
||
|
|
<div style="display:grid;grid-template-columns:120px 1fr;gap:6px 12px;font-size:12px;font-family:var(--mono)">
|
||
|
|
<div style="color:var(--text3)">Cliente:</div> <div id="lic-customer"></div>
|
||
|
|
<div style="color:var(--text3)">Prodotto:</div> <div id="lic-product"></div>
|
||
|
|
<div style="color:var(--text3)">Tier:</div> <div id="lic-tier"></div>
|
||
|
|
<div style="color:var(--text3)">Hostname:</div> <div id="lic-hostname"></div>
|
||
|
|
<div style="color:var(--text3)">Emessa:</div> <div id="lic-issued"></div>
|
||
|
|
<div style="color:var(--text3)">Scadenza:</div> <div id="lic-expires"></div>
|
||
|
|
<div style="color:var(--text3)">Token Gitea:</div><div id="lic-gitea"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="nav-row">
|
||
|
|
<div></div>
|
||
|
|
<button class="btn btn-brand" id="btn-after-license" onclick="goTab(1)" disabled style="opacity:.4;cursor:not-allowed">Avanti →</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 2: Cliente -->
|
||
|
|
<div class="tab-panel" id="panel-1">
|
||
|
|
<div class="panel-title">🏢 Informazioni cliente</div>
|
||
|
|
<div class="panel-sub">Identità dell'istanza: compare nel portale, nei messaggi dell'assistente e nei report.</div>
|
||
|
|
<div class="form-grid">
|
||
|
|
<div><label class="form-label">Nome breve <span>*</span></label><input class="form-input" id="cliente_name" placeholder="es. poliambulatorio-rossi"><div class="form-hint">Identificativo tecnico, minuscolo senza spazi</div></div>
|
||
|
|
<div><label class="form-label">Tipo organizzazione</label><select class="form-select" id="cliente_type"><option value="healthcare">Sanità / Healthcare</option><option value="customer_service">Customer service</option><option value="hospitality">Hospitality</option><option value="pa">Pubblica Amministrazione</option><option value="enterprise">Azienda</option></select></div>
|
||
|
|
<div class="form-full"><label class="form-label">Nome completo <span>*</span></label><input class="form-input" id="cliente_full" placeholder="es. Poliambulatorio Rossi S.r.l."></div>
|
||
|
|
<div><label class="form-label">Dominio email</label><input class="form-input" id="cliente_domain" placeholder="es. poliambulatoriorossi.it"><div class="form-hint">Dominio degli utenti del portale (opzionale)</div></div>
|
||
|
|
<div class="form-full"><label class="form-label">Contesto per l'assistente <span style="font-weight:400">(opzionale)</span></label><textarea class="form-input" id="ai_context" rows="3" placeholder="es. Poliambulatorio con 3 sedi, prenotazioni e ritiro referti, orari 8-20 nei giorni feriali."></textarea><div class="form-hint">Poche frasi sul cliente: entrano nelle istruzioni dell'assistente predefinito, modificabili dal portale.</div></div>
|
||
|
|
<div class="sec-div">Logo (opzionale)</div>
|
||
|
|
<div class="upload-area form-full" id="logo-drop" ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')" ondrop="handleLogoDrop(event)" onclick="document.getElementById('logo-input').click()">
|
||
|
|
<input type="file" id="logo-input" accept="image/*" style="display:none" onchange="handleLogoFile(this)">
|
||
|
|
<div style="font-size:24px;margin-bottom:4px">🖼️</div>
|
||
|
|
<div style="font-weight:600">Trascina il logo del cliente oppure clicca</div>
|
||
|
|
<div class="form-hint">PNG o SVG, max 2 MB — usato nel portale e nel widget</div>
|
||
|
|
<div class="upload-preview" id="logo-preview"><img id="logo-img" src=""><div style="font-size:11px;color:var(--ok);font-family:var(--mono)" id="logo-fname"></div></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(0)">← Indietro</button><button class="btn btn-brand" onclick="goTab(2)">Avanti →</button></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 3: Rete & SSL -->
|
||
|
|
<div class="tab-panel" id="panel-2">
|
||
|
|
<div class="panel-title">🌐 Rete & SSL</div>
|
||
|
|
<div class="panel-sub">Nome pubblico dell'istanza e certificato. Per WhatsApp e per i webhook dei canali serve HTTPS pubblico con certificato valido.</div>
|
||
|
|
<div class="form-grid">
|
||
|
|
<div class="form-full"><label class="form-label">Hostname principale <span>*</span></label><input class="form-input mono-input" id="domain" placeholder="es. iride.cliente.it"></div>
|
||
|
|
<div class="form-full"><label class="form-label">Nomi alternativi</label><input class="form-input mono-input" id="aliases" placeholder="es. assistente.cliente.it"><div class="form-hint">Separati da spazio — opzionale</div></div>
|
||
|
|
<div class="sec-div">Certificato SSL</div>
|
||
|
|
<div class="ssl-toggle">
|
||
|
|
<div class="ssl-btn active" id="ssl-le" onclick="setSsl('letsencrypt')">🔒 Let's Encrypt<small>Automatico, richiede DNS pubblico</small></div>
|
||
|
|
<div class="ssl-btn" id="ssl-manual" onclick="setSsl('manual')">📄 Certificato esistente<small>Carica .crt e .key</small></div>
|
||
|
|
<div class="ssl-btn" id="ssl-selfsigned" onclick="setSsl('selfsigned')">🔐 Autofirmato<small>LAN / demo — 10 anni</small></div>
|
||
|
|
</div>
|
||
|
|
<div id="le-block" class="form-full"><label class="form-label">Email amministratore <span>*</span></label><input class="form-input" id="admin_email" placeholder="admin@tecnotelsrl.com"><div class="form-hint">Per le notifiche di scadenza del certificato</div></div>
|
||
|
|
<div id="manual-block" class="form-full" style="display:none">
|
||
|
|
<div class="form-grid">
|
||
|
|
<div><label class="form-label">File .crt <span>*</span></label><input type="file" class="form-input" accept=".crt,.pem,.cer" onchange="uploadSsl(this,'cert')"><div class="form-hint" id="crt-status">Nessun file selezionato</div></div>
|
||
|
|
<div><label class="form-label">File .key <span>*</span></label><input type="file" class="form-input" accept=".key,.pem" onchange="uploadSsl(this,'key')"><div class="form-hint" id="key-status">Nessun file selezionato</div></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div id="selfsigned-block" class="form-full" style="display:none">
|
||
|
|
<div class="alert alert-warn">Certificato RSA 4096 autofirmato, validità 10 anni, con hostname, alias e IP del server nel SAN. I browser lo segnaleranno come non attendibile: va bene per LAN e demo, non per WhatsApp.</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(1)">← Indietro</button><button class="btn btn-brand" onclick="goTab(3)">Avanti →</button></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 4: AI -->
|
||
|
|
<div class="tab-panel" id="panel-3">
|
||
|
|
<div class="panel-title">🧠 Intelligenza artificiale</div>
|
||
|
|
<div class="panel-sub">Provider del modello testuale. "Fake" è deterministico e senza costi: va bene per demo e collaudo; la chiave si può inserire o cambiare dopo dal portale.</div>
|
||
|
|
<div class="form-grid">
|
||
|
|
<div><label class="form-label">Provider</label><select class="form-select" id="ai_provider" onchange="onProviderChange()"><option value="fake">Fake (demo, nessuna chiave)</option><option value="openai">OpenAI</option><option value="anthropic">Anthropic (disponibile nella slice 2)</option></select></div>
|
||
|
|
<div><label class="form-label">Modello</label><input class="form-input mono-input" id="ai_model" placeholder="gpt-4.1-mini" value="gpt-4.1-mini"></div>
|
||
|
|
<div class="form-full" id="ai-key-block" style="display:none"><label class="form-label">API key</label><input class="form-input mono-input" type="password" id="ai_api_key" placeholder="sk-..."><div class="form-hint">Salvata in /opt/iride/config/iride.json (0600). Prima di dati reali: DPA e regione di elaborazione verificati (B-063).</div></div>
|
||
|
|
</div>
|
||
|
|
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(2)">← Indietro</button><button class="btn btn-brand" onclick="goTab(4)">Avanti →</button></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 5: Utente admin -->
|
||
|
|
<div class="tab-panel" id="panel-4">
|
||
|
|
<div class="panel-title">👤 Utente amministratore</div>
|
||
|
|
<div class="panel-sub">Primo accesso al portale IRIDE. Gli altri utenti e i ruoli si creano dal portale.</div>
|
||
|
|
<div class="form-grid">
|
||
|
|
<div><label class="form-label">Username <span>*</span></label><input class="form-input" id="admin_username" placeholder="admin" value="admin"></div>
|
||
|
|
<div><label class="form-label">Email</label><input class="form-input" id="admin_email_user" placeholder="admin@cliente.it"></div>
|
||
|
|
<div><label class="form-label">Password <span>*</span></label><input class="form-input" type="password" id="admin_password" placeholder="Min. 12 caratteri"></div>
|
||
|
|
<div><label class="form-label">Conferma password <span>*</span></label><input class="form-input" type="password" id="admin_password2" placeholder="Ripeti la password"></div>
|
||
|
|
<div class="form-full" id="pw-error" style="display:none"><div class="alert alert-err">Le password non corrispondono o sono più corte di 12 caratteri</div></div>
|
||
|
|
</div>
|
||
|
|
<div class="nav-row"><button class="btn btn-ghost" onclick="goTab(3)">← Indietro</button><button class="btn btn-brand" onclick="goToInstall()">Riepilogo →</button></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- 6: Installa -->
|
||
|
|
<div class="tab-panel" id="panel-5">
|
||
|
|
<div class="panel-title">🚀 Riepilogo e installazione</div>
|
||
|
|
<div class="panel-sub">Controlla i dati. L'installazione clona il repository, crea l'ambiente Python, configura SSL, nginx e i servizi systemd, poi chiude la porta 8888.</div>
|
||
|
|
<div id="summary-box"></div>
|
||
|
|
<div id="errors-box" style="display:none"></div>
|
||
|
|
<div id="spinner-box" style="display:none;text-align:center;padding:40px 20px">
|
||
|
|
<div style="width:48px;height:48px;border:4px solid var(--border2);border-top-color:var(--brand);border-radius:50%;margin:0 auto 20px;animation:spin 1s linear infinite"></div>
|
||
|
|
<div style="font-size:15px;font-weight:600;margin-bottom:6px">Installazione in corso</div>
|
||
|
|
<div style="font-size:12px;color:var(--text3);font-family:var(--mono)" id="spinner-status">Avvio installazione...</div>
|
||
|
|
<pre id="log-box" style="text-align:left;margin-top:20px;max-height:260px;overflow:auto;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);padding:12px;font-family:var(--mono);font-size:11px;color:var(--text2)"></pre>
|
||
|
|
</div>
|
||
|
|
<div id="success-box" style="display:none;text-align:center;padding:40px 20px">
|
||
|
|
<div style="font-size:48px;margin-bottom:12px">✅</div>
|
||
|
|
<div style="font-size:18px;font-weight:700;margin-bottom:6px">IRIDE installata</div>
|
||
|
|
<div style="font-size:13px;color:var(--text2);margin-bottom:24px">La porta 8888 è stata chiusa. Configura i canali in <code>/opt/iride/config/channels.json</code> e <code>credentials.json</code>.</div>
|
||
|
|
<button class="btn btn-ok" id="btn-open" onclick="openIride()">Apri IRIDE →</button>
|
||
|
|
</div>
|
||
|
|
<div class="nav-row" id="install-nav">
|
||
|
|
<button class="btn btn-ghost" onclick="goTab(4)">← Indietro</button>
|
||
|
|
<button class="btn btn-ok" id="btn-install" onclick="avviaInstallazione()">🚀 Avvia installazione</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<footer class="footer">IRIDE — Tecnotel Servizi SRL · Web Installer v0.2.0 · La porta 8888 verrà chiusa al termine</footer>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
let currentTab = 0;
|
||
|
|
let sslMode = 'letsencrypt';
|
||
|
|
let certUploaded = false;
|
||
|
|
let keyUploaded = false;
|
||
|
|
let installing = false;
|
||
|
|
let installDomain = '';
|
||
|
|
let licenseValid = false;
|
||
|
|
|
||
|
|
async function loadMachineId() {
|
||
|
|
const el = document.getElementById('machine-id-display');
|
||
|
|
try {
|
||
|
|
const r = await fetch('/api/machine-id');
|
||
|
|
const d = await r.json();
|
||
|
|
el.value = (d && d.machine_id) ? d.machine_id : 'Errore caricamento';
|
||
|
|
} catch (e) { el.value = 'Errore: ' + e.message; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function copyMachineId() {
|
||
|
|
const el = document.getElementById('machine-id-display');
|
||
|
|
el.select();
|
||
|
|
navigator.clipboard.writeText(el.value).then(() => {
|
||
|
|
const btn = document.getElementById('btn-copy-mid');
|
||
|
|
const orig = btn.textContent;
|
||
|
|
btn.textContent = '✅ Copiato';
|
||
|
|
setTimeout(() => btn.textContent = orig, 1500);
|
||
|
|
}).catch(() => {});
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleLicenseDrop(ev) {
|
||
|
|
ev.preventDefault();
|
||
|
|
ev.currentTarget.classList.remove('drag');
|
||
|
|
if (ev.dataTransfer.files.length) uploadLicense(ev.dataTransfer.files[0]);
|
||
|
|
}
|
||
|
|
function handleLicenseFile(inp) { if (inp.files.length) uploadLicense(inp.files[0]); }
|
||
|
|
|
||
|
|
async function uploadLicense(file) {
|
||
|
|
const errEl = document.getElementById('lic-error');
|
||
|
|
const sumEl = document.getElementById('lic-summary');
|
||
|
|
const btnNext = document.getElementById('btn-after-license');
|
||
|
|
errEl.style.display = 'none'; sumEl.style.display = 'none';
|
||
|
|
try {
|
||
|
|
const text = await file.text();
|
||
|
|
const r = await fetch('/api/license/upload', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: text });
|
||
|
|
const d = await r.json();
|
||
|
|
if (!d.ok) {
|
||
|
|
errEl.textContent = 'Licenza non valida: ' + (d.error || 'errore sconosciuto');
|
||
|
|
errEl.style.display = 'block';
|
||
|
|
licenseValid = false;
|
||
|
|
btnNext.disabled = true; btnNext.style.opacity = '.4'; btnNext.style.cursor = 'not-allowed';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const s = d.summary || {};
|
||
|
|
document.getElementById('lic-customer').textContent = s.customer || '—';
|
||
|
|
document.getElementById('lic-product').textContent = s.product || '—';
|
||
|
|
document.getElementById('lic-tier').textContent = (s.tier || '—').toUpperCase();
|
||
|
|
document.getElementById('lic-hostname').textContent = s.issued_to || '—';
|
||
|
|
document.getElementById('lic-issued').textContent = s.issued_at || '—';
|
||
|
|
document.getElementById('lic-expires').textContent = s.expires_at || '—';
|
||
|
|
document.getElementById('lic-gitea').textContent = s.has_gitea ? '✓ Incluso' : '— Non incluso';
|
||
|
|
sumEl.style.display = 'block';
|
||
|
|
licenseValid = true;
|
||
|
|
btnNext.disabled = false; btnNext.style.opacity = '1'; btnNext.style.cursor = 'pointer';
|
||
|
|
if (s.issued_to && !g('domain')) document.getElementById('domain').value = s.issued_to;
|
||
|
|
} catch (e) {
|
||
|
|
errEl.textContent = 'Errore upload: ' + e.message;
|
||
|
|
errEl.style.display = 'block';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
window.addEventListener('DOMContentLoaded', loadMachineId);
|
||
|
|
|
||
|
|
function goTab(n) {
|
||
|
|
if (n > 0 && !licenseValid) return;
|
||
|
|
if (n > currentTab) document.getElementById('tab-' + currentTab).classList.add('done');
|
||
|
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||
|
|
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
|
||
|
|
document.querySelectorAll('.prog-step').forEach((s,i) => {
|
||
|
|
s.classList.remove('active','done');
|
||
|
|
if (i < n) s.classList.add('done'); else if (i === n) s.classList.add('active');
|
||
|
|
});
|
||
|
|
document.getElementById('panel-' + n).classList.add('active');
|
||
|
|
document.getElementById('tab-' + n).classList.add('active');
|
||
|
|
currentTab = n;
|
||
|
|
}
|
||
|
|
|
||
|
|
function goToInstall() {
|
||
|
|
const pw1 = g('admin_password'), pw2 = g('admin_password2');
|
||
|
|
const err = document.getElementById('pw-error');
|
||
|
|
if (pw1 !== pw2 || pw1.length < 12) { err.style.display = 'block'; return; }
|
||
|
|
err.style.display = 'none';
|
||
|
|
goTab(5);
|
||
|
|
buildSummary();
|
||
|
|
}
|
||
|
|
|
||
|
|
function setSsl(mode) {
|
||
|
|
sslMode = mode;
|
||
|
|
document.getElementById('ssl-le').classList.toggle('active', mode === 'letsencrypt');
|
||
|
|
document.getElementById('ssl-manual').classList.toggle('active', mode === 'manual');
|
||
|
|
document.getElementById('ssl-selfsigned').classList.toggle('active', mode === 'selfsigned');
|
||
|
|
document.getElementById('le-block').style.display = mode === 'letsencrypt' ? 'block' : 'none';
|
||
|
|
document.getElementById('manual-block').style.display = mode === 'manual' ? 'block' : 'none';
|
||
|
|
document.getElementById('selfsigned-block').style.display = mode === 'selfsigned' ? 'block' : 'none';
|
||
|
|
}
|
||
|
|
|
||
|
|
function onProviderChange() {
|
||
|
|
const p = g('ai_provider');
|
||
|
|
document.getElementById('ai-key-block').style.display = p === 'fake' ? 'none' : 'block';
|
||
|
|
const m = document.getElementById('ai_model');
|
||
|
|
if (p === 'openai' && (!m.value || m.value.startsWith('claude'))) m.value = 'gpt-4.1-mini';
|
||
|
|
if (p === 'anthropic' && (!m.value || m.value.startsWith('gpt'))) m.value = 'claude-sonnet-4-6';
|
||
|
|
}
|
||
|
|
|
||
|
|
async function uploadSsl(input, type) {
|
||
|
|
const file = input.files[0]; if (!file) return;
|
||
|
|
const sid = type === 'cert' ? 'crt-status' : 'key-status';
|
||
|
|
try {
|
||
|
|
const r = await fetch('/api/upload/' + type, { method: 'POST', body: await file.arrayBuffer() });
|
||
|
|
const d = await r.json();
|
||
|
|
if (d.ok) {
|
||
|
|
document.getElementById(sid).textContent = '✓ ' + file.name;
|
||
|
|
document.getElementById(sid).style.color = 'var(--ok)';
|
||
|
|
if (type === 'cert') certUploaded = true; else keyUploaded = true;
|
||
|
|
}
|
||
|
|
} catch(e) { document.getElementById(sid).textContent = 'Errore: ' + e.message; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleLogoDrop(e) { e.preventDefault(); document.getElementById('logo-drop').classList.remove('drag'); processLogo(e.dataTransfer.files[0]); }
|
||
|
|
function handleLogoFile(input) { processLogo(input.files[0]); }
|
||
|
|
async function processLogo(file) {
|
||
|
|
if (!file || file.size > 2*1024*1024) return;
|
||
|
|
const reader = new FileReader();
|
||
|
|
reader.onload = e => {
|
||
|
|
document.getElementById('logo-img').src = e.target.result;
|
||
|
|
document.getElementById('logo-fname').textContent = file.name;
|
||
|
|
document.getElementById('logo-preview').style.display = 'flex';
|
||
|
|
};
|
||
|
|
reader.readAsDataURL(file);
|
||
|
|
fetch('/api/upload/logo', { method: 'POST', body: await file.arrayBuffer() });
|
||
|
|
}
|
||
|
|
|
||
|
|
function g(id) { return (document.getElementById(id)?.value || '').trim(); }
|
||
|
|
|
||
|
|
function collectData() {
|
||
|
|
return {
|
||
|
|
cliente_name: g('cliente_name').toLowerCase().replace(/[^a-z0-9-]+/g, '-'), cliente_full: g('cliente_full'),
|
||
|
|
cliente_domain: g('cliente_domain'), cliente_type: g('cliente_type'), ai_context: g('ai_context'),
|
||
|
|
domain: g('domain'), aliases: g('aliases'), ssl_mode: sslMode, admin_email: g('admin_email'),
|
||
|
|
ai_provider: g('ai_provider') || 'fake', ai_model: g('ai_model'), ai_api_key: g('ai_api_key'),
|
||
|
|
admin_username: g('admin_username') || 'admin', admin_email_user: g('admin_email_user'),
|
||
|
|
admin_password: g('admin_password'),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildSummary() {
|
||
|
|
const d = collectData();
|
||
|
|
const errors = [];
|
||
|
|
if (!d.cliente_name) errors.push('Nome breve del cliente obbligatorio');
|
||
|
|
if (!d.cliente_full) errors.push('Nome completo del cliente obbligatorio');
|
||
|
|
if (!d.domain) errors.push('Hostname obbligatorio');
|
||
|
|
if (!d.admin_password) errors.push('Password admin obbligatoria');
|
||
|
|
if (d.ai_provider !== 'fake' && !d.ai_api_key) errors.push('API key del provider AI obbligatoria (oppure scegli Fake)');
|
||
|
|
if (sslMode === 'letsencrypt' && !d.admin_email) errors.push("Email per Let's Encrypt obbligatoria");
|
||
|
|
if (sslMode === 'manual' && (!certUploaded || !keyUploaded)) errors.push('Certificato SSL (.crt e .key) non caricato');
|
||
|
|
|
||
|
|
const row = (l, v) => '<div class="summary-row"><div class="summary-label">' + l + '</div><div class="summary-value ' + (v ? '' : 'empty') + '">' + (v || 'non configurato') + '</div></div>';
|
||
|
|
const sslLabel = sslMode === 'letsencrypt' ? "Let's Encrypt (automatico)" : sslMode === 'manual' ? "Certificato esistente caricato" : "Autofirmato (RSA 4096, 10 anni)";
|
||
|
|
document.getElementById('summary-box').innerHTML = '<div class="summary-table">' +
|
||
|
|
'<div class="summary-section">Cliente</div>' +
|
||
|
|
row('Nome breve', d.cliente_name) + row('Nome completo', d.cliente_full) + row('Tipo', d.cliente_type) + row('Dominio email', d.cliente_domain) +
|
||
|
|
(d.ai_context ? row('Contesto', d.ai_context.substring(0, 80) + (d.ai_context.length > 80 ? '…' : '')) : '') +
|
||
|
|
'<div class="summary-section">Rete & SSL</div>' + row('Hostname', d.domain) + row('Alias', d.aliases) + row('SSL', sslLabel) +
|
||
|
|
'<div class="summary-section">Intelligenza artificiale</div>' + row('Provider', d.ai_provider) + row('Modello', d.ai_provider === 'fake' ? '—' : d.ai_model) + row('API key', d.ai_provider === 'fake' ? '—' : (d.ai_api_key ? '••••' + d.ai_api_key.slice(-4) : '')) +
|
||
|
|
'<div class="summary-section">Utente admin</div>' + row('Username', d.admin_username) + row('Email', d.admin_email_user) +
|
||
|
|
'</div>';
|
||
|
|
const errBox = document.getElementById('errors-box');
|
||
|
|
if (errors.length) {
|
||
|
|
errBox.style.display = 'block';
|
||
|
|
errBox.innerHTML = '<div class="alert alert-err" style="margin-bottom:16px">⚠️ <strong>Correggere prima di procedere:</strong><ul style="margin-top:8px;padding-left:16px">' + errors.map(e => '<li style="margin-top:4px">' + e + '</li>').join('') + '</ul></div>';
|
||
|
|
document.getElementById('btn-install').disabled = true;
|
||
|
|
} else {
|
||
|
|
errBox.style.display = 'none';
|
||
|
|
document.getElementById('btn-install').disabled = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function avviaInstallazione() {
|
||
|
|
if (installing) return;
|
||
|
|
installing = true;
|
||
|
|
const data = collectData();
|
||
|
|
installDomain = data.domain;
|
||
|
|
document.getElementById('summary-box').style.display = 'none';
|
||
|
|
document.getElementById('errors-box').style.display = 'none';
|
||
|
|
document.getElementById('install-nav').style.display = 'none';
|
||
|
|
document.getElementById('spinner-box').style.display = 'block';
|
||
|
|
try {
|
||
|
|
const r = await fetch('/api/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||
|
|
const res = await r.json();
|
||
|
|
if (!res.ok) throw new Error(res.error || 'Errore sconosciuto');
|
||
|
|
pollStatus();
|
||
|
|
} catch(e) {
|
||
|
|
document.getElementById('spinner-status').textContent = 'ERRORE: ' + e.message;
|
||
|
|
document.getElementById('spinner-status').style.color = 'var(--err)';
|
||
|
|
installing = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function pollStatus() {
|
||
|
|
let errors = 0;
|
||
|
|
const statusEl = document.getElementById('spinner-status');
|
||
|
|
const logEl = document.getElementById('log-box');
|
||
|
|
const iv = setInterval(async () => {
|
||
|
|
try {
|
||
|
|
const r = await fetch('/api/status');
|
||
|
|
const d = await r.json();
|
||
|
|
errors = 0;
|
||
|
|
if (d.log && d.log.length) {
|
||
|
|
logEl.textContent = d.log.join('\n');
|
||
|
|
logEl.scrollTop = logEl.scrollHeight;
|
||
|
|
const last = d.log[d.log.length-1];
|
||
|
|
if (last.startsWith('__ERROR__')) statusEl.textContent = last.replace('__ERROR__: ', 'Errore: ');
|
||
|
|
else if (last.includes('──')) statusEl.textContent = last.replace(/^\[[^\]]+\]\s*/, '').replace(/─/g, '').trim();
|
||
|
|
}
|
||
|
|
if (d.done) { clearInterval(iv); showSuccess(); }
|
||
|
|
if (d.error) { clearInterval(iv); statusEl.style.color = 'var(--err)'; }
|
||
|
|
} catch(e) {
|
||
|
|
errors++;
|
||
|
|
if (errors >= 6) { clearInterval(iv); showSuccess(); }
|
||
|
|
}
|
||
|
|
}, 2000);
|
||
|
|
}
|
||
|
|
|
||
|
|
function showSuccess() {
|
||
|
|
document.getElementById('spinner-box').style.display = 'none';
|
||
|
|
document.getElementById('success-box').style.display = 'block';
|
||
|
|
}
|
||
|
|
function openIride() { window.location.href = 'https://' + installDomain; }
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|