# modules/sites.py — Managed application sites registry + live status import re import time from urllib.request import urlopen, Request from urllib.error import URLError from config import MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER from modules.backups import _ssh_main, get_all_root_containers, get_container_status # ──────────────────────────────────────────────────────────────── # STATIC SITE REGISTRY (source of truth for Application Sites UI) # ──────────────────────────────────────────────────────────────── PLATFORM = { 'id': 'cloudops', 'name': 'CloudOps Platform', 'tagline': 'Navitrends ops dashboard', 'domain': 'cloudops.nav.ovh', 'domain_protocol': 'https', 'ssl_configured': True, 'port': 8088, 'internal_port': 5000, 'container': 'management-platform', 'health_path': '/', 'brand_color': '#3b82f6', 'icon': 'fa-gauge-high', } SITES = [ { 'id': 'erpnext', 'name': 'ERPNext', 'tagline': 'Enterprise resource planning', 'category': 'ERP', 'icon': 'erpnext', 'brand_color': '#0089FF', 'compose_dir': 'frappe-setup', 'main_container': 'frappe-erpnext', 'containers': [ {'name': 'frappe-erpnext', 'role': 'App', 'image': 'frappe/erpnext:latest'}, {'name': 'frappe-mariadb', 'role': 'Database', 'image': 'mariadb:10.6'}, {'name': 'frappe-redis', 'role': 'Cache / Queue', 'image': 'redis:alpine'}, ], 'port': 8080, 'internal_port': 8000, 'domain': 'erpnext.navitrends.ovh', 'domain_protocol': 'http', 'ssl_configured': False, 'health_path': '/', 'volumes': ['frappe-setup_frappe-sites', 'frappe-setup_mariadb-data'], }, { 'id': 'odoo', 'name': 'Odoo', 'tagline': 'Business management suite', 'category': 'ERP', 'icon': 'odoo', 'brand_color': '#714B67', 'compose_dir': 'odoo-clean', 'main_container': 'odoo-clean-odoo-1', 'containers': [ {'name': 'odoo-clean-odoo-1', 'role': 'App', 'image': 'odoo:18'}, {'name': 'odoo-clean-db-1', 'role': 'Database', 'image': 'postgres:15'}, ], 'port': 8069, 'internal_port': 8069, 'domain': 'odooo.nav.ovh', 'domain_protocol': 'https', 'ssl_configured': True, 'health_path': '/web', 'volumes': ['odoo-clean_db-data', 'odoo-clean_odoo-etc'], }, { 'id': 'nextcloud', 'name': 'Nextcloud', 'tagline': 'File sync & collaboration', 'category': 'Storage', 'icon': 'nextcloud', 'brand_color': '#0082C9', 'compose_dir': 'nextcloud-setup', 'main_container': 'nextcloud-app', 'containers': [ {'name': 'nextcloud-app', 'role': 'App', 'image': 'nextcloud:latest'}, {'name': 'nextcloud-postgres', 'role': 'Database', 'image': 'postgres:15'}, ], 'port': 8082, 'internal_port': 80, 'domain': 'next.cloud.nav.ovh', 'domain_protocol': 'https', 'ssl_configured': True, 'health_path': '/status.php', 'volumes': ['nextcloud-setup_nextcloud-data', 'nextcloud-setup_nextcloud-db-data'], }, { 'id': 'mautic', 'name': 'Mautic', 'tagline': 'Marketing automation', 'category': 'Marketing', 'icon': 'mautic', 'brand_color': '#4E5E9E', 'compose_dir': 'mautic-setup', 'main_container': 'mautic-app', 'containers': [ {'name': 'mautic-app', 'role': 'App', 'image': 'mautic/mautic:latest'}, {'name': 'mautic-mariadb', 'role': 'Database', 'image': 'mariadb:10.11'}, ], 'port': 8081, 'internal_port': 80, 'domain': None, 'domain_protocol': 'http', 'ssl_configured': False, 'health_path': '/', 'volumes': ['mautic-setup_mautic-data', 'mautic-setup_mautic-db-data'], 'networks': ['mautic-network'], }, { 'id': 'n8n', 'name': 'n8n', 'tagline': 'Workflow automation', 'category': 'Automation', 'icon': 'n8n', 'brand_color': '#EA4B71', 'compose_dir': 'n8n-setup', 'main_container': 'n8n-app', 'containers': [ {'name': 'n8n-app', 'role': 'App', 'image': 'n8nio/n8n:latest'}, {'name': 'n8n-postgres', 'role': 'Database', 'image': 'postgres:15'}, ], 'port': 5678, 'internal_port': 5678, 'domain': None, 'domain_protocol': 'http', 'ssl_configured': False, 'health_path': '/healthz', 'volumes': ['n8n-setup_n8n-data', 'n8n-setup_n8n-db-data'], 'networks': ['n8n-network', 'integration-network'], }, ] SITE_BY_ID = {s['id']: s for s in SITES} def _build_urls(site): ip = MAIN_SERVER_IP port = site['port'] proto = site.get('domain_protocol', 'http') path = site.get('health_path', '/') or '/' ssl_configured = site.get('ssl_configured', proto == 'https') domain = site.get('domain') has_domain = bool(domain) if has_domain: if ssl_configured: access_url = f"{proto}://{domain}" health_url = f"{access_url}{path}" else: access_url = f"http://{domain}:{port}" health_url = f"{access_url}{path}" else: access_url = f"http://{ip}:{port}" health_url = f"{access_url}{path}" return { 'has_domain': has_domain, 'domain': domain, 'domain_protocol': proto, 'ssl_configured': ssl_configured, 'ip_url': f"http://{ip}:{port}", 'access_url': access_url.rstrip('/'), 'health_url': health_url, } def _parse_port_mapping(ports_str, host_port, internal_port=None): """Extract host:container mapping from docker ports string.""" if not ports_str: if internal_port and internal_port != host_port: return f"{host_port}→{internal_port}" return str(host_port) if host_port else None m = re.search(rf'0\.0\.0\.0:{host_port}->(\d+)/', ports_str) if m: return f"{host_port}→{m.group(1)}" m = re.search(r'0\.0\.0\.0:(\d+)->(\d+)/', ports_str) if m: return f"{m.group(1)}→{m.group(2)}" return ports_str[:60] if ports_str else None def _probe_http(url, timeout=5): """HTTP health probe — local urllib or remote curl via SSH.""" if RUNNING_ON_MAIN_SERVER: try: req = Request(url, method='GET', headers={'User-Agent': 'CloudOps-HealthCheck/1.0'}) with urlopen(req, timeout=timeout) as resp: code = resp.getcode() return { 'reachable': 200 <= code < 400, 'http_code': code, 'latency_ms': None, 'error': None, } except URLError as e: return {'reachable': False, 'http_code': None, 'latency_ms': None, 'error': str(e.reason)[:120]} except Exception as e: return {'reachable': False, 'http_code': None, 'latency_ms': None, 'error': str(e)[:120]} else: safe_url = url.replace("'", "'\\''") t0 = time.time() out, _ = _ssh_main( f"curl -sf -o /dev/null -w '%{{http_code}}' --connect-timeout {timeout} " f"-m {timeout} '{safe_url}' 2>/dev/null || echo '000'", timeout=timeout + 5, ) latency = int((time.time() - t0) * 1000) code_str = (out or '000').strip() try: code = int(code_str) except ValueError: code = 0 return { 'reachable': 200 <= code < 400, 'http_code': code if code else None, 'latency_ms': latency, 'error': None if 200 <= code < 400 else f'HTTP {code_str}', } def _container_map(): ctrs = get_all_root_containers() return {c['name']: c for c in ctrs} def get_sites_list(include_health=False): """Return all sites with live container + URL metadata.""" ctr_map = _container_map() results = [] for site in SITES: urls = _build_urls(site) main_name = site['main_container'] main_ctr = ctr_map.get(main_name, {}) main_status = get_container_status(main_name) if main_name else {'status': 'unknown'} containers_live = [] running_count = 0 for ctr_def in site.get('containers', []): name = ctr_def['name'] live = ctr_map.get(name, {}) status_raw = live.get('status', '') is_up = 'Up' in status_raw if is_up: running_count += 1 containers_live.append({ **ctr_def, 'status': 'running' if is_up else ('stopped' if live else 'not_found'), 'status_raw': status_raw or 'not found', 'image_live': live.get('image', ctr_def.get('image', '—')), 'ports': live.get('ports', '—'), }) entry = { 'id': site['id'], 'name': site['name'], 'tagline': site['tagline'], 'category': site['category'], 'icon': site['icon'], 'brand_color': site['brand_color'], 'compose_dir': site['compose_dir'], 'main_container': main_name, 'port': site['port'], 'internal_port': site.get('internal_port', site['port']), 'port_mapping': _parse_port_mapping( main_ctr.get('ports', ''), site['port'], site.get('internal_port') ), 'ports_raw': main_ctr.get('ports', '—'), 'volumes': site.get('volumes', []), 'networks': site.get('networks', ['default']), 'container_status': main_status['status'], 'container_running': main_status['status'] == 'running', 'containers': containers_live, 'containers_running': running_count, 'containers_total': len(site.get('containers', [])), **urls, } if include_health: if entry['container_running']: probe = _probe_http(urls['health_url']) entry['health'] = { 'status': 'up' if probe['reachable'] else 'down', **probe, } else: entry['health'] = { 'status': 'offline', 'reachable': False, 'http_code': None, 'latency_ms': None, 'error': 'Container not running', } results.append(entry) return results def get_site_health(site_id): site = SITE_BY_ID.get(site_id) if not site: return None urls = _build_urls(site) status = get_container_status(site['main_container']) if status['status'] != 'running': return { 'site_id': site_id, 'status': 'offline', 'reachable': False, 'http_code': None, 'latency_ms': None, 'error': 'Container not running', 'checked_at': time.time(), } t0 = time.time() probe = _probe_http(urls['health_url']) probe['latency_ms'] = probe.get('latency_ms') or int((time.time() - t0) * 1000) probe['site_id'] = site_id probe['status'] = 'up' if probe['reachable'] else 'down' probe['checked_at'] = time.time() return probe