From 82ee3aa6c2592a8307e7b5beb88b5c91b18f4662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Stach?= Date: Fri, 9 Oct 2026 15:42:31 +0200 Subject: [PATCH] feat: add phase 2 endpoints, session persistence and actions Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) --- README.md | 12 ++-- backend/main.py | 82 ++++++++++++++++++----- frontend/app.js | 158 +++++++++++++++++++++++++++++++++++++++++--- frontend/index.html | 30 ++++++++- 4 files changed, 249 insertions(+), 33 deletions(-) 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 = '
  • Loading...
  • '; try { - const res = await fetch(`http://localhost:8000/pods?namespace=${encodeURIComponent(ns)}`); + let url; + if (resType === 'pods') { + url = `http://localhost:8000/pods?namespace=${encodeURIComponent(ns)}${ctx ? '&context=' + encodeURIComponent(ctx) : ''}`; + } else if (resType === 'nodes') { + url = `http://localhost:8000/nodes${ctx ? '?context=' + encodeURIComponent(ctx) : ''}`; + } else if (resType === 'pvcs') { + url = `http://localhost:8000/pvcs?namespace=${encodeURIComponent(ns)}${ctx ? '&context=' + encodeURIComponent(ctx) : ''}`; + } else { + url = `http://localhost:8000/pods?namespace=${encodeURIComponent(ns)}`; + } + + const res = await fetch(url); const data = await res.json(); - podList.innerHTML = '
  • Pods loaded (raw output available)
  • '; + currentData = []; + try { + const obj = JSON.parse(data.raw || '{}'); + if (resType === 'pods' || resType === 'pvcs') { + const items = obj.items || []; + currentData = items.map(i => ({ + name: i.metadata?.name || 'unknown', + namespace: i.metadata?.namespace || ns + })); + } else if (resType === 'nodes') { + const items = obj.items || []; + currentData = items.map(i => ({ + name: i.metadata?.name || 'unknown', + namespace: '-' + })); + } + } catch (e) { + currentData = []; + } + renderList(); } catch (e) { - console.error('Failed to load pods', e); + console.error('Failed to load resources', e); + resourceList.innerHTML = '
  • Error loading resources
  • '; } } + function renderList() { + const filter = searchInput.value.toLowerCase(); + resourceList.innerHTML = ''; + const filtered = currentData.filter(item => item.name.toLowerCase().includes(filter)); + if (filtered.length === 0) { + resourceList.innerHTML = '
  • No results
  • '; + return; + } + filtered.forEach(item => { + const li = document.createElement('li'); + li.innerHTML = `${item.name} (${item.namespace})`; + resourceList.appendChild(li); + }); + } + + contextSelect.addEventListener('change', () => { saveSession(); loadNamespaces(); }); + namespaceSelect.addEventListener('change', () => { saveSession(); loadResources(); }); + resourceSelect.addEventListener('change', () => { saveSession(); loadResources(); }); + searchBtn.addEventListener('click', renderList); + searchInput.addEventListener('input', renderList); + setDefaultBtn.addEventListener('click', () => { saveSession(); alert('Defaults saved for session'); }); + + document.getElementById('btn-describe').addEventListener('click', async () => { + const name = actionName.value.trim(); + if (!name) return; + const resType = resourceSelect.value === 'pvcs' ? 'pvc' : (resourceSelect.value === 'nodes' ? 'node' : 'pod'); + const ns = namespaceSelect.value || 'default'; + const ctx = contextSelect.value || null; + try { + const url = new URL('http://localhost:8000/describe'); + url.searchParams.set('resource', resType); + url.searchParams.set('name', name); + url.searchParams.set('namespace', ns); + if (ctx) url.searchParams.set('context', ctx); + const res = await fetch(url); + const data = await res.json(); + actionOutput.textContent = data.output || data.error || JSON.stringify(data); + } catch (e) { + actionOutput.textContent = 'Error: ' + e; + } + }); + + document.getElementById('btn-logs').addEventListener('click', async () => { + const name = actionName.value.trim(); + if (!name) return; + const ns = namespaceSelect.value || 'default'; + const ctx = contextSelect.value || null; + try { + const url = new URL('http://localhost:8000/logs'); + url.searchParams.set('pod', name); + url.searchParams.set('namespace', ns); + if (ctx) url.searchParams.set('context', ctx); + const res = await fetch(url); + const data = await res.json(); + actionOutput.textContent = data.output || data.error || JSON.stringify(data); + } catch (e) { + actionOutput.textContent = 'Error: ' + e; + } + }); + + document.getElementById('btn-delete').addEventListener('click', async () => { + const name = actionName.value.trim(); + if (!name || !confirm('Delete ' + name + '?')) return; + const resType = resourceSelect.value === 'pvcs' ? 'pvc' : (resourceSelect.value === 'nodes' ? 'node' : 'pod'); + const ns = namespaceSelect.value || 'default'; + const ctx = contextSelect.value || null; + try { + const url = `http://localhost:8000/delete?resource=${encodeURIComponent(resType)}&name=${encodeURIComponent(name)}&namespace=${encodeURIComponent(ns)}${ctx ? '&context=' + encodeURIComponent(ctx) : ''}`; + const res = await fetch(url, { method: 'POST' }); + const data = await res.json(); + actionOutput.textContent = data.output || data.error || JSON.stringify(data); + loadResources(); + } catch (e) { + actionOutput.textContent = 'Error: ' + e; + } + }); + loadContexts(); - loadNamespaces(); - namespaceSelect.addEventListener('change', loadPods); - loadPods(); }); diff --git a/frontend/index.html b/frontend/index.html index 4fb8701..094d465 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,11 +17,37 @@ + + + + + + + +
    -

    Pods

    -
    • Loading...
    +

    Pods

    +
    • Loading...
    +
    + +
    +

    Actions

    +
    + + + + + +
    +