diff --git a/platform/app.py b/platform/app.py index ed5e25f..1026bb9 100644 --- a/platform/app.py +++ b/platform/app.py @@ -34,6 +34,7 @@ from modules.cloud_backup import ( r2_delete_backup, r2_upload_async, get_upload_job, r2_is_configured, R2_BUCKET_NAME, ) +from modules.sites import get_sites_list, get_site_health, get_architecture, SITES, PLATFORM app = Flask(__name__) app.secret_key = 'navitrends-secret-key-2025' @@ -114,6 +115,33 @@ def dashboard(): page_subtitle=MAIN_SERVER_IP) +@app.route('/sites') +@login_required +def sites_page(): + return render_template( + 'pages/sites.html', + main_server=MAIN_SERVER_IP, + site_count=len(SITES), + platform_domain=PLATFORM['domain'], + platform_url=f"{PLATFORM['domain_protocol']}://{PLATFORM['domain']}", + active_page='sites', + page_title='Application Sites', + page_subtitle='direct access · health · deployment info' + ) + + +@app.route('/architecture') +@login_required +def architecture_page(): + return render_template( + 'pages/architecture.html', + main_server=MAIN_SERVER_IP, + active_page='architecture', + page_title='Architecture', + page_subtitle='stacks · containers · networks' + ) + + @app.route('/containers') @login_required def containers_page(): @@ -180,6 +208,8 @@ def settings_page(): return render_template( 'pages/settings.html', main_server=MAIN_SERVER_IP, + platform_domain=PLATFORM['domain'], + platform_url=f"{PLATFORM['domain_protocol']}://{PLATFORM['domain']}", system=system, running_on_main=RUNNING_ON_MAIN_SERVER, active_page='settings', @@ -238,6 +268,28 @@ def api_containers_all(): return jsonify({'containers': all_ctrs, 'running': running}) +@app.route('/api/sites') +@login_required +def api_sites(): + include_health = request.args.get('health', '0') == '1' + return jsonify({'sites': get_sites_list(include_health=include_health)}) + + +@app.route('/api/sites//health') +@login_required +def api_site_health(site_id): + result = get_site_health(site_id) + if result is None: + return jsonify({'error': 'Site not found'}), 404 + return jsonify(result) + + +@app.route('/api/architecture') +@login_required +def api_architecture(): + return jsonify(get_architecture()) + + @app.route('/api/nav-summary') @login_required def api_nav_summary(): diff --git a/platform/modules/sites.py b/platform/modules/sites.py new file mode 100644 index 0000000..c3d1161 --- /dev/null +++ b/platform/modules/sites.py @@ -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), + }, + } diff --git a/platform/static/css/style.css b/platform/static/css/style.css index 9839985..8028b1b 100644 --- a/platform/static/css/style.css +++ b/platform/static/css/style.css @@ -558,6 +558,7 @@ a.sidebar-brand-link { transition: background 0.15s, color 0.15s; } .modal-close:hover { background: rgba(239,68,68,0.12); color: #ef4444; } +.modal-body { padding: 20px 24px 24px; } .audit-footer { display: flex; gap: 8px; margin-top: 18px; padding-top: 14px; border-top: 1px solid #1e2330; diff --git a/platform/static/js/platform.js b/platform/static/js/platform.js index a4355fa..60872c1 100644 --- a/platform/static/js/platform.js +++ b/platform/static/js/platform.js @@ -603,8 +603,16 @@ function pollRestore() { function refreshAll() { const btn = document.querySelector('.icon-btn'); if (btn) btn.classList.add('spinning'); - Promise.all([checkServerStatus(), refreshSystemMetrics(), refreshContainerStats(), refreshSidebarNavBadges()]) - .finally(() => { if (btn) btn.classList.remove('spinning'); }); + const extras = []; + if (typeof window.loadSites === 'function') extras.push(window.loadSites(false)); + if (typeof window.loadArchitecture === 'function') extras.push(window.loadArchitecture()); + Promise.all([ + checkServerStatus(), + refreshSystemMetrics(), + refreshContainerStats(), + refreshSidebarNavBadges(), + ...extras, + ]).finally(() => { if (btn) btn.classList.remove('spinning'); }); } document.addEventListener('DOMContentLoaded', () => { diff --git a/platform/templates/base.html b/platform/templates/base.html index d80a05a..c090446 100644 --- a/platform/templates/base.html +++ b/platform/templates/base.html @@ -27,6 +27,13 @@ Dashboard + + Application Sites + 5 + + + Architecture + All Containers diff --git a/platform/templates/pages/architecture.html b/platform/templates/pages/architecture.html new file mode 100644 index 0000000..63f1fa0 --- /dev/null +++ b/platform/templates/pages/architecture.html @@ -0,0 +1,276 @@ +{% extends "base.html" %} +{% block content %} + + + + + +
+
App Stacks
+
Containers
+
Running
+
Main Server
+
+ +
+
+
Infrastructure Topology
+ Loading… +
+
+ Running + Stopped / missing + Dependency +
+
+
flowchart TB
+      loading["Loading architecture…"]
+    
+
+
+ + + +
+ +
+
Loading…
+
+
+ + +{% endblock %} diff --git a/platform/templates/pages/settings.html b/platform/templates/pages/settings.html index 28a7a7c..9d56329 100644 --- a/platform/templates/pages/settings.html +++ b/platform/templates/pages/settings.html @@ -4,6 +4,13 @@
Platform Settings
+
+
+
+ + +
+
diff --git a/platform/templates/pages/sites.html b/platform/templates/pages/sites.html new file mode 100644 index 0000000..3eb0c4b --- /dev/null +++ b/platform/templates/pages/sites.html @@ -0,0 +1,364 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+ +
+
+
This platform
+ {{ platform_url }} +
+
+ + cloudops.nav.ovh + +
+ +
+
{{ site_count }}
Sites
+
Healthy
+
Containers Up
+
With Domain
+
+ +
+
+
Your Applications
+
+ Loading… + +
+
+
+
Loading sites…
+
+
+ +{# Site details modal #} + + + +{% endblock %} diff --git a/scripts/backup-myapps.sh b/scripts/backup-myapps.sh index 39592bb..1c287d3 100755 --- a/scripts/backup-myapps.sh +++ b/scripts/backup-myapps.sh @@ -395,4 +395,4 @@ log_status "SUCCESS" "$BACKUP_NAME" \ notify_failure() { echo "Backup FAILED: $BACKUP_NAME" | \ mail -s "[Navitrends] BACKUP FAILED - $(date)" ameniboukottaya@gmail.com -} +} \ No newline at end of file