mirror of
https://github.com/Rainyy21/k3s-health-dashboard.git
synced 2026-10-11 00:00:20 -04:00
fix some issuen, added cache and added a test for the config
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
# k3s Health Dashboard
|
||||||
|
|
||||||
|
A Streamlit dashboard for monitoring a k3s (or any Kubernetes) cluster — nodes, pods, deployments, and metrics in one place.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
main.py FastAPI backend (REST API)
|
||||||
|
cache.py Streamlit cache wrappers (TTL-based)
|
||||||
|
k8s_client.py Kubernetes API client & data helpers
|
||||||
|
config.py Config loaded from environment variables
|
||||||
|
routers/ FastAPI route handlers
|
||||||
|
app.py Streamlit frontend entry point
|
||||||
|
k3s-dashboard.yaml Kubernetes deployment manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- A running Kubernetes cluster with a valid kubeconfig at `~/.kube/config`
|
||||||
|
- (Optional) `metrics-server` installed on the cluster for CPU/memory usage
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the example environment file and edit as needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Key environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|-------------------|-----------------------|--------------------------------------|
|
||||||
|
| `KUBECONFIG` | `~/.kube/config` | Path to kubeconfig |
|
||||||
|
| `IN_CLUSTER` | (unset) | Set to `true` when running as a pod |
|
||||||
|
| `CACHE_TTL` | `15` | Seconds between Kubernetes API calls |
|
||||||
|
| `METRICS_ENABLED` | `true` | Set `false` if metrics-server absent |
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
**Streamlit dashboard:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens at `http://localhost:8501`.
|
||||||
|
|
||||||
|
**FastAPI backend only:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
API docs at `http://localhost:8000/docs`.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying to Kubernetes
|
||||||
|
|
||||||
|
1. Build and push the Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t your-registry/k3s-dashboard:latest .
|
||||||
|
docker push your-registry/k3s-dashboard:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update the image name in `k3s-dashboard.yaml`, then apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k3s-dashboard.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest creates a `ServiceAccount` with read-only `ClusterRole` access to nodes, pods, namespaces, and metrics.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import time
|
||||||
|
import streamlit as st
|
||||||
|
from app import cfg
|
||||||
|
from app.cache import (
|
||||||
|
clients,
|
||||||
|
cached_nodes,
|
||||||
|
cached_pods,
|
||||||
|
cached_deployments,
|
||||||
|
cached_namespaces,
|
||||||
|
cached_node_metrics,
|
||||||
|
clear,
|
||||||
|
)
|
||||||
|
|
||||||
|
st.set_page_config(page_title=cfg.page_title, page_icon=cfg.page_icon, layout="wide")
|
||||||
|
|
||||||
|
clients()
|
||||||
|
|
||||||
|
with st.sidebar:
|
||||||
|
st.title("☸️ k3s Dashboard")
|
||||||
|
|
||||||
|
auto_refresh = st.toggle("Auto-refresh (15s)", value=False)
|
||||||
|
|
||||||
|
if st.button("🔄 Refresh now"):
|
||||||
|
clear()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
namespaces = ["all"] + cached_namespaces()
|
||||||
|
sel_ns = st.selectbox("Namespace", namespaces)
|
||||||
|
|
||||||
|
view = st.radio("View", ["Overview", "Nodes", "Pods", "Deployments"])
|
||||||
|
|
||||||
|
|
||||||
|
# Auto-refresh: sleep only the time remaining since the last refresh,
|
||||||
|
# not the full TTL on every rerun (which would freeze the UI on sidebar clicks).
|
||||||
|
if auto_refresh:
|
||||||
|
now = time.time()
|
||||||
|
if "last_refresh" not in st.session_state:
|
||||||
|
st.session_state.last_refresh = now
|
||||||
|
elapsed = now - st.session_state.last_refresh
|
||||||
|
remaining = cfg.cache_ttl - elapsed
|
||||||
|
if remaining <= 0:
|
||||||
|
clear()
|
||||||
|
st.session_state.last_refresh = time.time()
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
time.sleep(remaining)
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Views ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if view == "Overview":
|
||||||
|
nodes = cached_nodes()
|
||||||
|
pods = cached_pods(sel_ns)
|
||||||
|
deployments = cached_deployments(sel_ns)
|
||||||
|
metrics = cached_node_metrics()
|
||||||
|
|
||||||
|
ready_nodes = sum(1 for n in nodes if n["ready"])
|
||||||
|
ready_pods = sum(1 for p in pods if p["ready"])
|
||||||
|
ready_deps = sum(1 for d in deployments if d["available"] >= d["desired"] > 0)
|
||||||
|
|
||||||
|
c1, c2, c3 = st.columns(3)
|
||||||
|
c1.metric("Nodes", f"{ready_nodes}/{len(nodes)} ready")
|
||||||
|
c2.metric("Pods", f"{ready_pods}/{len(pods)} ready")
|
||||||
|
c3.metric("Deployments", f"{ready_deps}/{len(deployments)} ready")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
st.subheader("Nodes")
|
||||||
|
st.dataframe(nodes, use_container_width=True)
|
||||||
|
|
||||||
|
st.subheader("Pods")
|
||||||
|
st.dataframe(pods, use_container_width=True)
|
||||||
|
|
||||||
|
elif view == "Nodes":
|
||||||
|
nodes = cached_nodes()
|
||||||
|
metrics = cached_node_metrics()
|
||||||
|
|
||||||
|
st.subheader("Nodes")
|
||||||
|
for node in nodes:
|
||||||
|
m = metrics.get(node["name"], {})
|
||||||
|
with st.expander(f"{'✅' if node['ready'] else '❌'} {node['name']} — {node['roles']}"):
|
||||||
|
col1, col2, col3 = st.columns(3)
|
||||||
|
col1.metric("CPU capacity", f"{node['cpu_cap']} cores")
|
||||||
|
col2.metric("Memory capacity", f"{node['mem_cap_mi']:.0f} Mi")
|
||||||
|
col3.metric("Age", node["age"])
|
||||||
|
if m:
|
||||||
|
col1.metric("CPU usage", m["cpu_usage"])
|
||||||
|
col2.metric("Memory usage", m["memory_usage"])
|
||||||
|
st.caption(f"OS: {node['os']} Kubelet: {node['kubelet_version']}")
|
||||||
|
|
||||||
|
elif view == "Pods":
|
||||||
|
pods = cached_pods(sel_ns)
|
||||||
|
st.subheader(f"Pods — {sel_ns}")
|
||||||
|
st.dataframe(pods, use_container_width=True)
|
||||||
|
|
||||||
|
elif view == "Deployments":
|
||||||
|
deployments = cached_deployments(sel_ns)
|
||||||
|
st.subheader(f"Deployments — {sel_ns}")
|
||||||
|
st.dataframe(deployments, use_container_width=True)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import streamlit as st
|
||||||
|
from app import cfg
|
||||||
|
from app.k8s_client import get_core, get_custom_client, fetch_nodes, fetch_pods
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource
|
||||||
|
def clients():
|
||||||
|
get_core()
|
||||||
|
get_custom_client()
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=cfg.cache_ttl)
|
||||||
|
def cached_nodes():
|
||||||
|
return fetch_nodes()
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=cfg.cache_ttl)
|
||||||
|
def cached_pods(namespace="all"):
|
||||||
|
return fetch_pods(namespace=namespace)
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=cfg.cache_ttl)
|
||||||
|
def cached_deployments(namespace="all"):
|
||||||
|
from kubernetes import client as _k8s
|
||||||
|
get_core() # ensures kubeconfig is loaded before AppsV1Api is created
|
||||||
|
apps_v1 = _k8s.AppsV1Api()
|
||||||
|
if namespace == "all":
|
||||||
|
items = apps_v1.list_deployment_for_all_namespaces().items
|
||||||
|
else:
|
||||||
|
items = apps_v1.list_namespaced_deployment(namespace=namespace).items
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"namespace": d.metadata.namespace,
|
||||||
|
"name": d.metadata.name,
|
||||||
|
"ready": f"{d.status.ready_replicas or 0}/{d.spec.replicas or 0}",
|
||||||
|
"available": d.status.available_replicas or 0,
|
||||||
|
"desired": d.spec.replicas or 0,
|
||||||
|
}
|
||||||
|
for d in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=cfg.cache_ttl)
|
||||||
|
def cached_namespaces():
|
||||||
|
return [ns.metadata.name for ns in get_core().list_namespace().items]
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_data(ttl=cfg.cache_ttl)
|
||||||
|
def cached_node_metrics():
|
||||||
|
try:
|
||||||
|
api = get_custom_client()
|
||||||
|
data = api.list_cluster_custom_object(
|
||||||
|
group="metrics.k8s.io", version="v1beta1", plural="nodes"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
item["metadata"]["name"]: {
|
||||||
|
"cpu_usage": item["usage"]["cpu"],
|
||||||
|
"memory_usage": item["usage"]["memory"],
|
||||||
|
}
|
||||||
|
for item in data["items"]
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def clear():
|
||||||
|
cached_nodes.clear()
|
||||||
|
cached_pods.clear()
|
||||||
|
cached_deployments.clear()
|
||||||
|
cached_namespaces.clear()
|
||||||
|
cached_node_metrics.clear()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from k8s_client import get_custom_client
|
from app.k8s_client import get_custom_client
|
||||||
|
|
||||||
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ uvicorn
|
|||||||
kubernetes
|
kubernetes
|
||||||
python-dotenv
|
python-dotenv
|
||||||
httpx
|
httpx
|
||||||
|
streamlit
|
||||||
|
pytest
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
import sys, pathlib
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
Reference in New Issue
Block a user