aipackage/autoinstall/websetup/server.py
2026-06-30 11:40:05 +05:30

843 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""
Nexus One AI — 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 has_nvidia_gpu():
"""Detect NVIDIA PCI devices before the driver or nvidia-smi exists."""
try:
for root, _, files in os.walk("/sys/bus/pci/devices"):
if "vendor" not in files:
continue
with open(os.path.join(root, "vendor")) as f:
if f.read().strip().lower() == "0x10de":
return True
except Exception:
pass
return False
def validate_static_network(ip, prefix, gateway, dns):
ipaddress.ip_address(ip)
ipaddress.ip_address(gateway)
ipaddress.ip_address(dns)
prefix_int = int(prefix)
if prefix_int < 1 or prefix_int > 32:
raise ValueError("CIDR prefix must be between 1 and 32")
return str(prefix_int)
def is_ip_in_use(ip):
"""Best-effort conflict check before taking a static IP."""
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "1", ip],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
except Exception:
return False
def apply_static_ip(iface, ip, prefix, gateway, dns):
prefix = validate_static_network(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 write_license_file(license_data, tier):
os.makedirs("/opt/cezen", exist_ok=True)
payload = {
"schema": "cezen.license.v1",
"customer_name": (license_data.get("customer_name") or "").strip(),
"customer_id": (license_data.get("customer_id") or "").strip(),
"contact_email": (license_data.get("contact_email") or "").strip(),
"license_key": (license_data.get("license_key") or "").strip(),
"tier": tier,
"support_until": (license_data.get("support_until") or "").strip(),
"install_type": (license_data.get("install_type") or "customer").strip(),
"issued_by": "Cezen",
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"signature_status": "unsigned-field-install",
}
with open("/opt/cezen/license.json", "w") as f:
json.dump(payload, f, indent=2)
subprocess.run(["chown", "root:cezen", "/opt/cezen/license.json"], check=False)
os.chmod("/opt/cezen/license.json", 0o640)
return payload
def run_install(tier, skip_tools, license_data=None):
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 ""
write_license_file(license_data or {}, tier)
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()
# Fresh NVIDIA servers do not have nvidia-smi yet, so detect the PCI
# device and run phase 1 to install drivers before the AI stack.
phase = "1" if has_nvidia_gpu() else "2"
cmd = ["bash", f"{AIPACKAGE_DIR}/install.sh", f"--phase={phase}", 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>Nexus One AI — 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 · License</div>
<div class="step" id="sbar-3">3 · Select Tier</div>
<div class="step" id="sbar-4">4 · AI Tools</div>
<div class="step" id="sbar-5">5 · 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="alert alert-info">
We will ping the IP before applying it. If it replies, we will block the change to avoid taking an address that is already in use.
</div>
<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: LICENSE ── -->
<div id="step-2" class="hidden">
<div class="card">
<h2>License & Customer Details</h2>
<p class="desc">Enter the customer/license details for this installation. Leave the license key blank for evaluation or PSU field staging.</p>
<div class="form-group">
<label>Customer / Organisation Name</label>
<input type="text" id="lic-customer-name" placeholder="e.g. Bharat Electronics Limited">
</div>
<div class="form-group">
<label>Customer ID / Project Code</label>
<input type="text" id="lic-customer-id" placeholder="e.g. BEL-PSU-2026">
</div>
<div class="form-group">
<label>Contact Email</label>
<input type="email" id="lic-contact-email" placeholder="admin@example.gov.in">
</div>
<div class="form-group">
<label>License Key / Activation Code</label>
<input type="text" id="lic-license-key" placeholder="Optional for offline field install">
</div>
<div class="form-group">
<label>Support Until</label>
<input type="date" id="lic-support-until">
</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: TIER ── -->
<div id="step-3" 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-starter" onclick="selectTier('starter')">
<div class="tier-name">Starter</div>
<div class="tier-gpu">1× RTX 5090 / 32GB VRAM</div>
<div class="tier-users">Small team deployment</div>
</div>
<div class="tier-card" id="tier-basic" onclick="selectTier('basic')">
<div class="tier-name">Entry</div>
<div class="tier-gpu">1× NVIDIA RTX Pro 6000 (96GB)</div>
<div class="tier-users">Up to 20 concurrent users</div>
</div>
<div class="tier-card" id="tier-pro" onclick="selectTier('pro')">
<div class="tier-name">Pro</div>
<div class="tier-gpu">2× RTX 5090 / RTX Pro class</div>
<div class="tier-users">Up to 100 concurrent users</div>
</div>
<div class="tier-card" id="tier-max" onclick="selectTier('max')">
<div class="tier-name">Max</div>
<div class="tier-gpu">48× H100/H200/A100 class</div>
<div class="tier-users">200+ concurrent users</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(2)">← Back</button>
<button class="btn btn-primary" onclick="goStep(4)">Next →</button>
</div>
</div>
<!-- ── STEP 4: TOOLS ── -->
<div id="step-4" 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(3)">← Back</button>
<button class="btn btn-primary" onclick="goStep(5)">Review & Install →</button>
</div>
</div>
<!-- ── STEP 5: CONFIRM + INSTALL ── -->
<div id="step-5" 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 Nexus One AI...</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 Nexus One AI 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(4)">← 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 = 'basic';
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('basic');
};
// ── Navigation ─────────────────────────────────────────────
function goStep(n) {
if (n === 4 && !selectedTier) { alert('Please select a tier.'); return; }
[1,2,3,4,5].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 === 5) 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);
return;
}
if (d.warning) {
alert(d.warning);
}
});
}
// ── Tier ───────────────────────────────────────────────────
function selectTier(t) {
selectedTier = t;
['starter','basic','pro','max'].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);
}
// ── License ────────────────────────────────────────────────
function collectLicense() {
return {
customer_name: document.getElementById('lic-customer-name').value.trim(),
customer_id: document.getElementById('lic-customer-id').value.trim(),
contact_email: document.getElementById('lic-contact-email').value.trim(),
license_key: document.getElementById('lic-license-key').value.trim(),
support_until: document.getElementById('lic-support-until').value.trim(),
install_type: document.getElementById('lic-license-key').value.trim() ? 'licensed' : 'field-staging'
};
}
// ── Summary ────────────────────────────────────────────────
function renderSummary() {
const ip = netMode === 'dhcp'
? document.getElementById('current-ip').textContent + ' (DHCP)'
: document.getElementById('ip-addr').value + '/' + document.getElementById('ip-prefix').value;
const license = collectLicense();
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">Customer</span><span class="val">${license.customer_name || 'Not entered'}</span></div>
<div class="summary-row"><span class="key">License</span><span class="val">${license.license_key ? 'Provided' : 'Field staging / evaluation'}</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, license: collectLicense() })
});
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)';
}
};
let reconnectAttempts = 0;
es.onerror = () => {
es.close();
if (installDone) return;
reconnectAttempts++;
lbl.textContent = `Connection lost — reconnecting... (${reconnectAttempts})`;
if (reconnectAttempts >= 5) {
// After 5 failed reconnects assume it's a real reboot
showRebootNotice();
} else {
// Try reconnecting after a delay
setTimeout(() => { if (!installDone) streamLog(); }, 4000);
}
};
}
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"
validate_static_network(body["ip"], body["prefix"], body["gateway"], body["dns"])
if is_ip_in_use(body["ip"]):
self.send_json({
"ok": False,
"error": f"IP address {body['ip']} is already replying to ping. Choose a different address."
}, 409)
return
apply_static_ip(iface, body["ip"], body["prefix"], body["gateway"], body["dns"])
self.send_json({
"ok": True,
"ip": get_ip(),
"warning": "Ping check passed before applying the static IP. Note: a non-reply does not guarantee the address is unused if another host blocks ICMP."
})
return
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", "basic")
skip = body.get("skip_tools", [])
license_data = body.get("license", {})
if not install_status["running"]:
t = threading.Thread(target=run_install, args=(tier, skip, license_data), 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 ─────────────────────────────────────────────────
def show_console_banner(ip):
"""Write the setup URL banner to /dev/tty1 so it appears on the physical console."""
banner = f"""
\033[1;36m╔══════════════════════════════════════════════════════╗
║ ║
║ CEZEN AI SUITE — SERVER SETUP ║
║ ║
║ Open a browser on any computer on this network: ║
║ ║
\033[1;33m➜ http://{ip:<42}\033[1;36m║
\033[1;33m➜ http://cezenai.local\033[1;36m ║
║ ║
║ Complete setup from your browser — no keyboard ║
║ input needed here. ║
║ ║
╚══════════════════════════════════════════════════════╝\033[0m
"""
# Write to tty1 (physical console) and stdout (journalctl)
print(banner)
try:
with open("/dev/tty1", "w") as tty:
tty.write(banner)
except Exception:
pass # tty1 may not be accessible in all environments
# Also update /etc/issue so the URL appears above the login prompt
try:
with open("/etc/issue", "w") as f:
f.write(f"Ubuntu 22.04.5 LTS \\n \\l\n\n")
f.write(f" \033[1;36mNexus One AI Setup:\033[0m http://{ip} | http://cezenai.local\n\n")
except Exception:
pass
if __name__ == "__main__":
# Ensure log file exists
open(INSTALL_LOG, "a").close()
ip = get_ip()
show_console_banner(ip)
server = HTTPServer(("0.0.0.0", 80), Handler)
server.serve_forever()