Update installer and runtime setup flow

This commit is contained in:
Jino Jose 2026-07-09 10:30:10 +05:30
parent 8603fb2bc2
commit 7540da04a8
5 changed files with 385 additions and 54 deletions

View File

@ -10,7 +10,7 @@ Default admin on first run:
password: Cezen@2024! (forced change on first login) password: Cezen@2024! (forced change on first login)
""" """
import os, sqlite3, subprocess, json, uuid, secrets, hashlib, shutil, tempfile, base64, zipfile, sys import os, sqlite3, subprocess, json, uuid, secrets, hashlib, shutil, tempfile, base64, zipfile, sys, logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -705,7 +705,7 @@ def init_db():
utcnow()) utcnow())
) )
db.commit() db.commit()
print("[cezen] Default admin created — username: admin password: Cezen@2024!") logger.info("Default admin account created — username: admin password: Cezen@2024! (change required on first login)")
# Seed default branding settings # Seed default branding settings
defaults = { defaults = {
@ -803,6 +803,76 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ── Structured logging ───────────────────────────────────────────────────────
# Enterprise basic: a real log file + levels, instead of the handful of bare
# print() calls this file previously relied on. Log location follows DATA_DIR
# so it lands in the same place as the rest of Nexus One AI's runtime state.
LOG_FILE = DATA_DIR / "cezen-api.log"
logger = logging.getLogger("cezen")
if not logger.handlers:
logger.setLevel(logging.INFO)
_fmt = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
try:
_file_handler = logging.FileHandler(str(LOG_FILE))
_file_handler.setFormatter(_fmt)
logger.addHandler(_file_handler)
except Exception:
pass # e.g. read-only filesystem — fall back to console only
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(_fmt)
logger.addHandler(_console_handler)
# ── Request correlation ID ───────────────────────────────────────────────────
# Every request/response gets an X-Request-Id so a support engineer can tie a
# user-reported error to a specific log line, without needing to reproduce it.
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
req_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
request.state.request_id = req_id
try:
response = await call_next(request)
except Exception:
# Let the global exception handler below produce the JSON body; just
# make sure this middleware doesn't swallow the request id in that path.
raise
response.headers["X-Request-Id"] = req_id
return response
# ── Global exception handler ─────────────────────────────────────────────────
# Any exception that isn't already an HTTPException (i.e. a genuine bug, not a
# handled validation/business-logic error) previously fell through to
# Starlette's plain-text "Internal Server Error" — breaking the JSON contract
# every other response follows, and giving the user/support engineer nothing
# to go on. This logs the real exception server-side and returns a safe,
# structured, correlatable error to the client instead.
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
req_id = getattr(request.state, "request_id", None) or str(uuid.uuid4())
logger.error(
"Unhandled exception on %s %s [request_id=%s]: %s",
request.method, request.url.path, req_id, exc, exc_info=True,
)
return JSONResponse(
status_code=500,
content={
"detail": "An unexpected error occurred. This has been logged — if it persists, contact support with the request ID below.",
"code": "internal_error",
"request_id": req_id,
},
headers={"X-Request-Id": req_id},
)
# ── Friendly error helper ────────────────────────────────────────────────────
# Several endpoints previously returned raw Python/subprocess exception text
# (disk errno strings, Ollama CLI stderr, urllib exception reprs) straight to
# the client. This logs the real error server-side and raises a structured,
# actionable HTTPException instead — {code, message, remediation} rather than
# a bare string, so the portal can show "what happened / why / how to fix it".
def raise_api_error(status_code: int, code: str, message: str, remediation: str = "", *, log_context: str = "", log_exc=None):
if log_exc is not None:
logger.error("%s: %s", log_context or code, log_exc, exc_info=True)
raise HTTPException(status_code=status_code, detail={"code": code, "message": message, "remediation": remediation})
# ── Auth helpers ────────────────────────────────────────────────────────────── # ── Auth helpers ──────────────────────────────────────────────────────────────
def create_token(user_id: int, username: str, role: str) -> tuple[str, str]: def create_token(user_id: int, username: str, role: str) -> tuple[str, str]:
@ -1492,7 +1562,10 @@ async def delete_model(model_name: str, request: Request, admin: dict = Depends(
db.commit() db.commit()
db.close() db.close()
if "error" in result: if "error" in result:
raise HTTPException(status_code=500, detail=result["error"]) raise_api_error(500, "model_delete_failed",
f"Could not delete model '{model_name}'.",
"Check that Ollama is running (systemctl status ollama) and that no chat session currently has this model loaded, then try again.",
log_context=f"model_delete({model_name})", log_exc=result["error"])
return {"ok": True} return {"ok": True}
# ── Offline model upload / load ─────────────────────────────────────────────── # ── Offline model upload / load ───────────────────────────────────────────────
@ -1536,9 +1609,21 @@ async def upload_model(
if not chunk: if not chunk:
break break
await out.write(chunk) await out.write(chunk)
except OSError as e:
dest.unlink(missing_ok=True)
if getattr(e, "errno", None) == 28: # ENOSPC
raise_api_error(500, "disk_full",
"The upload failed because the server has run out of disk space.",
f"Free up space in {UPLOAD_DIR} (or expand storage) and try the upload again.",
log_context="upload_model (disk full)", log_exc=e)
raise_api_error(500, "upload_failed", "The model upload failed while writing to disk.",
"Check that the server has enough free disk space and that the destination directory is writable, then retry.",
log_context="upload_model", log_exc=e)
except Exception as e: except Exception as e:
dest.unlink(missing_ok=True) dest.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"Upload failed: {e}") raise_api_error(500, "upload_failed", "The model upload failed.",
"Check your network connection and try uploading again. If this keeps happening, contact support.",
log_context="upload_model", log_exc=e)
size_gb = round(dest.stat().st_size / 1e9, 2) size_gb = round(dest.stat().st_size / 1e9, 2)
db = get_db() db = get_db()
@ -1584,10 +1669,16 @@ async def load_model(body: dict, request: Request, admin: dict = Depends(admin_o
) )
modelfile_path.unlink(missing_ok=True) modelfile_path.unlink(missing_ok=True)
if result.returncode != 0: if result.returncode != 0:
raise HTTPException(status_code=500, detail=result.stderr or "ollama create failed") raise_api_error(500, "model_load_failed",
except subprocess.TimeoutExpired: f"Ollama could not register '{model_name}' as a model.",
"Confirm the uploaded .gguf file is not corrupted and that Ollama has enough free disk/memory, then try loading it again.",
log_context=f"load_model({model_name})", log_exc=result.stderr or "ollama create failed (no stderr)")
except subprocess.TimeoutExpired as e:
modelfile_path.unlink(missing_ok=True) modelfile_path.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="ollama create timed out (>5 min)") raise_api_error(500, "model_load_timeout",
f"Registering '{model_name}' with Ollama took too long and timed out (5 min limit).",
"This usually happens with very large model files or a slow/busy server. Try again during a quieter period, or check server load.",
log_context=f"load_model({model_name})", log_exc=e)
db = get_db() db = get_db()
audit(db, admin["sub"], admin["username"], "model_load", audit(db, admin["sub"], admin["username"], "model_load",
@ -2135,9 +2226,15 @@ def _chroma_req(method: str, path: str, **kwargs):
return json.loads(r.read().decode()) return json.loads(r.read().decode())
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body = e.read().decode() body = e.read().decode()
raise HTTPException(status_code=e.code, detail=f"ChromaDB: {body}") raise_api_error(e.code, "chromadb_error",
"The knowledge base service (ChromaDB) rejected this request.",
"Check the ChromaDB service logs for details, then try again.",
log_context=f"_chroma_req({method} {path})", log_exc=body)
except Exception as e: except Exception as e:
raise HTTPException(status_code=503, detail=f"ChromaDB unavailable: {e}") raise_api_error(503, "chromadb_unavailable",
"The knowledge base service (ChromaDB) is unreachable.",
"Check that the ChromaDB service is running (systemctl status chromadb) and try again in a moment.",
log_context=f"_chroma_req({method} {path})", log_exc=e)
def _ollama_embed(texts: list[str], model: str = OLLAMA_EMBED) -> list[list[float]]: def _ollama_embed(texts: list[str], model: str = OLLAMA_EMBED) -> list[list[float]]:
"""Get embeddings from Ollama for a list of texts.""" """Get embeddings from Ollama for a list of texts."""
@ -2151,7 +2248,10 @@ def _ollama_embed(texts: list[str], model: str = OLLAMA_EMBED) -> list[list[floa
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
embeddings.append(json.loads(r.read().decode())["embedding"]) embeddings.append(json.loads(r.read().decode())["embedding"])
except Exception as e: except Exception as e:
raise HTTPException(status_code=503, detail=f"Ollama embedding error: {e}") raise_api_error(503, "embedding_failed",
"Could not generate embeddings — the Ollama embedding model is unreachable.",
f"Check that Ollama is running and that the '{model}' embedding model is pulled (ollama list), then retry.",
log_context=f"_ollama_embed(model={model})", log_exc=e)
return embeddings return embeddings
# Collections # Collections
@ -2514,7 +2614,10 @@ async def launch_job(body: dict, _licensed: dict = Depends(require_feature("fine
(utcnow(), job_id)) (utcnow(), job_id))
db.commit() db.commit()
db.close() db.close()
raise HTTPException(status_code=500, detail=f"Failed to start training process: {e}") raise_api_error(500, "training_launch_failed",
f"Could not start the training job for '{body['name']}'.",
"Check that the training script and base model are available on the server and that there's enough free GPU/CPU capacity, then try launching the job again.",
log_context=f"training_launch(job_id={job_id})", log_exc=e)
db.close() db.close()
_audit(None, admin["username"], "training_launch", _audit(None, admin["username"], "training_launch",
@ -4437,7 +4540,7 @@ def _add_to_scheduler(job: dict):
misfire_grace_time=300, misfire_grace_time=300,
) )
except Exception as e: except Exception as e:
print(f"[cezen] Failed to schedule job {job['id']}: {e}") logger.warning("Failed to schedule job %s: %s", job['id'], e)
class ScheduledJobCreate(BaseModel): class ScheduledJobCreate(BaseModel):
@ -5730,7 +5833,153 @@ async def process_meeting(file: UploadFile = File(...), meta: str = Form("{}"),
# ── Health ──────────────────────────────────────────────────────────────────── # ── Health ────────────────────────────────────────────────────────────────────
# Real subsystem checks for a unified Health Center (Phase 3 of the v1.0 GA
# polish pass). Each check returns {"status": "ok"|"warning"|"critical",
# "detail": "<human-readable, actionable message>"} — never a bare boolean —
# so the portal can show *why* something is degraded, not just that it is.
_LICENSE_STATUS_HEALTH = {
"valid": ("ok", "License is valid."),
"missing": ("warning", "No license installed — running in field-staging mode. Upload a signed license in Settings > License."),
"invalid_signature": ("critical", "The installed license failed signature verification. Re-upload a valid signed license file."),
"expired": ("critical", "The installed license has expired. Contact support@cezentech.com to renew."),
"not_yet_valid": ("warning", "The installed license is not yet in its valid date range."),
"machine_mismatch": ("critical", "The installed license is bound to different hardware. Contact support@cezentech.com."),
}
def _health_database() -> dict:
try:
conn = get_db()
conn.execute("SELECT 1").fetchone()
conn.close()
return {"status": "ok", "detail": "Database is reachable."}
except Exception as e:
return {"status": "critical", "detail": f"Cannot reach the database at {DB_PATH}: {e}"}
def _health_scheduler() -> dict:
try:
running = bool(_scheduler.running)
job_count = len(_scheduler.get_jobs())
if running:
return {"status": "ok", "detail": f"Scheduler is running ({job_count} job{'s' if job_count != 1 else ''} registered)."}
return {"status": "critical", "detail": "Scheduler process is not running — scheduled jobs will not fire. Restart the cezen-api service."}
except Exception as e:
return {"status": "critical", "detail": f"Could not read scheduler state: {e}"}
def _health_storage() -> dict:
try:
disk = psutil.disk_usage("/")
pct = disk.percent
free_gb = round(disk.free / 1e9, 1)
if pct >= 90:
return {"status": "critical", "detail": f"Disk is {pct:.0f}% full ({free_gb} GB free). Free up space or expand storage — installs and uploads may start failing."}
if pct >= 75:
return {"status": "warning", "detail": f"Disk is {pct:.0f}% full ({free_gb} GB free). Plan for cleanup or expansion soon."}
return {"status": "ok", "detail": f"Disk usage is {pct:.0f}% ({free_gb} GB free)."}
except Exception as e:
return {"status": "critical", "detail": f"Could not read disk usage: {e}"}
def _health_backup() -> dict:
try:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(BACKUP_DIR.glob("cezen-backup-*.zip"), key=lambda p: p.stat().st_mtime, reverse=True)
if not files:
return {"status": "warning", "detail": "No backups have been taken yet. Configure a backup schedule in Settings."}
age_hrs = (datetime.now(timezone.utc).timestamp() - files[0].stat().st_mtime) / 3600
when = f"{age_hrs:.0f} hour{'s' if age_hrs >= 2 else ''} ago" if age_hrs < 48 else f"{age_hrs/24:.0f} days ago"
if age_hrs > 7 * 24:
return {"status": "critical", "detail": f"Last backup was {when} — well overdue. Check the backup schedule."}
if age_hrs > 24:
return {"status": "warning", "detail": f"Last backup was {when}. Confirm the backup schedule is still running."}
return {"status": "ok", "detail": f"Last backup was {when}."}
except Exception as e:
return {"status": "critical", "detail": f"Could not check backup status: {e}"}
def _health_license() -> dict:
try:
entitlement = _entitlement_summary()
status_key = entitlement.get("license_status", "missing")
level, detail = _LICENSE_STATUS_HEALTH.get(status_key, ("warning", f"Unrecognized license status '{status_key}'."))
return {"status": level, "detail": detail}
except Exception as e:
return {"status": "warning", "detail": f"Could not evaluate license status: {e}"}
def _health_ssl() -> dict:
# Nexus One AI currently ships with HTTP only (no TLS termination configured
# in the nginx role) — report this honestly rather than claiming "ok".
return {"status": "warning", "detail": "No TLS/SSL certificate is configured — the portal is served over HTTP only. Consider placing this deployment behind TLS termination."}
def _health_portal() -> dict:
import socket
try:
s = socket.create_connection(("127.0.0.1", 80), timeout=1.5)
s.close()
return {"status": "ok", "detail": "Portal (nginx, port 80) is reachable."}
except OSError:
return {"status": "critical", "detail": "Portal (nginx, port 80) is not reachable. Check the nginx service."}
def _health_models() -> dict:
try:
data = ollama_get("/api/tags")
if data is None:
return {"status": "critical", "detail": "Ollama is not reachable at " + OLLAMA_URL + ". Check the ollama service."}
count = len(data.get("models", []))
if count == 0:
return {"status": "warning", "detail": "Ollama is reachable but no models are installed yet."}
return {"status": "ok", "detail": f"Ollama is reachable ({count} model{'s' if count != 1 else ''} installed)."}
except Exception as e:
return {"status": "critical", "detail": f"Could not reach Ollama: {e}"}
def _health_gpu_drivers_cuda() -> dict:
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=name,driver_version,temperature.gpu,utilization.gpu",
"--format=csv,noheader"], timeout=5, stderr=subprocess.STDOUT,
).decode().strip().split("\n")[0]
name, driver, temp, util = [p.strip() for p in out.split(",")]
gpu = {"status": "ok", "detail": f"{name} detected — driver {driver}, {temp}°C, {util} utilisation."}
except FileNotFoundError:
gpu = {"status": "critical", "detail": "nvidia-smi was not found. The NVIDIA driver may not be installed."}
except subprocess.TimeoutExpired:
gpu = {"status": "critical", "detail": "nvidia-smi timed out. The GPU driver may be unresponsive."}
except Exception as e:
gpu = {"status": "critical", "detail": f"GPU check failed: {e}"}
drivers = {"status": gpu["status"], "detail": gpu["detail"]}
try:
import re as _re
raw = subprocess.check_output(["nvidia-smi"], timeout=5, stderr=subprocess.STDOUT).decode()
m = _re.search(r"CUDA Version:\s*([\d.]+)", raw)
if m:
cuda = {"status": "ok", "detail": f"CUDA {m.group(1)} detected."}
else:
cuda = {"status": "warning", "detail": "nvidia-smi ran but no CUDA version could be parsed."}
except Exception as e:
cuda = {"status": "critical" if gpu["status"] == "critical" else "warning", "detail": f"Could not determine CUDA version: {e}"}
return {"gpu": gpu, "drivers": drivers, "cuda": cuda}
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
return {"status": "ok", "version": "1.0.0"} gpu_group = _health_gpu_drivers_cuda()
checks = {
"api": {"status": "ok", "detail": "API process is responding."},
"database": _health_database(),
"scheduler": _health_scheduler(),
"storage": _health_storage(),
"backup": _health_backup(),
"license": _health_license(),
"ssl": _health_ssl(),
"portal": _health_portal(),
"models": _health_models(),
**gpu_group,
}
order = {"critical": 0, "warning": 1, "ok": 2}
overall = min((c["status"] for c in checks.values()), key=lambda s: order.get(s, 1))
return {
"status": overall,
"version": "1.0.0",
"generated_at": utcnow(),
"checks": checks,
}

View File

@ -20,7 +20,7 @@ Usage (called by main.py):
--ollama-url http://localhost:11434 --ollama-url http://localhost:11434
""" """
import argparse, json, os, sqlite3, sys, uuid import argparse, json, logging, os, sqlite3, sys, uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -38,6 +38,27 @@ CHUNK_SIZE = 512 # tokens/chars per chunk
CHUNK_OVERLAP = 64 # overlap between consecutive chunks CHUNK_OVERLAP = 64 # overlap between consecutive chunks
BATCH_SIZE = 16 # how many chunks to embed + upsert at once BATCH_SIZE = 16 # how many chunks to embed + upsert at once
# ── Logging ───────────────────────────────────────────────────────────────────
# Ingest runs as a detached subprocess of the API, so failures used to be
# surfaced to the portal as a raw Python traceback stuffed into error_msg.
# Now the full traceback goes to a log file (server-side only) and the DB gets
# a short, human-readable message instead.
_LOG_FILE = Path(args.db_path).resolve().parent / "cezen-ingest.log"
logger = logging.getLogger("cezen.ingest")
if not logger.handlers:
_handler = logging.FileHandler(_LOG_FILE)
_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(_handler)
logger.setLevel(logging.INFO)
def friendly_error(e: Exception, context: str) -> str:
"""Log the full exception + traceback server-side; return a short, actionable message for the UI."""
logger.error("doc_id=%s context=%s", args.doc_id, context, exc_info=True)
msg = str(e).strip() or e.__class__.__name__
if len(msg) > 200:
msg = msg[:200] + "..."
return f"{msg} (see server ingest log for full details, request context: {context})"
def utcnow(): def utcnow():
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
@ -256,8 +277,7 @@ def main():
processed += len(batch) processed += len(batch)
except Exception as e: except Exception as e:
import traceback set_status("failed", chunk_count=processed, error=friendly_error(e, "embed_and_upsert"))
set_status("failed", chunk_count=processed, error=f"{e}\n{traceback.format_exc()[:500]}")
sys.exit(1) sys.exit(1)
set_status("ready", chunk_count=total) set_status("ready", chunk_count=total)
@ -266,9 +286,8 @@ if __name__ == "__main__":
try: try:
main() main()
except KeyboardInterrupt: except KeyboardInterrupt:
set_status("failed", error="Interrupted") set_status("failed", error="Ingest was interrupted before it could finish. Try uploading the document again.")
sys.exit(130) sys.exit(130)
except Exception as e: except Exception as e:
import traceback set_status("failed", error=friendly_error(e, "main"))
set_status("failed", error=f"{e}\n{traceback.format_exc()[:500]}")
sys.exit(1) sys.exit(1)

View File

@ -260,6 +260,23 @@ d=json.load(open("/tmp/cezen-license-check.json"))
print((d.get("license") or {}).get("status","missing")) print((d.get("license") or {}).get("status","missing"))
PY PY
) )
# Human-readable label for display only — the raw code above (e.g.
# "invalid_signature", "machine_mismatch") is what gets persisted to
# install-record.json and is meant for machines/support, not end users.
LICENSE_STATUS_LABEL=$(LICENSE_STATUS="$LICENSE_STATUS" python3 - <<'PY'
import os
labels = {
"valid": "Valid",
"missing": "No license installed (field-staging mode)",
"expired": "Expired — contact support@cezentech.com",
"invalid_signature": "Invalid signature — re-upload a valid signed license",
"not_yet_valid": "Not yet in its valid date range",
"machine_mismatch": "Bound to different hardware — contact support@cezentech.com",
}
status = os.environ.get("LICENSE_STATUS", "missing")
print(labels.get(status, status))
PY
)
LICENSE_ALLOWED_TIER=$(python3 - <<'PY' LICENSE_ALLOWED_TIER=$(python3 - <<'PY'
import json import json
d=json.load(open("/tmp/cezen-license-check.json")) d=json.load(open("/tmp/cezen-license-check.json"))
@ -285,7 +302,7 @@ if [ "$WORKSTATION_MODE" = true ]; then
# /opt/cezen/tier and the install-record below is what # /opt/cezen/tier and the install-record below is what
# actually drives Workstation branding/entitlement. # actually drives Workstation branding/entitlement.
whiptail --title "$TITLE" \ whiptail --title "$TITLE" \
--msgbox "\nNexus One AI Workstation\n\nLicense status: ${LICENSE_STATUS}\nHardware recommendation: ${HARDWARE_TIER}\n\nThis image installs the personal-appliance stack (local chat, personal RAG, document intelligence, prompt studio) — it is not a Server tier and will not be offered a Server S/M/L/Max upgrade path." \ --msgbox "\nNexus One AI Workstation\n\nLicense status: ${LICENSE_STATUS_LABEL}\nHardware recommendation: ${HARDWARE_TIER}\n\nThis image installs the personal-appliance stack (local chat, personal RAG, document intelligence, prompt studio) — it is not a Server tier and will not be offered a Server S/M/L/Max upgrade path." \
$H $W $H $W
else else
mapfile -t TIER_MENU < <(python3 - <<'PY' mapfile -t TIER_MENU < <(python3 - <<'PY'
@ -306,13 +323,13 @@ PY
if [ "${#TIER_MENU[@]}" -eq 0 ]; then if [ "${#TIER_MENU[@]}" -eq 0 ]; then
whiptail --title "$TITLE" \ whiptail --title "$TITLE" \
--msgbox "\nNo installable tiers are available.\n\nLicense status: ${LICENSE_STATUS}\nHardware recommendation: ${HARDWARE_TIER}\n\nCheck the signed license or contact Cezen support." \ --msgbox "\nNo installable tiers are available.\n\nLicense status: ${LICENSE_STATUS_LABEL}\nHardware recommendation: ${HARDWARE_TIER}\n\nCheck the signed license or contact Cezen support." \
$H $W $H $W
exit 1 exit 1
fi fi
whiptail --title "$TITLE" \ whiptail --title "$TITLE" \
--msgbox "\nLicense status: ${LICENSE_STATUS}\nAllowed tier: ${LICENSE_ALLOWED_TIER}\nHardware recommendation: ${HARDWARE_TIER}\n\nOnly valid tiers will be shown next." \ --msgbox "\nLicense status: ${LICENSE_STATUS_LABEL}\nAllowed tier: ${LICENSE_ALLOWED_TIER}\nHardware recommendation: ${HARDWARE_TIER}\n\nOnly valid tiers will be shown next." \
$H $W $H $W
TIER=$(whiptail --title "$TITLE" \ TIER=$(whiptail --title "$TITLE" \
@ -357,7 +374,7 @@ TOOLS_DISPLAY=$(echo "$TOOLS" | tr -d '"' | tr ' ' '\n' | sed 's/^/ · /' | tr
MY_IP=$(hostname -I | awk '{print $1}') MY_IP=$(hostname -I | awk '{print $1}')
LICENSE_DISPLAY="Field staging / evaluation" LICENSE_DISPLAY="Field staging / evaluation"
if [ -n "$LICENSE_PATH" ]; then if [ -n "$LICENSE_PATH" ]; then
LICENSE_DISPLAY="Signed file (${LICENSE_STATUS})" LICENSE_DISPLAY="Signed file (${LICENSE_STATUS_LABEL})"
fi fi
whiptail --title "$TITLE" \ whiptail --title "$TITLE" \
@ -433,7 +450,7 @@ if bash "$AIPACKAGE_DIR/install.sh" --tier="$TIER" >> "$INSTALL_LOG_FILE" 2>&1;
# Mark as configured only after the installer finishes successfully. # Mark as configured only after the installer finishes successfully.
touch /opt/cezen/.setup-done touch /opt/cezen/.setup-done
whiptail --title "$TITLE" \ whiptail --title "$TITLE" \
--msgbox "\nInstaller command finished successfully.\n\nPortal:\n http://localhost\n\nFor detailed logs, run:\n sudo tail -f $INSTALL_LOG_FILE" \ --msgbox "\nInstaller command finished successfully.\n\nPortal -> http://localhost\nOllama API -> http://localhost:11434\nGrafana -> http://localhost:3000\n\nAdmin login -> admin / Cezen@2024!\n(you will be required to change this on first login)\n\nFor detailed logs, run:\n sudo tail -f $INSTALL_LOG_FILE" \
$H $W $H $W
else else
whiptail --title "$TITLE" \ whiptail --title "$TITLE" \

View File

@ -325,6 +325,7 @@ HTML = r"""<!DOCTYPE html>
.alert-info { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; } .alert-info { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; }
.alert-success { background: #ECFDF5; color: #065F46; border: 1px solid #A7F3D0; } .alert-success { background: #ECFDF5; color: #065F46; border: 1px solid #A7F3D0; }
.alert-error { background: #FEF2F2; color: #991B1B; border: 1px solid #FECACA; } .alert-error { background: #FEF2F2; color: #991B1B; border: 1px solid #FECACA; }
.alert-warning { background: #FFFBEB; color: #92400E; border: 1px solid #FDE68A; }
.hidden { display: none !important; } .hidden { display: none !important; }
#done-screen { text-align: center; padding: 48px 0; } #done-screen { text-align: center; padding: 48px 0; }
@ -336,10 +337,12 @@ HTML = r"""<!DOCTYPE html>
</head> </head>
<body> <body>
<div id="wizard-banner" class="alert hidden" style="position:fixed;top:20px;right:20px;max-width:380px;z-index:1000;box-shadow:0 8px 24px rgba(0,0,0,.12)"></div>
<header> <header>
<div> <div>
<div class="logo">CEZEN AI SUITE</div> <div class="logo">NEXUS ONE AI</div>
<div class="sub">Server Setup Wizard</div> <div class="sub">Powered by Cezen &nbsp;·&nbsp; Server Setup Wizard</div>
</div> </div>
</header> </header>
@ -448,25 +451,25 @@ HTML = r"""<!DOCTYPE html>
<p class="desc">Choose the tier that matches your GPU hardware.</p> <p class="desc">Choose the tier that matches your GPU hardware.</p>
<div class="tier-grid"> <div class="tier-grid">
<div class="tier-card" id="tier-starter" onclick="selectTier('starter')"> <div class="tier-card" id="tier-starter" onclick="selectTier('starter')">
<div class="tier-name">Starter</div> <div class="tier-name">Server S</div>
<div class="tier-gpu">1× RTX 5090 / 32GB VRAM</div> <div class="tier-gpu">1× RTX 5090 / 32GB VRAM</div>
<div class="tier-users">Small team deployment</div> <div class="tier-users">Small team deployment</div>
<div class="tier-note" id="tier-note-starter"></div> <div class="tier-note" id="tier-note-starter"></div>
</div> </div>
<div class="tier-card" id="tier-basic" onclick="selectTier('basic')"> <div class="tier-card" id="tier-basic" onclick="selectTier('basic')">
<div class="tier-name">Entry</div> <div class="tier-name">Server M</div>
<div class="tier-gpu">1× NVIDIA RTX Pro 6000 (96GB)</div> <div class="tier-gpu">1× NVIDIA RTX Pro 6000 (96GB)</div>
<div class="tier-users">Up to 20 concurrent users</div> <div class="tier-users">Up to 20 concurrent users</div>
<div class="tier-note" id="tier-note-basic"></div> <div class="tier-note" id="tier-note-basic"></div>
</div> </div>
<div class="tier-card" id="tier-pro" onclick="selectTier('pro')"> <div class="tier-card" id="tier-pro" onclick="selectTier('pro')">
<div class="tier-name">Pro</div> <div class="tier-name">Server L</div>
<div class="tier-gpu">2× RTX 5090 / RTX Pro class</div> <div class="tier-gpu">2× RTX 5090 / RTX Pro class</div>
<div class="tier-users">Up to 100 concurrent users</div> <div class="tier-users">Up to 100 concurrent users</div>
<div class="tier-note" id="tier-note-pro"></div> <div class="tier-note" id="tier-note-pro"></div>
</div> </div>
<div class="tier-card" id="tier-max" onclick="selectTier('max')"> <div class="tier-card" id="tier-max" onclick="selectTier('max')">
<div class="tier-name">Max</div> <div class="tier-name">Server Max</div>
<div class="tier-gpu">48× H100/H200/A100 class</div> <div class="tier-gpu">48× H100/H200/A100 class</div>
<div class="tier-users">200+ concurrent users</div> <div class="tier-users">200+ concurrent users</div>
<div class="tier-note" id="tier-note-max"></div> <div class="tier-note" id="tier-note-max"></div>
@ -516,31 +519,64 @@ HTML = r"""<!DOCTYPE html>
</div> </div>
<div id="done-screen" class="hidden"> <div id="done-screen" class="hidden">
<div class="done-icon"></div> <div class="done-icon">&#10003;</div>
<h2>Installation Complete!</h2> <h2>Installation Complete!</h2>
<p>Your Nexus One AI is ready.</p> <p>Your Nexus One AI is ready.</p>
<div class="services card" style="margin-top:24px;text-align:left"> <div class="services card" style="margin-top:24px;text-align:left">
<div class="summary-row"><span class="key">Portal</span><span class="val badge">http://localhost</span></div>
<div class="summary-row"><span class="key">Open WebUI</span><span class="val badge">:3001</span></div> <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">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">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">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 class="summary-row"><span class="key">Grafana</span><span class="val badge">:3000</span></div>
</div> </div>
<div class="alert alert-info" style="max-width:420px;margin:20px auto 0;text-align:left">
<strong>Admin login:</strong> admin / Cezen@2024!<br>
You will be required to change this password on first login.
</div>
</div> </div>
<div class="btn-row" id="install-btn-row"> <div class="btn-row" id="install-btn-row">
<button class="btn btn-secondary" onclick="goStep(4)"> Back</button> <button class="btn btn-secondary" onclick="goStep(4)"> Back</button>
<button class="btn btn-primary" id="install-btn" onclick="startInstall()">🚀 Start Installation</button> <button class="btn btn-primary" id="install-btn" onclick="startInstall()">Start Installation</button>
</div> </div>
</div> </div>
</div><!-- /container --> </div><!-- /container -->
<script> <script>
// Shared banner (replaces native alert() for validation/errors)
let _bannerTimer = null;
function showBanner(msg, type) {
type = type || 'error';
const el = document.getElementById('wizard-banner');
el.className = 'alert alert-' + type;
el.textContent = msg;
el.classList.remove('hidden');
clearTimeout(_bannerTimer);
_bannerTimer = setTimeout(() => el.classList.add('hidden'), 6000);
}
// State // State
let netMode = 'dhcp'; let netMode = 'dhcp';
let selectedTier = 'basic'; let selectedTier = 'basic';
let tierPreview = null; let tierPreview = null;
const TIER_LABELS = { starter: 'Server S', basic: 'Server M', pro: 'Server L', max: 'Server Max' };
function tierLabel(slug) { return TIER_LABELS[slug] || (slug ? slug.charAt(0).toUpperCase()+slug.slice(1) : 'Unknown'); }
// Raw codes from cezen_license.evaluate_license() (valid/missing/expired/
// invalid_signature/not_yet_valid/machine_mismatch) aren't meant for an
// end-user installer screen translate them the same way the portal's
// /api/health does, so a customer never sees a bare internal status code.
const LICENSE_STATUS_LABELS = {
valid: 'Valid',
missing: 'No license installed (field-staging mode)',
expired: 'Expired — contact support@cezentech.com',
invalid_signature: 'Invalid signature — re-upload a valid signed license',
not_yet_valid: 'Not yet in its valid date range',
machine_mismatch: 'Bound to different hardware — contact support@cezentech.com',
};
function licenseStatusLabel(status) { return LICENSE_STATUS_LABELS[status] || (status || 'Unknown'); }
let tools = { let tools = {
ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true }, ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true },
jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', icon: '📓', on: true }, jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', icon: '📓', on: true },
@ -563,7 +599,7 @@ window.onload = () => {
// Navigation // Navigation
function goStep(n) { function goStep(n) {
if (n === 4 && !selectedTier) { alert('Please select a tier.'); return; } if (n === 4 && !selectedTier) { showBanner('Please select a tier before continuing.', 'error'); return; }
[1,2,3,4,5].forEach(i => { [1,2,3,4,5].forEach(i => {
document.getElementById('step-'+i).classList.toggle('hidden', i !== n); document.getElementById('step-'+i).classList.toggle('hidden', i !== n);
const bar = document.getElementById('sbar-'+i); const bar = document.getElementById('sbar-'+i);
@ -595,11 +631,11 @@ function applyStaticIP() {
fetch('/api/network', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) fetch('/api/network', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })
.then(r=>r.json()).then(d=>{ .then(r=>r.json()).then(d=>{
if(!d.ok) { if(!d.ok) {
alert('Network config failed: ' + d.error); showBanner('Network config failed: ' + d.error, 'error');
return; return;
} }
if (d.warning) { if (d.warning) {
alert(d.warning); showBanner(d.warning, 'warning');
} }
}); });
} }
@ -673,13 +709,13 @@ async function prepareTierStep() {
}); });
renderTierAvailability(res); renderTierAvailability(res);
document.getElementById('license-preview').innerHTML = ` document.getElementById('license-preview').innerHTML = `
<strong>Status:</strong> ${esc(res.license?.status || 'missing')}<br> <strong>Status:</strong> ${esc(licenseStatusLabel(res.license?.status || 'missing'))}<br>
<strong>Allowed tier:</strong> ${esc(res.license?.allowed_tier || 'basic')}<br> <strong>Allowed tier:</strong> ${esc(res.license?.allowed_tier || 'basic')}<br>
<strong>Hardware recommendation:</strong> ${esc(res.hardware?.recommended_tier || 'starter')} <strong>Hardware recommendation:</strong> ${esc(res.hardware?.recommended_tier || 'starter')}
`; `;
goStep(3); goStep(3);
} catch (err) { } catch (err) {
alert('License preview failed: ' + err.message); showBanner('License preview failed: ' + err.message, 'error');
} }
} }
@ -694,10 +730,10 @@ function renderSummary() {
document.getElementById('summary-rows').innerHTML = ` 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">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">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_json ? (tierPreview?.license?.status || 'Provided') : 'Field staging / evaluation'}</span></div> <div class="summary-row"><span class="key">License</span><span class="val">${license.license_json ? licenseStatusLabel(tierPreview?.license?.status || 'missing') : 'Field staging / evaluation'}</span></div>
<div class="summary-row"><span class="key">Allowed Tier</span><span class="val">${tierPreview?.license?.allowed_tier || 'basic'}</span></div> <div class="summary-row"><span class="key">Allowed Tier</span><span class="val">${tierLabel(tierPreview?.license?.allowed_tier || 'basic')}</span></div>
<div class="summary-row"><span class="key">Hardware Tier</span><span class="val">${tierPreview?.hardware?.recommended_tier || 'starter'}</span></div> <div class="summary-row"><span class="key">Hardware Tier</span><span class="val">${tierLabel(tierPreview?.hardware?.recommended_tier || 'starter')}</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">Tier</span><span class="val">${tierLabel(selectedTier)}</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> <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>` : ''} ${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>` : ''}
`; `;
@ -718,7 +754,7 @@ function startInstall() {
const data = await r.json(); const data = await r.json();
if (!r.ok || !data.ok) throw new Error(data.error || 'Install start failed'); if (!r.ok || !data.ok) throw new Error(data.error || 'Install start failed');
}).catch(err => { }).catch(err => {
alert(err.message); showBanner(err.message, 'error');
document.getElementById('install-btn-row').classList.remove('hidden'); document.getElementById('install-btn-row').classList.remove('hidden');
document.getElementById('summary-card').classList.remove('hidden'); document.getElementById('summary-card').classList.remove('hidden');
document.getElementById('progress-wrap').classList.remove('show'); document.getElementById('progress-wrap').classList.remove('show');
@ -799,23 +835,25 @@ function showRebootNotice() {
document.getElementById('progress-wrap').style.display = 'none'; document.getElementById('progress-wrap').style.display = 'none';
document.getElementById('done-screen').classList.remove('hidden'); document.getElementById('done-screen').classList.remove('hidden');
document.getElementById('done-screen').innerHTML = ` document.getElementById('done-screen').innerHTML = `
<div class="done-icon">🔄</div> <div class="done-icon">&#8635;</div>
<h2>Server is Rebooting</h2> <h2>Server is Rebooting</h2>
<p style="margin-bottom:16px">NVIDIA drivers installed. Phase 2 (AI stack) is installing automatically after reboot.</p> <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"> <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> 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> <code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">ssh cezen@&lt;server-ip&gt;</code>
then then
<code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">journalctl -fu cezen-phase2</code> <code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">journalctl -fu cezen-phase2</code>
</div> </div>
<div class="services card" style="margin:0 auto;max-width:400px;text-align:left"> <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> <p style="font-size:13px;color:var(--muted);margin-bottom:12px">Services will be available at:</p>
<div class="summary-row"><span class="key">Portal</span><span class="val badge">http://localhost</span></div>
<div class="summary-row"><span class="key">Open WebUI</span><span class="val badge">:3001</span></div> <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">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">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">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 class="summary-row"><span class="key">Grafana</span><span class="val badge">:3000</span></div>
</div> </div>
<p style="font-size:13px;color:var(--muted);margin-top:16px">Admin login (admin / Cezen@2024!, change required on first login) will be ready once Phase 2 completes.</p>
`; `;
} }
</script> </script>
@ -950,7 +988,7 @@ def show_console_banner(ip):
\033[1;36m \033[1;36m
CEZEN AI SUITE SERVER SETUP NEXUS ONE AI SERVER SETUP
Open a browser on any computer on this network: Open a browser on any computer on this network:

View File

@ -421,16 +421,24 @@ run_phase2() {
# 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
# Fixed-width box so every row lines up regardless of tier-name length.
local box_w=62
pad_row() { printf '║ %-*s║\n' "$((box_w-4))" "$1"; }
echo "" echo ""
echo "╔══════════════════════════════════════════╗" printf '╔%s╗\n' "$(printf '═%.0s' $(seq 1 "$box_w"))"
echo "║ Nexus One AI installation complete! ║" pad_row "Nexus One AI installation complete!"
echo "║ Tier: $(printf '%-33s' "$DISPLAY_TIER")" pad_row "Tier: $DISPLAY_TIER"
echo "║ ║" pad_row ""
echo "║ Portal → http://localhost ║" pad_row "Portal -> http://localhost"
echo "║ Ollama API → http://localhost:11434 ║" pad_row "Ollama API -> http://localhost:11434"
echo "║ vLLM API → http://localhost:8000 ║" pad_row "vLLM API -> http://localhost:8000"
echo "║ Grafana → http://localhost:3000 ║" pad_row "JupyterLab -> http://localhost:8888"
echo "╚══════════════════════════════════════════╝" pad_row "MLflow -> http://localhost:5000"
pad_row "Grafana -> http://localhost:3000"
pad_row ""
pad_row "Admin login -> admin / Cezen@2024!"
pad_row "(you will be required to change this on first login)"
printf '╚%s╝\n' "$(printf '═%.0s' $(seq 1 "$box_w"))"
} }
# ── Main ─────────────────────────────────────── # ── Main ───────────────────────────────────────