Rewrite Application Sites page to reflect real k8s state
modules/sites.py was still a fully Docker-era hardcoded registry: container names (odoo-clean-odoo-1, frappe-erpnext, nextcloud-app, mautic-app, n8n-app) that no longer exist post-migration, checked via `docker inspect` over SSH, plus hardcoded domain/port fields - nextcloud and mautic even had domain: None, silently falling back to dead Docker host-port URLs while their real Ingress domains (next.cloud.nav.ovh, mautics.nav.ovh) sat unused. Now sources everything live from the cluster: - Domain + TLS from the actual Ingress object per app (via the new get_ingress_info() in modules/kubernetes.py), not a static guess. Odoo/Nextcloud's Ingress lives in `default` (see prior commit for the RBAC this needed); n8n/mautic/erpnext's lives in their own namespace. - App/DB/cache/worker/scheduler status from real Deployment state (list_deployments_for_namespace(), new in modules/kubernetes.py) instead of `docker inspect`. - Health probe now hits the real domain directly from the pod (it has normal internet egress) instead of SSH-ing back out to the host to curl a public HTTPS URL, which is what the RUNNING_ON_MAIN_SERVER branch would have done for a pod whose hostname never matches the main-server hostname check. Same API/template contract as before (get_sites_list/get_site_health return the same field shapes) - templates/pages/sites.html needs no changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017avLHFqkiti3g62Anq9sVA
This commit is contained in:
@@ -21,11 +21,12 @@ NAMESPACES = [
|
||||
_core_v1 = None
|
||||
_apps_v1 = None
|
||||
_custom_objects = None
|
||||
_networking_v1 = None
|
||||
K8S_AVAILABLE = False
|
||||
|
||||
|
||||
def _init_client():
|
||||
global _core_v1, _apps_v1, _custom_objects, K8S_AVAILABLE
|
||||
global _core_v1, _apps_v1, _custom_objects, _networking_v1, K8S_AVAILABLE
|
||||
if _core_v1 is not None or not _K8S_IMPORT_OK:
|
||||
return
|
||||
try:
|
||||
@@ -33,6 +34,7 @@ def _init_client():
|
||||
_core_v1 = client.CoreV1Api()
|
||||
_apps_v1 = client.AppsV1Api()
|
||||
_custom_objects = client.CustomObjectsApi()
|
||||
_networking_v1 = client.NetworkingV1Api()
|
||||
K8S_AVAILABLE = True
|
||||
except Exception as e:
|
||||
print(f"[kubernetes] in-cluster config unavailable: {e}")
|
||||
@@ -181,6 +183,54 @@ def list_deployments():
|
||||
return deployments
|
||||
|
||||
|
||||
def get_ingress_info(name, namespace):
|
||||
"""Real domain/TLS for one Ingress, or None if unavailable/not found.
|
||||
Used by modules/sites.py instead of the old hardcoded Docker-era
|
||||
domain fields — reflects actual live k8s Ingress state."""
|
||||
_init_client()
|
||||
if not K8S_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
ing = _networking_v1.read_namespaced_ingress(name, namespace)
|
||||
except Exception:
|
||||
return None
|
||||
host = None
|
||||
if ing.spec and ing.spec.rules:
|
||||
for rule in ing.spec.rules:
|
||||
if rule.host:
|
||||
host = rule.host
|
||||
break
|
||||
tls = bool(ing.spec and ing.spec.tls)
|
||||
return {'host': host, 'tls': tls}
|
||||
|
||||
|
||||
def list_deployments_for_namespace(ns):
|
||||
"""Targeted single-namespace deployment list (vs. list_deployments()'s
|
||||
fixed NAMESPACES sweep) — used by modules/sites.py per app."""
|
||||
_init_client()
|
||||
if not K8S_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
resp = _apps_v1.list_namespaced_deployment(ns)
|
||||
except Exception as e:
|
||||
print(f"[kubernetes] list deployments failed for {ns}: {e}")
|
||||
return []
|
||||
out = []
|
||||
for dep in resp.items:
|
||||
desired = dep.spec.replicas or 0
|
||||
ready = dep.status.ready_replicas or 0
|
||||
containers = dep.spec.template.spec.containers or []
|
||||
image = containers[0].image if containers else '—'
|
||||
out.append({
|
||||
'name': dep.metadata.name,
|
||||
'desired': desired,
|
||||
'ready': ready,
|
||||
'health': _deploy_health(desired, ready),
|
||||
'image': image,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_pod_metrics():
|
||||
"""Best-effort CPU/mem per pod via metrics-server. Returns {} if it's not
|
||||
installed or unreachable — callers must treat an empty dict as 'no data',
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
# modules/sites.py — Managed application sites registry + live status
|
||||
import re
|
||||
#
|
||||
# REWRITTEN 2026-08-21: this used to be a fully Docker-era hardcoded registry
|
||||
# (container names like odoo-clean-odoo-1/frappe-erpnext/nextcloud-app that
|
||||
# no longer exist since the k8s migration, checked via `docker inspect` over
|
||||
# SSH) with hardcoded domain/port fields — nextcloud and mautic even had
|
||||
# `domain: None`, silently falling back to dead Docker host-port URLs while
|
||||
# their real k8s Ingress domains (next.cloud.nav.ovh, mautics.nav.ovh) sat
|
||||
# unused. Now sources everything live from the cluster: Deployment/pod
|
||||
# status via modules/kubernetes.py's in-cluster client, and domain/TLS via
|
||||
# the actual Ingress objects (not a static guess) — see
|
||||
# management-platform-sites-rbac.yaml for the narrow ingress-read grant this
|
||||
# needed in the `default` namespace (odoo/nextcloud's Ingress historically
|
||||
# live there, not in their app namespace).
|
||||
import time
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
import urllib.request
|
||||
|
||||
from config import MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER
|
||||
from modules.backups import _ssh_main, get_all_root_containers, get_container_status
|
||||
from modules.kubernetes import get_ingress_info, list_deployments_for_namespace
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# STATIC SITE REGISTRY (source of truth for Application Sites UI)
|
||||
# STATIC SITE REGISTRY — identity/namespace/naming only. Live state
|
||||
# (domain, TLS, deployment/pod health) is always fetched fresh below,
|
||||
# never hardcoded.
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
PLATFORM = {
|
||||
@@ -35,20 +47,21 @@ SITES = [
|
||||
'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,
|
||||
'namespace': 'erpnext',
|
||||
'main_deployment': 'erpnext-web',
|
||||
'service_port': 8000,
|
||||
'ingress_name': 'erpnext-ingress',
|
||||
'ingress_namespace': 'erpnext',
|
||||
'health_path': '/',
|
||||
'volumes': ['frappe-setup_frappe-sites', 'frappe-setup_mariadb-data'],
|
||||
'pvcs': ['erpnext-sites', 'erpnext-mariadb-data'],
|
||||
'roles': {
|
||||
'erpnext-web': 'App',
|
||||
'erpnext-worker': 'Worker',
|
||||
'erpnext-scheduler': 'Scheduler',
|
||||
'erpnext-mariadb': 'Database',
|
||||
'erpnext-redis-cache': 'Cache',
|
||||
'erpnext-redis-queue': 'Queue',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'odoo',
|
||||
@@ -57,19 +70,18 @@ SITES = [
|
||||
'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,
|
||||
'namespace': 'odoo',
|
||||
'main_deployment': 'odoo',
|
||||
'service_port': 8069,
|
||||
# Historical: created in `default` during migration, not the `odoo` ns.
|
||||
'ingress_name': 'odoo-ingress',
|
||||
'ingress_namespace': 'default',
|
||||
'health_path': '/web',
|
||||
'volumes': ['odoo-clean_db-data', 'odoo-clean_odoo-etc'],
|
||||
'pvcs': ['odoo-data', 'odoo-postgres-data'],
|
||||
'roles': {
|
||||
'odoo': 'App',
|
||||
'odoo-postgres': 'Database',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'nextcloud',
|
||||
@@ -78,19 +90,18 @@ SITES = [
|
||||
'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,
|
||||
'namespace': 'nextcloud',
|
||||
'main_deployment': 'nextcloud',
|
||||
'service_port': 80,
|
||||
# Historical: created in `default` during migration, not the `nextcloud` ns.
|
||||
'ingress_name': 'nextcloud-ingress',
|
||||
'ingress_namespace': 'default',
|
||||
'health_path': '/status.php',
|
||||
'volumes': ['nextcloud-setup_nextcloud-data', 'nextcloud-setup_nextcloud-db-data'],
|
||||
'pvcs': ['nextcloud-app-data', 'nextcloud-postgres-data'],
|
||||
'roles': {
|
||||
'nextcloud': 'App',
|
||||
'nextcloud-postgres': 'Database',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'mautic',
|
||||
@@ -99,20 +110,17 @@ SITES = [
|
||||
'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,
|
||||
'namespace': 'mautic',
|
||||
'main_deployment': 'mautic',
|
||||
'service_port': 80,
|
||||
'ingress_name': 'mautic-ingress',
|
||||
'ingress_namespace': 'mautic',
|
||||
'health_path': '/',
|
||||
'volumes': ['mautic-setup_mautic-data', 'mautic-setup_mautic-db-data'],
|
||||
'networks': ['mautic-network'],
|
||||
'pvcs': ['mautic-app-data', 'mautic-db-data'],
|
||||
'roles': {
|
||||
'mautic': 'App',
|
||||
'mautic-mariadb': 'Database',
|
||||
},
|
||||
},
|
||||
{
|
||||
'id': 'n8n',
|
||||
@@ -121,210 +129,176 @@ SITES = [
|
||||
'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,
|
||||
'namespace': 'n8n',
|
||||
'main_deployment': 'n8n',
|
||||
'service_port': 5678,
|
||||
'ingress_name': 'n8n-ingress',
|
||||
'ingress_namespace': 'n8n',
|
||||
'health_path': '/healthz',
|
||||
'volumes': ['n8n-setup_n8n-data', 'n8n-setup_n8n-db-data'],
|
||||
'networks': ['n8n-network', 'integration-network'],
|
||||
'pvcs': ['n8n-data', 'n8n-postgres-data'],
|
||||
'roles': {
|
||||
'n8n': 'App',
|
||||
'n8n-postgres': 'Database',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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')
|
||||
def _build_urls(site, ingress_info):
|
||||
"""Domain/TLS come from the live Ingress object — never hardcoded.
|
||||
ingress_info is None if the Ingress couldn't be read (RBAC/API issue)
|
||||
or {'host': str|None, 'tls': bool} otherwise."""
|
||||
path = site.get('health_path', '/') or '/'
|
||||
ssl_configured = site.get('ssl_configured', proto == 'https')
|
||||
|
||||
domain = site.get('domain')
|
||||
has_domain = bool(domain)
|
||||
host = ingress_info.get('host') if ingress_info else None
|
||||
has_domain = bool(host)
|
||||
ssl_configured = bool(ingress_info and ingress_info.get('tls'))
|
||||
|
||||
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}"
|
||||
proto = 'https' if ssl_configured else 'http'
|
||||
access_url = f"{proto}://{host}"
|
||||
health_url = f"{access_url}{path}"
|
||||
else:
|
||||
access_url = None
|
||||
health_url = None
|
||||
|
||||
return {
|
||||
'has_domain': has_domain,
|
||||
'domain': domain,
|
||||
'domain_protocol': proto,
|
||||
'domain': host,
|
||||
'domain_protocol': 'https' if ssl_configured else 'http',
|
||||
'ssl_configured': ssl_configured,
|
||||
'ip_url': f"http://{ip}:{port}",
|
||||
'access_url': access_url.rstrip('/'),
|
||||
'access_url': (access_url or '').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("'", "'\\''")
|
||||
"""Direct HTTP probe. The management-platform pod has normal internet
|
||||
egress and resolves public DNS just like any other pod — no need to
|
||||
hop back out to the host via SSH the way the old Docker-era code did."""
|
||||
try:
|
||||
req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'CloudOps-HealthCheck/1.0'})
|
||||
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}',
|
||||
}
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
code = resp.getcode()
|
||||
latency_ms = int((time.time() - t0) * 1000)
|
||||
return {
|
||||
'reachable': 200 <= code < 400,
|
||||
'http_code': code,
|
||||
'latency_ms': latency_ms,
|
||||
'error': None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'reachable': False, 'http_code': None, 'latency_ms': None, 'error': str(e)[:120]}
|
||||
|
||||
|
||||
def _container_map():
|
||||
ctrs = get_all_root_containers()
|
||||
return {c['name']: c for c in ctrs}
|
||||
def _deployments_for_site(site):
|
||||
"""Live Deployment status for every stack member (app/db/cache/etc),
|
||||
keyed by k8s Deployment name."""
|
||||
live = {d['name']: d for d in list_deployments_for_namespace(site['namespace'])}
|
||||
roles = site.get('roles', {})
|
||||
containers = []
|
||||
running_count = 0
|
||||
for dep_name, role in roles.items():
|
||||
d = live.get(dep_name)
|
||||
if d:
|
||||
is_up = d['ready'] >= 1 and d['desired'] >= 1
|
||||
if is_up:
|
||||
running_count += 1
|
||||
containers.append({
|
||||
'name': dep_name,
|
||||
'role': role,
|
||||
'status': 'running' if is_up else 'stopped',
|
||||
'image_live': d['image'],
|
||||
'ports': f"{d['ready']}/{d['desired']} ready",
|
||||
})
|
||||
else:
|
||||
containers.append({
|
||||
'name': dep_name,
|
||||
'role': role,
|
||||
'status': 'not_found',
|
||||
'image_live': '—',
|
||||
'ports': '—',
|
||||
})
|
||||
main = live.get(site['main_deployment'])
|
||||
main_running = bool(main and main['ready'] >= 1 and main['desired'] >= 1)
|
||||
return containers, running_count, main_running
|
||||
|
||||
|
||||
def _site_entry(site, include_health):
|
||||
ingress_info = get_ingress_info(site['ingress_name'], site['ingress_namespace'])
|
||||
urls = _build_urls(site, ingress_info)
|
||||
containers, running_count, main_running = _deployments_for_site(site)
|
||||
|
||||
entry = {
|
||||
'id': site['id'],
|
||||
'name': site['name'],
|
||||
'tagline': site['tagline'],
|
||||
'category': site['category'],
|
||||
'icon': site['icon'],
|
||||
'brand_color': site['brand_color'],
|
||||
'compose_dir': site['namespace'], # k8s namespace, shown as "COMPOSE PROJECT" in the UI
|
||||
'main_container': site['main_deployment'],
|
||||
'port': site['service_port'],
|
||||
'internal_port': site['service_port'],
|
||||
'port_mapping': f"svc/{site['service_port']}",
|
||||
'ports_raw': f"ClusterIP:{site['service_port']}",
|
||||
'volumes': site.get('pvcs', []),
|
||||
'ip_url': f"{site['main_deployment']}.{site['namespace']}.svc.cluster.local:{site['service_port']} (cluster-internal only)",
|
||||
'container_status': 'running' if main_running else 'stopped',
|
||||
'container_running': main_running,
|
||||
'containers': containers,
|
||||
'containers_running': running_count,
|
||||
'containers_total': len(site.get('roles', {})),
|
||||
**urls,
|
||||
}
|
||||
|
||||
if include_health:
|
||||
if not urls['has_domain']:
|
||||
entry['health'] = {
|
||||
'status': 'down', 'reachable': False, 'http_code': None,
|
||||
'latency_ms': None, 'error': 'No Ingress host found for this app',
|
||||
}
|
||||
elif not main_running:
|
||||
entry['health'] = {
|
||||
'status': 'offline', 'reachable': False, 'http_code': None,
|
||||
'latency_ms': None, 'error': 'Deployment not running',
|
||||
}
|
||||
else:
|
||||
probe = _probe_http(urls['health_url'])
|
||||
entry['health'] = {'status': 'up' if probe['reachable'] else 'down', **probe}
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
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
|
||||
"""Return all sites with live k8s Deployment + Ingress state."""
|
||||
return [_site_entry(site, include_health) for site in SITES]
|
||||
|
||||
|
||||
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':
|
||||
ingress_info = get_ingress_info(site['ingress_name'], site['ingress_namespace'])
|
||||
urls = _build_urls(site, ingress_info)
|
||||
_, _, main_running = _deployments_for_site(site)
|
||||
|
||||
if not main_running:
|
||||
return {
|
||||
'site_id': site_id,
|
||||
'status': 'offline',
|
||||
'reachable': False,
|
||||
'http_code': None,
|
||||
'latency_ms': None,
|
||||
'error': 'Container not running',
|
||||
'site_id': site_id, 'status': 'offline', 'reachable': False,
|
||||
'http_code': None, 'latency_ms': None, 'error': 'Deployment not running',
|
||||
'checked_at': time.time(),
|
||||
}
|
||||
t0 = time.time()
|
||||
if not urls['has_domain']:
|
||||
return {
|
||||
'site_id': site_id, 'status': 'down', 'reachable': False,
|
||||
'http_code': None, 'latency_ms': None, 'error': 'No Ingress host found',
|
||||
'checked_at': 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()
|
||||
|
||||
Reference in New Issue
Block a user