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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user