feat: add phase 2 endpoints, session persistence and actions
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
This commit is contained in:
1 parent
18ca7b5885
commit
82ee3aa6c2
4 files changed
+249
-33
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+65
-17
@@ -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}
|
||||
+149
-9
@@ -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 = '<li>Loading...</li>';
|
||||
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 = '<li>Pods loaded (raw output available)</li>';
|
||||
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 = '<li>Error loading resources</li>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<li>No results</li>';
|
||||
return;
|
||||
}
|
||||
filtered.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<strong>${item.name}</strong> <span style="color:#666">(${item.namespace})</span>`;
|
||||
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();
|
||||
});
|
||||
+28
-2
@@ -17,11 +17,37 @@
|
||||
|
||||
<label for="namespace">Namespace:</label>
|
||||
<select id="namespace"><option>default</option></select>
|
||||
|
||||
<label for="resource-type">Resource:</label>
|
||||
<select id="resource-type">
|
||||
<option value="pods">Pods</option>
|
||||
<option value="nodes">Nodes</option>
|
||||
<option value="pvcs">PVCs</option>
|
||||
</select>
|
||||
|
||||
<button id="set-default">Set as default</button>
|
||||
</section>
|
||||
|
||||
<section id="search">
|
||||
<input type="text" id="search-input" placeholder="Search by name..." />
|
||||
<button id="search-btn">Search / Refresh</button>
|
||||
</section>
|
||||
|
||||
<section id="resources">
|
||||
<h2>Pods</h2>
|
||||
<ul id="pod-list"><li>Loading...</li></ul>
|
||||
<h2 id="resource-title">Pods</h2>
|
||||
<ul id="resource-list"><li>Loading...</li></ul>
|
||||
</section>
|
||||
|
||||
<section id="actions">
|
||||
<h2>Actions</h2>
|
||||
<div>
|
||||
<label>Resource name:</label>
|
||||
<input type="text" id="action-name" placeholder="name" />
|
||||
<button id="btn-describe">Describe</button>
|
||||
<button id="btn-logs">Logs</button>
|
||||
<button id="btn-delete" class="danger">Delete</button>
|
||||
</div>
|
||||
<pre id="action-output"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
|
||||
Reference in new issue
Block a user