working version 1

This commit is contained in:
Isaac Johnson 2026-07-23 19:15:19 -05:00
commit 62e55446fe
13 changed files with 824 additions and 0 deletions

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

165
app.py Normal file
View File

@ -0,0 +1,165 @@
import os
from flask import Flask, jsonify, render_template
from kubernetes import client, config
from kubernetes.client.rest import ApiException
app = Flask(__name__)
def get_k8s_client():
try:
config.load_incluster_config()
except config.ConfigException:
try:
config.load_kube_config()
except Exception as e:
print(f"Error loading kubeconfig: {e}")
# Try a fallback if they mounted to /root/.kube/config but KUBECONFIG is wrong
try:
config.load_kube_config(config_file="/root/.kube/config")
except Exception as e2:
raise RuntimeError(f"Failed to load kubeconfig. Make sure the file exists and is accessible. Error: {e}")
return client.CoreV1Api(), client.CustomObjectsApi()
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
@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
for node in nodes:
node_name = node.metadata.name
cpu_allocatable = parse_cpu(node.status.allocatable.get('cpu', '0'))
mem_allocatable = parse_memory(node.status.allocatable.get('memory', '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_data = {
"name": node_name,
"type": "node",
"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
for pod in pods:
if pod.spec.node_name == node_name:
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
# 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_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)
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
@app.route('/')
def index():
return render_template('index.html')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

6
helm-chart/Chart.yaml Normal file
View File

@ -0,0 +1,6 @@
apiVersion: v2
name: K8sMonitor
description: A Helm chart for a containerized python web app.
type: Project
version: 0.1.0
appVersion: "1.0.0"

78
helm-chart/INSTALL.md Normal file
View File

@ -0,0 +1,78 @@
# K8sMonitor Helm Chart
This Helm chart provides a standardized way to deploy the **K8sMonitor** python web application on a Kubernetes cluster.
## Prerequisites
- A running Kubernetes cluster.
- [Helm](https://helm.sh/) installed on your local machine or CI/CD runner.
- Access to a container registry where the `python-web-app` image is stored (if using a custom image).
## Getting Started
### 1. Prepare the Configuration
The default configuration can be found in `values.yaml`. You can override any value here before installing or pass them via `--set` or `-f <file>`.
Example of modifying ingress annotations:
```yaml
ingress:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
```
### 2. Install the Chart
To install the chart using the default values:
```bash
helm install k8smonitor ./helm-chart
```
To install with a custom values file:
```bash
helm install k8smonitor ./helm-chart -f custom-values.yaml
```
### 3. Upgrade the Chart
To update your installation with newer configuration or version:
```bash
helm upgrade k8smonitor ./helm-chart
```
## Configuration Options
| Value | Description | Default |
|------|-------------|---------|
| `replicaCount` | Number of desired pods | 2 |
| `image.repository` | The Docker image to pull | python-web-app |
| `service.port` | The port the service listens on | 80 |
| `ingress.enabled` | Enable or disable Ingress | true |
| `persistence.size` | Size of the PersistentVolumeClaim | 1Gi |
## Verification
Once installed, you can check the status of your deployment:
```bash
# List all pods for the app
kubectl get pods -l app=k8smonitor
# Check the service
kubectl get svc k8smonitor
# Check the ingress
kubectl get ingress k8smonitor
```
## Troubleshooting
If the pod is not running, check the logs:
```bash
kubectl logs -l app=k8smonitor
```
Check for events in the namespace:
```bash
kubectl get events --sort-by=.metadata.creationTimestamp
```

View File

@ -0,0 +1,29 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "k8smonitor.fullname" . }}
labels:
{{- include "k8smonitor.selectorLabels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "k8smonitor.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "k8smonitor.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- name: http
containerPort: {{ .Values.service.port }}
volumeMounts:
- name: data
mountPath: /app/data
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "k8smonitor.fullname" . }}-data

View File

@ -0,0 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "k8smonitor.fullname" . }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
rules:
- host: {{ index .Values.ingress.hosts 0 .host | default "k8smonitor.local" }}
http:
path:
- path: /
pathType: ImplementationSpecific
backend:
service:
name: {{ include "k8smonitor.fullname" . }}
port:
number: {{ .Values.service.port }}

View File

@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "k8smonitor.fullname" . }}-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.size }}

View File

@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "k8smonitor.fullname" . }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
selector:
{{- include "k8smonitor.selectorLabels" . | nindent 6 }}

25
helm-chart/values.yaml Normal file
View File

@ -0,0 +1,25 @@
replicaCount: 2
image:
repository: python-web-app
pullPolicy: IfNotPresent
tag: "latest"
service:
type: ClusterIP
port: 80
ingress:
enabled: true
hosts:
- host: k8smonitor.local
paths:
- path: /
pathType: ImplementationSpecific
annotations:
kubernetes.io/ingress.nginx._tls.k8smonitor.local: "cert-manager-challenge"
persistence:
enabled: true
size: 1Gi
storageClassName: standard

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask==3.0.0
kubernetes==28.1.0
gunicorn==21.2.0

202
static/app.js Normal file
View File

@ -0,0 +1,202 @@
let currentMetric = 'cpu';
let clusterData = null;
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatCpu = (millicores) => {
if (millicores === 0) return '0 m';
return `${millicores} m`;
};
document.getElementById('btn-cpu').addEventListener('click', (e) => {
document.getElementById('btn-cpu').classList.add('active');
document.getElementById('btn-mem').classList.remove('active');
currentMetric = 'cpu';
if (clusterData) renderChart(clusterData);
});
document.getElementById('btn-mem').addEventListener('click', (e) => {
document.getElementById('btn-mem').classList.add('active');
document.getElementById('btn-cpu').classList.remove('active');
currentMetric = 'memory';
if (clusterData) renderChart(clusterData);
});
async function fetchData() {
try {
const response = await fetch('/api/cluster-data');
if (!response.ok) throw new Error('Failed to fetch data');
clusterData = await response.json();
document.getElementById('loading').classList.add('hidden');
renderChart(clusterData);
} catch (error) {
console.error(error);
document.getElementById('loading').innerHTML = `<p style="color: #ef4444;">Error loading data: ${error.message}</p>`;
}
}
function renderChart(data) {
const container = document.getElementById('chart-container');
const width = container.clientWidth;
const height = container.clientHeight;
// Clear previous
d3.select('#chart-container').selectAll('*').remove();
const svg = d3.select('#chart-container')
.append('svg')
.attr('width', width)
.attr('height', height)
.style('font-family', 'Inter, sans-serif');
// Create hierarchy
const root = d3.hierarchy(data)
.sum(d => {
if (d.type === 'node') return 0;
return d[currentMetric] || 0;
})
.sort((a, b) => b.value - a.value);
// Create Treemap
d3.treemap()
.size([width, height])
.paddingTop(28)
.paddingRight(8)
.paddingInner(4)
.paddingBottom(8)
.paddingLeft(8)
.round(true)(root);
// Get nodes and pods
const nodes = root.descendants().filter(d => d.depth === 1); // Nodes
const pods = root.leaves(); // Pods and Unused
// Color scale for pods
const getColor = (d) => {
if (d.data.type === 'unused') return 'var(--color-unused)';
if (d.data.status === 'Running') return 'var(--color-running)';
if (d.data.status === 'Pending') return 'var(--color-pending)';
return 'var(--color-failed)';
};
// Draw Node Groups
const nodeGroups = svg.selectAll('.node-group')
.data(nodes)
.enter()
.append('g')
.attr('class', 'node-group')
.attr('transform', d => `translate(${d.x0},${d.y0})`);
// Node Background
nodeGroups.append('rect')
.attr('class', 'node-rect')
.attr('width', d => Math.max(0, d.x1 - d.x0))
.attr('height', d => Math.max(0, d.y1 - d.y0));
// Node Label
nodeGroups.append('text')
.attr('class', 'node-label')
.attr('x', 8)
.attr('y', 20)
.text(d => d.data.name);
// Draw Pods
const podGroups = svg.selectAll('.pod-group')
.data(pods)
.enter()
.append('g')
.attr('class', 'pod-group')
.attr('transform', d => `translate(${d.x0},${d.y0})`);
// Pod Rect
podGroups.append('rect')
.attr('class', 'pod-rect')
.attr('width', d => Math.max(0, d.x1 - d.x0))
.attr('height', d => Math.max(0, d.y1 - d.y0))
.attr('fill', d => getColor(d))
.on('mousemove', function(event, d) {
const tooltip = document.getElementById('tooltip');
tooltip.classList.remove('hidden');
// Boundary detection for tooltip
const tWidth = 240;
let left = event.pageX + 15;
let top = event.pageY + 15;
if (left + tWidth > window.innerWidth) {
left = event.pageX - tWidth - 15;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
let val = currentMetric === 'cpu' ? formatCpu(d.data[`${currentMetric}_raw`]) : formatBytes(d.data[`${currentMetric}_raw`]);
if (d.data.type === 'unused') {
tooltip.innerHTML = `
<div class="tooltip-title">Available Capacity</div>
<div class="tooltip-row">
<span class="tooltip-label">${currentMetric.toUpperCase()}:</span>
<span class="tooltip-value">${val}</span>
</div>
`;
} else {
tooltip.innerHTML = `
<div class="tooltip-title">${d.data.name}</div>
<div class="tooltip-row">
<span class="tooltip-label">Namespace:</span>
<span class="tooltip-value">${d.data.namespace}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">Status:</span>
<span class="tooltip-value">${d.data.status}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">${currentMetric.toUpperCase()}:</span>
<span class="tooltip-value">${val}</span>
</div>
`;
}
})
.on('mouseout', function() {
document.getElementById('tooltip').classList.add('hidden');
});
// Pod Label (only if there is space)
podGroups.append('text')
.attr('class', 'pod-label')
.attr('x', 4)
.attr('y', 14)
.text(d => {
if (d.data.type === 'unused') return '';
const width = d.x1 - d.x0;
const height = d.y1 - d.y0;
if (width > 60 && height > 20) {
let txt = d.data.name;
if (txt.length * 5 > width) {
return txt.substring(0, Math.floor(width/5) - 2) + '..';
}
return txt;
}
return '';
});
}
// Handle resize
window.addEventListener('resize', () => {
if (clusterData) {
renderChart(clusterData);
}
});
// Initial fetch
fetchData();
// Poll every 30 seconds
setInterval(fetchData, 30000);

219
static/style.css Normal file
View File

@ -0,0 +1,219 @@
:root {
--bg-color: #0f172a;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--glass-bg: rgba(30, 41, 59, 0.7);
--glass-border: rgba(255, 255, 255, 0.1);
/* Chart Colors */
--color-running: rgba(16, 185, 129, 0.8);
--color-pending: rgba(245, 158, 11, 0.8);
--color-failed: rgba(239, 68, 68, 0.8);
--color-unused: rgba(148, 163, 184, 0.15);
--color-node: rgba(30, 41, 59, 0.9);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
min-height: 100vh;
overflow: hidden;
background-image:
radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 1.5rem;
gap: 1.5rem;
}
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 1rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--accent);
}
.logo h1 {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-main);
letter-spacing: -0.025em;
}
.toggle-container {
display: flex;
background: rgba(15, 23, 42, 0.6);
padding: 0.25rem;
border-radius: 0.5rem;
border: 1px solid var(--glass-border);
}
.toggle-btn {
background: transparent;
border: none;
color: var(--text-muted);
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
}
.toggle-btn.active {
background: var(--accent);
color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.main-content {
flex: 1;
position: relative;
display: flex;
justify-content: center;
align-items: center;
border-radius: 1rem;
overflow: hidden;
}
.loading {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem;
z-index: 10;
transition: opacity 0.3s ease;
}
.loading.hidden {
opacity: 0;
pointer-events: none;
}
.spinner {
width: 3rem;
height: 3rem;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.chart-container {
width: 100%;
height: 100%;
}
.node-group {
transition: all 0.3s ease;
}
.node-rect {
fill: var(--color-node);
stroke: var(--glass-border);
stroke-width: 1px;
rx: 8;
ry: 8;
}
.pod-rect {
stroke: rgba(0,0,0,0.3);
stroke-width: 1px;
rx: 4;
ry: 4;
transition: fill 0.3s ease, opacity 0.3s ease, stroke 0.3s ease;
cursor: pointer;
}
.pod-rect:hover {
opacity: 0.9;
stroke: white;
}
.node-label {
fill: var(--text-main);
font-size: 0.875rem;
font-weight: 600;
pointer-events: none;
}
.pod-label {
fill: white;
font-size: 0.7rem;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
}
.tooltip {
position: absolute;
padding: 1rem;
pointer-events: none;
z-index: 100;
font-size: 0.875rem;
transition: opacity 0.2s ease, transform 0.1s ease;
min-width: 240px;
}
.tooltip.hidden {
opacity: 0;
}
.tooltip-title {
font-weight: 700;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--glass-border);
padding-bottom: 0.5rem;
word-break: break-all;
}
.tooltip-row {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
gap: 1rem;
}
.tooltip-label {
color: var(--text-muted);
}
.tooltip-value {
font-weight: 600;
}

45
templates/index.html Normal file
View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kubernetes Cluster Visualization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<script src="https://d3js.org/d3.v7.min.js"></script>
</head>
<body>
<div class="app-container">
<header class="glass-panel header">
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h1>K8s Pulse</h1>
</div>
<div class="controls">
<div class="toggle-container">
<button class="toggle-btn active" id="btn-cpu">CPU</button>
<button class="toggle-btn" id="btn-mem">Memory</button>
</div>
</div>
</header>
<main class="main-content">
<div id="loading" class="loading glass-panel">
<div class="spinner"></div>
<p>Analyzing Cluster...</p>
</div>
<div id="chart-container" class="chart-container"></div>
</main>
</div>
<div id="tooltip" class="tooltip glass-panel hidden"></div>
<script src="/static/app.js"></script>
</body>
</html>