feat(manager): add Nexus Instance Manager for demo orchestration

- Complete Flask-based control panel for multi-tenant POS instances
- Dashboard with global stats, system health, and recent demos
- Demo provisioning in 1 click with auto-expiration tracking
- Tenant management: activate/deactivate, reset data, delete
- Health monitoring: PostgreSQL, Redis, disk, memory, systemd services
- Migration orchestration UI for running schema updates across all tenants
- JWT authentication with manager_users table
- Dark theme SPA frontend with real-time search and actions
- systemd service file included
This commit is contained in:
2026-05-17 21:01:01 +00:00
parent da362e32a6
commit be4bb8d9ad
20 changed files with 2685 additions and 0 deletions

View File

View File

@@ -0,0 +1,35 @@
"""Admin dashboard blueprint."""
from flask import Blueprint, jsonify
from blueprints.auth_bp import require_manager_auth
from services import tenant_service, migration_service
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@admin_bp.route("/stats", methods=["GET"])
@require_manager_auth
def dashboard_stats():
return jsonify(tenant_service.get_dashboard_stats())
@admin_bp.route("/migrations", methods=["GET"])
@require_manager_auth
def list_migrations():
return jsonify({
"migrations": migration_service.list_available_migrations(),
"tenants": migration_service.get_tenant_versions()
})
@admin_bp.route("/migrations/run-all", methods=["POST"])
@require_manager_auth
def run_all_migrations():
result = migration_service.run_all_pending_migrations()
return jsonify(result)
@admin_bp.route("/migrations/run/<version>", methods=["POST"])
@require_manager_auth
def run_specific_migration(version):
result = migration_service.run_migration_on_all_tenants(version)
return jsonify({"results": result})

View File

@@ -0,0 +1,99 @@
"""Auth blueprint for Nexus Manager."""
import datetime
import jwt
import bcrypt
from flask import Blueprint, request, jsonify, current_app
from config import MANAGER_JWT_SECRET, MANAGER_JWT_EXPIRES
from services.tenant_service import get_master_conn
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
def hash_password(password):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def check_password(password, hashed):
return bcrypt.checkpw(password.encode(), hashed.encode())
def create_manager_token(user_id, email, role="admin"):
payload = {
"user_id": user_id,
"email": email,
"role": role,
"type": "access",
"exp": datetime.datetime.utcnow() + datetime.timedelta(seconds=MANAGER_JWT_EXPIRES),
"iat": datetime.datetime.utcnow()
}
return jwt.encode(payload, MANAGER_JWT_SECRET, algorithm="HS256")
def decode_manager_token(token):
try:
return jwt.decode(token, MANAGER_JWT_SECRET, algorithms=["HS256"])
except Exception:
return None
def require_manager_auth(f):
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
token = None
if auth_header.startswith("Bearer "):
token = auth_header[7:]
elif request.cookies.get("manager_token"):
token = request.cookies.get("manager_token")
if not token:
return jsonify({"error": "Unauthorized"}), 401
payload = decode_manager_token(token)
if not payload or payload.get("type") != "access":
return jsonify({"error": "Invalid or expired token"}), 401
request.manager_user = payload
return f(*args, **kwargs)
return decorated
@auth_bp.route("/login", methods=["POST"])
def login():
data = request.get_json() or {}
email = data.get("email", "").strip().lower()
password = data.get("password", "")
if not email or not password:
return jsonify({"error": "Email and password required"}), 400
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT id, email, password_hash, role, name
FROM manager_users
WHERE email = %s AND is_active = true
""", (email,))
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "Invalid credentials"}), 401
user_id, db_email, pwd_hash, role, name = row
if not check_password(password, pwd_hash):
return jsonify({"error": "Invalid credentials"}), 401
token = create_manager_token(user_id, db_email, role)
return jsonify({
"access_token": token,
"user": {"id": user_id, "email": db_email, "role": role, "name": name}
})
@auth_bp.route("/me", methods=["GET"])
@require_manager_auth
def me():
return jsonify({"user": request.manager_user})

View File

@@ -0,0 +1,42 @@
"""Demo provisioning blueprint."""
from flask import Blueprint, request, jsonify
from blueprints.auth_bp import require_manager_auth
from services import tenant_service
demos_bp = Blueprint("demos", __name__, url_prefix="/api/demos")
@demos_bp.route("", methods=["POST"])
@require_manager_auth
def create_demo():
data = request.get_json() or {}
name = data.get("name", "").strip()
email = data.get("email", "").strip()
days = data.get("days")
subdomain = data.get("subdomain", "").strip() or None
pin = data.get("pin", "0000").strip()
if not name:
return jsonify({"error": "Business name is required"}), 400
try:
result = tenant_service.create_demo(
name=name,
email=email,
demo_days=days,
subdomain=subdomain,
pin=pin
)
return jsonify({"data": result}), 201
except ValueError as e:
return jsonify({"error": str(e)}), 409
except Exception as e:
return jsonify({"error": str(e)}), 500
@demos_bp.route("", methods=["GET"])
@require_manager_auth
def list_demos():
all_tenants = tenant_service.list_tenants(include_stats=True)
demos = [t for t in all_tenants if t.get("is_demo")]
return jsonify({"data": demos})

View File

@@ -0,0 +1,18 @@
"""Health check blueprint."""
from flask import Blueprint, jsonify
from blueprints.auth_bp import require_manager_auth
from services import health_service
health_bp = Blueprint("health", __name__, url_prefix="/api/health")
@health_bp.route("", methods=["GET"])
@require_manager_auth
def full_health():
return jsonify(health_service.get_full_health_report())
@health_bp.route("/tenant/<db_name>", methods=["GET"])
@require_manager_auth
def tenant_health(db_name):
return jsonify(health_service.get_tenant_health(db_name))

View File

@@ -0,0 +1,60 @@
"""Tenant management blueprint."""
from flask import Blueprint, request, jsonify
from blueprints.auth_bp import require_manager_auth
from services import tenant_service
tenants_bp = Blueprint("tenants", __name__, url_prefix="/api/tenants")
@tenants_bp.route("", methods=["GET"])
@require_manager_auth
def list_tenants():
include_stats = request.args.get("stats", "false").lower() == "true"
return jsonify({"data": tenant_service.list_tenants(include_stats=include_stats)})
@tenants_bp.route("/<int:tenant_id>", methods=["GET"])
@require_manager_auth
def get_tenant(tenant_id):
tenant = tenant_service.get_tenant(tenant_id)
if not tenant:
return jsonify({"error": "Tenant not found"}), 404
return jsonify({"data": tenant})
@tenants_bp.route("/<int:tenant_id>/stats", methods=["GET"])
@require_manager_auth
def get_tenant_stats(tenant_id):
tenant = tenant_service.get_tenant(tenant_id)
if not tenant:
return jsonify({"error": "Tenant not found"}), 404
return jsonify({"data": tenant_service._get_tenant_quick_stats(tenant["db_name"])})
@tenants_bp.route("/<int:tenant_id>/toggle", methods=["POST"])
@require_manager_auth
def toggle_tenant(tenant_id):
data = request.get_json() or {}
active = data.get("active", True)
result = tenant_service.toggle_tenant(tenant_id, active)
return jsonify(result)
@tenants_bp.route("/<int:tenant_id>/reset", methods=["POST"])
@require_manager_auth
def reset_tenant(tenant_id):
try:
result = tenant_service.reset_tenant(tenant_id)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
@tenants_bp.route("/<int:tenant_id>", methods=["DELETE"])
@require_manager_auth
def delete_tenant(tenant_id):
try:
result = tenant_service.delete_tenant(tenant_id)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500