0.8 - fix health check, added pod counts on node
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 1m42s Details

This commit is contained in:
Isaac Johnson 2026-07-29 05:40:29 -05:00
parent e73e5257c9
commit dedac92afa
7 changed files with 124 additions and 64 deletions

1
.gitignore vendored
View File

@ -190,3 +190,4 @@ pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,linux # End of https://www.toptal.com/developers/gitignore/api/python,linux
myvalues.yaml myvalues.yaml

169
app.py
View File

@ -1,5 +1,6 @@
import os import os
import configparser import configparser
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, jsonify, render_template from flask import Flask, jsonify, render_template
from kubernetes import client, config from kubernetes import client, config
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
@ -17,7 +18,13 @@ def get_version():
app = Flask(__name__) app = Flask(__name__)
_v1_client = None
_custom_client = None
def get_k8s_client(): 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: try:
config.load_incluster_config() config.load_incluster_config()
except config.ConfigException: except config.ConfigException:
@ -36,7 +43,9 @@ def get_k8s_client():
pass pass
if not loaded: if not loaded:
raise RuntimeError(f"Failed to load kubeconfig. Make sure the file exists and is accessible. Error: {e}") 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): def parse_cpu(cpu_str):
if not cpu_str: return 0 if not cpu_str: return 0
@ -64,32 +73,62 @@ def parse_memory(mem_str):
except: except:
return 0 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') @app.route('/api/cluster-data')
def cluster_data(): def cluster_data():
v1, custom = get_k8s_client()
cluster = {
"name": "Cluster",
"children": []
}
try: try:
nodes = v1.list_node().items v1, custom = get_k8s_client()
pods = v1.list_pod_for_all_namespaces().items
with ThreadPoolExecutor(max_workers=4) as executor:
node_metrics = {} fut_nodes = executor.submit(fetch_nodes, v1)
pod_metrics = {} fut_pods = executor.submit(fetch_pods, v1)
try: fut_node_metrics = executor.submit(fetch_node_metrics, custom)
nm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "nodes") fut_pod_metrics = executor.submit(fetch_pod_metrics, custom)
for item in nm.get('items', []):
node_metrics[item['metadata']['name']] = item['usage'] nodes = fut_nodes.result()
pods = fut_pods.result()
pm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "pods") node_metrics = fut_node_metrics.result()
for item in pm.get('items', []): pod_metrics = fut_pod_metrics.result()
key = f"{item['metadata']['namespace']}/{item['metadata']['name']}"
pod_metrics[key] = item['containers'] cluster = {
except ApiException: "name": "Cluster",
pass # Metrics server not available "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: for node in nodes:
node_name = node.metadata.name 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_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_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 = { node_data = {
"name": node_name, "name": node_name,
"type": "node", "type": "node",
@ -141,6 +187,10 @@ def cluster_data():
"internalIp": internal_ip, "internalIp": internal_ip,
"os_image": os_image, "os_image": os_image,
"osImage": 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, "cpu_allocatable": cpu_allocatable,
"mem_allocatable": mem_allocatable, "mem_allocatable": mem_allocatable,
"cpu_usage": node_usage_cpu, "cpu_usage": node_usage_cpu,
@ -151,42 +201,41 @@ def cluster_data():
node_cpu_total = 0 node_cpu_total = 0
node_mem_total = 0 node_mem_total = 0
for pod in pods: for pod in node_pods:
if pod.spec.node_name == node_name: pod_cpu_req = 0
pod_cpu_req = 0 pod_mem_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: # D3 Treemap doesn't like 0 values, so minimum size 1
for c in pod.spec.containers: pod_cpu_val = max(pod_cpu, 1)
if c.resources and c.resources.requests: pod_mem_val = max(pod_mem, 1)
pod_cpu_req += parse_cpu(c.resources.requests.get('cpu', '0'))
pod_mem_req += parse_memory(c.resources.requests.get('memory', '0')) node_cpu_total += pod_cpu
node_mem_total += pod_mem
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)
node_cpu_total += pod_cpu node_data["children"].append({
node_mem_total += pod_mem "name": pod.metadata.name,
"namespace": pod.metadata.namespace,
node_data["children"].append({ "type": "pod",
"name": pod.metadata.name, "cpu": pod_cpu_val,
"namespace": pod.metadata.namespace, "memory": pod_mem_val,
"type": "pod", "cpu_raw": pod_cpu,
"cpu": pod_cpu_val, "memory_raw": pod_mem,
"memory": pod_mem_val, "status": pod.status.phase
"cpu_raw": pod_cpu, })
"memory_raw": pod_mem,
"status": pod.status.phase
})
# Optionally add an "Unused" block so the node size matches allocatable # Optionally add an "Unused" block so the node size matches allocatable
unused_cpu = max(0, cpu_allocatable - node_cpu_total) unused_cpu = max(0, cpu_allocatable - node_cpu_total)
@ -217,6 +266,10 @@ def cluster_data():
def version_route(): def version_route():
return jsonify({"version": get_version()}) return jsonify({"version": get_version()})
@app.route('/healthz')
def healthz():
return jsonify({"status": "ok"}), 200
@app.route('/') @app.route('/')
def index(): def index():
return render_template('index.html', version=get_version()) return render_template('index.html', version=get_version())

View File

@ -2,5 +2,5 @@ apiVersion: v2
name: k8s-pulse name: k8s-pulse
description: A Helm chart for k8s-pulse, a Kubernetes cluster resource visualizer. description: A Helm chart for k8s-pulse, a Kubernetes cluster resource visualizer.
type: application type: application
version: 0.1.0 version: 0.1.1
appVersion: "1.0.4" appVersion: "1.0.8"

View File

@ -42,13 +42,13 @@ spec:
protocol: TCP protocol: TCP
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /healthz
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /healthz
port: http port: http
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10

BIN
static/2026-07-herdrbg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

@ -405,7 +405,8 @@ function build3DCluster(data) {
isControlPlane: isControlPlane, isControlPlane: isControlPlane,
roles: n.data.roles || (isControlPlane ? ['control-plane'] : ['worker']), roles: n.data.roles || (isControlPlane ? ['control-plane'] : ['worker']),
internalIp: n.data.internalIp || n.data.internal_ip || 'N/A', 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; platformMesh.userData = nodeUserData;
@ -601,6 +602,7 @@ function onPointerMove(event) {
if (data.type === 'node') { if (data.type === 'node') {
const isCP = data.isControlPlane || (data.roles && (data.roles.includes('control-plane') || data.roles.includes('master'))); 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 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 = ` tooltip.innerHTML = `
<div class="tooltip-title"> <div class="tooltip-title">
<span>${data.name}</span> <span>${data.name}</span>
@ -614,6 +616,10 @@ function onPointerMove(event) {
<span class="tooltip-label">OS Image:</span> <span class="tooltip-label">OS Image:</span>
<span class="tooltip-value">${data.osImage || 'N/A'}</span> <span class="tooltip-value">${data.osImage || 'N/A'}</span>
</div> </div>
<div class="tooltip-row">
<span class="tooltip-label">Non-terminated Pods:</span>
<span class="tooltip-value">${podCount}</span>
</div>
`; `;
} else if (data.type === 'unused') { } else if (data.type === 'unused') {
const valCpu = formatCpu(data.cpu_raw); const valCpu = formatCpu(data.cpu_raw);

View File

@ -1,2 +1,2 @@
[metadata] [metadata]
version = 0.7 version = 0.8