working version 1
This commit is contained in:
commit
62e55446fe
|
|
@ -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"]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "k8smonitor.fullname" . }}-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.size }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
flask==3.0.0
|
||||||
|
kubernetes==28.1.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue