diff --git a/README.md b/README.md index 5104a14..ba36ab6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This app provides a lightweight local interface to manage Kubernetes clusters vi - Add logs viewer - Expand actions: describe, delete, logs, and more - Improve error handling and feedback +- Session persistence for context/namespace defaults ### Phase 3: Polish & Safety - Confirmation dialogs for destructive actions @@ -37,11 +38,12 @@ This app provides a lightweight local interface to manage Kubernetes clusters vi ## Roadmap / Checklist - [x] Add readme describing kubectl gui plan -- [ ] Phase 1: Prototype backend and basic frontend -- [ ] Phase 1: Context and namespace selection -- [ ] Phase 1: List pods and nodes -- [ ] Phase 2: Search pods, nodes, PVCs, and others -- [ ] Phase 2: Describe, delete, and logs actions +- [x] Phase 1: Prototype backend and basic frontend +- [x] Phase 1: Context and namespace selection +- [x] Phase 1: List pods and nodes +- [x] Phase 2: Search pods, nodes, PVCs, and others +- [x] Phase 2: Describe, delete, and logs actions +- [x] Phase 2: Session persistence for context/namespace - [ ] Phase 3: Safety confirmations and error handling - [ ] Phase 3: UI polish and documentation diff --git a/backend/main.py b/backend/main.py index 8c6e9bd..5424f8b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,41 +12,89 @@ app.add_middleware( allow_headers=["*"], ) +def run_kubectl(args, context=None): + cmd = ["kubectl"] + if context: + cmd += ["--context", context] + cmd += args + result = subprocess.run(cmd, capture_output=True, text=True) + return result + @app.get("/") def root(): return {"message": "Kubectl Web GUI backend"} @app.get("/contexts") def get_contexts(): - result = subprocess.run( - ["kubectl", "config", "get-contexts", "-o", "name"], - capture_output=True, text=True - ) + result = run_kubectl(["config", "get-contexts", "-o", "name"]) contexts = [line for line in result.stdout.splitlines() if line] current = "" try: - cur = subprocess.run( - ["kubectl", "config", "current-context"], - capture_output=True, text=True - ) + cur = run_kubectl(["config", "current-context"]) current = cur.stdout.strip() except Exception: pass return {"current": current, "contexts": contexts} @app.get("/namespaces") -def get_namespaces(): - result = subprocess.run( - ["kubectl", "get", "namespaces", "-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}"], - capture_output=True, text=True +def get_namespaces(context: str = None): + result = run_kubectl( + ["get", "namespaces", "-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}"], + context=context ) namespaces = [line for line in result.stdout.splitlines() if line] return {"namespaces": namespaces} @app.get("/pods") -def get_pods(namespace: str = "default"): - result = subprocess.run( - ["kubectl", "get", "pods", "-n", namespace, "-o", "json"], - capture_output=True, text=True +def get_pods(namespace: str = "default", context: str = None): + result = run_kubectl( + ["get", "pods", "-n", namespace, "-o", "json"], + context=context ) - return {"raw": result.stdout, "namespace": namespace} + return {"raw": result.stdout, "namespace": namespace, "context": context} + +@app.get("/nodes") +def get_nodes(context: str = None): + result = run_kubectl( + ["get", "nodes", "-o", "json"], + context=context + ) + return {"raw": result.stdout, "context": context} + +@app.get("/pvcs") +def get_pvcs(namespace: str = "default", context: str = None): + result = run_kubectl( + ["get", "pvc", "-n", namespace, "-o", "json"], + context=context + ) + return {"raw": result.stdout, "namespace": namespace, "context": context} + +@app.get("/describe") +def describe(resource: str, name: str, namespace: str = "default", context: str = None): + allowed = {"pod", "node", "pvc", "service", "deployment"} + if resource not in allowed: + return {"error": "Invalid resource type"} + result = run_kubectl( + ["describe", resource, name, "-n", namespace], + context=context + ) + return {"output": result.stdout, "stderr": result.stderr, "returncode": result.returncode} + +@app.post("/delete") +def delete(resource: str, name: str, namespace: str = "default", context: str = None): + allowed = {"pod", "node", "pvc", "service", "deployment"} + if resource not in allowed: + return {"error": "Invalid resource type"} + result = run_kubectl( + ["delete", resource, name, "-n", namespace, "--ignore-not-found=true"], + context=context + ) + return {"output": result.stdout, "stderr": result.stderr, "returncode": result.returncode} + +@app.get("/logs") +def logs(pod: str, namespace: str = "default", context: str = None, tail: int = 100): + result = run_kubectl( + ["logs", pod, "-n", namespace, "--tail", str(tail)], + context=context + ) + return {"output": result.stdout, "stderr": result.stderr, "returncode": result.returncode} diff --git a/frontend/app.js b/frontend/app.js index d6a1a31..cdc1d23 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,7 +1,31 @@ document.addEventListener('DOMContentLoaded', () => { const contextSelect = document.getElementById('context'); const namespaceSelect = document.getElementById('namespace'); - const podList = document.getElementById('pod-list'); + const resourceSelect = document.getElementById('resource-type'); + const resourceList = document.getElementById('resource-list'); + const resourceTitle = document.getElementById('resource-title'); + const searchInput = document.getElementById('search-input'); + const searchBtn = document.getElementById('search-btn'); + const setDefaultBtn = document.getElementById('set-default'); + const actionName = document.getElementById('action-name'); + const actionOutput = document.getElementById('action-output'); + + let currentData = []; + + function saveSession() { + sessionStorage.setItem('k8s_context', contextSelect.value); + sessionStorage.setItem('k8s_namespace', namespaceSelect.value); + sessionStorage.setItem('k8s_resource', resourceSelect.value); + } + + function loadSession() { + const ctx = sessionStorage.getItem('k8s_context'); + const ns = sessionStorage.getItem('k8s_namespace'); + const res = sessionStorage.getItem('k8s_resource'); + if (ctx) contextSelect.value = ctx; + if (ns) namespaceSelect.value = ns; + if (res) resourceSelect.value = res; + } async function loadContexts() { try { @@ -15,6 +39,8 @@ document.addEventListener('DOMContentLoaded', () => { if (ctx === data.current) opt.selected = true; contextSelect.appendChild(opt); }); + loadSession(); + loadNamespaces(); } catch (e) { console.error('Failed to load contexts', e); } @@ -22,7 +48,10 @@ document.addEventListener('DOMContentLoaded', () => { async function loadNamespaces() { try { - const res = await fetch('http://localhost:8000/namespaces'); + const ctx = contextSelect.value || null; + const url = new URL('http://localhost:8000/namespaces'); + if (ctx) url.searchParams.set('context', ctx); + const res = await fetch(url); const data = await res.json(); namespaceSelect.innerHTML = ''; data.namespaces.forEach(ns => { @@ -31,24 +60,135 @@ document.addEventListener('DOMContentLoaded', () => { opt.textContent = ns; namespaceSelect.appendChild(opt); }); + loadSession(); + loadResources(); } catch (e) { console.error('Failed to load namespaces', e); } } - async function loadPods() { + async function loadResources() { + const resType = resourceSelect.value; const ns = namespaceSelect.value || 'default'; + const ctx = contextSelect.value || null; + resourceTitle.textContent = resType.charAt(0).toUpperCase() + resType.slice(1); + resourceList.innerHTML = '