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
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 1m42s
Details
This commit is contained in:
parent
e73e5257c9
commit
dedac92afa
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
159
app.py
159
app.py
|
|
@ -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
|
|
||||||
|
|
||||||
node_metrics = {}
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
pod_metrics = {}
|
fut_nodes = executor.submit(fetch_nodes, v1)
|
||||||
try:
|
fut_pods = executor.submit(fetch_pods, v1)
|
||||||
nm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "nodes")
|
fut_node_metrics = executor.submit(fetch_node_metrics, custom)
|
||||||
for item in nm.get('items', []):
|
fut_pod_metrics = executor.submit(fetch_pod_metrics, custom)
|
||||||
node_metrics[item['metadata']['name']] = item['usage']
|
|
||||||
|
|
||||||
pm = custom.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "pods")
|
nodes = fut_nodes.result()
|
||||||
for item in pm.get('items', []):
|
pods = fut_pods.result()
|
||||||
key = f"{item['metadata']['namespace']}/{item['metadata']['name']}"
|
node_metrics = fut_node_metrics.result()
|
||||||
pod_metrics[key] = item['containers']
|
pod_metrics = fut_pod_metrics.result()
|
||||||
except ApiException:
|
|
||||||
pass # Metrics server not available
|
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:
|
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:
|
if pod.spec.containers:
|
||||||
for c in pod.spec.containers:
|
for c in pod.spec.containers:
|
||||||
if c.resources and c.resources.requests:
|
if c.resources and c.resources.requests:
|
||||||
pod_cpu_req += parse_cpu(c.resources.requests.get('cpu', '0'))
|
pod_cpu_req += parse_cpu(c.resources.requests.get('cpu', '0'))
|
||||||
pod_mem_req += parse_memory(c.resources.requests.get('memory', '0'))
|
pod_mem_req += parse_memory(c.resources.requests.get('memory', '0'))
|
||||||
|
|
||||||
key = f"{pod.metadata.namespace}/{pod.metadata.name}"
|
key = f"{pod.metadata.namespace}/{pod.metadata.name}"
|
||||||
if key in pod_metrics:
|
if key in pod_metrics:
|
||||||
pod_cpu = sum([parse_cpu(c['usage'].get('cpu', '0')) for c in pod_metrics[key]])
|
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]])
|
pod_mem = sum([parse_memory(c['usage'].get('memory', '0')) for c in pod_metrics[key]])
|
||||||
else:
|
else:
|
||||||
pod_cpu = pod_cpu_req
|
pod_cpu = pod_cpu_req
|
||||||
pod_mem = pod_mem_req
|
pod_mem = pod_mem_req
|
||||||
|
|
||||||
# D3 Treemap doesn't like 0 values, so minimum size 1
|
# D3 Treemap doesn't like 0 values, so minimum size 1
|
||||||
pod_cpu_val = max(pod_cpu, 1)
|
pod_cpu_val = max(pod_cpu, 1)
|
||||||
pod_mem_val = max(pod_mem, 1)
|
pod_mem_val = max(pod_mem, 1)
|
||||||
|
|
||||||
node_cpu_total += pod_cpu
|
node_cpu_total += pod_cpu
|
||||||
node_mem_total += pod_mem
|
node_mem_total += pod_mem
|
||||||
|
|
||||||
node_data["children"].append({
|
node_data["children"].append({
|
||||||
"name": pod.metadata.name,
|
"name": pod.metadata.name,
|
||||||
"namespace": pod.metadata.namespace,
|
"namespace": pod.metadata.namespace,
|
||||||
"type": "pod",
|
"type": "pod",
|
||||||
"cpu": pod_cpu_val,
|
"cpu": pod_cpu_val,
|
||||||
"memory": pod_mem_val,
|
"memory": pod_mem_val,
|
||||||
"cpu_raw": pod_cpu,
|
"cpu_raw": pod_cpu,
|
||||||
"memory_raw": pod_mem,
|
"memory_raw": pod_mem,
|
||||||
"status": pod.status.phase
|
"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())
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
[metadata]
|
[metadata]
|
||||||
version = 0.7
|
version = 0.8
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue