diff --git a/.gitignore b/.gitignore index 5fbc16d..e24ce90 100644 --- a/.gitignore +++ b/.gitignore @@ -190,3 +190,4 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python,linux myvalues.yaml + diff --git a/app.py b/app.py index b454d7e..355e70b 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import os import configparser +from concurrent.futures import ThreadPoolExecutor from flask import Flask, jsonify, render_template from kubernetes import client, config from kubernetes.client.rest import ApiException @@ -17,7 +18,13 @@ def get_version(): app = Flask(__name__) +_v1_client = None +_custom_client = None + def get_k8s_client(): + global _v1_client, _custom_client + if _v1_client is not None and _custom_client is not None: + return _v1_client, _custom_client try: config.load_incluster_config() except config.ConfigException: @@ -36,7 +43,9 @@ def get_k8s_client(): pass if not loaded: raise RuntimeError(f"Failed to load kubeconfig. Make sure the file exists and is accessible. Error: {e}") - return client.CoreV1Api(), client.CustomObjectsApi() + _v1_client = client.CoreV1Api() + _custom_client = client.CustomObjectsApi() + return _v1_client, _custom_client def parse_cpu(cpu_str): if not cpu_str: return 0 @@ -64,32 +73,62 @@ def parse_memory(mem_str): except: return 0 +K8S_TIMEOUT = 5.0 +METRICS_TIMEOUT = 3.0 + +def fetch_nodes(v1): + return v1.list_node(_request_timeout=K8S_TIMEOUT).items + +def fetch_pods(v1): + return v1.list_pod_for_all_namespaces(_request_timeout=K8S_TIMEOUT).items + +def fetch_node_metrics(custom): + node_metrics = {} + try: + nm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "nodes", _request_timeout=METRICS_TIMEOUT) + for item in nm.get('items', []): + node_metrics[item['metadata']['name']] = item['usage'] + except Exception as e: + print(f"Metrics server node fetch error or unavailable: {e}") + return node_metrics + +def fetch_pod_metrics(custom): + pod_metrics = {} + try: + pm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "pods", _request_timeout=METRICS_TIMEOUT) + for item in pm.get('items', []): + key = f"{item['metadata']['namespace']}/{item['metadata']['name']}" + pod_metrics[key] = item['containers'] + except Exception as e: + print(f"Metrics server pod fetch error or unavailable: {e}") + return pod_metrics + @app.route('/api/cluster-data') def cluster_data(): - v1, custom = get_k8s_client() - - cluster = { - "name": "Cluster", - "children": [] - } - try: - nodes = v1.list_node().items - pods = v1.list_pod_for_all_namespaces().items - - node_metrics = {} - pod_metrics = {} - try: - nm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "nodes") - for item in nm.get('items', []): - node_metrics[item['metadata']['name']] = item['usage'] - - pm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "pods") - for item in pm.get('items', []): - key = f"{item['metadata']['namespace']}/{item['metadata']['name']}" - pod_metrics[key] = item['containers'] - except ApiException: - pass # Metrics server not available + v1, custom = get_k8s_client() + + with ThreadPoolExecutor(max_workers=4) as executor: + fut_nodes = executor.submit(fetch_nodes, v1) + fut_pods = executor.submit(fetch_pods, v1) + fut_node_metrics = executor.submit(fetch_node_metrics, custom) + fut_pod_metrics = executor.submit(fetch_pod_metrics, custom) + + nodes = fut_nodes.result() + pods = fut_pods.result() + node_metrics = fut_node_metrics.result() + pod_metrics = fut_pod_metrics.result() + + cluster = { + "name": "Cluster", + "children": [] + } + + pods_by_node = {} + for pod in pods: + nname = pod.spec.node_name + if nname: + pods_by_node.setdefault(nname, []).append(pod) for node in nodes: node_name = node.metadata.name @@ -131,6 +170,13 @@ def cluster_data(): node_usage_cpu = parse_cpu(node_metrics.get(node_name, {}).get('cpu', '0')) node_usage_mem = parse_memory(node_metrics.get(node_name, {}).get('memory', '0')) + node_pods = pods_by_node.get(node_name, []) + non_terminated_pods = [ + p for p in node_pods + if p.status and getattr(p.status, 'phase', None) not in ['Succeeded', 'Failed'] + ] + non_terminated_pod_count = len(non_terminated_pods) + node_data = { "name": node_name, "type": "node", @@ -141,6 +187,10 @@ def cluster_data(): "internalIp": internal_ip, "os_image": os_image, "osImage": os_image, + "non_terminated_pods": non_terminated_pod_count, + "nonTerminatedPods": non_terminated_pod_count, + "pod_count": non_terminated_pod_count, + "podCount": non_terminated_pod_count, "cpu_allocatable": cpu_allocatable, "mem_allocatable": mem_allocatable, "cpu_usage": node_usage_cpu, @@ -151,42 +201,41 @@ def cluster_data(): node_cpu_total = 0 node_mem_total = 0 - for pod in pods: - if pod.spec.node_name == node_name: - pod_cpu_req = 0 - pod_mem_req = 0 + for pod in node_pods: + pod_cpu_req = 0 + pod_mem_req = 0 + + if pod.spec.containers: + for c in pod.spec.containers: + if c.resources and c.resources.requests: + pod_cpu_req += parse_cpu(c.resources.requests.get('cpu', '0')) + pod_mem_req += parse_memory(c.resources.requests.get('memory', '0')) + + key = f"{pod.metadata.namespace}/{pod.metadata.name}" + if key in pod_metrics: + pod_cpu = sum([parse_cpu(c['usage'].get('cpu', '0')) for c in pod_metrics[key]]) + pod_mem = sum([parse_memory(c['usage'].get('memory', '0')) for c in pod_metrics[key]]) + else: + pod_cpu = pod_cpu_req + pod_mem = pod_mem_req - if pod.spec.containers: - for c in pod.spec.containers: - if c.resources and c.resources.requests: - pod_cpu_req += parse_cpu(c.resources.requests.get('cpu', '0')) - pod_mem_req += parse_memory(c.resources.requests.get('memory', '0')) - - key = f"{pod.metadata.namespace}/{pod.metadata.name}" - if key in pod_metrics: - pod_cpu = sum([parse_cpu(c['usage'].get('cpu', '0')) for c in pod_metrics[key]]) - pod_mem = sum([parse_memory(c['usage'].get('memory', '0')) for c in pod_metrics[key]]) - else: - pod_cpu = pod_cpu_req - pod_mem = pod_mem_req - - # D3 Treemap doesn't like 0 values, so minimum size 1 - pod_cpu_val = max(pod_cpu, 1) - pod_mem_val = max(pod_mem, 1) + # D3 Treemap doesn't like 0 values, so minimum size 1 + pod_cpu_val = max(pod_cpu, 1) + pod_mem_val = max(pod_mem, 1) + + node_cpu_total += pod_cpu + node_mem_total += pod_mem - node_cpu_total += pod_cpu - node_mem_total += pod_mem - - node_data["children"].append({ - "name": pod.metadata.name, - "namespace": pod.metadata.namespace, - "type": "pod", - "cpu": pod_cpu_val, - "memory": pod_mem_val, - "cpu_raw": pod_cpu, - "memory_raw": pod_mem, - "status": pod.status.phase - }) + node_data["children"].append({ + "name": pod.metadata.name, + "namespace": pod.metadata.namespace, + "type": "pod", + "cpu": pod_cpu_val, + "memory": pod_mem_val, + "cpu_raw": pod_cpu, + "memory_raw": pod_mem, + "status": pod.status.phase + }) # Optionally add an "Unused" block so the node size matches allocatable unused_cpu = max(0, cpu_allocatable - node_cpu_total) @@ -217,6 +266,10 @@ def cluster_data(): def version_route(): return jsonify({"version": get_version()}) +@app.route('/healthz') +def healthz(): + return jsonify({"status": "ok"}), 200 + @app.route('/') def index(): return render_template('index.html', version=get_version()) diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index 7f6ec69..a8c5111 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: k8s-pulse description: A Helm chart for k8s-pulse, a Kubernetes cluster resource visualizer. type: application -version: 0.1.0 -appVersion: "1.0.4" +version: 0.1.1 +appVersion: "1.0.8" diff --git a/helm-chart/templates/deployment.yaml b/helm-chart/templates/deployment.yaml index 3793e4e..8829eb4 100644 --- a/helm-chart/templates/deployment.yaml +++ b/helm-chart/templates/deployment.yaml @@ -42,13 +42,13 @@ spec: protocol: TCP livenessProbe: httpGet: - path: / + path: /healthz port: http initialDelaySeconds: 10 periodSeconds: 10 readinessProbe: httpGet: - path: / + path: /healthz port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/static/2026-07-herdrbg.png b/static/2026-07-herdrbg.png new file mode 100644 index 0000000..f807506 Binary files /dev/null and b/static/2026-07-herdrbg.png differ diff --git a/static/app.js b/static/app.js index f6fc70b..82357b0 100644 --- a/static/app.js +++ b/static/app.js @@ -405,7 +405,8 @@ function build3DCluster(data) { isControlPlane: isControlPlane, roles: n.data.roles || (isControlPlane ? ['control-plane'] : ['worker']), internalIp: n.data.internalIp || n.data.internal_ip || 'N/A', - osImage: n.data.osImage || n.data.os_image || 'N/A' + osImage: n.data.osImage || n.data.os_image || 'N/A', + nonTerminatedPods: n.data.nonTerminatedPods ?? n.data.non_terminated_pods ?? n.data.podCount ?? n.data.pod_count ?? (n.data.children ? n.data.children.filter(c => c.type === 'pod' && c.status !== 'Succeeded' && c.status !== 'Failed').length : 0) }; platformMesh.userData = nodeUserData; @@ -601,6 +602,7 @@ function onPointerMove(event) { if (data.type === 'node') { const isCP = data.isControlPlane || (data.roles && (data.roles.includes('control-plane') || data.roles.includes('master'))); const badgeLabel = data.roles && data.roles.length ? data.roles.join(', ') : (isCP ? 'control-plane' : 'Node'); + const podCount = data.nonTerminatedPods ?? data.non_terminated_pods ?? data.podCount ?? data.pod_count ?? 0; tooltip.innerHTML = `
${data.name} @@ -614,6 +616,10 @@ function onPointerMove(event) { OS Image: ${data.osImage || 'N/A'}
+
+ Non-terminated Pods: + ${podCount} +
`; } else if (data.type === 'unused') { const valCpu = formatCpu(data.cpu_raw); diff --git a/version.ini b/version.ini index 6f4c939..f501a3b 100644 --- a/version.ini +++ b/version.ini @@ -1,2 +1,2 @@ [metadata] -version = 0.7 +version = 0.8