2026-07-24 00:15:19 +00:00
|
|
|
import os
|
2026-07-24 13:20:03 +00:00
|
|
|
import configparser
|
2026-07-29 10:40:29 +00:00
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-07-24 00:15:19 +00:00
|
|
|
from flask import Flask, jsonify, render_template
|
|
|
|
|
from kubernetes import client, config
|
|
|
|
|
from kubernetes.client.rest import ApiException
|
|
|
|
|
|
2026-07-24 13:20:03 +00:00
|
|
|
def get_version():
|
|
|
|
|
try:
|
|
|
|
|
ini_path = os.path.join(os.path.dirname(__file__), 'version.ini')
|
|
|
|
|
if os.path.exists(ini_path):
|
|
|
|
|
parser = configparser.ConfigParser()
|
|
|
|
|
parser.read(ini_path)
|
|
|
|
|
return parser.get('metadata', 'version', fallback='0.5')
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error reading version.ini: {e}")
|
|
|
|
|
return '0.5'
|
|
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
2026-07-29 10:40:29 +00:00
|
|
|
_v1_client = None
|
|
|
|
|
_custom_client = None
|
|
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
def get_k8s_client():
|
2026-07-29 10:40:29 +00:00
|
|
|
global _v1_client, _custom_client
|
|
|
|
|
if _v1_client is not None and _custom_client is not None:
|
|
|
|
|
return _v1_client, _custom_client
|
2026-07-24 00:15:19 +00:00
|
|
|
try:
|
|
|
|
|
config.load_incluster_config()
|
|
|
|
|
except config.ConfigException:
|
|
|
|
|
try:
|
|
|
|
|
config.load_kube_config()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error loading kubeconfig: {e}")
|
2026-07-24 13:20:03 +00:00
|
|
|
loaded = False
|
|
|
|
|
for fallback_path in ["/home/nonroot/.kube/config", "/root/.kube/config"]:
|
|
|
|
|
try:
|
|
|
|
|
if os.path.exists(fallback_path):
|
|
|
|
|
config.load_kube_config(config_file=fallback_path)
|
|
|
|
|
loaded = True
|
|
|
|
|
break
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if not loaded:
|
2026-07-24 00:15:19 +00:00
|
|
|
raise RuntimeError(f"Failed to load kubeconfig. Make sure the file exists and is accessible. Error: {e}")
|
2026-07-29 10:40:29 +00:00
|
|
|
_v1_client = client.CoreV1Api()
|
|
|
|
|
_custom_client = client.CustomObjectsApi()
|
|
|
|
|
return _v1_client, _custom_client
|
2026-07-24 00:15:19 +00:00
|
|
|
|
|
|
|
|
def parse_cpu(cpu_str):
|
|
|
|
|
if not cpu_str: return 0
|
|
|
|
|
cpu_str = str(cpu_str)
|
|
|
|
|
if cpu_str.endswith('m'):
|
|
|
|
|
return int(cpu_str[:-1])
|
|
|
|
|
if cpu_str.endswith('n'):
|
|
|
|
|
return int(cpu_str[:-1]) // 1000000
|
|
|
|
|
try:
|
|
|
|
|
return int(float(cpu_str) * 1000)
|
|
|
|
|
except:
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
def parse_memory(mem_str):
|
|
|
|
|
if not mem_str: return 0
|
|
|
|
|
mem_str = str(mem_str)
|
|
|
|
|
if mem_str.endswith('Ki'):
|
|
|
|
|
return int(mem_str[:-2]) * 1024
|
|
|
|
|
if mem_str.endswith('Mi'):
|
|
|
|
|
return int(mem_str[:-2]) * 1024**2
|
|
|
|
|
if mem_str.endswith('Gi'):
|
|
|
|
|
return int(mem_str[:-2]) * 1024**3
|
|
|
|
|
try:
|
|
|
|
|
return int(mem_str)
|
|
|
|
|
except:
|
|
|
|
|
return 0
|
|
|
|
|
|
2026-07-29 10:40:29 +00:00
|
|
|
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
|
|
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
@app.route('/api/cluster-data')
|
|
|
|
|
def cluster_data():
|
|
|
|
|
try:
|
2026-07-29 10:40:29 +00:00
|
|
|
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)
|
2026-07-24 00:15:19 +00:00
|
|
|
|
|
|
|
|
for node in nodes:
|
|
|
|
|
node_name = node.metadata.name
|
2026-07-28 12:05:24 +00:00
|
|
|
|
|
|
|
|
internal_ip = "N/A"
|
|
|
|
|
if node.status and getattr(node.status, 'addresses', None):
|
|
|
|
|
for addr in node.status.addresses:
|
|
|
|
|
addr_type = getattr(addr, 'type', None) or (addr.get('type') if isinstance(addr, dict) else None)
|
|
|
|
|
addr_val = getattr(addr, 'address', None) or (addr.get('address') if isinstance(addr, dict) else None)
|
|
|
|
|
if addr_type == 'InternalIP':
|
|
|
|
|
internal_ip = addr_val
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
os_image = "N/A"
|
|
|
|
|
if node.status and getattr(node.status, 'node_info', None):
|
|
|
|
|
node_info = node.status.node_info
|
|
|
|
|
if isinstance(node_info, dict):
|
|
|
|
|
os_image = node_info.get('osImage') or node_info.get('os_image') or "N/A"
|
|
|
|
|
else:
|
|
|
|
|
os_image = getattr(node_info, 'os_image', None) or getattr(node_info, 'os_image', "N/A")
|
|
|
|
|
|
|
|
|
|
labels = node.metadata.labels or {}
|
|
|
|
|
roles = []
|
|
|
|
|
for k in labels:
|
|
|
|
|
if k.startswith("node-role.kubernetes.io/"):
|
|
|
|
|
role_name = k.split("/", 1)[1]
|
|
|
|
|
if role_name:
|
|
|
|
|
roles.append(role_name)
|
|
|
|
|
|
|
|
|
|
is_control_plane = any(r in ["control-plane", "master"] for r in roles) or ("control-plane" in node_name) or ("master" in node_name)
|
|
|
|
|
if is_control_plane and not roles:
|
|
|
|
|
roles = ["control-plane" if "control-plane" in node_name else "master"]
|
|
|
|
|
elif not roles:
|
|
|
|
|
roles = ["worker"]
|
|
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
cpu_allocatable = parse_cpu(node.status.allocatable.get('cpu', '0'))
|
|
|
|
|
mem_allocatable = parse_memory(node.status.allocatable.get('memory', '0'))
|
2026-07-28 12:05:24 +00:00
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
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'))
|
2026-07-28 12:05:24 +00:00
|
|
|
|
2026-07-29 10:40:29 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
node_data = {
|
|
|
|
|
"name": node_name,
|
|
|
|
|
"type": "node",
|
2026-07-28 12:05:24 +00:00
|
|
|
"roles": roles,
|
|
|
|
|
"is_control_plane": is_control_plane,
|
|
|
|
|
"isControlPlane": is_control_plane,
|
|
|
|
|
"internal_ip": internal_ip,
|
|
|
|
|
"internalIp": internal_ip,
|
|
|
|
|
"os_image": os_image,
|
|
|
|
|
"osImage": os_image,
|
2026-07-29 10:40:29 +00:00
|
|
|
"non_terminated_pods": non_terminated_pod_count,
|
|
|
|
|
"nonTerminatedPods": non_terminated_pod_count,
|
|
|
|
|
"pod_count": non_terminated_pod_count,
|
|
|
|
|
"podCount": non_terminated_pod_count,
|
2026-07-24 00:15:19 +00:00
|
|
|
"cpu_allocatable": cpu_allocatable,
|
|
|
|
|
"mem_allocatable": mem_allocatable,
|
|
|
|
|
"cpu_usage": node_usage_cpu,
|
|
|
|
|
"mem_usage": node_usage_mem,
|
|
|
|
|
"children": []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
node_cpu_total = 0
|
|
|
|
|
node_mem_total = 0
|
|
|
|
|
|
2026-07-29 10:40:29 +00:00
|
|
|
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
|
2026-07-24 00:15:19 +00:00
|
|
|
|
2026-07-29 10:40:29 +00:00
|
|
|
# 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
|
2026-07-24 00:15:19 +00:00
|
|
|
|
2026-07-29 10:40:29 +00:00
|
|
|
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
|
|
|
|
|
})
|
2026-07-24 00:15:19 +00:00
|
|
|
|
|
|
|
|
# Optionally add an "Unused" block so the node size matches allocatable
|
|
|
|
|
unused_cpu = max(0, cpu_allocatable - node_cpu_total)
|
|
|
|
|
unused_mem = max(0, mem_allocatable - node_mem_total)
|
|
|
|
|
|
|
|
|
|
if unused_cpu > 0 or unused_mem > 0:
|
|
|
|
|
node_data["children"].append({
|
|
|
|
|
"name": "Available Capacity",
|
|
|
|
|
"namespace": "",
|
|
|
|
|
"type": "unused",
|
|
|
|
|
"cpu": max(unused_cpu, 1),
|
|
|
|
|
"memory": max(unused_mem, 1),
|
|
|
|
|
"cpu_raw": unused_cpu,
|
|
|
|
|
"memory_raw": unused_mem,
|
|
|
|
|
"status": "Available"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
cluster["children"].append(node_data)
|
|
|
|
|
|
|
|
|
|
return jsonify(cluster)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
import traceback
|
|
|
|
|
traceback.print_exc()
|
|
|
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
2026-07-24 13:20:03 +00:00
|
|
|
@app.route('/api/version')
|
|
|
|
|
def version_route():
|
|
|
|
|
return jsonify({"version": get_version()})
|
|
|
|
|
|
2026-07-29 10:40:29 +00:00
|
|
|
@app.route('/healthz')
|
|
|
|
|
def healthz():
|
|
|
|
|
return jsonify({"status": "ok"}), 200
|
|
|
|
|
|
2026-07-24 00:15:19 +00:00
|
|
|
@app.route('/')
|
|
|
|
|
def index():
|
2026-07-24 13:20:03 +00:00
|
|
|
return render_template('index.html', version=get_version())
|
2026-07-24 00:15:19 +00:00
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
app.run(host='0.0.0.0', port=5000)
|