ADD: sites and architecture module
This commit is contained in:
412
platform/modules/sites.py
Normal file
412
platform/modules/sites.py
Normal file
@@ -0,0 +1,412 @@
|
||||
# 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 UI + architecture)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
PLATFORM = {
|
||||
'id': 'cloudops',
|
||||
'name': 'CloudOps Platform',
|
||||
'tagline': 'Navitrends ops dashboard',
|
||||
'domain': 'cloudops.nav.ovh',
|
||||
'domain_protocol': 'https',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'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 '/'
|
||||
|
||||
domain = site.get('domain')
|
||||
has_domain = bool(domain)
|
||||
|
||||
if has_domain:
|
||||
primary_url = f"{proto}://{domain}{path}"
|
||||
access_url = f"{proto}://{domain}"
|
||||
else:
|
||||
primary_url = f"http://{ip}:{port}{path}"
|
||||
access_url = f"http://{ip}:{port}"
|
||||
|
||||
return {
|
||||
'has_domain': has_domain,
|
||||
'domain': domain,
|
||||
'domain_protocol': proto,
|
||||
'ip_url': f"http://{ip}:{port}",
|
||||
'access_url': access_url.rstrip('/'),
|
||||
'health_url': primary_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
|
||||
|
||||
|
||||
def get_architecture():
|
||||
"""Topology for architecture page — stacks, platform, shared infra."""
|
||||
ctr_map = _container_map()
|
||||
stacks = []
|
||||
|
||||
for site in SITES:
|
||||
nodes = []
|
||||
edges = []
|
||||
main = site['main_container']
|
||||
db_nodes = [c for c in site.get('containers', []) if c['role'] == 'Database']
|
||||
cache_nodes = [c for c in site.get('containers', []) if 'Cache' in c.get('role', '')]
|
||||
|
||||
for ctr in site.get('containers', []):
|
||||
live = ctr_map.get(ctr['name'], {})
|
||||
is_up = 'Up' in live.get('status', '')
|
||||
nodes.append({
|
||||
'id': ctr['name'],
|
||||
'label': ctr['name'],
|
||||
'role': ctr['role'],
|
||||
'type': 'database' if ctr['role'] == 'Database' else (
|
||||
'cache' if 'Cache' in ctr['role'] else 'app'
|
||||
),
|
||||
'status': 'running' if is_up else ('stopped' if live else 'missing'),
|
||||
'image': live.get('image', ctr.get('image', '')),
|
||||
})
|
||||
|
||||
for db in db_nodes:
|
||||
edges.append({'from': main, 'to': db['name'], 'label': 'DB'})
|
||||
for cache in cache_nodes:
|
||||
edges.append({'from': main, 'to': cache['name'], 'label': 'Redis'})
|
||||
|
||||
urls = _build_urls(site)
|
||||
stacks.append({
|
||||
'id': site['id'],
|
||||
'name': site['name'],
|
||||
'category': site['category'],
|
||||
'brand_color': site['brand_color'],
|
||||
'compose_dir': site['compose_dir'],
|
||||
'port': site['port'],
|
||||
'domain': site.get('domain'),
|
||||
'access_url': urls['access_url'],
|
||||
'nodes': nodes,
|
||||
'edges': edges,
|
||||
'networks': site.get('networks', ['bridge']),
|
||||
'running': sum(1 for n in nodes if n['status'] == 'running'),
|
||||
'total': len(nodes),
|
||||
})
|
||||
|
||||
platform_status = 'running'
|
||||
if RUNNING_ON_MAIN_SERVER:
|
||||
out, _ = _ssh_main("docker inspect --format='{{.State.Status}}' management-platform 2>/dev/null")
|
||||
if out.strip().lower() not in ('running', 'restarting'):
|
||||
platform_status = out.strip() or 'unknown'
|
||||
else:
|
||||
out, _ = _ssh_main("docker inspect --format='{{.State.Status}}' management-platform 2>/dev/null")
|
||||
platform_status = out.strip().lower() if out else 'unknown'
|
||||
|
||||
plat_urls = _build_urls({
|
||||
'domain': PLATFORM['domain'],
|
||||
'domain_protocol': PLATFORM['domain_protocol'],
|
||||
'port': PLATFORM['port'],
|
||||
'health_path': PLATFORM['health_path'],
|
||||
})
|
||||
|
||||
return {
|
||||
'server_ip': MAIN_SERVER_IP,
|
||||
'platform': {
|
||||
'id': PLATFORM['id'],
|
||||
'name': PLATFORM['name'],
|
||||
'tagline': PLATFORM['tagline'],
|
||||
'container': PLATFORM['container'],
|
||||
'port': PLATFORM['port'],
|
||||
'internal_port': PLATFORM['internal_port'],
|
||||
'domain': PLATFORM['domain'],
|
||||
'domain_protocol': PLATFORM['domain_protocol'],
|
||||
'status': platform_status,
|
||||
'brand_color': PLATFORM['brand_color'],
|
||||
**plat_urls,
|
||||
},
|
||||
'stacks': stacks,
|
||||
'shared': {
|
||||
'networks': ['integration-network', 'mautic-network', 'n8n-network'],
|
||||
'backup_path': '/root/backups',
|
||||
'vm_backup_path': '/backups/main-server',
|
||||
},
|
||||
'summary': {
|
||||
'sites': len(SITES),
|
||||
'containers': sum(s['total'] for s in stacks),
|
||||
'running': sum(s['running'] for s in stacks),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user