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:
129
manager/README.md
Normal file
129
manager/README.md
Normal file
@@ -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
|
||||||
99
manager/app.py
Normal file
99
manager/app.py
Normal file
@@ -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/<path:filename>")
|
||||||
|
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)
|
||||||
0
manager/blueprints/__init__.py
Normal file
0
manager/blueprints/__init__.py
Normal file
35
manager/blueprints/admin_bp.py
Normal file
35
manager/blueprints/admin_bp.py
Normal 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})
|
||||||
99
manager/blueprints/auth_bp.py
Normal file
99
manager/blueprints/auth_bp.py
Normal 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})
|
||||||
42
manager/blueprints/demos_bp.py
Normal file
42
manager/blueprints/demos_bp.py
Normal 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})
|
||||||
18
manager/blueprints/health_bp.py
Normal file
18
manager/blueprints/health_bp.py
Normal 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))
|
||||||
60
manager/blueprints/tenants_bp.py
Normal file
60
manager/blueprints/tenants_bp.py
Normal 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
|
||||||
50
manager/config.py
Normal file
50
manager/config.py
Normal file
@@ -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"
|
||||||
5
manager/requirements.txt
Normal file
5
manager/requirements.txt
Normal file
@@ -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
|
||||||
86
manager/scripts/init_manager.py
Normal file
86
manager/scripts/init_manager.py
Normal file
@@ -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()
|
||||||
0
manager/services/__init__.py
Normal file
0
manager/services/__init__.py
Normal file
166
manager/services/health_service.py
Normal file
166
manager/services/health_service.py
Normal file
@@ -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)}
|
||||||
100
manager/services/migration_service.py
Normal file
100
manager/services/migration_service.py
Normal file
@@ -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
|
||||||
305
manager/services/tenant_service.py
Normal file
305
manager/services/tenant_service.py
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
663
manager/static/css/manager.css
Normal file
663
manager/static/css/manager.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
479
manager/static/js/manager.js
Normal file
479
manager/static/js/manager.js
Normal file
@@ -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 = `
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label">Disco usado</span>
|
||||||
|
<span class="health-value">${s.system.disk_percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.disk_percent}%; background:${getBarColor(s.system.disk_percent)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px">
|
||||||
|
<span class="health-label">Memoria usada</span>
|
||||||
|
<span class="health-value">${s.system.memory_percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.memory_percent}%; background:${getBarColor(s.system.memory_percent)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px">
|
||||||
|
<span class="health-label">Disco libre</span>
|
||||||
|
<span class="health-value">${s.system.disk_free_gb} GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label">RAM disponible</span>
|
||||||
|
<span class="health-value">${s.system.memory_available_gb} GB</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(d.name)}</strong></td>
|
||||||
|
<td><code>${escapeHtml(d.subdomain)}</code></td>
|
||||||
|
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||||
|
<td>${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos activas</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(d.name)}</strong></td>
|
||||||
|
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
|
||||||
|
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
|
||||||
|
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
|
||||||
|
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("demo-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector("button[type=submit]");
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> 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 = `
|
||||||
|
<h4><i class="fas fa-check-circle"></i> Demo creada exitosamente</h4>
|
||||||
|
<div class="copy-row"><strong>URL:</strong> <code>${d.access_url}</code> <button class="btn-icon" onclick="copyText('${d.access_url}')"><i class="fas fa-copy"></i></button></div>
|
||||||
|
<div class="copy-row"><strong>Subdominio:</strong> <code>${d.subdomain}</code></div>
|
||||||
|
<div class="copy-row"><strong>PIN Owner:</strong> <code>${d.owner_pin}</code></div>
|
||||||
|
<div class="copy-row"><strong>Expira:</strong> ${new Date(d.expires_at).toLocaleDateString()}</div>
|
||||||
|
`;
|
||||||
|
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 => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.id}</td>
|
||||||
|
<td><strong>${escapeHtml(t.name)}</strong></td>
|
||||||
|
<td><code>${escapeHtml(t.subdomain)}</code></td>
|
||||||
|
<td>${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")}</td>
|
||||||
|
<td>${t.schema_version || "v0.0"}</td>
|
||||||
|
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||||
|
<td>${formatDate(t.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
|
||||||
|
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
|
||||||
|
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("") || `<tr><td colspan="8" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${pg.version}</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Master DB</span><span class="health-value">${pg.master_size_mb} MB</span></div>
|
||||||
|
` : renderError(pg.error);
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
const rd = h.redis;
|
||||||
|
document.getElementById("health-redis").innerHTML = rd.status === "ok" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${rd.version}</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Memoria</span><span class="health-value">${rd.used_memory_human}</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Clientes</span><span class="health-value">${rd.connected_clients}</span></div>
|
||||||
|
` : renderError(rd.error);
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
const dk = h.disk;
|
||||||
|
document.getElementById("health-disk").innerHTML = dk.status === "ok" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${dk.total_gb} GB</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Usado</span><span class="health-value">${dk.used_gb} GB (${dk.percent_used}%)</span></div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${dk.percent_used}%; background:${getBarColor(dk.percent_used)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px"><span class="health-label">Libre</span><span class="health-value">${dk.free_gb} GB</span></div>
|
||||||
|
` : renderError(dk.error);
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
const mem = h.memory;
|
||||||
|
document.getElementById("health-memory").innerHTML = mem.status === "ok" ? `
|
||||||
|
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${mem.total_gb} GB</span></div>
|
||||||
|
<div class="health-item"><span class="health-label">Usada</span><span class="health-value">${mem.used_gb} GB (${mem.percent_used}%)</span></div>
|
||||||
|
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${mem.percent_used}%; background:${getBarColor(mem.percent_used)}"></div></div>
|
||||||
|
<div class="health-item" style="margin-top:12px"><span class="health-label">Disponible</span><span class="health-value">${mem.available_gb} GB</span></div>
|
||||||
|
` : renderError(mem.error);
|
||||||
|
|
||||||
|
// Services
|
||||||
|
const svcs = h.services || {};
|
||||||
|
document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => `
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label"><i class="fas fa-${s.active ? "check-circle" : "times-circle"}" style="color:${s.active ? "var(--success)" : "var(--danger)"}; margin-right:6px"></i>${name}</span>
|
||||||
|
<span class="health-value" style="color:${s.active ? "var(--success)" : "var(--danger)"}">${s.state}</span>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
const httpChecks = ["pos", "dashboard", "quart"];
|
||||||
|
document.getElementById("health-http").innerHTML = `
|
||||||
|
<div class="grid-3">
|
||||||
|
${httpChecks.map(key => {
|
||||||
|
const svc = h[key];
|
||||||
|
const ok = svc && svc.status === "ok";
|
||||||
|
return `
|
||||||
|
<div class="health-item">
|
||||||
|
<span class="health-label">${key.toUpperCase()}</span>
|
||||||
|
<span class="health-value" style="color:${ok ? "var(--success)" : "var(--danger)"}">
|
||||||
|
${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(msg) {
|
||||||
|
return `<div class="text-muted" style="padding:20px; text-align:center; color:var(--danger)"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(msg)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(t.name)}</td>
|
||||||
|
<td><code>${t.db_name}</code></td>
|
||||||
|
<td>${t.version}</td>
|
||||||
|
<td>${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <strong>${escapeHtml(name)}</strong>? 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 = `<i class="fas fa-${type === "success" ? "check-circle" : type === "error" ? "exclamation-circle" : "info-circle"}"></i> ${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 `<span class="tag tag-${type}">${escapeHtml(text)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
21
manager/systemd/nexus-manager.service
Normal file
21
manager/systemd/nexus-manager.service
Normal file
@@ -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
|
||||||
324
manager/templates/index.html
Normal file
324
manager/templates/index.html
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nexus Instance Manager</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/manager.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div id="login-screen" class="login-screen">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<h1>Nexus Manager</h1>
|
||||||
|
<p>Control Central de Instancias</p>
|
||||||
|
</div>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" id="login-email" required placeholder="admin@nexus.local">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Contraseña</label>
|
||||||
|
<input type="password" id="login-password" required placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<i class="fas fa-sign-in-alt"></i> Ingresar
|
||||||
|
</button>
|
||||||
|
<div id="login-error" class="alert alert-error" style="display:none;"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main App -->
|
||||||
|
<div id="app" class="app" style="display:none;">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>Nexus Manager</span>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="#/dashboard" class="nav-item active" data-page="dashboard">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/demos" class="nav-item" data-page="demos">
|
||||||
|
<i class="fas fa-rocket"></i>
|
||||||
|
<span>Crear Demos</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/tenants" class="nav-item" data-page="tenants">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
|
<span>Tenants</span>
|
||||||
|
<span class="badge" id="tenant-count">0</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/health" class="nav-item" data-page="health">
|
||||||
|
<i class="fas fa-heartbeat"></i>
|
||||||
|
<span>Salud</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/migrations" class="nav-item" data-page="migrations">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>Migraciones</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info">
|
||||||
|
<span id="user-email">admin</span>
|
||||||
|
<button onclick="logout()" class="btn-icon" title="Cerrar sesión">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<h2 id="page-title">Dashboard</h2>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="status-indicator" id="system-status">
|
||||||
|
<i class="fas fa-circle"></i> Online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Dashboard Page -->
|
||||||
|
<section id="page-dashboard" class="page">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-blue"><i class="fas fa-building"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-total">0</h3>
|
||||||
|
<p>Total Tenants</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-green"><i class="fas fa-check-circle"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-active">0</h3>
|
||||||
|
<p>Activos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-purple"><i class="fas fa-rocket"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-demos">0</h3>
|
||||||
|
<p>Demos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon bg-orange"><i class="fas fa-clock"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 id="stat-expiring">0</h3>
|
||||||
|
<p>Expiran pronto</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-server"></i> Estado del Sistema</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="system-health-summary">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-building"></i> Demos Recientes</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Nombre</th><th>Subdominio</th><th>Expira</th><th>Estado</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recent-demos-table">
|
||||||
|
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Demos Page -->
|
||||||
|
<section id="page-demos" class="page" style="display:none;">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-plus-circle"></i> Nueva Demo</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="demo-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nombre del negocio *</label>
|
||||||
|
<input type="text" id="demo-name" required placeholder="Refaccionaria López">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email de contacto</label>
|
||||||
|
<input type="email" id="demo-email" placeholder="cliente@email.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Días de vigencia</label>
|
||||||
|
<input type="number" id="demo-days" value="14" min="1" max="90">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PIN del owner</label>
|
||||||
|
<input type="text" id="demo-pin" value="0000" maxlength="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Subdominio (opcional)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="demo-subdomain" placeholder="refaccionaria-lopez">
|
||||||
|
<span class="input-suffix">.nexusautoparts.com.mx</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-rocket"></i> Crear Demo
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="demo-result" class="result-box" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-list"></i> Demos Activas</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Negocio</th><th>URL</th><th>Días rest.</th><th>Acciones</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="demos-table">
|
||||||
|
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tenants Page -->
|
||||||
|
<section id="page-tenants" class="page" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-building"></i> Todos los Tenants</h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
<input type="text" id="tenant-search" placeholder="Buscar..." class="input-sm">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="loadTenants(true)">
|
||||||
|
<i class="fas fa-sync"></i> Refrescar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Subdominio</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Versión</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Creado</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tenants-table">
|
||||||
|
<tr><td colspan="8" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Health Page -->
|
||||||
|
<section id="page-health" class="page" style="display:none;">
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-database"></i> PostgreSQL</h3></div>
|
||||||
|
<div class="card-body" id="health-postgresql"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-bolt"></i> Redis</h3></div>
|
||||||
|
<div class="card-body" id="health-redis"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-hdd"></i> Disco</h3></div>
|
||||||
|
<div class="card-body" id="health-disk"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-memory"></i> Memoria</h3></div>
|
||||||
|
<div class="card-body" id="health-memory"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-cogs"></i> Servicios Systemd</h3></div>
|
||||||
|
<div class="card-body" id="health-services"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h3><i class="fas fa-network-wired"></i> Servicios HTTP</h3></div>
|
||||||
|
<div class="card-body" id="health-http"><div class="loading">...</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Migrations Page -->
|
||||||
|
<section id="page-migrations" class="page" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-database"></i> Migraciones de Schema</h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-primary" onclick="runAllMigrations()">
|
||||||
|
<i class="fas fa-play"></i> Ejecutar todas pendientes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="migration-log" class="log-box" style="display:none;"></div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Tenant</th><th>DB</th><th>Versión actual</th><th>Estado</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="migrations-table">
|
||||||
|
<tr><td colspan="4" class="text-muted">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-overlay" onclick="closeModal()"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title">Confirmar</h3>
|
||||||
|
<button class="btn-icon" onclick="closeModal()"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-body"></div>
|
||||||
|
<div class="modal-footer" id="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Cancelar</button>
|
||||||
|
<button class="btn btn-danger" id="modal-confirm-btn">Confirmar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="/static/js/manager.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
manager/wsgi.py
Normal file
4
manager/wsgi.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""WSGI entry point for Nexus Instance Manager."""
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
application = create_app()
|
||||||
Reference in New Issue
Block a user