diff --git a/manager/README.md b/manager/README.md new file mode 100644 index 0000000..d6965e7 --- /dev/null +++ b/manager/README.md @@ -0,0 +1,129 @@ +# Nexus Instance Manager + +Panel de control central para gestionar instancias multi-tenant de Nexus POS. + +## Qué hace + +- **Crear demos** en 1 clic con subdominio, PIN de acceso y fecha de expiración +- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd) +- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar +- **Ejecutar migraciones** de schema en todos los tenants desde una UI +- **Dashboard** con estadísticas globales y alertas de demos por expirar + +## Estructura + +``` +manager/ +├── app.py # Flask app principal +├── config.py # Variables de entorno +├── wsgi.py # Entry point para Gunicorn +├── requirements.txt # Dependencias +├── services/ # Lógica de negocio +│ ├── health_service.py # Health checks de infraestructura +│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS) +│ └── migration_service.py# Orquestación de migraciones +├── blueprints/ # API REST +│ ├── auth_bp.py # Login/logout JWT +│ ├── tenants_bp.py # Gestión de tenants +│ ├── demos_bp.py # Creación de demos +│ ├── health_bp.py # Health checks +│ └── admin_bp.py # Dashboard stats y migraciones +├── static/ # Frontend SPA +│ ├── css/manager.css +│ └── js/manager.js +├── templates/ +│ └── index.html # Single Page App +├── scripts/ +│ └── init_manager.py # Inicialización de DB + admin +└── systemd/ + └── nexus-manager.service +``` + +## Instalación rápida + +### 1. Dependencias + +```bash +cd /home/Autopartes/manager +pip install -r requirements.txt +``` + +### 2. Inicializar base de datos y usuario admin + +```bash +cd /home/Autopartes/manager +python scripts/init_manager.py --email admin@nexus.local --password nexus2026 --name "Super Admin" +``` + +Esto crea: +- Tabla `manager_users` (login del panel) +- Tabla `manager_audit_log` (registro de acciones) +- Usuario admin por defecto + +### 3. Configurar variables de entorno + +Asegúrate de que estas variables estén disponibles (en systemd o `.env`): + +```bash +MASTER_DB_URL=postgresql://postgres@/nexus_autoparts +TENANT_DB_URL_TEMPLATE=postgresql://postgres@/{db_name} +MANAGER_JWT_SECRET=genera-un-segredo-largo-aqui +POS_DIR=/home/Autopartes/pos +REDIS_URL=redis://localhost:6379/0 +``` + +### 4. Registrar servicio systemd + +```bash +cp systemd/nexus-manager.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable nexus-manager +systemctl start nexus-manager +``` + +Accede en: `http://TU_IP:5003` + +### 5. (Opcional) Agregar a nginx + +```nginx +server { + listen 80; + server_name manager.nexusautoparts.com.mx; + + location / { + proxy_pass http://127.0.0.1:5003; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + } +} +``` + +## Uso + +### Crear una demo +1. Ve a la sección **Crear Demos** +2. Llena nombre del negocio, email, días de vigencia +3. El subdominio se genera automáticamente (puedes personalizarlo) +4. Click en **Crear Demo** +5. El panel muestra la URL de acceso y el PIN del owner + +### Resetear una demo +- Presiona el ícono de 🔄 en la tabla de demos +- Limpia TODO el inventario, ventas, clientes, facturas +- Conserva empleados (incluyendo el owner) y configuración fiscal + +### Eliminar una demo +- Presiona 🗑️ y confirma +- Borra permanentemente la base de datos del tenant + +### Migraciones +- Ve a **Migraciones** para ver la versión de schema de cada tenant +- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants + +## Notas de seguridad + +- Cambia `MANAGER_JWT_SECRET` en producción +- El panel expone acciones destructivas (delete/reset); protege el acceso con firewall o VPN +- Usa HTTPS en producción diff --git a/manager/app.py b/manager/app.py new file mode 100644 index 0000000..c84236b --- /dev/null +++ b/manager/app.py @@ -0,0 +1,99 @@ +"""Nexus Instance Manager — Flask Application.""" +import os +import sys +from datetime import datetime +from flask import Flask, jsonify, render_template, send_from_directory, request + +# Ensure POS modules are importable for tenant_manager reuse +POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos") +if POS_DIR not in sys.path: + sys.path.insert(0, POS_DIR) + +from config import APP_NAME, APP_VERSION +from blueprints.auth_bp import auth_bp, require_manager_auth +from blueprints.tenants_bp import tenants_bp +from blueprints.demos_bp import demos_bp +from blueprints.health_bp import health_bp +from blueprints.admin_bp import admin_bp + + +def create_app(): + app = Flask( + __name__, + template_folder="templates", + static_folder="static" + ) + app.secret_key = os.environ.get("MANAGER_JWT_SECRET", "dev-secret-change-me") + + # Register blueprints + app.register_blueprint(auth_bp) + app.register_blueprint(tenants_bp) + app.register_blueprint(demos_bp) + app.register_blueprint(health_bp) + app.register_blueprint(admin_bp) + + # ─── Frontend Routes ─────────────────────────────────────────────────── + @app.route("/") + def index(): + return render_template("index.html") + + @app.route("/login") + def login_page(): + return render_template("index.html") + + @app.route("/dashboard") + def dashboard_page(): + return render_template("index.html") + + @app.route("/tenants") + def tenants_page(): + return render_template("index.html") + + @app.route("/demos") + def demos_page(): + return render_template("index.html") + + @app.route("/health") + def health_page(): + return render_template("index.html") + + @app.route("/migrations") + def migrations_page(): + return render_template("index.html") + + # ─── Static Asset Helpers ────────────────────────────────────────────── + @app.route("/static/") + def static_files(filename): + return send_from_directory("static", filename) + + # ─── API Status ──────────────────────────────────────────────────────── + @app.route("/api/status") + def api_status(): + return jsonify({ + "app": APP_NAME, + "version": APP_VERSION, + "timestamp": datetime.utcnow().isoformat(), + "pos_dir": POS_DIR + }) + + # ─── Error Handlers ──────────────────────────────────────────────────── + @app.errorhandler(404) + def not_found(e): + if request.path.startswith("/api/"): + return jsonify({"error": "Not found"}), 404 + return render_template("index.html") + + @app.errorhandler(500) + def internal_error(e): + if request.path.startswith("/api/"): + return jsonify({"error": "Internal server error"}), 500 + return render_template("index.html") + + return app + + +# Entry point for Gunicorn: gunicorn -w 2 -b 0.0.0.0:5003 app:app +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5003, debug=True) diff --git a/manager/blueprints/__init__.py b/manager/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manager/blueprints/admin_bp.py b/manager/blueprints/admin_bp.py new file mode 100644 index 0000000..b5258c4 --- /dev/null +++ b/manager/blueprints/admin_bp.py @@ -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/", methods=["POST"]) +@require_manager_auth +def run_specific_migration(version): + result = migration_service.run_migration_on_all_tenants(version) + return jsonify({"results": result}) diff --git a/manager/blueprints/auth_bp.py b/manager/blueprints/auth_bp.py new file mode 100644 index 0000000..55bbc03 --- /dev/null +++ b/manager/blueprints/auth_bp.py @@ -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}) diff --git a/manager/blueprints/demos_bp.py b/manager/blueprints/demos_bp.py new file mode 100644 index 0000000..77da3d3 --- /dev/null +++ b/manager/blueprints/demos_bp.py @@ -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}) diff --git a/manager/blueprints/health_bp.py b/manager/blueprints/health_bp.py new file mode 100644 index 0000000..ce9b7f8 --- /dev/null +++ b/manager/blueprints/health_bp.py @@ -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/", methods=["GET"]) +@require_manager_auth +def tenant_health(db_name): + return jsonify(health_service.get_tenant_health(db_name)) diff --git a/manager/blueprints/tenants_bp.py b/manager/blueprints/tenants_bp.py new file mode 100644 index 0000000..13ea21b --- /dev/null +++ b/manager/blueprints/tenants_bp.py @@ -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("/", 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("//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("//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("//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("/", 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 diff --git a/manager/config.py b/manager/config.py new file mode 100644 index 0000000..04fc1e9 --- /dev/null +++ b/manager/config.py @@ -0,0 +1,50 @@ +"""Nexus Instance Manager — Configuration.""" +import os + +# ─── Database ────────────────────────────────────────────────────────────── +MASTER_DB_URL = os.environ.get("MASTER_DB_URL") or os.environ.get("DATABASE_URL") +if not MASTER_DB_URL: + raise ValueError( + "MASTER_DB_URL environment variable is required. " + "Example: postgresql://user:pass@localhost/nexus_autoparts" + ) + +TENANT_DB_URL_TEMPLATE = os.environ.get("TENANT_DB_URL_TEMPLATE") +if not TENANT_DB_URL_TEMPLATE: + raise ValueError( + "TENANT_DB_URL_TEMPLATE environment variable is required. " + "Example: postgresql://user:pass@localhost/{db_name}" + ) + +# ─── Security ────────────────────────────────────────────────────────────── +MANAGER_JWT_SECRET = os.environ.get("MANAGER_JWT_SECRET") +if not MANAGER_JWT_SECRET: + raise ValueError( + "MANAGER_JWT_SECRET environment variable is required. " + "Generate one with: python3 -c 'import secrets; print(secrets.token_hex(32))'" + ) + +MANAGER_JWT_EXPIRES = int(os.environ.get("MANAGER_JWT_EXPIRES", "28800")) # 8 hours + +# Internal API key for manager-to-POS operations +INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "") + +# ─── Demo Settings ───────────────────────────────────────────────────────── +DEMO_DEFAULT_DAYS = int(os.environ.get("DEMO_DEFAULT_DAYS", "14")) +DEMO_DEFAULT_PIN = os.environ.get("DEMO_DEFAULT_PIN", "0000") +DEMO_SUBDOMAIN_PREFIX = os.environ.get("DEMO_SUBDOMAIN_PREFIX", "demo") + +# ─── Services Health Check ───────────────────────────────────────────────── +POS_URL = os.environ.get("POS_URL", "http://127.0.0.1:5001/pos/health") +DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "http://127.0.0.1:5000/") +QUART_URL = os.environ.get("QUART_URL", "http://127.0.0.1:5002/") +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") + +# ─── Paths ───────────────────────────────────────────────────────────────── +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos") +MIGRATIONS_DIR = os.path.join(POS_DIR, "migrations") + +# ─── App Identity ────────────────────────────────────────────────────────── +APP_NAME = "Nexus Instance Manager" +APP_VERSION = "1.0.0" diff --git a/manager/requirements.txt b/manager/requirements.txt new file mode 100644 index 0000000..fa58a07 --- /dev/null +++ b/manager/requirements.txt @@ -0,0 +1,5 @@ +Flask>=2.3.0 +psycopg2-binary>=2.9.0 +bcrypt>=4.0.0 +PyJWT>=2.8.0 +redis>=5.0.0 diff --git a/manager/scripts/init_manager.py b/manager/scripts/init_manager.py new file mode 100644 index 0000000..7c4aeb7 --- /dev/null +++ b/manager/scripts/init_manager.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Initialize Nexus Instance Manager: create admin tables and default user.""" +import os +import sys +import bcrypt +import argparse + +# Add manager to path +MANAGER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if MANAGER_DIR not in sys.path: + sys.path.insert(0, MANAGER_DIR) + +from services.tenant_service import get_master_conn + + +def init_schema(): + """Create manager_users table in master DB if not exists.""" + conn = get_master_conn() + cur = conn.cursor() + + cur.execute(""" + CREATE TABLE IF NOT EXISTS manager_users ( + id SERIAL PRIMARY KEY, + email VARCHAR(200) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL DEFAULT 'Admin', + password_hash VARCHAR(200) NOT NULL, + role VARCHAR(20) DEFAULT 'admin', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS manager_audit_log ( + id SERIAL PRIMARY KEY, + user_email VARCHAR(200), + action VARCHAR(100) NOT NULL, + details JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + """) + + conn.commit() + cur.close() + conn.close() + print("[OK] Manager schema initialized.") + + +def create_admin(email, password, name="Admin"): + """Create or update admin user.""" + pwd_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + conn = get_master_conn() + cur = conn.cursor() + cur.execute(""" + INSERT INTO manager_users (email, name, password_hash, role) + VALUES (%s, %s, %s, 'admin') + ON CONFLICT (email) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + name = EXCLUDED.name, + is_active = TRUE + """, (email.lower(), name, pwd_hash)) + conn.commit() + cur.close() + conn.close() + print(f"[OK] Admin user '{email}' created/updated.") + + +def main(): + parser = argparse.ArgumentParser(description="Initialize Nexus Manager") + parser.add_argument("--email", default="admin@nexus.local", help="Admin email") + parser.add_argument("--password", default="nexus2026", help="Admin password") + parser.add_argument("--name", default="Super Admin", help="Admin display name") + args = parser.parse_args() + + print("Nexus Instance Manager — Initialization") + print("=" * 40) + init_schema() + create_admin(args.email, args.password, args.name) + print("=" * 40) + print(f"Login: {args.email}") + print(f"URL: http://manager-ip:5003") + + +if __name__ == "__main__": + main() diff --git a/manager/services/__init__.py b/manager/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/manager/services/health_service.py b/manager/services/health_service.py new file mode 100644 index 0000000..30b35a5 --- /dev/null +++ b/manager/services/health_service.py @@ -0,0 +1,166 @@ +"""Health monitoring service for Nexus infrastructure.""" +import subprocess +import shutil +import socket +import urllib.request +import urllib.error +import psycopg2 +import redis +from config import ( + MASTER_DB_URL, REDIS_URL, POS_URL, DASHBOARD_URL, QUART_URL, + TENANT_DB_URL_TEMPLATE +) + + +def check_postgresql(): + """Check PostgreSQL connectivity.""" + try: + conn = psycopg2.connect(MASTER_DB_URL, connect_timeout=5) + cur = conn.cursor() + cur.execute("SELECT version(), pg_database_size('nexus_autoparts')") + version, size = cur.fetchone() + cur.close() + conn.close() + return { + "status": "ok", + "version": version.split()[1] if version else "unknown", + "master_size_mb": round(size / (1024 * 1024), 2) + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +def check_redis(): + """Check Redis connectivity.""" + try: + r = redis.from_url(REDIS_URL, socket_connect_timeout=3) + info = r.info() + return { + "status": "ok", + "version": info.get("redis_version", "unknown"), + "used_memory_human": info.get("used_memory_human", "?"), + "connected_clients": info.get("connected_clients", 0) + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +def check_http_service(name, url, timeout=5): + """Generic HTTP health check.""" + try: + req = urllib.request.Request(url, method="GET") + req.add_header("User-Agent", "Nexus-Manager/1.0") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return { + "status": "ok", + "http_status": resp.status, + "latency_ms": None # Could add timing later + } + except urllib.error.HTTPError as e: + return {"status": "warning", "http_status": e.code, "error": str(e)} + except Exception as e: + return {"status": "error", "error": str(e)} + + +def check_disk_space(path="/"): + """Check disk usage.""" + try: + total, used, free = shutil.disk_usage(path) + return { + "status": "ok", + "total_gb": round(total / (1024**3), 2), + "used_gb": round(used / (1024**3), 2), + "free_gb": round(free / (1024**3), 2), + "percent_used": round((used / total) * 100, 1) + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +def check_memory(): + """Check system memory via /proc/meminfo.""" + try: + with open("/proc/meminfo") as f: + meminfo = f.read() + data = {} + for line in meminfo.splitlines(): + if ":" in line: + key, value = line.split(":", 1) + data[key.strip()] = int(value.strip().split()[0]) # kB + total = data.get("MemTotal", 0) / 1024 / 1024 # GB + available = data.get("MemAvailable", data.get("MemFree", 0)) / 1024 / 1024 + used = total - available + return { + "status": "ok", + "total_gb": round(total, 2), + "used_gb": round(used, 2), + "available_gb": round(available, 2), + "percent_used": round((used / total) * 100, 1) if total else 0 + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +def check_systemd_service(service_name): + """Check systemd service status.""" + try: + result = subprocess.run( + ["systemctl", "is-active", service_name], + capture_output=True, text=True, timeout=5 + ) + active = result.stdout.strip() == "active" + return { + "status": "ok" if active else "warning", + "active": active, + "state": result.stdout.strip() + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +def get_full_health_report(): + """Aggregate health report for all services.""" + return { + "postgresql": check_postgresql(), + "redis": check_redis(), + "pos": check_http_service("pos", POS_URL), + "dashboard": check_http_service("dashboard", DASHBOARD_URL), + "quart": check_http_service("quart", QUART_URL), + "disk": check_disk_space(), + "memory": check_memory(), + "services": { + "nexus": check_systemd_service("nexus.service"), + "nexus-pos": check_systemd_service("nexus-pos.service"), + "nexus-quart": check_systemd_service("nexus-quart.service"), + "nexus-celery": check_systemd_service("nexus-celery.service"), + } + } + + +def get_tenant_health(db_name, timeout=5): + """Check connectivity to a specific tenant database.""" + dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name) + try: + conn = psycopg2.connect(dsn, connect_timeout=timeout) + cur = conn.cursor() + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM employees WHERE is_active = true) as employees, + (SELECT COUNT(*) FROM inventory WHERE is_active = true) as inventory, + (SELECT COUNT(*) FROM customers WHERE is_active = true) as customers, + (SELECT COUNT(*) FROM sales WHERE created_at > NOW() - INTERVAL '30 days') as sales_30d, + pg_database_size(current_database()) as db_size + """) + row = cur.fetchone() + cur.close() + conn.close() + return { + "status": "ok", + "employees": row[0], + "inventory": row[1], + "customers": row[2], + "sales_30d": row[3], + "db_size_mb": round(row[4] / (1024 * 1024), 2) + } + except Exception as e: + return {"status": "error", "error": str(e)} diff --git a/manager/services/migration_service.py b/manager/services/migration_service.py new file mode 100644 index 0000000..758de45 --- /dev/null +++ b/manager/services/migration_service.py @@ -0,0 +1,100 @@ +"""Migration orchestration service.""" +import os +import sys + +POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos") +if POS_DIR not in sys.path: + sys.path.insert(0, POS_DIR) + +from tenant_db import get_master_conn +from config import MIGRATIONS_DIR + + +def list_available_migrations(): + """List migrations found in POS migrations directory.""" + migrations = [] + if os.path.isdir(MIGRATIONS_DIR): + for fname in sorted(os.listdir(MIGRATIONS_DIR)): + if fname.endswith(".sql") and fname.startswith("v"): + version = fname.replace(".sql", "") + migrations.append({"version": version, "file": fname}) + return migrations + + +def get_tenant_versions(): + """Get schema version for every tenant.""" + conn = get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT t.id, t.name, t.db_name, COALESCE(v.version, 'v0.0') as version + FROM tenants t + LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id + WHERE t.is_active = true + ORDER BY t.id + """) + results = [] + for row in cur.fetchall(): + results.append({ + "tenant_id": row[0], "name": row[1], "db_name": row[2], "version": row[3] + }) + cur.close() + conn.close() + return results + + +def run_migration_on_tenant(db_name, version): + """Apply a single migration file to a tenant DB.""" + from migrations.runner import apply_migration + return apply_migration(db_name, version) + + +def run_all_pending_migrations(): + """Run all pending migrations on all active tenants (wrapper around POS runner).""" + from migrations.runner import run_migrations + import io + import contextlib + + # Capture stdout to return as log + f = io.StringIO() + with contextlib.redirect_stdout(f): + run_migrations() + return {"log": f.getvalue()} + + +def run_migration_on_all_tenants(version): + """Apply one specific migration version to all tenants that don't have it.""" + from migrations.runner import MIGRATIONS, apply_migration + + conn = get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT t.id, t.db_name, COALESCE(v.version, 'v0.0') as version + FROM tenants t + LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id + WHERE t.is_active = true + """) + tenants = cur.fetchall() + cur.close() + conn.close() + + results = [] + for tenant_id, db_name, current_version in tenants: + if current_version >= version: + results.append({"tenant_id": tenant_id, "db_name": db_name, "skipped": True, "reason": "already at or past version"}) + continue + + success = apply_migration(db_name, version) + if success: + # Update version tracker + conn2 = get_master_conn() + cur2 = conn2.cursor() + cur2.execute(""" + INSERT INTO tenant_schema_version (tenant_id, version) + VALUES (%s, %s) + ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW() + """, (tenant_id, version, version)) + conn2.commit() + cur2.close() + conn2.close() + results.append({"tenant_id": tenant_id, "db_name": db_name, "success": success}) + return results diff --git a/manager/services/tenant_service.py b/manager/services/tenant_service.py new file mode 100644 index 0000000..c06c69e --- /dev/null +++ b/manager/services/tenant_service.py @@ -0,0 +1,305 @@ +"""Tenant management service wrapping POS tenant_manager.""" +import os +import sys +import psycopg2 +from psycopg2 import sql +from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT + +# Add POS to path so we can reuse tenant_manager +POS_DIR = os.environ.get("POS_DIR", "/home/Autopartes/pos") +if POS_DIR not in sys.path: + sys.path.insert(0, POS_DIR) + +from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE, DEMO_DEFAULT_DAYS + + +def get_master_conn(): + return psycopg2.connect(MASTER_DB_URL) + + +def list_tenants(include_stats=False): + """List all tenants with optional per-tenant stats.""" + conn = get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active, + t.created_at, COALESCE(s.expires_at, NULL) as expires_at, + COALESCE(v.version, 'v0.0') as schema_version + FROM tenants t + LEFT JOIN subscriptions s ON s.tenant_id = t.id + LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id + ORDER BY t.id DESC + """) + cols = [desc[0] for desc in cur.description] + tenants = [] + for row in cur.fetchall(): + tenant = dict(zip(cols, row)) + tenant["created_at"] = str(tenant["created_at"]) if tenant["created_at"] else None + tenant["expires_at"] = str(tenant["expires_at"]) if tenant["expires_at"] else None + tenant["is_demo"] = tenant["plan"] in ("demo", "trial") + tenant["demo_days_left"] = None + if tenant["expires_at"]: + from datetime import datetime + try: + exp = datetime.fromisoformat(tenant["expires_at"].replace("Z", "+00:00")) + now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now() + tenant["demo_days_left"] = max(0, (exp - now).days) + except Exception: + pass + tenants.append(tenant) + cur.close() + conn.close() + + if include_stats: + for t in tenants: + t["stats"] = _get_tenant_quick_stats(t["db_name"]) + return tenants + + +def get_tenant(tenant_id): + """Get single tenant details.""" + conn = get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT t.id, t.name, t.db_name, t.subdomain, t.rfc, t.plan, t.is_active, + t.created_at, COALESCE(s.expires_at, NULL) as expires_at, + COALESCE(s.status, 'unknown') as subscription_status, + COALESCE(v.version, 'v0.0') as schema_version + FROM tenants t + LEFT JOIN subscriptions s ON s.tenant_id = t.id + LEFT JOIN tenant_schema_version v ON v.tenant_id = t.id + WHERE t.id = %s + """, (tenant_id,)) + row = cur.fetchone() + cur.close() + conn.close() + if not row: + return None + keys = ["id", "name", "db_name", "subdomain", "rfc", "plan", "is_active", + "created_at", "expires_at", "subscription_status", "schema_version"] + return {k: str(v) if v is not None else None for k, v in zip(keys, row)} + + +def _get_tenant_quick_stats(db_name): + """Quick stats for a tenant DB.""" + dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name) + try: + conn = psycopg2.connect(dsn, connect_timeout=5) + cur = conn.cursor() + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM employees WHERE is_active = true), + (SELECT COUNT(*) FROM inventory WHERE is_active = true), + (SELECT COUNT(*) FROM customers WHERE is_active = true), + (SELECT COUNT(*) FROM sales WHERE status = 'completed'), + pg_database_size(current_database()) + """) + emp, inv, cust, sales, size = cur.fetchone() + cur.close() + conn.close() + return { + "employees": emp, + "inventory_items": inv, + "customers": cust, + "completed_sales": sales, + "db_size_mb": round(size / (1024 * 1024), 2) + } + except Exception as e: + return {"error": str(e)} + + +def create_demo(name, email, demo_days=None, subdomain=None, pin="0000"): + """Provision a new demo tenant using POS tenant_manager.""" + from services.tenant_manager import provision_tenant + from datetime import datetime, timedelta + + days = demo_days or DEMO_DEFAULT_DAYS + if not subdomain: + from services.tenant_manager import generate_subdomain + subdomain = generate_subdomain(name) + # Ensure uniqueness by appending random suffix if needed + conn = get_master_conn() + cur = conn.cursor() + cur.execute("SELECT 1 FROM tenants WHERE subdomain = %s", (subdomain,)) + if cur.fetchone(): + import secrets + subdomain = f"{subdomain}-{secrets.token_hex(2)}" + cur.close() + conn.close() + + result = provision_tenant( + name=name, + rfc=None, + owner_name="Admin Demo", + owner_email=email, + owner_pin=pin, + subdomain=subdomain + ) + + # Mark as demo plan and set expiration + tenant_id = result["tenant_id"] + conn = get_master_conn() + cur = conn.cursor() + cur.execute("UPDATE tenants SET plan = 'demo' WHERE id = %s", (tenant_id,)) + cur.execute(""" + INSERT INTO subscriptions (tenant_id, plan, status, expires_at) + VALUES (%s, 'demo', 'active', %s) + ON CONFLICT (tenant_id) DO UPDATE SET + plan = 'demo', + status = 'active', + expires_at = EXCLUDED.expires_at + """, (tenant_id, datetime.now() + timedelta(days=days))) + conn.commit() + cur.close() + conn.close() + + result["demo_days"] = days + result["expires_at"] = str(datetime.now() + timedelta(days=days)) + result["access_url"] = f"https://{subdomain}.nexusautoparts.com.mx/pos/login" + result["owner_pin"] = pin + return result + + +def reset_tenant(tenant_id, keep_config=True): + """Reset a tenant: truncate business data but keep structure and owner.""" + tenant = get_tenant(tenant_id) + if not tenant: + raise ValueError("Tenant not found") + db_name = tenant["db_name"] + dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name) + + tables_to_truncate = [ + "inventory_operations", + "inventory", + "sale_items", + "sales", + "customer_payments", + "cash_register_closings", + "cash_register_movements", + "cash_registers", + "invoices", + "accounting_entries", + "journal_entries", + "service_orders", + "fleet_vehicles", + "crm_activities", + "quotations", + "quotation_items", + "savings_transactions", + "savings_accounts", + "supplier_orders", + "supplier_order_items", + "warranty_claims", + "notifications", + "inventory_uploads", + ] + + conn = psycopg2.connect(dsn) + cur = conn.cursor() + try: + for table in tables_to_truncate: + try: + cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE") + except Exception: + pass # Table may not exist + conn.commit() + success = True + except Exception as e: + conn.rollback() + success = False + raise RuntimeError(f"Reset failed: {e}") + finally: + cur.close() + conn.close() + + return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)} + + +def delete_tenant(tenant_id): + """Permanently delete a tenant and its database.""" + tenant = get_tenant(tenant_id) + if not tenant: + raise ValueError("Tenant not found") + db_name = tenant["db_name"] + + conn = get_master_conn() + cur = conn.cursor() + + # Drop database + try: + master_conn = psycopg2.connect(MASTER_DB_URL) + master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) + master_cur = master_conn.cursor() + master_cur.execute( + sql.SQL('DROP DATABASE IF EXISTS {}').format(sql.Identifier(db_name)) + ) + master_cur.close() + master_conn.close() + except Exception as e: + pass + + # Clean master records + cur.execute("DELETE FROM tenant_schema_version WHERE tenant_id = %s", (tenant_id,)) + cur.execute("DELETE FROM subscriptions WHERE tenant_id = %s", (tenant_id,)) + cur.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,)) + conn.commit() + cur.close() + conn.close() + return {"success": True, "tenant_id": tenant_id, "db_name": db_name} + + +def toggle_tenant(tenant_id, active): + """Activate or deactivate a tenant.""" + conn = get_master_conn() + cur = conn.cursor() + cur.execute("UPDATE tenants SET is_active = %s WHERE id = %s", (active, tenant_id)) + conn.commit() + rowcount = cur.rowcount + cur.close() + conn.close() + return {"success": rowcount > 0, "tenant_id": tenant_id, "is_active": active} + + +def get_tenant_login_url(subdomain): + """Generate login URL for a tenant.""" + domain = os.environ.get("NEXUS_DOMAIN", "nexusautoparts.com.mx") + return f"https://{subdomain}.{domain}/pos/login" + + +def get_dashboard_stats(): + """Global stats for the manager dashboard.""" + conn = get_master_conn() + cur = conn.cursor() + + cur.execute("SELECT COUNT(*) FROM tenants") + total = cur.fetchone()[0] + + cur.execute("SELECT COUNT(*) FROM tenants WHERE is_active = true") + active = cur.fetchone()[0] + + cur.execute("SELECT COUNT(*) FROM tenants WHERE plan = 'demo'") + demos = cur.fetchone()[0] + + cur.execute(""" + SELECT COUNT(*) FROM subscriptions + WHERE status = 'active' AND expires_at < NOW() + INTERVAL '7 days' + """) + expiring_soon = cur.fetchone()[0] + + cur.close() + conn.close() + + # Get system health summary + from services.health_service import check_disk_space, check_memory + disk = check_disk_space() + mem = check_memory() + + return { + "tenants": {"total": total, "active": active, "demos": demos, "expiring_soon": expiring_soon}, + "system": { + "disk_percent": disk.get("percent_used"), + "memory_percent": mem.get("percent_used"), + "disk_free_gb": disk.get("free_gb"), + "memory_available_gb": mem.get("available_gb") + } + } diff --git a/manager/static/css/manager.css b/manager/static/css/manager.css new file mode 100644 index 0000000..132ae17 --- /dev/null +++ b/manager/static/css/manager.css @@ -0,0 +1,663 @@ +:root { + --bg-dark: #0f1117; + --bg-card: #1a1d26; + --bg-sidebar: #161920; + --bg-hover: #232631; + --border: #2a2e3b; + --text-primary: #e8eaf0; + --text-secondary: #9ca3af; + --accent: #3b82f6; + --accent-hover: #2563eb; + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #06b6d4; + --purple: #8b5cf6; + --radius: 10px; + --shadow: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -1px rgba(0,0,0,0.2); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-dark); + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + overflow: hidden; +} + +/* ─── Login ─────────────────────────────────────────────────────────────── */ +.login-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #0f1117 0%, #1a1d26 100%); +} + +.login-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: var(--shadow); +} + +.login-logo { + text-align: center; + margin-bottom: 32px; +} + +.login-logo i { + font-size: 48px; + color: var(--accent); + margin-bottom: 12px; +} + +.login-logo h1 { + font-size: 24px; + font-weight: 700; + margin-bottom: 4px; +} + +.login-logo p { + color: var(--text-secondary); + font-size: 14px; +} + +/* ─── Layout ────────────────────────────────────────────────────────────── */ +.app { + display: flex; + height: 100vh; + overflow: hidden; +} + +.sidebar { + width: 260px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.sidebar-brand { + padding: 20px 24px; + display: flex; + align-items: center; + gap: 12px; + font-size: 18px; + font-weight: 700; + border-bottom: 1px solid var(--border); +} + +.sidebar-brand i { + color: var(--accent); + font-size: 22px; +} + +.sidebar-nav { + flex: 1; + padding: 16px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: 8px; + color: var(--text-secondary); + text-decoration: none; + transition: all 0.2s; + position: relative; +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav-item.active { + background: rgba(59, 130, 246, 0.15); + color: var(--accent); + font-weight: 500; +} + +.nav-item .badge { + margin-left: auto; + background: var(--bg-hover); + color: var(--text-secondary); + font-size: 11px; + padding: 2px 8px; + border-radius: 20px; +} + +.sidebar-footer { + padding: 16px; + border-top: 1px solid var(--border); +} + +.user-info { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--text-secondary); + font-size: 13px; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.topbar { + height: 60px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; +} + +.topbar h2 { + font-size: 18px; + font-weight: 600; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--success); +} + +.status-indicator i { + font-size: 8px; +} + +.status-indicator.warning { color: var(--warning); } +.status-indicator.error { color: var(--danger); } + +.content { + flex: 1; + overflow-y: auto; + padding: 24px 28px; +} + +.page { animation: fadeIn 0.2s ease; } + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ─── Cards & Grid ──────────────────────────────────────────────────────── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.card-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-header h3 { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} + +.card-header h3 i { + color: var(--accent); +} + +.card-body { + padding: 24px; +} + +.card-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + display: flex; + align-items: center; + gap: 16px; + box-shadow: var(--shadow); +} + +.stat-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #fff; +} + +.bg-blue { background: linear-gradient(135deg, #3b82f6, #2563eb); } +.bg-green { background: linear-gradient(135deg, #22c55e, #16a34a); } +.bg-purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); } +.bg-orange { background: linear-gradient(135deg, #f59e0b, #d97706); } +.bg-red { background: linear-gradient(135deg, #ef4444, #dc2626); } +.bg-cyan { background: linear-gradient(135deg, #06b6d4, #0891b2); } + +.stat-info h3 { + font-size: 24px; + font-weight: 700; + margin-bottom: 2px; +} + +.stat-info p { + color: var(--text-secondary); + font-size: 13px; +} + +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} + +/* ─── Tables ────────────────────────────────────────────────────────────── */ +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th { + text-align: left; + padding: 12px 16px; + color: var(--text-secondary); + font-weight: 500; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.table td { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.table tr:hover td { + background: rgba(255,255,255,0.02); +} + +.table.compact td, .table.compact th { + padding: 10px 12px; +} + +/* ─── Forms ─────────────────────────────────────────────────────────────── */ +.form-group { + margin-bottom: 18px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 14px; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.input-group { + display: flex; + align-items: center; +} + +.input-group input { + border-radius: 8px 0 0 8px; + border-right: none; +} + +.input-suffix { + padding: 10px 14px; + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: 0 8px 8px 0; + color: var(--text-secondary); + font-size: 13px; + white-space: nowrap; +} + +/* ─── Buttons ───────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border-radius: 8px; + border: none; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} + +.btn-primary:hover { background: var(--accent-hover); } + +.btn-secondary { + background: var(--bg-hover); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:hover { background: var(--border); } + +.btn-danger { + background: var(--danger); + color: #fff; +} + +.btn-danger:hover { background: #dc2626; } + +.btn-success { + background: var(--success); + color: #fff; +} + +.btn-sm { padding: 6px 12px; font-size: 12px; } +.btn-block { width: 100%; justify-content: center; } +.btn-icon { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px; + border-radius: 6px; +} + +.btn-icon:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ─── Badges & Tags ─────────────────────────────────────────────────────── */ +.tag { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.tag-success { background: rgba(34,197,94,0.15); color: var(--success); } +.tag-warning { background: rgba(245,158,11,0.15); color: var(--warning); } +.tag-danger { background: rgba(239,68,68,0.15); color: var(--danger); } +.tag-info { background: rgba(6,182,212,0.15); color: var(--info); } +.tag-default { background: var(--bg-hover); color: var(--text-secondary); } + +/* ─── Alerts & Boxes ────────────────────────────────────────────────────── */ +.alert { + padding: 12px 16px; + border-radius: 8px; + font-size: 13px; + margin-top: 12px; +} + +.alert-error { + background: rgba(239,68,68,0.1); + border: 1px solid rgba(239,68,68,0.2); + color: var(--danger); +} + +.alert-success { + background: rgba(34,197,94,0.1); + border: 1px solid rgba(34,197,94,0.2); + color: var(--success); +} + +.result-box { + background: rgba(34,197,94,0.05); + border: 1px solid rgba(34,197,94,0.2); + border-radius: 8px; + padding: 16px; + margin-top: 16px; +} + +.result-box h4 { + margin-bottom: 8px; + color: var(--success); +} + +.result-box .copy-row { + display: flex; + align-items: center; + gap: 10px; + margin: 6px 0; + font-size: 13px; +} + +.result-box code { + background: var(--bg-dark); + padding: 4px 8px; + border-radius: 4px; + font-family: 'Fira Code', monospace; + font-size: 12px; +} + +.log-box { + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; + font-family: 'Fira Code', monospace; + font-size: 12px; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +/* ─── Modal ─────────────────────────────────────────────────────────────── */ +.modal { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 100%; + max-width: 480px; + box-shadow: var(--shadow); + animation: modalIn 0.2s ease; +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-body { + padding: 24px; + color: var(--text-secondary); +} + +.modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* ─── Toast ─────────────────────────────────────────────────────────────── */ +#toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 18px; + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: 10px; + animation: toastIn 0.3s ease; + min-width: 280px; +} + +.toast.success { border-left: 3px solid var(--success); } +.toast.error { border-left: 3px solid var(--danger); } +.toast.warning { border-left: 3px solid var(--warning); } + +@keyframes toastIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ─── Utilities ─────────────────────────────────────────────────────────── */ +.loading { + color: var(--text-secondary); + font-style: italic; + padding: 20px; + text-align: center; +} + +.text-muted { color: var(--text-secondary); } +.text-center { text-align: center; } + +.health-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.health-item:last-child { border-bottom: none; } + +.health-label { color: var(--text-secondary); font-size: 13px; } +.health-value { font-weight: 500; font-size: 13px; } + +.health-bar-bg { + height: 6px; + background: var(--bg-dark); + border-radius: 3px; + margin-top: 8px; + overflow: hidden; +} + +.health-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.5s ease; +} + +/* ─── Responsive ────────────────────────────────────────────────────────── */ +@media (max-width: 1200px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .grid-2, .grid-3 { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .sidebar { width: 64px; } + .sidebar-brand span, .nav-item span, .user-info span { display: none; } + .stats-grid { grid-template-columns: 1fr; } +} diff --git a/manager/static/js/manager.js b/manager/static/js/manager.js new file mode 100644 index 0000000..327c33b --- /dev/null +++ b/manager/static/js/manager.js @@ -0,0 +1,479 @@ +/** + * Nexus Instance Manager — Frontend SPA + */ + +const API_BASE = ""; +let currentToken = localStorage.getItem("manager_token") || ""; + +// ─── Router ──────────────────────────────────────────────────────────────── +const routes = { + "#dashboard": "dashboard", + "#demos": "demos", + "#tenants": "tenants", + "#health": "health", + "#migrations": "migrations" +}; + +function navigate() { + const hash = window.location.hash || "#dashboard"; + const page = routes[hash] || "dashboard"; + + document.querySelectorAll(".page").forEach(p => p.style.display = "none"); + document.getElementById(`page-${page}`).style.display = "block"; + + document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active")); + const nav = document.querySelector(`.nav-item[data-page="${page}"]`); + if (nav) nav.classList.add("active"); + + const titles = { + dashboard: "Dashboard", + demos: "Crear Demos", + tenants: "Tenants", + health: "Salud del Sistema", + migrations: "Migraciones" + }; + document.getElementById("page-title").textContent = titles[page] || "Dashboard"; + + // Load page data + if (page === "dashboard") loadDashboard(); + if (page === "demos") loadDemos(); + if (page === "tenants") loadTenants(); + if (page === "health") loadHealth(); + if (page === "migrations") loadMigrations(); +} + +window.addEventListener("hashchange", navigate); + +// ─── Auth ────────────────────────────────────────────────────────────────── +async function api(url, opts = {}) { + const options = { + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${currentToken}` + }, + ...opts + }; + if (opts.body && typeof opts.body !== "string") { + options.body = JSON.stringify(opts.body); + } + const res = await fetch(`${API_BASE}${url}`, options); + if (res.status === 401) { + logout(); + return null; + } + const data = await res.json().catch(() => ({})); + return { status: res.status, data }; +} + +function showLogin() { + document.getElementById("login-screen").style.display = "flex"; + document.getElementById("app").style.display = "none"; +} + +function showApp() { + document.getElementById("login-screen").style.display = "none"; + document.getElementById("app").style.display = "flex"; + navigate(); +} + +async function initAuth() { + if (!currentToken) { + showLogin(); + return; + } + const res = await api("/api/auth/me"); + if (res && res.status === 200) { + document.getElementById("user-email").textContent = res.data.user.email; + showApp(); + } else { + showLogin(); + } +} + +document.getElementById("login-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const email = document.getElementById("login-email").value; + const password = document.getElementById("login-password").value; + const errEl = document.getElementById("login-error"); + errEl.style.display = "none"; + + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }) + }); + const data = await res.json(); + + if (res.ok) { + currentToken = data.access_token; + localStorage.setItem("manager_token", currentToken); + document.getElementById("user-email").textContent = data.user.email; + showApp(); + } else { + errEl.textContent = data.error || "Error de autenticación"; + errEl.style.display = "block"; + } +}); + +function logout() { + currentToken = ""; + localStorage.removeItem("manager_token"); + showLogin(); +} + +// ─── Dashboard ───────────────────────────────────────────────────────────── +async function loadDashboard() { + const statsRes = await api("/api/admin/stats"); + if (statsRes && statsRes.status === 200) { + const s = statsRes.data; + document.getElementById("stat-total").textContent = s.tenants.total; + document.getElementById("stat-active").textContent = s.tenants.active; + document.getElementById("stat-demos").textContent = s.tenants.demos; + document.getElementById("stat-expiring").textContent = s.tenants.expiring_soon; + + const healthEl = document.getElementById("system-health-summary"); + healthEl.innerHTML = ` +
+ Disco usado + ${s.system.disk_percent}% +
+
+
+ Memoria usada + ${s.system.memory_percent}% +
+
+
+ Disco libre + ${s.system.disk_free_gb} GB +
+
+ RAM disponible + ${s.system.memory_available_gb} GB +
+ `; + } + + const tenantsRes = await api("/api/demos"); + if (tenantsRes && tenantsRes.status === 200) { + const tbody = document.getElementById("recent-demos-table"); + const demos = tenantsRes.data.data.slice(0, 5); + tbody.innerHTML = demos.map(d => ` + + ${escapeHtml(d.name)} + ${escapeHtml(d.subdomain)} + ${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"} + ${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")} + + `).join("") || `No hay demos activas`; + } +} + +function getBarColor(pct) { + if (pct < 60) return "var(--success)"; + if (pct < 85) return "var(--warning)"; + return "var(--danger)"; +} + +// ─── Demos ───────────────────────────────────────────────────────────────── +async function loadDemos() { + const res = await api("/api/demos"); + if (!res || res.status !== 200) return; + + const tbody = document.getElementById("demos-table"); + const demos = res.data.data; + tbody.innerHTML = demos.map(d => ` + + ${escapeHtml(d.name)} + ${escapeHtml(d.subdomain)} + ${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"} + + + + + + + `).join("") || `No hay demos`; +} + +document.getElementById("demo-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = e.target.querySelector("button[type=submit]"); + const originalText = btn.innerHTML; + btn.innerHTML = ` Creando...`; + btn.disabled = true; + + const payload = { + name: document.getElementById("demo-name").value, + email: document.getElementById("demo-email").value, + days: parseInt(document.getElementById("demo-days").value), + pin: document.getElementById("demo-pin").value, + subdomain: document.getElementById("demo-subdomain").value || undefined + }; + + const res = await api("/api/demos", { method: "POST", body: payload }); + const resultBox = document.getElementById("demo-result"); + + if (res && res.status === 201) { + const d = res.data.data; + resultBox.innerHTML = ` +

Demo creada exitosamente

+
URL: ${d.access_url}
+
Subdominio: ${d.subdomain}
+
PIN Owner: ${d.owner_pin}
+
Expira: ${new Date(d.expires_at).toLocaleDateString()}
+ `; + resultBox.style.display = "block"; + toast("Demo creada correctamente", "success"); + document.getElementById("demo-form").reset(); + loadDemos(); + } else { + toast(res?.data?.error || "Error al crear demo", "error"); + } + + btn.innerHTML = originalText; + btn.disabled = false; +}); + +// ─── Tenants ─────────────────────────────────────────────────────────────── +async function loadTenants(withStats = false) { + const res = await api(`/api/tenants?stats=${withStats}`); + if (!res || res.status !== 200) return; + + const tbody = document.getElementById("tenants-table"); + const tenants = res.data.data; + document.getElementById("tenant-count").textContent = tenants.length; + + tbody.innerHTML = tenants.map(t => ` + + ${t.id} + ${escapeHtml(t.name)} + ${escapeHtml(t.subdomain)} + ${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")} + ${t.schema_version || "v0.0"} + ${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")} + ${formatDate(t.created_at)} + + + + + + + `).join("") || `No hay tenants`; +} + +document.getElementById("tenant-search")?.addEventListener("input", (e) => { + const term = e.target.value.toLowerCase(); + document.querySelectorAll("#tenants-table tr").forEach(row => { + row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none"; + }); +}); + +// ─── Health ──────────────────────────────────────────────────────────────── +async function loadHealth() { + const res = await api("/api/health"); + if (!res || res.status !== 200) return; + + const h = res.data; + + // PostgreSQL + const pg = h.postgresql; + document.getElementById("health-postgresql").innerHTML = pg.status === "ok" ? ` +
EstadoOnline
+
Versión${pg.version}
+
Master DB${pg.master_size_mb} MB
+ ` : renderError(pg.error); + + // Redis + const rd = h.redis; + document.getElementById("health-redis").innerHTML = rd.status === "ok" ? ` +
EstadoOnline
+
Versión${rd.version}
+
Memoria${rd.used_memory_human}
+
Clientes${rd.connected_clients}
+ ` : renderError(rd.error); + + // Disk + const dk = h.disk; + document.getElementById("health-disk").innerHTML = dk.status === "ok" ? ` +
Total${dk.total_gb} GB
+
Usado${dk.used_gb} GB (${dk.percent_used}%)
+
+
Libre${dk.free_gb} GB
+ ` : renderError(dk.error); + + // Memory + const mem = h.memory; + document.getElementById("health-memory").innerHTML = mem.status === "ok" ? ` +
Total${mem.total_gb} GB
+
Usada${mem.used_gb} GB (${mem.percent_used}%)
+
+
Disponible${mem.available_gb} GB
+ ` : renderError(mem.error); + + // Services + const svcs = h.services || {}; + document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => ` +
+ ${name} + ${s.state} +
+ `).join(""); + + // HTTP + const httpChecks = ["pos", "dashboard", "quart"]; + document.getElementById("health-http").innerHTML = ` +
+ ${httpChecks.map(key => { + const svc = h[key]; + const ok = svc && svc.status === "ok"; + return ` +
+ ${key.toUpperCase()} + + ${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")} + +
+ `; + }).join("")} +
+ `; +} + +function renderError(msg) { + return `
${escapeHtml(msg)}
`; +} + +// ─── Migrations ──────────────────────────────────────────────────────────── +async function loadMigrations() { + const res = await api("/api/admin/migrations"); + if (!res || res.status !== 200) return; + + const tbody = document.getElementById("migrations-table"); + const tenants = res.data.tenants || []; + tbody.innerHTML = tenants.map(t => { + const needsUpdate = t.version !== (res.data.migrations.slice(-1)[0]?.version || t.version); + return ` + + ${escapeHtml(t.name)} + ${t.db_name} + ${t.version} + ${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")} + + `; + }).join("") || `No hay tenants`; +} + +async function runAllMigrations() { + if (!confirm("¿Ejecutar todas las migraciones pendientes en TODOS los tenants?")) return; + + const logBox = document.getElementById("migration-log"); + logBox.style.display = "block"; + logBox.textContent = "Ejecutando migraciones..."; + + const res = await api("/api/admin/migrations/run-all", { method: "POST" }); + if (res && res.status === 200) { + logBox.textContent = res.data.log || "Completado"; + toast("Migraciones ejecutadas", "success"); + loadMigrations(); + } else { + logBox.textContent = "Error: " + (res?.data?.error || "Unknown"); + toast("Error en migraciones", "error"); + } +} + +// ─── Actions ─────────────────────────────────────────────────────────────── +async function toggleTenant(id, active) { + const res = await api(`/api/tenants/${id}/toggle`, { + method: "POST", + body: { active } + }); + if (res && res.status === 200) { + toast(active ? "Tenant activado" : "Tenant desactivado", "success"); + loadTenants(); + loadDemos(); + } else { + toast(res?.data?.error || "Error", "error"); + } +} + +async function resetTenant(id) { + if (!confirm("¿Resetear TODOS los datos de negocio de este tenant? Se conservan empleados y configuración.")) return; + + const res = await api(`/api/tenants/${id}/reset`, { method: "POST" }); + if (res && res.status === 200) { + toast("Tenant reseteado", "success"); + } else { + toast(res?.data?.error || "Error al resetear", "error"); + } +} + +function confirmDelete(id, name) { + openModal( + "Eliminar Tenant", + `¿Eliminar permanentemente ${escapeHtml(name)}? Esta acción no se puede deshacer. Se borrará la base de datos completa.`, + async () => { + const res = await api(`/api/tenants/${id}`, { method: "DELETE" }); + if (res && res.status === 200) { + toast("Tenant eliminado", "success"); + loadTenants(); + loadDemos(); + } else { + toast(res?.data?.error || "Error al eliminar", "error"); + } + closeModal(); + } + ); +} + +// ─── Modal ───────────────────────────────────────────────────────────────── +function openModal(title, body, onConfirm) { + document.getElementById("modal-title").textContent = title; + document.getElementById("modal-body").innerHTML = body; + const btn = document.getElementById("modal-confirm-btn"); + btn.onclick = onConfirm; + document.getElementById("modal").style.display = "flex"; +} + +function closeModal() { + document.getElementById("modal").style.display = "none"; +} + +// ─── Toast ───────────────────────────────────────────────────────────────── +function toast(message, type = "info") { + const container = document.getElementById("toast-container"); + const el = document.createElement("div"); + el.className = `toast ${type}`; + el.innerHTML = ` ${escapeHtml(message)}`; + container.appendChild(el); + setTimeout(() => { + el.style.opacity = "0"; + el.style.transform = "translateX(100%)"; + setTimeout(() => el.remove(), 300); + }, 4000); +} + +// ─── Utilities ───────────────────────────────────────────────────────────── +function escapeHtml(text) { + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +function tag(text, type) { + return `${escapeHtml(text)}`; +} + +function formatDate(iso) { + if (!iso) return "-"; + const d = new Date(iso); + return d.toLocaleDateString("es-MX"); +} + +function copyText(text) { + navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success")); +} + +// ─── Init ────────────────────────────────────────────────────────────────── +document.addEventListener("DOMContentLoaded", initAuth); diff --git a/manager/systemd/nexus-manager.service b/manager/systemd/nexus-manager.service new file mode 100644 index 0000000..89ec4ed --- /dev/null +++ b/manager/systemd/nexus-manager.service @@ -0,0 +1,21 @@ +[Unit] +Description=Nexus Instance Manager (Control Central) +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/home/Autopartes/manager +ExecStart=/usr/local/bin/gunicorn -w 2 --threads 4 -b 0.0.0.0:5003 "app:create_app()" +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 +Environment=PYTHONPATH=/home/Autopartes/manager:/home/Autopartes/pos +Environment=MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts +Environment=TENANT_DB_URL_TEMPLATE=postgresql://postgres@localhost/{db_name} +Environment=MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string +Environment=POS_DIR=/home/Autopartes/pos +Environment=REDIS_URL=redis://localhost:6379/0 + +[Install] +WantedBy=multi-user.target diff --git a/manager/templates/index.html b/manager/templates/index.html new file mode 100644 index 0000000..104e0eb --- /dev/null +++ b/manager/templates/index.html @@ -0,0 +1,324 @@ + + + + + + Nexus Instance Manager + + + + + + + + + + + + + + + +
+ + + + diff --git a/manager/wsgi.py b/manager/wsgi.py new file mode 100644 index 0000000..4334f43 --- /dev/null +++ b/manager/wsgi.py @@ -0,0 +1,4 @@ +"""WSGI entry point for Nexus Instance Manager.""" +from app import create_app + +application = create_app()