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:
parent
a1948d93fe
commit
1991307903
@ -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(',')"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
666
autoinstall/websetup/server.py
Normal file
666
autoinstall/websetup/server.py
Normal 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>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)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>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 ─────────────────────────────────────────────────
|
||||||
|
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()
|
||||||
11
install.sh
11
install.sh
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user