730 lines
32 KiB
Python
730 lines
32 KiB
Python
#!/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 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 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 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()
|
||
# 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>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>20–40 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)';
|
||
}
|
||
};
|
||
|
||
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>20–30 more minutes</strong>. You can monitor it via:<br>
|
||
<code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">ssh cezen@<server-ip></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 ─────────────────────────────────────────────────
|
||
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;36mCezen AI Suite 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()
|