ADD: sites and architecture module
This commit is contained in:
@@ -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/<site_id>/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():
|
||||
|
||||
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),
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
<a class="nav-item {% if active_page == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
|
||||
<i class="fas fa-gauge-high"></i><span>Dashboard</span>
|
||||
</a>
|
||||
<a class="nav-item {% if active_page == 'sites' %}active{% endif %}" href="{{ url_for('sites_page') }}">
|
||||
<i class="fas fa-globe"></i><span>Application Sites</span>
|
||||
<span class="nav-badge" id="nav-badge-sites">5</span>
|
||||
</a>
|
||||
<a class="nav-item {% if active_page == 'architecture' %}active{% endif %}" href="{{ url_for('architecture_page') }}">
|
||||
<i class="fas fa-sitemap"></i><span>Architecture</span>
|
||||
</a>
|
||||
<a class="nav-item {% if active_page == 'containers' %}active{% endif %}" href="{{ url_for('containers_page') }}">
|
||||
<i class="fas fa-cubes"></i><span>All Containers</span>
|
||||
<span class="nav-badge" id="nav-badge-containers">—</span>
|
||||
|
||||
276
platform/templates/pages/architecture.html
Normal file
276
platform/templates/pages/architecture.html
Normal file
@@ -0,0 +1,276 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
|
||||
<style>
|
||||
.arch-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.arch-diagram-wrap {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
overflow-x: auto;
|
||||
min-height: 320px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.arch-diagram-wrap .mermaid { display: flex; justify-content: center; }
|
||||
.arch-stacks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.arch-stack-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.arch-stack-card h4 {
|
||||
font-size: 14px; font-weight: 700; margin: 0 0 4px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
.arch-stack-meta { font-size: 11px; color: var(--text3); margin-bottom: 10px; font-family: var(--mono); }
|
||||
.arch-node-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.arch-node {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 11px; font-family: var(--mono);
|
||||
padding: 6px 8px; background: var(--surface2);
|
||||
border-radius: 6px; border: 1px solid var(--border);
|
||||
}
|
||||
.arch-node .role-badge {
|
||||
font-size: 8px; letter-spacing: 0.06em; padding: 2px 5px;
|
||||
border-radius: 4px; background: var(--border); color: var(--text3);
|
||||
}
|
||||
.arch-node.running .status-dot { background: var(--green); }
|
||||
.arch-node.stopped .status-dot, .arch-node.missing .status-dot { background: var(--red); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.arch-legend {
|
||||
display: flex; gap: 16px; flex-wrap: wrap;
|
||||
font-size: 11px; color: var(--text2); margin-bottom: 14px;
|
||||
}
|
||||
.arch-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.infra-box {
|
||||
background: var(--surface2); border: 1px dashed var(--border2);
|
||||
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px;
|
||||
}
|
||||
.infra-box h4 { font-size: 13px; margin: 0 0 10px; }
|
||||
.infra-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.infra-chip {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
padding: 4px 10px; border-radius: 20px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
}
|
||||
@media (max-width: 900px) { .arch-summary { grid-template-columns: repeat(2, 1fr); } }
|
||||
</style>
|
||||
|
||||
<div class="arch-summary" id="arch-summary">
|
||||
<div class="stat-card"><div class="stat-number" id="arch-sites">—</div><div class="stat-label">App Stacks</div></div>
|
||||
<div class="stat-card"><div class="stat-number" id="arch-ctrs" style="color:var(--accent2);">—</div><div class="stat-label">Containers</div></div>
|
||||
<div class="stat-card"><div class="stat-number" id="arch-running" style="color:var(--green);">—</div><div class="stat-label">Running</div></div>
|
||||
<div class="stat-card"><div class="stat-number" id="arch-server" style="font-size:14px;color:var(--cyan);">—</div><div class="stat-label">Main Server</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px;">
|
||||
<div class="card-header">
|
||||
<div class="card-title"><i class="fas fa-sitemap"></i> Infrastructure Topology</div>
|
||||
<span class="card-meta" id="arch-refresh-meta">Loading…</span>
|
||||
</div>
|
||||
<div class="arch-legend">
|
||||
<span><span class="status-dot" style="background:var(--green);"></span> Running</span>
|
||||
<span><span class="status-dot" style="background:var(--red);"></span> Stopped / missing</span>
|
||||
<span><i class="fas fa-arrow-right" style="font-size:10px;color:var(--text3);"></i> Dependency</span>
|
||||
</div>
|
||||
<div class="arch-diagram-wrap" id="mermaid-wrap">
|
||||
<pre class="mermaid" id="mermaid-diagram">flowchart TB
|
||||
loading["Loading architecture…"]
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="infra-box" id="infra-box" style="display:none;">
|
||||
<h4><i class="fas fa-server" style="color:var(--accent2);margin-right:6px;"></i>Shared Infrastructure</h4>
|
||||
<div class="infra-list" id="infra-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title"><i class="fas fa-layer-group"></i> Stack Breakdown</div>
|
||||
<a href="/sites" class="btn btn-ghost btn-sm"><i class="fas fa-globe"></i> Open Sites</a>
|
||||
</div>
|
||||
<div class="arch-stacks" id="arch-stacks">
|
||||
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function nodeStatusClass(st) {
|
||||
return st === 'running' ? 'running' : 'stopped';
|
||||
}
|
||||
|
||||
function buildMermaid(data) {
|
||||
const ip = data.server_ip || 'server';
|
||||
const plat = data.platform || {};
|
||||
const platSt = plat.status === 'running' ? 'running' : 'stopped';
|
||||
const platLabel = plat.domain
|
||||
? `CloudOps Platform<br/>${plat.domain}`
|
||||
: `CloudOps Platform<br/>:${plat.port || 8088}`;
|
||||
|
||||
let lines = ['flowchart TB'];
|
||||
lines.push(` subgraph HOST["🖥 Main Server ${ip}"]`);
|
||||
lines.push(` direction TB`);
|
||||
lines.push(` PLATFORM["${platLabel}"]:::${platSt}`);
|
||||
lines.push(` INTERNET(("🌐 Users / Internet"))`);
|
||||
lines.push(` INTERNET --> PLATFORM`);
|
||||
lines.push(` INTERNET --> APPS`);
|
||||
|
||||
lines.push(` subgraph APPS["Application Stacks"]`);
|
||||
lines.push(` direction LR`);
|
||||
|
||||
(data.stacks || []).forEach((stack, i) => {
|
||||
const sid = 'stack_' + stack.id;
|
||||
const stackRunning = stack.running === stack.total && stack.total > 0;
|
||||
const stackCls = stackRunning ? 'running' : (stack.running > 0 ? 'partial' : 'stopped');
|
||||
lines.push(` subgraph ${sid}["${stack.name} :${stack.port}"]`);
|
||||
lines.push(` direction TB`);
|
||||
|
||||
(stack.nodes || []).forEach(node => {
|
||||
const nid = node.id.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
const cls = node.status === 'running' ? 'running' : 'stopped';
|
||||
const shortLabel = node.label.length > 22 ? node.label.substring(0, 20) + '…' : node.label;
|
||||
lines.push(` ${nid}["${shortLabel}<br/><small>${node.role}</small>"]:::${cls}`);
|
||||
});
|
||||
|
||||
(stack.edges || []).forEach(edge => {
|
||||
const from = edge.from.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
const to = edge.to.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
lines.push(` ${from} --> ${to}`);
|
||||
});
|
||||
|
||||
lines.push(` end`);
|
||||
lines.push(` ${sid}:::${stackCls}`);
|
||||
lines.push(` INTERNET --> ${sid}`);
|
||||
});
|
||||
|
||||
lines.push(` end`);
|
||||
lines.push(` end`);
|
||||
|
||||
const nets = (data.shared && data.shared.networks) || [];
|
||||
if (nets.length) {
|
||||
lines.push(` subgraph NETS["Docker Networks"]`);
|
||||
nets.forEach((n, i) => {
|
||||
const nid = 'net_' + i;
|
||||
lines.push(` ${nid}["${n}"]:::network`);
|
||||
});
|
||||
lines.push(` end`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' classDef running fill:#0d2818,stroke:#22c55e,color:#e8ecf4');
|
||||
lines.push(' classDef stopped fill:#2a1215,stroke:#ef4444,color:#e8ecf4');
|
||||
lines.push(' classDef partial fill:#2a2010,stroke:#f59e0b,color:#e8ecf4');
|
||||
lines.push(' classDef network fill:#111827,stroke:#a78bfa,color:#e8ecf4');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderStackCards(stacks) {
|
||||
const el = document.getElementById('arch-stacks');
|
||||
if (!stacks.length) {
|
||||
el.innerHTML = '<div class="empty-state">No stacks</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = stacks.map(stack => {
|
||||
const nodes = (stack.nodes || []).map(n => `
|
||||
<div class="arch-node ${nodeStatusClass(n.status)}">
|
||||
<span class="status-dot"></span>
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;">${esc(n.label)}</span>
|
||||
<span class="role-badge">${esc(n.role)}</span>
|
||||
</div>`).join('');
|
||||
const domain = stack.domain ? esc(stack.domain) : `IP :${stack.port}`;
|
||||
return `<div class="arch-stack-card" style="border-left-color:${esc(stack.brand_color)}">
|
||||
<h4>
|
||||
${esc(stack.name)}
|
||||
<span class="badge ${stack.running === stack.total && stack.total ? 'badge-run' : 'badge-stop'}">${stack.running}/${stack.total}</span>
|
||||
</h4>
|
||||
<div class="arch-stack-meta">${domain} · ~/docker-compose/${esc(stack.compose_dir)}</div>
|
||||
<div class="arch-node-list">${nodes}</div>
|
||||
<div style="margin-top:10px;">
|
||||
<a href="/sites" class="btn btn-ghost btn-sm" onclick="event.preventDefault();window.location='/sites'">
|
||||
<i class="fas fa-arrow-up-right-from-square"></i> ${esc(stack.access_url || '')}
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderInfra(shared, platform) {
|
||||
const box = document.getElementById('infra-box');
|
||||
const list = document.getElementById('infra-list');
|
||||
const chips = [];
|
||||
if (platform.domain) {
|
||||
chips.push(`Platform: ${platform.access_url || platform.domain}`);
|
||||
}
|
||||
chips.push(`Container: ${platform.container || 'management-platform'} :${platform.port || 8088}`);
|
||||
(shared.networks || []).forEach(n => chips.push(`Network: ${n}`));
|
||||
if (shared.backup_path) chips.push(`Backups: ${shared.backup_path}`);
|
||||
if (shared.vm_backup_path) chips.push(`VM sync: ${shared.vm_backup_path}`);
|
||||
list.innerHTML = chips.map(c => `<span class="infra-chip">${esc(c)}</span>`).join('');
|
||||
box.style.display = '';
|
||||
}
|
||||
|
||||
async function loadArchitecture() {
|
||||
const meta = document.getElementById('arch-refresh-meta');
|
||||
try {
|
||||
meta.textContent = 'Loading…';
|
||||
const res = await fetch('/api/architecture');
|
||||
const data = await res.json();
|
||||
|
||||
const sum = data.summary || {};
|
||||
document.getElementById('arch-sites').textContent = sum.sites || 0;
|
||||
document.getElementById('arch-ctrs').textContent = sum.containers || 0;
|
||||
document.getElementById('arch-running').textContent = sum.running || 0;
|
||||
document.getElementById('arch-server').textContent = data.server_ip || '—';
|
||||
|
||||
renderStackCards(data.stacks || []);
|
||||
renderInfra(data.shared || {}, data.platform || {});
|
||||
|
||||
const diagram = buildMermaid(data);
|
||||
const wrap = document.getElementById('mermaid-wrap');
|
||||
wrap.innerHTML = `<pre class="mermaid" id="mermaid-diagram">${diagram}</pre>`;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: document.documentElement.getAttribute('data-theme') === 'light' ? 'default' : 'dark',
|
||||
flowchart: { curve: 'basis', padding: 16 },
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
await mermaid.run({ nodes: [document.getElementById('mermaid-diagram')] });
|
||||
|
||||
meta.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
meta.textContent = 'Error';
|
||||
document.getElementById('arch-stacks').innerHTML =
|
||||
`<div class="empty-state" style="color:var(--red);">Failed: ${esc(e)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.loadArchitecture = loadArchitecture;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadArchitecture();
|
||||
setInterval(loadArchitecture, 45000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,13 @@
|
||||
<div class="card-header"><div class="card-title"><i class="fas fa-sliders"></i> Platform Settings</div></div>
|
||||
<div style="max-width:460px; display:flex; flex-direction:column; gap:14px;">
|
||||
<div class="form-group"><label class="form-label">MAIN SERVER IP</label><input class="form-input" value="{{ main_server }}" readonly></div>
|
||||
<div class="form-group"><label class="form-label">PLATFORM DOMAIN</label><input class="form-input" value="{{ platform_domain }}" readonly></div>
|
||||
<div class="form-group"><label class="form-label">PLATFORM URL</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input class="form-input" value="{{ platform_url }}" readonly style="flex:1;">
|
||||
<a class="btn btn-primary btn-sm" href="{{ platform_url }}" target="_blank" rel="noopener"><i class="fas fa-arrow-up-right-from-square"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">PLATFORM HOST</label><input class="form-input" value="{{ 'Main Server' if running_on_main else 'VM (backup mode)' }}" readonly></div>
|
||||
<div class="form-group"><label class="form-label">VM BACKUP PATH</label><input class="form-input" value="/backups/main-server/" readonly></div>
|
||||
<div class="form-group"><label class="form-label">MAIN SERVER UPTIME</label><input class="form-input" id="settings-uptime" value="{{ system.uptime }}" readonly></div>
|
||||
|
||||
364
platform/templates/pages/sites.html
Normal file
364
platform/templates/pages/sites.html
Normal file
@@ -0,0 +1,364 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<style>
|
||||
.sites-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.sites-summary .stat-card { text-align: center; }
|
||||
.sites-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.site-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--trans), box-shadow var(--trans);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.site-card:hover {
|
||||
border-color: var(--border2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.site-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
}
|
||||
.site-icon-wrap {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.site-icon-wrap img { width: 28px; height: 28px; }
|
||||
.site-icon-wrap i { font-size: 22px; }
|
||||
.site-card-title { flex: 1; min-width: 0; }
|
||||
.site-card-title h3 {
|
||||
font-size: 16px; font-weight: 700; margin: 0 0 2px;
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
}
|
||||
.site-tagline { font-size: 12px; color: var(--text3); margin: 0; }
|
||||
.site-category {
|
||||
font-family: var(--mono); font-size: 9px; letter-spacing: 0.08em;
|
||||
color: var(--text3); background: var(--surface);
|
||||
border: 1px solid var(--border); padding: 2px 7px; border-radius: 20px;
|
||||
}
|
||||
.site-card-body { padding: 16px 18px; flex: 1; display: flex; flex-direction: column; gap: 12px; }
|
||||
.site-row {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.site-row-label {
|
||||
font-family: var(--mono); font-size: 9px; font-weight: 700;
|
||||
letter-spacing: 0.08em; color: var(--text3);
|
||||
min-width: 62px; padding-top: 2px;
|
||||
}
|
||||
.site-row-value { flex: 1; min-width: 0; word-break: break-all; }
|
||||
.site-url-link {
|
||||
color: var(--accent2); text-decoration: none; font-family: var(--mono); font-size: 11px;
|
||||
}
|
||||
.site-url-link:hover { text-decoration: underline; }
|
||||
.domain-set { color: var(--green); }
|
||||
.domain-unset { color: var(--text3); font-style: italic; }
|
||||
.health-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 20px;
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.health-pill.up { background: rgba(34,197,94,0.12); color: var(--green); border: 1px solid rgba(34,197,94,0.25); }
|
||||
.health-pill.down { background: rgba(239,68,68,0.12); color: var(--red); border: 1px solid rgba(239,68,68,0.25); }
|
||||
.health-pill.offline { background: rgba(148,163,184,0.12); color: var(--text3); border: 1px solid var(--border); }
|
||||
.health-pill.checking { background: rgba(59,130,246,0.12); color: var(--accent2); border: 1px solid rgba(59,130,246,0.25); }
|
||||
.health-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.health-pill.checking .dot { animation: pulse-dot 1s infinite; }
|
||||
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||||
.site-card-footer {
|
||||
display: flex; gap: 8px; padding: 0 18px 16px; flex-wrap: wrap;
|
||||
}
|
||||
.site-detail-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.site-detail-item {
|
||||
background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px 12px;
|
||||
}
|
||||
.site-detail-item .lbl {
|
||||
font-family: var(--mono); font-size: 9px; color: var(--text3);
|
||||
letter-spacing: 0.08em; margin-bottom: 4px;
|
||||
}
|
||||
.site-detail-item .val {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--text); word-break: break-all;
|
||||
}
|
||||
.site-ctr-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
|
||||
.site-ctr-table th {
|
||||
text-align: left; font-family: var(--mono); font-size: 9px;
|
||||
color: var(--text3); padding: 6px 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.site-ctr-table td { padding: 8px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.site-ctr-table tr:last-child td { border-bottom: none; }
|
||||
@media (max-width: 900px) {
|
||||
.sites-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
.site-detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card" style="margin-bottom:16px;padding:14px 18px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div class="site-icon-wrap" style="width:40px;height:40px;border-color:var(--accent)33;">
|
||||
<i class="fas fa-gauge-high" style="color:var(--accent);font-size:18px;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:600;">This platform</div>
|
||||
<a class="site-url-link" href="{{ platform_url }}" target="_blank" rel="noopener">{{ platform_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-ghost btn-sm" href="{{ platform_url }}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-arrow-up-right-from-square"></i> cloudops.nav.ovh
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sites-summary" id="sites-summary">
|
||||
<div class="stat-card"><div class="stat-number" id="ss-total">{{ site_count }}</div><div class="stat-label">Sites</div></div>
|
||||
<div class="stat-card"><div class="stat-number" id="ss-up" style="color:var(--green);">—</div><div class="stat-label">Healthy</div></div>
|
||||
<div class="stat-card"><div class="stat-number" id="ss-running" style="color:var(--accent2);">—</div><div class="stat-label">Containers Up</div></div>
|
||||
<div class="stat-card"><div class="stat-number" id="ss-domains" style="color:var(--purple);">—</div><div class="stat-label">With Domain</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px;">
|
||||
<div class="card-header">
|
||||
<div class="card-title"><i class="fas fa-globe"></i> Your Applications</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="card-meta" id="sites-refresh-meta">Loading…</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadSites(true)"><i class="fas fa-heart-pulse"></i> Health check all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sites-grid" id="sites-grid">
|
||||
<div class="empty-state" style="grid-column:1/-1;"><i class="fas fa-spinner fa-spin"></i> Loading sites…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Site details modal #}
|
||||
<div id="site-detail-modal" class="modal-overlay" style="display:none;" onclick="closeSiteModal(event)">
|
||||
<div class="modal-box" style="max-width:640px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="site-modal-title"><i class="fas fa-circle-info" style="color:var(--accent2);"></i> Site Details</div>
|
||||
<button class="modal-close" onclick="closeSiteModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="site-modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SITE_ICONS = {
|
||||
erpnext: 'https://cdn.simpleicons.org/erpnext/0089FF',
|
||||
odoo: 'https://cdn.simpleicons.org/odoo/714B67',
|
||||
nextcloud: 'https://cdn.simpleicons.org/nextcloud/0082C9',
|
||||
mautic: 'https://cdn.simpleicons.org/mautic/4E5E9E',
|
||||
n8n: 'https://cdn.simpleicons.org/n8n/EA4B71',
|
||||
};
|
||||
|
||||
let sitesData = [];
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function healthPill(health, containerRunning) {
|
||||
if (!health) {
|
||||
return containerRunning
|
||||
? '<span class="health-pill checking"><span class="dot"></span>Unchecked</span>'
|
||||
: '<span class="health-pill offline"><span class="dot"></span>Offline</span>';
|
||||
}
|
||||
const st = health.status || 'down';
|
||||
const labels = { up: 'Active', down: 'Unreachable', offline: 'Offline', checking: 'Checking…' };
|
||||
const extra = health.latency_ms != null ? ` · ${health.latency_ms}ms` : '';
|
||||
const code = health.http_code ? ` · HTTP ${health.http_code}` : '';
|
||||
return `<span class="health-pill ${st}"><span class="dot"></span>${labels[st] || st}${extra}${code}</span>`;
|
||||
}
|
||||
|
||||
function renderSiteCard(site) {
|
||||
const iconUrl = SITE_ICONS[site.icon] || '';
|
||||
const iconHtml = iconUrl
|
||||
? `<img src="${iconUrl}" alt="${esc(site.name)}" onerror="this.parentElement.innerHTML='<i class=\\'fas fa-server\\' style=\\'color:${esc(site.brand_color)}\\'></i>'">`
|
||||
: `<i class="fas fa-server" style="color:${esc(site.brand_color)}"></i>`;
|
||||
|
||||
const domainHtml = site.has_domain
|
||||
? `<span class="domain-set"><i class="fas fa-check-circle"></i> ${esc(site.domain)}</span>`
|
||||
: `<span class="domain-unset"><i class="fas fa-minus-circle"></i> Not configured — using IP</span>`;
|
||||
|
||||
return `<article class="site-card" data-site-id="${esc(site.id)}" style="border-top:3px solid ${esc(site.brand_color)}">
|
||||
<div class="site-card-header">
|
||||
<div class="site-icon-wrap" style="border-color:${esc(site.brand_color)}33">${iconHtml}</div>
|
||||
<div class="site-card-title">
|
||||
<h3>${esc(site.name)} <span class="site-category">${esc(site.category)}</span></h3>
|
||||
<p class="site-tagline">${esc(site.tagline)}</p>
|
||||
</div>
|
||||
<div id="health-${esc(site.id)}">${healthPill(site.health, site.container_running)}</div>
|
||||
</div>
|
||||
<div class="site-card-body">
|
||||
<div class="site-row">
|
||||
<span class="site-row-label">DOMAIN</span>
|
||||
<span class="site-row-value">${domainHtml}</span>
|
||||
</div>
|
||||
<div class="site-row">
|
||||
<span class="site-row-label">URL</span>
|
||||
<span class="site-row-value">
|
||||
<a class="site-url-link" href="${esc(site.access_url)}" target="_blank" rel="noopener">${esc(site.access_url)}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="site-row">
|
||||
<span class="site-row-label">PORT</span>
|
||||
<span class="site-row-value" style="font-family:var(--mono);">
|
||||
${esc(site.port_mapping || site.port)}
|
||||
${site.internal_port && site.internal_port !== site.port ? ` <span class="dim">(internal ${site.internal_port})</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class="site-row">
|
||||
<span class="site-row-label">CONTAINER</span>
|
||||
<span class="site-row-value">
|
||||
${site.container_running
|
||||
? `<span class="badge badge-run">Running</span>`
|
||||
: `<span class="badge badge-stop">Stopped</span>`}
|
||||
<span class="dim" style="font-family:var(--mono);margin-left:6px;">${esc(site.main_container)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-card-footer">
|
||||
<a class="btn btn-primary btn-sm" href="${esc(site.access_url)}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-arrow-up-right-from-square"></i> Open site
|
||||
</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick="pingSite('${esc(site.id)}')">
|
||||
<i class="fas fa-heart-pulse"></i> Ping
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="openSiteDetails('${esc(site.id)}')">
|
||||
<i class="fas fa-circle-info"></i> Details
|
||||
</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function updateSummary(sites) {
|
||||
const up = sites.filter(s => s.health && s.health.status === 'up').length;
|
||||
const running = sites.reduce((n, s) => n + (s.containers_running || 0), 0);
|
||||
const domains = sites.filter(s => s.has_domain).length;
|
||||
document.getElementById('ss-up').textContent = up;
|
||||
document.getElementById('ss-running').textContent = running;
|
||||
document.getElementById('ss-domains').textContent = domains;
|
||||
}
|
||||
|
||||
async function loadSites(withHealth = false) {
|
||||
const grid = document.getElementById('sites-grid');
|
||||
const meta = document.getElementById('sites-refresh-meta');
|
||||
try {
|
||||
meta.textContent = withHealth ? 'Running health checks…' : 'Refreshing…';
|
||||
const res = await fetch('/api/sites?health=' + (withHealth ? '1' : '0'));
|
||||
const data = await res.json();
|
||||
sitesData = data.sites || [];
|
||||
grid.innerHTML = sitesData.length
|
||||
? sitesData.map(renderSiteCard).join('')
|
||||
: '<div class="empty-state" style="grid-column:1/-1;"><i class="fas fa-inbox"></i>No sites configured</div>';
|
||||
updateSummary(sitesData);
|
||||
meta.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1;color:var(--red);">Failed to load: ${esc(e)}</div>`;
|
||||
meta.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
async function pingSite(siteId) {
|
||||
const el = document.getElementById('health-' + siteId);
|
||||
if (el) el.innerHTML = healthPill({ status: 'checking' }, true);
|
||||
try {
|
||||
const res = await fetch('/api/sites/' + siteId + '/health');
|
||||
const health = await res.json();
|
||||
const site = sitesData.find(s => s.id === siteId);
|
||||
if (site) site.health = health;
|
||||
if (el) el.innerHTML = healthPill(health, site?.container_running);
|
||||
updateSummary(sitesData);
|
||||
} catch (e) {
|
||||
if (el) el.innerHTML = healthPill({ status: 'down', error: String(e) }, true);
|
||||
}
|
||||
}
|
||||
|
||||
function openSiteDetails(siteId) {
|
||||
const site = sitesData.find(s => s.id === siteId);
|
||||
if (!site) return;
|
||||
const modal = document.getElementById('site-detail-modal');
|
||||
const body = document.getElementById('site-modal-body');
|
||||
document.getElementById('site-modal-title').innerHTML =
|
||||
`<i class="fas fa-circle-info" style="color:${esc(site.brand_color)}"></i> ${esc(site.name)} — Deployment`;
|
||||
|
||||
const ctrRows = (site.containers || []).map(c => `
|
||||
<tr>
|
||||
<td class="ct-name">${esc(c.name)}</td>
|
||||
<td>${esc(c.role)}</td>
|
||||
<td>${c.status === 'running' ? '<span class="badge badge-run">Up</span>' : '<span class="badge badge-stop">Down</span>'}</td>
|
||||
<td class="ct-image">${esc(c.image_live || c.image)}</td>
|
||||
<td class="ct-ports">${esc(c.ports || '—')}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="site-detail-grid">
|
||||
<div class="site-detail-item"><div class="lbl">COMPOSE PROJECT</div><div class="val">~/${esc(site.compose_dir)}</div></div>
|
||||
<div class="site-detail-item"><div class="lbl">MAIN CONTAINER</div><div class="val">${esc(site.main_container)}</div></div>
|
||||
<div class="site-detail-item"><div class="lbl">HOST PORT</div><div class="val">${esc(site.port)}</div></div>
|
||||
<div class="site-detail-item"><div class="lbl">PORT MAPPING</div><div class="val">${esc(site.port_mapping || site.port + '→' + site.internal_port)}</div></div>
|
||||
<div class="site-detail-item"><div class="lbl">ACCESS URL</div><div class="val"><a class="site-url-link" href="${esc(site.access_url)}" target="_blank">${esc(site.access_url)}</a></div></div>
|
||||
<div class="site-detail-item"><div class="lbl">DOMAIN</div><div class="val">${site.has_domain ? esc(site.domain) : '— (IP only)'}</div></div>
|
||||
<div class="site-detail-item"><div class="lbl">IP FALLBACK</div><div class="val">${esc(site.ip_url)}</div></div>
|
||||
<div class="site-detail-item"><div class="lbl">HEALTH ENDPOINT</div><div class="val">${esc(site.health_url)}</div></div>
|
||||
</div>
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px;"><i class="fas fa-cubes" style="color:var(--accent2);margin-right:6px;"></i>Stack containers (${site.containers_running}/${site.containers_total} running)</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="site-ctr-table">
|
||||
<thead><tr><th>NAME</th><th>ROLE</th><th>STATUS</th><th>IMAGE</th><th>PORTS</th></tr></thead>
|
||||
<tbody>${ctrRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${site.volumes && site.volumes.length ? `
|
||||
<div style="margin-top:14px;font-size:12px;font-weight:600;"><i class="fas fa-hard-drive" style="color:var(--cyan);margin-right:6px;"></i>Volumes</div>
|
||||
<div style="font-family:var(--mono);font-size:11px;color:var(--text2);margin-top:6px;">${site.volumes.map(v => esc(v)).join('<br>')}</div>` : ''}
|
||||
${site.networks && site.networks.length ? `
|
||||
<div style="margin-top:14px;font-size:12px;font-weight:600;"><i class="fas fa-network-wired" style="color:var(--purple);margin-right:6px;"></i>Networks</div>
|
||||
<div style="font-family:var(--mono);font-size:11px;color:var(--text2);margin-top:6px;">${site.networks.map(n => esc(n)).join(', ')}</div>` : ''}
|
||||
<div style="margin-top:18px;display:flex;gap:8px;">
|
||||
<a class="btn btn-primary btn-sm" href="${esc(site.access_url)}" target="_blank"><i class="fas fa-arrow-up-right-from-square"></i> Open site</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeSiteModal()">Close</button>
|
||||
</div>`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeSiteModal(e) {
|
||||
if (!e || e.target === document.getElementById('site-detail-modal')) {
|
||||
document.getElementById('site-detail-modal').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
window.loadSites = loadSites;
|
||||
window.pingSite = pingSite;
|
||||
window.openSiteDetails = openSiteDetails;
|
||||
window.closeSiteModal = closeSiteModal;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSites(true);
|
||||
setInterval(() => loadSites(false), 30000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user