Add read-only Kubernetes cluster view alongside Docker container view
New modules/kubernetes.py lists pods and deployments across the 8 app namespaces via the in-cluster management-platform-viewer-sa (read-only, no secrets/exec/log), with metrics-server usage as a best-effort extra that degrades silently when unreachable. New /cluster page and /api/cluster endpoint, nav entry, and templates render pods/deployments grouped by namespace using the existing card/badge design system. Additive only — no existing Docker routes or views touched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ from modules.cloud_backup import (
|
|||||||
r2_is_configured, R2_BUCKET_NAME,
|
r2_is_configured, R2_BUCKET_NAME,
|
||||||
)
|
)
|
||||||
from modules.sites import get_sites_list, get_site_health, SITES, PLATFORM
|
from modules.sites import get_sites_list, get_site_health, SITES, PLATFORM
|
||||||
|
from modules.kubernetes import get_cluster_overview
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = 'navitrends-secret-key-2025'
|
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')
|
@app.route('/backups')
|
||||||
@login_required
|
@login_required
|
||||||
def backups_page():
|
def backups_page():
|
||||||
@@ -266,6 +279,12 @@ def api_containers_all():
|
|||||||
return jsonify({'containers': all_ctrs, 'running': running})
|
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')
|
@app.route('/api/sites')
|
||||||
@login_required
|
@login_required
|
||||||
def api_sites():
|
def api_sites():
|
||||||
|
|||||||
241
platform/modules/kubernetes.py
Normal file
241
platform/modules/kubernetes.py
Normal file
@@ -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),
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
boto3==1.43.5
|
boto3==1.43.5
|
||||||
flask
|
flask
|
||||||
psutil
|
psutil
|
||||||
|
kubernetes
|
||||||
@@ -1028,6 +1028,72 @@ a.sidebar-brand-link {
|
|||||||
gap: 10px;
|
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 ─────────────────────────────────────────────────── */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed; inset: 0; z-index: 1000;
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
<i class="fas fa-cubes"></i><span>All Containers</span>
|
<i class="fas fa-cubes"></i><span>All Containers</span>
|
||||||
<span class="nav-badge" id="nav-badge-containers">—</span>
|
<span class="nav-badge" id="nav-badge-containers">—</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-item {% if active_page == 'cluster' %}active{% endif %}" href="{{ url_for('cluster_page') }}">
|
||||||
|
<i class="fas fa-diagram-project"></i><span>Cluster</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="nav-section-label" style="margin-top:20px;">OPERATIONS</div>
|
<div class="nav-section-label" style="margin-top:20px;">OPERATIONS</div>
|
||||||
<a class="nav-item {% if active_page == 'restore' %}active{% endif %}" href="{{ url_for('restore_page') }}">
|
<a class="nav-item {% if active_page == 'restore' %}active{% endif %}" href="{{ url_for('restore_page') }}">
|
||||||
|
|||||||
100
platform/templates/pages/cluster.html
Normal file
100
platform/templates/pages/cluster.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title"><i class="fas fa-diagram-project"></i> Cluster Overview</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="loadCluster()"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="cluster-summary" id="cluster-summary">
|
||||||
|
<div class="stat-card"><div class="stat-number" id="cs-pods">—</div><div class="stat-label">Total Pods</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-number" style="color:var(--green);" id="cs-healthy">—</div><div class="stat-label">Healthy</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-number" style="color:var(--yellow);" id="cs-degraded">—</div><div class="stat-label">Degraded</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-number" style="color:var(--red);" id="cs-failed">—</div><div class="stat-label">Failed</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-number is-text" id="cs-deploys">—</div><div class="stat-label">Deployments Healthy</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cluster-note-wrap"></div>
|
||||||
|
|
||||||
|
<div class="ns-grid" id="ns-grid">
|
||||||
|
<div class="empty-state"><i class="fas fa-spinner fa-spin"></i> Loading cluster state…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const NAMESPACES = ['n8n', 'odoo', 'mautic', 'erpnext', 'nextcloud', 'jenkins-agents', 'jenkins', 'management-platform'];
|
||||||
|
|
||||||
|
function statusClass(health) {
|
||||||
|
return health === 'healthy' ? 'status-healthy' : health === 'failed' ? 'status-failed' : 'status-degraded';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNamespace(ns, data) {
|
||||||
|
const pods = data.pods || [];
|
||||||
|
const deploys = data.deployments || [];
|
||||||
|
|
||||||
|
const deploysHtml = deploys.length ? `
|
||||||
|
<div class="ns-deploys-row">
|
||||||
|
${deploys.map(d => `
|
||||||
|
<span class="deploy-chip">
|
||||||
|
<span class="dot ${statusClass(d.health)}"></span>
|
||||||
|
${d.name} <span style="color:var(--text3);">${d.ready}/${d.desired}</span>
|
||||||
|
</span>
|
||||||
|
`).join('')}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const podsHtml = pods.length ? `
|
||||||
|
<div class="ns-pods-list">
|
||||||
|
${pods.map(p => `
|
||||||
|
<div class="pod-row">
|
||||||
|
<span class="pod-status-dot ${statusClass(p.health)}"></span>
|
||||||
|
<span class="pod-name" title="${p.name}">${p.name}</span>
|
||||||
|
<span class="pod-ready">${p.ready}</span>
|
||||||
|
<span class="pod-restarts ${p.restarts > 0 ? 'warn' : ''}">${p.restarts} restart${p.restarts === 1 ? '' : 's'}</span>
|
||||||
|
<span class="pod-age">${p.age}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>` : '<div class="empty-state" style="padding:20px;"><i class="fas fa-circle-nodes"></i> No pods</div>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="ns-card">
|
||||||
|
<div class="ns-card-header">
|
||||||
|
<span class="ns-name">${ns}</span>
|
||||||
|
<span class="ns-counts">${pods.length} pod${pods.length === 1 ? '' : 's'} · ${deploys.length} deploy${deploys.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
${deploysHtml}
|
||||||
|
${podsHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCluster() {
|
||||||
|
const grid = document.getElementById('ns-grid');
|
||||||
|
const noteWrap = document.getElementById('cluster-note-wrap');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cluster');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.available) {
|
||||||
|
grid.innerHTML = '<div class="empty-state"><i class="fas fa-triangle-exclamation"></i> Kubernetes API not reachable from this pod</div>';
|
||||||
|
noteWrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = data.summary || {};
|
||||||
|
document.getElementById('cs-pods').textContent = s.total_pods ?? '—';
|
||||||
|
document.getElementById('cs-healthy').textContent = s.healthy ?? '—';
|
||||||
|
document.getElementById('cs-degraded').textContent = s.degraded ?? '—';
|
||||||
|
document.getElementById('cs-failed').textContent = s.failed ?? '—';
|
||||||
|
document.getElementById('cs-deploys').textContent = `${s.deployments_healthy ?? '—'}/${s.total_deployments ?? '—'}`;
|
||||||
|
|
||||||
|
noteWrap.innerHTML = data.metrics_available ? '' :
|
||||||
|
'<div class="cluster-note"><i class="fas fa-info-circle"></i> Resource metrics unavailable — metrics-server not reachable.</div>';
|
||||||
|
|
||||||
|
grid.innerHTML = NAMESPACES.map(ns => renderNamespace(ns, data.namespaces[ns] || {})).join('');
|
||||||
|
} catch (e) {
|
||||||
|
grid.innerHTML = `<div class="empty-state" style="color:var(--red);"><i class="fas fa-circle-exclamation"></i> ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadCluster);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user