Add web setup UI for first-boot config

- server.py: Flask-free Python web server on port 80
  - 4-step wizard: network → tier → tools → live install log
  - DHCP or static IP support
  - SSE log streaming, handles phase-1 reboot gracefully
- user-data: deploy web UI service instead of TUI (whiptail)
  - sets hostname to cezenai (accessible as cezenai.local)
  - installs python3, avahi-daemon
- entry.yml: skip roles based on skip_roles var from web UI
- install.sh: reads /opt/cezen/install.conf for tier & skip_roles
This commit is contained in:
Jino Jose 2026-06-24 12:30:56 +05:30
parent a1948d93fe
commit 1991307903
4 changed files with 717 additions and 28 deletions

View File

@ -10,15 +10,26 @@
cezen_home: "/opt/cezen" cezen_home: "/opt/cezen"
python_version: "3.11" python_version: "3.11"
cuda_version: "12.4" cuda_version: "12.4"
skip_roles: "" # comma-separated list of role names to skip (set by install.sh)
roles: roles:
- base - role: base
- docker when: "'base' not in skip_roles.split(',')"
- k3s - role: docker
- ollama when: "'docker' not in skip_roles.split(',')"
- vllm - role: k3s
- jupyterlab when: "'k3s' not in skip_roles.split(',')"
- chromadb - role: ollama
- mlflow when: "'ollama' not in skip_roles.split(',')"
- minio - role: vllm
- monitoring when: "'vllm' not in skip_roles.split(',')"
- role: jupyterlab
when: "'jupyterlab' not in skip_roles.split(',')"
- role: chromadb
when: "'chromadb' not in skip_roles.split(',')"
- role: mlflow
when: "'mlflow' not in skip_roles.split(',')"
- role: minio
when: "'minio' not in skip_roles.split(',')"
- role: monitoring
when: "'monitoring' not in skip_roles.split(',')"

View File

@ -8,7 +8,7 @@ autoinstall:
layout: us layout: us
# ── Network: DHCP on first ethernet ─────────── # ── Network: DHCP on first ethernet ───────────
# Final network config is set by firstboot-setup.sh wizard # Final network config is set by the web setup UI (browser on port 80)
network: network:
network: network:
version: 2 version: 2
@ -46,8 +46,10 @@ autoinstall:
- git - git
- curl - curl
- wget - wget
- whiptail
- net-tools - net-tools
- python3
- python3-pip
- avahi-daemon
# ── Late commands ───────────────────────────── # ── Late commands ─────────────────────────────
late-commands: late-commands:
@ -62,35 +64,36 @@ autoinstall:
# Clone the Cezen AI installer # Clone the Cezen AI installer
- git clone https://cgit.cezentech.com/jinojose/aipackage.git /target/opt/aipackage || true - git clone https://cgit.cezentech.com/jinojose/aipackage.git /target/opt/aipackage || true
# Deploy the firstboot setup wizard # Deploy the web setup server
- cp /target/opt/aipackage/autoinstall/firstboot-setup.sh /target/opt/cezen-setup.sh - mkdir -p /target/opt/cezen
- chmod +x /target/opt/cezen-setup.sh - cp /target/opt/aipackage/autoinstall/websetup/server.py /target/opt/cezen/websetup.py
- chmod +x /target/opt/cezen/websetup.py
# Create firstboot systemd service (runs the TUI wizard on first login) # Set hostname to cezenai so it's reachable as cezenai.local via mDNS
- echo "cezenai" > /target/etc/hostname
- sed -i 's/aiserver/cezenai/g' /target/etc/hosts || true
# Create cezen-setup web UI systemd service
- | - |
cat > /target/etc/systemd/system/cezen-setup.service << 'EOF' cat > /target/etc/systemd/system/cezen-setup.service << 'EOF'
[Unit] [Unit]
Description=Cezen AI Suite Setup Wizard Description=Cezen AI Suite — Web Setup UI
After=network-online.target getty@tty1.service After=network-online.target avahi-daemon.service
Wants=network-online.target Wants=network-online.target
ConditionPathExists=!/opt/cezen/.setup-done ConditionPathExists=!/opt/cezen/.setup-done
[Service] [Service]
Type=oneshot Type=simple
ExecStart=/bin/bash /opt/cezen-setup.sh ExecStart=/usr/bin/python3 /opt/cezen/websetup.py
StandardInput=tty Restart=on-failure
StandardOutput=tty RestartSec=5
StandardError=tty
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
- curtin in-target -- systemctl enable cezen-setup.service - curtin in-target -- systemctl enable cezen-setup.service
- curtin in-target -- systemctl enable avahi-daemon.service
# ── Skip confirmations ───────────────────────── # ── Skip confirmations ─────────────────────────
user-data: user-data:

View File

@ -0,0 +1,666 @@
#!/usr/bin/env python3
"""
Cezen AI Suite First Boot Web Setup Server
Serves on port 80. Access from any browser on the same network.
"""
import os, json, subprocess, threading, time, socket, ipaddress
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
SETUP_DONE_FILE = "/opt/cezen/.setup-done"
INSTALL_LOG = "/var/log/cezen-install.log"
AIPACKAGE_DIR = "/opt/aipackage"
install_proc = None
install_status = {"running": False, "done": False, "error": None}
# ─── Helpers ──────────────────────────────────────────────
def get_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except:
return "unknown"
def get_interfaces():
try:
out = subprocess.check_output(["ip", "-o", "link", "show"], text=True)
ifaces = []
for line in out.splitlines():
name = line.split(": ")[1].split("@")[0]
if name not in ("lo",) and not name.startswith(("docker","br-","veth","k3s")):
ifaces.append(name)
return ifaces
except:
return ["eth0"]
def apply_static_ip(iface, ip, prefix, gateway, dns):
config = f"""network:
version: 2
ethernets:
{iface}:
dhcp4: false
addresses:
- {ip}/{prefix}
routes:
- to: default
via: {gateway}
nameservers:
addresses: [{dns}]
"""
with open("/etc/netplan/99-cezen-static.yaml", "w") as f:
f.write(config)
subprocess.run(["netplan", "apply"], capture_output=True)
time.sleep(3)
def run_install(tier, skip_tools):
global install_status
install_status = {"running": True, "done": False, "error": None}
try:
# Write config so phase 2 (post-reboot) knows what to skip
os.makedirs("/opt/cezen", exist_ok=True)
skip_str = ",".join(skip_tools) if skip_tools else ""
with open("/opt/cezen/install.conf", "w") as f:
f.write(f"TIER={tier}\nSKIP_ROLES={skip_str}\n")
# Mark setup done NOW so this web UI doesn't restart after the phase-1 reboot
open(SETUP_DONE_FILE, "w").close()
env = os.environ.copy()
# Phase 1: installs NVIDIA drivers, registers cezen-phase2 systemd service,
# then reboots. Phase 2 (full stack) runs automatically after reboot.
cmd = ["bash", f"{AIPACKAGE_DIR}/install.sh", "--phase=1", f"--tier={tier}"]
with open(INSTALL_LOG, "w") as log:
proc = subprocess.Popen(cmd, stdout=log, stderr=log, env=env)
proc.wait()
# Reaches here only if no reboot happened (e.g. no GPU / drivers already installed)
install_status = {"running": False, "done": True, "error": None}
except Exception as e:
install_status = {"running": False, "done": False, "error": str(e)}
# ─── HTML UI ──────────────────────────────────────────────
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cezen AI Suite Server Setup</title>
<style>
:root {
--navy: #1B2A4A;
--navy2: #223A5E;
--teal: #0D9488;
--teal2: #14B8A6;
--white: #ffffff;
--off: #F1F5F9;
--text: #1E293B;
--muted: #64748B;
--red: #EF4444;
--green: #10B981;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--off); color: var(--text); min-height: 100vh; }
header { background: var(--navy); padding: 18px 32px; display: flex; align-items: center; gap: 16px; }
header .logo { color: var(--teal2); font-size: 22px; font-weight: 700; letter-spacing: 1px; }
header .sub { color: #94A3B8; font-size: 13px; }
.container { max-width: 860px; margin: 40px auto; padding: 0 20px; }
/* Steps */
.steps { display: flex; gap: 0; margin-bottom: 36px; }
.step { flex: 1; text-align: center; padding: 10px; font-size: 13px; color: var(--muted);
border-bottom: 3px solid #CBD5E1; position: relative; }
.step.active { color: var(--teal); border-color: var(--teal); font-weight: 600; }
.step.done { color: var(--navy); border-color: var(--navy2); }
/* Cards */
.card { background: white; border-radius: 12px; padding: 28px 32px; margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.card h2 { font-size: 18px; color: var(--navy); margin-bottom: 6px; }
.card p.desc { font-size: 14px; color: var(--muted); margin-bottom: 24px; }
/* Tier cards */
.tier-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.tier-card { border: 2px solid #E2E8F0; border-radius: 10px; padding: 20px 16px; cursor: pointer;
transition: all .2s; text-align: center; }
.tier-card:hover { border-color: var(--teal2); background: #F0FDFA; }
.tier-card.selected { border-color: var(--teal); background: #CCFBF1; }
.tier-card .tier-name { font-size: 16px; font-weight: 700; color: var(--navy); margin-bottom: 4px; }
.tier-card .tier-gpu { font-size: 12px; color: var(--teal); font-weight: 600; margin-bottom: 8px; }
.tier-card .tier-users { font-size: 12px; color: var(--muted); }
/* Tool toggles */
.tool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.tool-item { display: flex; align-items: center; gap: 12px; padding: 14px 16px;
border: 1.5px solid #E2E8F0; border-radius: 8px; cursor: pointer; transition: all .15s; }
.tool-item:hover { border-color: var(--teal2); background: #F0FDFA; }
.tool-item.selected { border-color: var(--teal); background: #CCFBF1; }
.tool-item .tool-icon { width: 36px; height: 36px; border-radius: 8px; background: var(--navy);
display: flex; align-items: center; justify-content: center;
color: var(--teal2); font-size: 16px; flex-shrink: 0; }
.tool-item .tool-name { font-size: 14px; font-weight: 600; color: var(--navy); }
.tool-item .tool-desc { font-size: 12px; color: var(--muted); }
.tool-toggle { margin-left: auto; width: 40px; height: 22px; border-radius: 11px;
background: #CBD5E1; position: relative; flex-shrink: 0; transition: background .2s; }
.tool-item.selected .tool-toggle { background: var(--teal); }
.tool-toggle::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
border-radius: 50%; background: white; transition: left .2s; }
.tool-item.selected .tool-toggle::after { left: 21px; }
/* Network */
.net-mode { display: flex; gap: 12px; margin-bottom: 20px; }
.net-btn { flex: 1; padding: 14px; border: 2px solid #E2E8F0; border-radius: 8px; cursor: pointer;
text-align: center; font-size: 14px; font-weight: 600; color: var(--muted); background: white; transition: all .15s; }
.net-btn.active { border-color: var(--teal); color: var(--teal); background: #F0FDFA; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; font-weight: 600; color: var(--navy); margin-bottom: 6px; }
.form-group input { width: 100%; padding: 10px 14px; border: 1.5px solid #CBD5E1; border-radius: 8px;
font-size: 14px; outline: none; transition: border .15s; }
.form-group input:focus { border-color: var(--teal); }
.static-fields { display: none; }
.static-fields.show { display: block; }
.ip-row { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
/* Buttons */
.btn-row { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; }
.btn { padding: 12px 28px; border-radius: 8px; font-size: 15px; font-weight: 600;
cursor: pointer; border: none; transition: all .15s; }
.btn-primary { background: var(--teal); color: white; }
.btn-primary:hover { background: var(--teal2); }
.btn-secondary { background: var(--off); color: var(--navy); border: 1.5px solid #CBD5E1; }
.btn-secondary:hover { background: #E2E8F0; }
/* Progress */
.progress-wrap { display: none; }
.progress-wrap.show { display: block; }
.progress-bar-bg { background: #E2E8F0; border-radius: 99px; height: 10px; margin: 16px 0; overflow: hidden; }
.progress-bar { height: 10px; border-radius: 99px; background: linear-gradient(90deg, var(--teal), var(--teal2));
width: 0%; transition: width .5s; }
.log-box { background: #0F172A; color: #94A3B8; font-family: monospace; font-size: 12px;
border-radius: 8px; padding: 16px; height: 280px; overflow-y: auto; line-height: 1.6; }
.log-line.ok { color: #34D399; }
.log-line.fail { color: #F87171; }
/* Summary */
.summary-row { display: flex; justify-content: space-between; padding: 10px 0;
border-bottom: 1px solid #F1F5F9; font-size: 14px; }
.summary-row:last-child { border: none; }
.summary-row .key { color: var(--muted); }
.summary-row .val { font-weight: 600; color: var(--navy); }
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: 12px;
font-weight: 600; background: #CCFBF1; color: var(--teal); }
.alert { padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; }
.alert-info { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; }
.alert-success { background: #ECFDF5; color: #065F46; border: 1px solid #A7F3D0; }
.alert-error { background: #FEF2F2; color: #991B1B; border: 1px solid #FECACA; }
.hidden { display: none !important; }
#done-screen { text-align: center; padding: 48px 0; }
#done-screen .done-icon { font-size: 64px; margin-bottom: 16px; }
#done-screen h2 { font-size: 24px; color: var(--navy); margin-bottom: 8px; }
#done-screen p { color: var(--muted); font-size: 15px; }
#done-screen .services { margin: 28px auto; max-width: 400px; text-align: left; }
</style>
</head>
<body>
<header>
<div>
<div class="logo">CEZEN AI SUITE</div>
<div class="sub">Server Setup Wizard</div>
</div>
</header>
<div class="container">
<div class="steps" id="step-bar">
<div class="step active" id="sbar-1">1 · Network</div>
<div class="step" id="sbar-2">2 · Select Tier</div>
<div class="step" id="sbar-3">3 · AI Tools</div>
<div class="step" id="sbar-4">4 · Install</div>
</div>
<!-- STEP 1: NETWORK -->
<div id="step-1">
<div class="card">
<h2>Network Configuration</h2>
<p class="desc">Choose how this server gets its IP address. You can change this later.</p>
<div class="net-mode">
<button class="net-btn active" id="btn-dhcp" onclick="setNetMode('dhcp')">
DHCP (Automatic)
</button>
<button class="net-btn" id="btn-static" onclick="setNetMode('static')">
📌 Static IP (Manual)
</button>
</div>
<div id="dhcp-info" class="alert alert-info">
Server will get an IP automatically from your network. Current IP: <strong id="current-ip">detecting...</strong>
</div>
<div class="static-fields" id="static-fields">
<div class="ip-row">
<div class="form-group">
<label>IP Address</label>
<input type="text" id="ip-addr" placeholder="e.g. 192.168.1.100">
</div>
<div class="form-group">
<label>Prefix (CIDR)</label>
<input type="text" id="ip-prefix" placeholder="24" value="24">
</div>
</div>
<div class="form-group">
<label>Default Gateway</label>
<input type="text" id="ip-gateway" placeholder="e.g. 192.168.1.1">
</div>
<div class="form-group">
<label>DNS Server</label>
<input type="text" id="ip-dns" placeholder="e.g. 8.8.8.8" value="8.8.8.8">
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="goStep(2)">Next </button>
</div>
</div>
<!-- STEP 2: TIER -->
<div id="step-2" class="hidden">
<div class="card">
<h2>Select AI Package Tier</h2>
<p class="desc">Choose the tier that matches your GPU hardware.</p>
<div class="tier-grid">
<div class="tier-card" id="tier-entry" onclick="selectTier('entry')">
<div class="tier-name">Entry</div>
<div class="tier-gpu">3× NVIDIA L40S</div>
<div class="tier-users">Up to 20 concurrent users</div>
</div>
<div class="tier-card" id="tier-mid" onclick="selectTier('mid')">
<div class="tier-name">Mid</div>
<div class="tier-gpu">3× RTX Pro 6000</div>
<div class="tier-users">Up to 50 concurrent users</div>
</div>
<div class="tier-card" id="tier-advanced" onclick="selectTier('advanced')">
<div class="tier-name">Advanced</div>
<div class="tier-gpu">8× HGX H200</div>
<div class="tier-users">200+ concurrent users</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(1)"> Back</button>
<button class="btn btn-primary" onclick="goStep(3)">Next </button>
</div>
</div>
<!-- STEP 3: TOOLS -->
<div id="step-3" class="hidden">
<div class="card">
<h2>Select AI Tools</h2>
<p class="desc">Toggle the components you want installed. Recommended defaults are pre-selected.</p>
<div class="tool-grid" id="tool-grid">
<!-- injected by JS -->
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(2)"> Back</button>
<button class="btn btn-primary" onclick="goStep(4)">Review & Install </button>
</div>
</div>
<!-- STEP 4: CONFIRM + INSTALL -->
<div id="step-4" class="hidden">
<div class="card" id="summary-card">
<h2>Review Configuration</h2>
<p class="desc">Confirm your settings before installation begins.</p>
<div id="summary-rows"></div>
<div class="alert alert-info" style="margin-top:20px">
Installation takes <strong>2040 minutes</strong>. The server will reboot once to load NVIDIA drivers, then continue automatically.
</div>
</div>
<div class="progress-wrap" id="progress-wrap">
<div class="card">
<h2>Installing Cezen AI Suite...</h2>
<div class="progress-bar-bg"><div class="progress-bar" id="progress-bar"></div></div>
<p id="progress-label" style="font-size:13px;color:var(--muted);margin-bottom:12px">Starting...</p>
<div class="log-box" id="log-box"></div>
</div>
</div>
<div id="done-screen" class="hidden">
<div class="done-icon"></div>
<h2>Installation Complete!</h2>
<p>Your Cezen AI Suite is ready.</p>
<div class="services card" style="margin-top:24px;text-align:left">
<div class="summary-row"><span class="key">Open WebUI</span><span class="val badge">:3001</span></div>
<div class="summary-row"><span class="key">JupyterLab</span><span class="val badge">:8888</span></div>
<div class="summary-row"><span class="key">MLflow</span><span class="val badge">:5000</span></div>
<div class="summary-row"><span class="key">MinIO</span><span class="val badge">:9000</span></div>
<div class="summary-row"><span class="key">Grafana</span><span class="val badge">:3000</span></div>
</div>
</div>
<div class="btn-row" id="install-btn-row">
<button class="btn btn-secondary" onclick="goStep(3)"> Back</button>
<button class="btn btn-primary" id="install-btn" onclick="startInstall()">🚀 Start Installation</button>
</div>
</div>
</div><!-- /container -->
<script>
// State
let netMode = 'dhcp';
let selectedTier = 'entry';
let tools = {
ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true },
jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', icon: '📓', on: true },
chromadb: { name: 'ChromaDB', desc: 'Vector DB for RAG', icon: '🗄', on: true },
vllm: { name: 'vLLM', desc: 'OpenAI-compatible API', icon: '', on: true },
mlflow: { name: 'MLflow', desc: 'Experiment tracking', icon: '📊', on: true },
minio: { name: 'MinIO', desc: 'S3-compatible storage', icon: '🪣', on: true },
monitoring: { name: 'Grafana + Prometheus',desc: 'GPU & system monitoring', icon: '📈', on: true },
k3s: { name: 'K3s', desc: 'Lightweight Kubernetes', icon: '', on: true },
};
// Init
window.onload = () => {
fetch('/api/status').then(r=>r.json()).then(d=>{
document.getElementById('current-ip').textContent = d.ip || 'unknown';
});
renderTools();
selectTier('entry');
};
// Navigation
function goStep(n) {
if (n === 2 && !selectedTier) { alert('Please select a tier.'); return; }
[1,2,3,4].forEach(i => {
document.getElementById('step-'+i).classList.toggle('hidden', i !== n);
const bar = document.getElementById('sbar-'+i);
bar.classList.remove('active','done');
if (i === n) bar.classList.add('active');
if (i < n) bar.classList.add('done');
});
if (n === 4) renderSummary();
if (n === 2 && netMode === 'static') applyStaticIP();
}
// Network
function setNetMode(m) {
netMode = m;
document.getElementById('btn-dhcp').classList.toggle('active', m==='dhcp');
document.getElementById('btn-static').classList.toggle('active', m==='static');
document.getElementById('dhcp-info').style.display = m==='dhcp' ? 'block' : 'none';
document.getElementById('static-fields').classList.toggle('show', m==='static');
}
function applyStaticIP() {
const body = {
mode: 'static',
ip: document.getElementById('ip-addr').value,
prefix: document.getElementById('ip-prefix').value,
gateway: document.getElementById('ip-gateway').value,
dns: document.getElementById('ip-dns').value,
};
fetch('/api/network', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })
.then(r=>r.json()).then(d=>{ if(!d.ok) alert('Network config failed: ' + d.error); });
}
// Tier
function selectTier(t) {
selectedTier = t;
['entry','mid','advanced'].forEach(x =>
document.getElementById('tier-'+x).classList.toggle('selected', x===t));
}
// Tools
function renderTools() {
const g = document.getElementById('tool-grid');
g.innerHTML = Object.entries(tools).map(([k,v]) => `
<div class="tool-item ${v.on?'selected':''}" id="tool-${k}" onclick="toggleTool('${k}')">
<div class="tool-icon">${v.icon}</div>
<div>
<div class="tool-name">${v.name}</div>
<div class="tool-desc">${v.desc}</div>
</div>
<div class="tool-toggle"></div>
</div>`).join('');
}
function toggleTool(k) {
tools[k].on = !tools[k].on;
document.getElementById('tool-'+k).classList.toggle('selected', tools[k].on);
}
// Summary
function renderSummary() {
const ip = netMode === 'dhcp'
? document.getElementById('current-ip').textContent + ' (DHCP)'
: document.getElementById('ip-addr').value + '/' + document.getElementById('ip-prefix').value;
const onTools = Object.entries(tools).filter(([,v])=>v.on).map(([,v])=>v.name).join(', ');
const offTools = Object.entries(tools).filter(([,v])=>!v.on);
document.getElementById('summary-rows').innerHTML = `
<div class="summary-row"><span class="key">Network</span><span class="val">${ip}</span></div>
<div class="summary-row"><span class="key">Tier</span><span class="val">${selectedTier.charAt(0).toUpperCase()+selectedTier.slice(1)}</span></div>
<div class="summary-row"><span class="key">Tools</span><span class="val" style="font-size:13px;text-align:right;max-width:60%">${onTools}</span></div>
${offTools.length ? `<div class="summary-row"><span class="key">Skipped</span><span class="val" style="color:var(--muted);font-size:13px">${offTools.map(([,v])=>v.name).join(', ')}</span></div>` : ''}
`;
}
// Install
function startInstall() {
document.getElementById('install-btn-row').classList.add('hidden');
document.getElementById('summary-card').classList.add('hidden');
document.getElementById('progress-wrap').classList.add('show');
const skip = Object.entries(tools).filter(([,v])=>!v.on).map(([k])=>k);
fetch('/api/install', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ tier: selectedTier, skip_tools: skip })
});
streamLog();
}
function streamLog() {
const log = document.getElementById('log-box');
const bar = document.getElementById('progress-bar');
const lbl = document.getElementById('progress-label');
const steps = ['base','nvidia','docker','k3s','ollama','vllm','jupyterlab','chromadb','mlflow','minio','monitoring'];
let stepIdx = 0;
let installDone = false;
const es = new EventSource('/api/progress');
es.onmessage = e => {
const line = e.data;
const div = document.createElement('div');
div.className = 'log-line' + (line.includes('ok:') || line.includes('changed:') ? ' ok' : line.includes('fatal:') || line.includes('FAILED') ? ' fail' : '');
div.textContent = line;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
steps.forEach((s,i) => {
if (line.toLowerCase().includes(s) && i >= stepIdx) {
stepIdx = i;
bar.style.width = Math.round((i+1)/steps.length * 90) + '%';
lbl.textContent = 'Installing ' + s + '...';
}
});
if (line.includes('Rebooting') || line.includes('reboot')) {
installDone = true;
bar.style.width = '45%';
lbl.textContent = 'Server is rebooting — Phase 2 installs automatically...';
es.close();
showRebootNotice();
}
if (line.includes('INSTALL COMPLETE') || line.includes('installation complete')) {
installDone = true;
bar.style.width = '100%';
lbl.textContent = 'Complete!';
es.close();
setTimeout(() => {
document.getElementById('progress-wrap').style.display = 'none';
document.getElementById('done-screen').classList.remove('hidden');
}, 2000);
}
if (line.includes('FAILED') && line.includes('PLAY RECAP')) {
es.close();
lbl.textContent = 'Error — check log above';
lbl.style.color = 'var(--red)';
}
};
es.onerror = () => {
es.close();
if (!installDone) {
// Connection lost most likely the server rebooted
showRebootNotice();
}
};
}
function showRebootNotice() {
document.getElementById('progress-wrap').style.display = 'none';
document.getElementById('done-screen').classList.remove('hidden');
document.getElementById('done-screen').innerHTML = `
<div class="done-icon">🔄</div>
<h2>Server is Rebooting</h2>
<p style="margin-bottom:16px">NVIDIA drivers installed. Phase 2 (AI stack) is installing automatically after reboot.</p>
<div class="alert alert-info" style="max-width:500px;margin:0 auto 24px">
Phase 2 takes <strong>2030 more minutes</strong>. You can monitor it via:<br>
<code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">ssh cezen@&lt;server-ip&gt;</code>
then
<code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">journalctl -fu cezen-phase2</code>
</div>
<div class="services card" style="margin:0 auto;max-width:400px;text-align:left">
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Services will be available at:</p>
<div class="summary-row"><span class="key">Open WebUI</span><span class="val badge">:3001</span></div>
<div class="summary-row"><span class="key">JupyterLab</span><span class="val badge">:8888</span></div>
<div class="summary-row"><span class="key">MLflow</span><span class="val badge">:5000</span></div>
<div class="summary-row"><span class="key">MinIO</span><span class="val badge">:9000</span></div>
<div class="summary-row"><span class="key">Grafana</span><span class="val badge">:3000</span></div>
</div>
`;
}
</script>
</body>
</html>
"""
# ─── HTTP Handler ─────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): pass # suppress access log
def send_json(self, data, code=200):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlparse(self.path).path
if path == "/" or path == "/index.html":
body = HTML.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body)
elif path == "/api/status":
self.send_json({
"ip": get_ip(),
"interfaces": get_interfaces(),
"setup_done": os.path.exists(SETUP_DONE_FILE),
"install_status": install_status,
})
elif path == "/api/progress":
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
try:
with open(INSTALL_LOG, "r") as f:
f.seek(0, 2) # seek to end
while install_status["running"]:
line = f.readline()
if line:
msg = f"data: {line.rstrip()}\n\n"
self.wfile.write(msg.encode())
self.wfile.flush()
else:
time.sleep(0.5)
# Stream remaining lines after finish
for line in f:
msg = f"data: {line.rstrip()}\n\n"
self.wfile.write(msg.encode())
self.wfile.write(b"data: === INSTALL COMPLETE ===\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
path = urlparse(self.path).path
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
if path == "/api/network":
try:
if body.get("mode") == "static":
ifaces = get_interfaces()
iface = ifaces[0] if ifaces else "eth0"
apply_static_ip(iface, body["ip"], body["prefix"], body["gateway"], body["dns"])
self.send_json({"ok": True, "ip": get_ip()})
except Exception as e:
self.send_json({"ok": False, "error": str(e)}, 500)
elif path == "/api/install":
global install_proc
tier = body.get("tier", "entry")
skip = body.get("skip_tools", [])
if not install_status["running"]:
t = threading.Thread(target=run_install, args=(tier, skip), daemon=True)
t.start()
self.send_json({"ok": True})
else:
self.send_json({"ok": False, "error": "Install already running"})
else:
self.send_response(404)
self.end_headers()
# ─── Main ─────────────────────────────────────────────────
if __name__ == "__main__":
# Ensure log file exists
open(INSTALL_LOG, "a").close()
ip = get_ip()
print(f"\n{'='*50}")
print(f" Cezen AI Suite — Setup Server")
print(f" Open in browser: http://{ip}")
print(f" Or: http://cezenai.local")
print(f"{'='*50}\n")
server = HTTPServer(("0.0.0.0", 80), Handler)
server.serve_forever()

View File

@ -9,13 +9,18 @@ set -e
TIER="entry" TIER="entry"
PHASE="1" PHASE="1"
SKIP_ROLES=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ANSIBLE_DIR="$SCRIPT_DIR/ansible" ANSIBLE_DIR="$SCRIPT_DIR/ansible"
# Load saved config (written by web setup UI before phase 1)
[ -f /opt/cezen/install.conf ] && source /opt/cezen/install.conf
for arg in "$@"; do for arg in "$@"; do
case $arg in case $arg in
--tier=*) TIER="${arg#*=}" ;; --tier=*) TIER="${arg#*=}" ;;
--phase=*) PHASE="${arg#*=}" ;; --phase=*) PHASE="${arg#*=}" ;;
--skip=*) SKIP_ROLES="${arg#*=}" ;;
esac esac
done done
@ -101,9 +106,13 @@ run_phase2() {
echo "✓ NVIDIA driver: $(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1)" echo "✓ NVIDIA driver: $(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1)"
fi fi
# Build skip_roles extra var (comma-separated list, empty string = skip nothing)
EXTRA_VARS="tier=$TIER skip_roles=\"$SKIP_ROLES\""
echo "→ Tier: $TIER | Skip: ${SKIP_ROLES:-none}"
ANSIBLE_STDOUT_CALLBACK=yaml \ ANSIBLE_STDOUT_CALLBACK=yaml \
ansible-playbook -i localhost, -c local "$ANSIBLE_DIR/entry.yml" \ ansible-playbook -i localhost, -c local "$ANSIBLE_DIR/entry.yml" \
-e "tier=$TIER" -e "$EXTRA_VARS"
# Disable one-shot service so it doesn't run again on next reboot # Disable one-shot service so it doesn't run again on next reboot
systemctl disable cezen-phase2.service 2>/dev/null || true systemctl disable cezen-phase2.service 2>/dev/null || true