From 0cae6d64adc922074a5622599746c2e618c996fb Mon Sep 17 00:00:00 2001 From: Rainy21 Date: Tue, 12 May 2026 14:59:19 -0400 Subject: [PATCH] fix some issuen, added cache and added a test for the config --- README.md | 86 +++++++++++++++ app.py | 99 ++++++++++++++++++ app/cache.py | 71 +++++++++++++ app/routers/__init__.py | 0 app/routers/metrics.py | 2 +- requirements.txt | 4 +- .../test_nodes.cpython-313-pytest-9.0.2.pyc | Bin 31664 -> 31659 bytes tests/conftest.py | 3 + 8 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 README.md create mode 100644 app/cache.py create mode 100644 app/routers/__init__.py create mode 100644 tests/conftest.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..98b3ce3 --- /dev/null +++ b/README.md @@ -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. diff --git a/app.py b/app.py index e69de29..6d43c2a 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/app/cache.py b/app/cache.py new file mode 100644 index 0000000..ea98729 --- /dev/null +++ b/app/cache.py @@ -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() diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/metrics.py b/app/routers/metrics.py index 575dd87..e002e94 100644 --- a/app/routers/metrics.py +++ b/app/routers/metrics.py @@ -1,5 +1,5 @@ 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"]) diff --git a/requirements.txt b/requirements.txt index cce9705..cc69d99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ fastapi uvicorn kubernetes python-dotenv -httpx +httpx +streamlit +pytest diff --git a/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_nodes.cpython-313-pytest-9.0.2.pyc index b5e7b8729894de21f09e9cd6319e7b4f290a57d4..090940e39c5ca33a9e899c83d443a7c5fdaf927d 100644 GIT binary patch delta 91 zcmdn+opJSdM&8f7yj%=G@cPGx%$pl|t(lpGm?k%BicL;nRuM{KVqgem_h$}f3}!N5 v4CM)8n!Hg{cJprLd}hXLleO6;1yr>!s+!L>=E04;*33*&Oq27%#U>{(s|eUKYJlW`Accj2L4jd& z5A!r;^~*qh1H%^?1|HFF>jsw_lCtf-jlNe{#BN9{UY8ELpyqp`?1F08W*_$W5&+YQ B9!dZJ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..911e9f6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import sys, pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))