Update installer and runtime setup flow
This commit is contained in:
parent
8603fb2bc2
commit
7540da04a8
@ -10,7 +10,7 @@ Default admin on first run:
|
||||
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 pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
@ -705,7 +705,7 @@ def init_db():
|
||||
utcnow())
|
||||
)
|
||||
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
|
||||
defaults = {
|
||||
@ -803,6 +803,76 @@ app.add_middleware(
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
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.close()
|
||||
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}
|
||||
|
||||
# ── Offline model upload / load ───────────────────────────────────────────────
|
||||
@ -1536,9 +1609,21 @@ async def upload_model(
|
||||
if not chunk:
|
||||
break
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=result.stderr or "ollama create failed")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise_api_error(500, "model_load_failed",
|
||||
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)
|
||||
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()
|
||||
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())
|
||||
except urllib.error.HTTPError as e:
|
||||
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:
|
||||
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]]:
|
||||
"""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:
|
||||
embeddings.append(json.loads(r.read().decode())["embedding"])
|
||||
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
|
||||
|
||||
# Collections
|
||||
@ -2514,7 +2614,10 @@ async def launch_job(body: dict, _licensed: dict = Depends(require_feature("fine
|
||||
(utcnow(), job_id))
|
||||
db.commit()
|
||||
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()
|
||||
_audit(None, admin["username"], "training_launch",
|
||||
@ -4437,7 +4540,7 @@ def _add_to_scheduler(job: dict):
|
||||
misfire_grace_time=300,
|
||||
)
|
||||
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):
|
||||
@ -5730,7 +5833,153 @@ async def process_meeting(file: UploadFile = File(...), meta: str = Form("{}"),
|
||||
|
||||
|
||||
# ── 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")
|
||||
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,
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ Usage (called by main.py):
|
||||
--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 pathlib import Path
|
||||
|
||||
@ -38,6 +38,27 @@ CHUNK_SIZE = 512 # tokens/chars per chunk
|
||||
CHUNK_OVERLAP = 64 # overlap between consecutive chunks
|
||||
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():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@ -256,8 +277,7 @@ def main():
|
||||
processed += len(batch)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
set_status("failed", chunk_count=processed, error=f"{e}\n{traceback.format_exc()[:500]}")
|
||||
set_status("failed", chunk_count=processed, error=friendly_error(e, "embed_and_upsert"))
|
||||
sys.exit(1)
|
||||
|
||||
set_status("ready", chunk_count=total)
|
||||
@ -266,9 +286,8 @@ if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
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)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
set_status("failed", error=f"{e}\n{traceback.format_exc()[:500]}")
|
||||
set_status("failed", error=friendly_error(e, "main"))
|
||||
sys.exit(1)
|
||||
|
||||
@ -260,6 +260,23 @@ d=json.load(open("/tmp/cezen-license-check.json"))
|
||||
print((d.get("license") or {}).get("status","missing"))
|
||||
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'
|
||||
import 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
|
||||
# actually drives Workstation branding/entitlement.
|
||||
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
|
||||
else
|
||||
mapfile -t TIER_MENU < <(python3 - <<'PY'
|
||||
@ -306,13 +323,13 @@ PY
|
||||
|
||||
if [ "${#TIER_MENU[@]}" -eq 0 ]; then
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
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}')
|
||||
LICENSE_DISPLAY="Field staging / evaluation"
|
||||
if [ -n "$LICENSE_PATH" ]; then
|
||||
LICENSE_DISPLAY="Signed file (${LICENSE_STATUS})"
|
||||
LICENSE_DISPLAY="Signed file (${LICENSE_STATUS_LABEL})"
|
||||
fi
|
||||
|
||||
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.
|
||||
touch /opt/cezen/.setup-done
|
||||
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
|
||||
else
|
||||
whiptail --title "$TITLE" \
|
||||
|
||||
@ -325,6 +325,7 @@ HTML = r"""<!DOCTYPE html>
|
||||
.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; }
|
||||
.alert-warning { background: #FFFBEB; color: #92400E; border: 1px solid #FDE68A; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
#done-screen { text-align: center; padding: 48px 0; }
|
||||
@ -336,10 +337,12 @@ HTML = r"""<!DOCTYPE html>
|
||||
</head>
|
||||
<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>
|
||||
<div>
|
||||
<div class="logo">CEZEN AI SUITE</div>
|
||||
<div class="sub">Server Setup Wizard</div>
|
||||
<div class="logo">NEXUS ONE AI</div>
|
||||
<div class="sub">Powered by Cezen · Server Setup Wizard</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -448,25 +451,25 @@ HTML = r"""<!DOCTYPE html>
|
||||
<p class="desc">Choose the tier that matches your GPU hardware.</p>
|
||||
<div class="tier-grid">
|
||||
<div class="tier-card" id="tier-starter" onclick="selectTier('starter')">
|
||||
<div class="tier-name">Starter</div>
|
||||
<div class="tier-name">Server S</div>
|
||||
<div class="tier-gpu">1× RTX 5090 / 32GB VRAM</div>
|
||||
<div class="tier-users">Small team deployment</div>
|
||||
<div class="tier-note" id="tier-note-starter"></div>
|
||||
</div>
|
||||
<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-users">Up to 20 concurrent users</div>
|
||||
<div class="tier-note" id="tier-note-basic"></div>
|
||||
</div>
|
||||
<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-users">Up to 100 concurrent users</div>
|
||||
<div class="tier-note" id="tier-note-pro"></div>
|
||||
</div>
|
||||
<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">4–8× H100/H200/A100 class</div>
|
||||
<div class="tier-users">200+ concurrent users</div>
|
||||
<div class="tier-note" id="tier-note-max"></div>
|
||||
@ -516,31 +519,64 @@ HTML = r"""<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<div id="done-screen" class="hidden">
|
||||
<div class="done-icon">✅</div>
|
||||
<div class="done-icon">✓</div>
|
||||
<h2>Installation Complete!</h2>
|
||||
<p>Your Nexus One AI is ready.</p>
|
||||
<div class="services card" style="margin-top:24px;text-align:left">
|
||||
<div class="summary-row"><span class="key">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">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 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 class="btn-row" id="install-btn-row">
|
||||
<button class="btn btn-secondary" onclick="goStep(4)">← Back</button>
|
||||
<button class="btn btn-primary" id="install-btn" onclick="startInstall()">🚀 Start Installation</button>
|
||||
<button class="btn btn-primary" id="install-btn" onclick="startInstall()">Start Installation</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /container -->
|
||||
|
||||
<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 ──────────────────────────────────────────────────
|
||||
let netMode = 'dhcp';
|
||||
let selectedTier = 'basic';
|
||||
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 = {
|
||||
ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true },
|
||||
jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', icon: '📓', on: true },
|
||||
@ -563,7 +599,7 @@ window.onload = () => {
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────
|
||||
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 => {
|
||||
document.getElementById('step-'+i).classList.toggle('hidden', i !== n);
|
||||
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) })
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!d.ok) {
|
||||
alert('Network config failed: ' + d.error);
|
||||
showBanner('Network config failed: ' + d.error, 'error');
|
||||
return;
|
||||
}
|
||||
if (d.warning) {
|
||||
alert(d.warning);
|
||||
showBanner(d.warning, 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -673,13 +709,13 @@ async function prepareTierStep() {
|
||||
});
|
||||
renderTierAvailability(res);
|
||||
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>Hardware recommendation:</strong> ${esc(res.hardware?.recommended_tier || 'starter')}
|
||||
`;
|
||||
goStep(3);
|
||||
} 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 = `
|
||||
<div class="summary-row"><span class="key">Network</span><span class="val">${ip}</span></div>
|
||||
<div class="summary-row"><span class="key">Customer</span><span class="val">${license.customer_name || 'Not entered'}</span></div>
|
||||
<div class="summary-row"><span class="key">License</span><span class="val">${license.license_json ? (tierPreview?.license?.status || 'Provided') : '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">Hardware Tier</span><span class="val">${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">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">${tierLabel(tierPreview?.license?.allowed_tier || 'basic')}</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">${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>
|
||||
${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();
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || 'Install start failed');
|
||||
}).catch(err => {
|
||||
alert(err.message);
|
||||
showBanner(err.message, 'error');
|
||||
document.getElementById('install-btn-row').classList.remove('hidden');
|
||||
document.getElementById('summary-card').classList.remove('hidden');
|
||||
document.getElementById('progress-wrap').classList.remove('show');
|
||||
@ -799,23 +835,25 @@ 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>
|
||||
<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>
|
||||
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">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">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>
|
||||
<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>
|
||||
@ -950,7 +988,7 @@ def show_console_banner(ip):
|
||||
|
||||
\033[1;36m╔══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ CEZEN AI SUITE — SERVER SETUP ║
|
||||
║ NEXUS ONE AI — SERVER SETUP ║
|
||||
║ ║
|
||||
║ Open a browser on any computer on this network: ║
|
||||
║ ║
|
||||
|
||||
26
install.sh
26
install.sh
@ -421,16 +421,24 @@ run_phase2() {
|
||||
# Disable one-shot service so it doesn't run again on next reboot
|
||||
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 "║ Nexus One AI installation complete! ║"
|
||||
echo "║ Tier: $(printf '%-33s' "$DISPLAY_TIER")║"
|
||||
echo "║ ║"
|
||||
echo "║ Portal → http://localhost ║"
|
||||
echo "║ Ollama API → http://localhost:11434 ║"
|
||||
echo "║ vLLM API → http://localhost:8000 ║"
|
||||
echo "║ Grafana → http://localhost:3000 ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
printf '╔%s╗\n' "$(printf '═%.0s' $(seq 1 "$box_w"))"
|
||||
pad_row "Nexus One AI installation complete!"
|
||||
pad_row "Tier: $DISPLAY_TIER"
|
||||
pad_row ""
|
||||
pad_row "Portal -> http://localhost"
|
||||
pad_row "Ollama API -> http://localhost:11434"
|
||||
pad_row "vLLM API -> http://localhost:8000"
|
||||
pad_row "JupyterLab -> http://localhost:8888"
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user