diff --git a/platform/app.py b/platform/app.py index 050f765..8aca4c6 100644 --- a/platform/app.py +++ b/platform/app.py @@ -35,6 +35,7 @@ from modules.cloud_backup import ( r2_is_configured, R2_BUCKET_NAME, ) from modules.sites import get_sites_list, get_site_health, SITES, PLATFORM +from modules.kubernetes import get_cluster_overview app = Flask(__name__) app.secret_key = 'navitrends-secret-key-2025' @@ -151,6 +152,18 @@ def containers_page(): ) +@app.route('/cluster') +@login_required +def cluster_page(): + return render_template( + 'pages/cluster.html', + main_server=MAIN_SERVER_IP, + active_page='cluster', + page_title='Cluster', + page_subtitle='kubernetes · 8 namespaces' + ) + + @app.route('/backups') @login_required def backups_page(): @@ -266,6 +279,12 @@ def api_containers_all(): return jsonify({'containers': all_ctrs, 'running': running}) +@app.route('/api/cluster') +@login_required +def api_cluster(): + return jsonify(get_cluster_overview()) + + @app.route('/api/sites') @login_required def api_sites(): diff --git a/platform/modules/kubernetes.py b/platform/modules/kubernetes.py new file mode 100644 index 0000000..da1bb3d --- /dev/null +++ b/platform/modules/kubernetes.py @@ -0,0 +1,241 @@ +# modules/kubernetes.py — read-only cluster view via the in-cluster ServiceAccount +# +# Auth: management-platform-viewer-sa (get/list/watch on pods, services, +# deployments.apps — no secrets, no write, no exec/log), bound per-namespace +# across the 8 app namespaces below. There is no cluster-wide RoleBinding, +# so every list call below is scoped to one namespace at a time. +from datetime import datetime, timezone + +try: + from kubernetes import client, config as k8s_config + from kubernetes.client.rest import ApiException + _K8S_IMPORT_OK = True +except ImportError: + _K8S_IMPORT_OK = False + +NAMESPACES = [ + 'n8n', 'odoo', 'mautic', 'erpnext', 'nextcloud', + 'jenkins-agents', 'jenkins', 'management-platform', +] + +_core_v1 = None +_apps_v1 = None +_custom_objects = None +K8S_AVAILABLE = False + + +def _init_client(): + global _core_v1, _apps_v1, _custom_objects, K8S_AVAILABLE + if _core_v1 is not None or not _K8S_IMPORT_OK: + return + try: + k8s_config.load_incluster_config() + _core_v1 = client.CoreV1Api() + _apps_v1 = client.AppsV1Api() + _custom_objects = client.CustomObjectsApi() + K8S_AVAILABLE = True + except Exception as e: + print(f"[kubernetes] in-cluster config unavailable: {e}") + + +# ──────────────────────────────────────────────────────────────── +# HELPERS +# ──────────────────────────────────────────────────────────────── + +def _age_str(dt): + if not dt: + return '—' + seconds = int((datetime.now(timezone.utc) - dt).total_seconds()) + if seconds < 60: + return f'{seconds}s' + minutes = seconds // 60 + if minutes < 60: + return f'{minutes}m' + hours = minutes // 60 + if hours < 24: + return f'{hours}h {minutes % 60}m' + days = hours // 24 + return f'{days}d {hours % 24}h' + + +_CRASH_REASONS = ('CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull', 'Error') + + +def _pod_health(pod): + statuses = pod.status.container_statuses or [] + for cs in statuses: + waiting = cs.state.waiting if cs.state else None + if waiting and waiting.reason in _CRASH_REASONS: + return 'failed' + phase = pod.status.phase or 'Unknown' + if phase == 'Failed': + return 'failed' + if phase == 'Running': + all_ready = all(cs.ready for cs in statuses) if statuses else True + return 'healthy' if all_ready else 'degraded' + return 'degraded' # Pending, Unknown, etc. + + +def _ready_count(pod): + statuses = pod.status.container_statuses or [] + ready = sum(1 for cs in statuses if cs.ready) + return f'{ready}/{len(statuses)}' + + +def _restart_count(pod): + statuses = pod.status.container_statuses or [] + return sum(cs.restart_count for cs in statuses) + + +def _deploy_health(desired, ready): + if desired == 0: + return 'degraded' + if ready == desired: + return 'healthy' + if ready == 0: + return 'failed' + return 'degraded' + + +def _parse_cpu(v): + v = (v or '0').strip() + try: + if v.endswith('n'): + return int(v[:-1]) / 1_000_000 + if v.endswith('u'): + return int(v[:-1]) / 1_000 + if v.endswith('m'): + return int(v[:-1]) + return float(v) * 1000 + except ValueError: + return 0 + + +_MEM_UNITS = {'Ki': 1 / 1024, 'Mi': 1, 'Gi': 1024, 'K': 1 / 1024, 'M': 1, 'G': 1024} + + +def _parse_mem_mib(v): + v = (v or '0').strip() + for suffix, factor in _MEM_UNITS.items(): + if v.endswith(suffix): + try: + return float(v[:-len(suffix)]) * factor + except ValueError: + return 0 + try: + return float(v) / (1024 * 1024) + except ValueError: + return 0 + + +# ──────────────────────────────────────────────────────────────── +# PODS / DEPLOYMENTS +# ──────────────────────────────────────────────────────────────── + +def list_pods(): + _init_client() + pods = [] + if not K8S_AVAILABLE: + return pods + for ns in NAMESPACES: + try: + resp = _core_v1.list_namespaced_pod(ns) + except ApiException as e: + print(f"[kubernetes] list pods failed for {ns}: {e}") + continue + for pod in resp.items: + pods.append({ + 'name': pod.metadata.name, + 'namespace': ns, + 'phase': pod.status.phase or 'Unknown', + 'health': _pod_health(pod), + 'ready': _ready_count(pod), + 'restarts': _restart_count(pod), + 'age': _age_str(pod.metadata.creation_timestamp), + }) + return pods + + +def list_deployments(): + _init_client() + deployments = [] + if not K8S_AVAILABLE: + return deployments + for ns in NAMESPACES: + try: + resp = _apps_v1.list_namespaced_deployment(ns) + except ApiException as e: + print(f"[kubernetes] list deployments failed for {ns}: {e}") + continue + for dep in resp.items: + desired = dep.spec.replicas or 0 + ready = dep.status.ready_replicas or 0 + deployments.append({ + 'name': dep.metadata.name, + 'namespace': ns, + 'ready': ready, + 'desired': desired, + 'health': _deploy_health(desired, ready), + 'age': _age_str(dep.metadata.creation_timestamp), + }) + return deployments + + +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', + never as an error.""" + _init_client() + metrics = {} + if not K8S_AVAILABLE: + return metrics + for ns in NAMESPACES: + try: + resp = _custom_objects.list_namespaced_custom_object( + 'metrics.k8s.io', 'v1beta1', ns, 'pods' + ) + except Exception: + continue + for item in resp.get('items', []): + name = item.get('metadata', {}).get('name') + containers = item.get('containers', []) + cpu = sum(_parse_cpu(c.get('usage', {}).get('cpu')) for c in containers) + mem = sum(_parse_mem_mib(c.get('usage', {}).get('memory')) for c in containers) + metrics[f'{ns}/{name}'] = {'cpu_millicores': round(cpu), 'mem_mib': round(mem)} + return metrics + + +def get_cluster_overview(): + """Single call powering the Cluster page: pods + deployments grouped by + namespace, plus summary counts. Mirrors the shape of get_all_stats() / + get_sites_list() in the docker-facing modules.""" + pods = list_pods() + deployments = list_deployments() + metrics = get_pod_metrics() + + for pod in pods: + key = f"{pod['namespace']}/{pod['name']}" + if key in metrics: + pod['metrics'] = metrics[key] + + by_namespace = {ns: {'pods': [], 'deployments': []} for ns in NAMESPACES} + for pod in pods: + by_namespace[pod['namespace']]['pods'].append(pod) + for dep in deployments: + by_namespace[dep['namespace']]['deployments'].append(dep) + + summary = { + 'total_pods': len(pods), + 'healthy': sum(1 for p in pods if p['health'] == 'healthy'), + 'degraded': sum(1 for p in pods if p['health'] == 'degraded'), + 'failed': sum(1 for p in pods if p['health'] == 'failed'), + 'total_deployments': len(deployments), + 'deployments_healthy': sum(1 for d in deployments if d['health'] == 'healthy'), + } + + return { + 'available': K8S_AVAILABLE, + 'namespaces': by_namespace, + 'summary': summary, + 'metrics_available': bool(metrics), + } diff --git a/platform/requirements.txt b/platform/requirements.txt index ab4ab05..6d82a78 100644 --- a/platform/requirements.txt +++ b/platform/requirements.txt @@ -1,3 +1,4 @@ boto3==1.43.5 flask -psutil \ No newline at end of file +psutil +kubernetes \ No newline at end of file diff --git a/platform/static/css/style.css b/platform/static/css/style.css index 05aaa4f..6c3581b 100644 --- a/platform/static/css/style.css +++ b/platform/static/css/style.css @@ -1028,6 +1028,72 @@ a.sidebar-brand-link { gap: 10px; } +/* ── CLUSTER PAGE ──────────────────────────────────────────── */ +.badge-warn, .badge.warn { background: rgba(245,185,66,0.12); color: var(--yellow); border: 1px solid rgba(245,185,66,0.25); } +.badge-warn::before, .badge.warn::before { content:''; width:5px; height:5px; background:var(--yellow); border-radius:50%; } +[data-theme="light"] .badge-warn, [data-theme="light"] .badge.warn { background: rgba(217,119,6,0.1); } + +.cluster-summary { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} +@media (max-width: 900px) { .cluster-summary { grid-template-columns: repeat(2, 1fr); } } + +.ns-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); + gap: 16px; +} +.ns-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 18px 20px; + box-shadow: var(--shadow); +} +.ns-card-header { + display: flex; align-items: center; gap: 10px; + padding-bottom: 12px; margin-bottom: 14px; + border-bottom: 1px solid var(--border); +} +.ns-name { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--text); } +.ns-counts { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--text3); } + +.ns-deploys-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } +.deploy-chip { + display: inline-flex; align-items: center; gap: 7px; + padding: 5px 11px; border-radius: 20px; + background: var(--surface2); border: 1px solid var(--border); + font-family: var(--mono); font-size: 11px; color: var(--text2); +} +.deploy-chip .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } + +.ns-pods-list { display: flex; flex-direction: column; gap: 2px; } +.pod-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 4px; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.ns-pods-list .pod-row:last-child { border-bottom: none; } +.pod-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } +.pod-name { font-family: var(--mono); color: var(--text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pod-ready { font-family: var(--mono); font-size: 11px; color: var(--text3); } +.pod-restarts { font-family: var(--mono); font-size: 11px; min-width: 60px; text-align: right; color: var(--text3); } +.pod-restarts.warn { color: var(--yellow); } +.pod-age { font-family: var(--mono); font-size: 11px; color: var(--text3); min-width: 56px; text-align: right; } + +.status-healthy { color: var(--green); background: var(--green); } +.status-degraded { color: var(--yellow); background: var(--yellow); } +.status-failed { color: var(--red); background: var(--red); } + +.cluster-note { + font-size: 12px; color: var(--text3); font-family: var(--mono); + padding: 10px 14px; margin-bottom: 18px; + background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); +} + /* ── MODAL ─────────────────────────────────────────────────── */ .modal-overlay { position: fixed; inset: 0; z-index: 1000; diff --git a/platform/templates/base.html b/platform/templates/base.html index 9d431df..705cd91 100644 --- a/platform/templates/base.html +++ b/platform/templates/base.html @@ -35,6 +35,9 @@ All Containers + + Cluster + diff --git a/platform/templates/pages/cluster.html b/platform/templates/pages/cluster.html new file mode 100644 index 0000000..b03ed54 --- /dev/null +++ b/platform/templates/pages/cluster.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% block content %} + +
+
+
Cluster Overview
+ +
+
+
Total Pods
+
Healthy
+
Degraded
+
Failed
+
Deployments Healthy
+
+
+ +
+ +
+
Loading cluster state…
+
+ + +{% endblock %}