Compare commits

...
17 Commits
Author SHA1 Message Date
Jiří Stach 77bd79f892 gitignore edit 2026-10-09 19:24:30 +02:00
Jiří Stachandaider ebc8196c02 feat: add persistent json cache with background updates
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 18:44:51 +02:00
Jiří Stachandaider 5d10b660d6 feat: add background renewal caching with 5m ttl
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 18:34:34 +02:00
Jiří Stachandaider ea86194ce8 docs: add cache proposal for background renewal
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 18:25:46 +02:00
Jiří Stachandaider 77febb993e feat: add backend caching and pagination for resources
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 18:01:48 +02:00
Jiří Stachandaider f8422404d7 fix: hide delete/logs/exec for configmaps and nodes
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 17:38:07 +02:00
Jiří Stachandaider c47bf0ea16 feat: add cordon/uncordon endpoints and node action toggles
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 17:32:46 +02:00
Jiří Stachandaider 508cdff893 feat: add configmaps, exec action, and reorder phases
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 17:22:27 +02:00
Jiří Stach cdd3e3818c adding stuff to gitignore 2026-10-09 17:18:39 +02:00
Jiří Stachandaider a0bf943601 feat: add pagination and click selection to resource list
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 17:04:32 +02:00
Jiří Stachandaider 5f42b89be7 feat: polish UI and document phase 3/4 plan
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 16:16:36 +02:00
Jiří Stachandaider 82ee3aa6c2 feat: add phase 2 endpoints, session persistence and actions
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 15:42:31 +02:00
Jiří Stachandaider 18ca7b5885 docs: add local webserver testing section to readme
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 15:30:07 +02:00
Jiří Stachandaider 82a0839004 feat: add phase 1 fastapi backend and frontend
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 15:23:11 +02:00
Jiří Stach e67d1f3316 chore: add .gitignore for aider files 2026-10-09 15:23:09 +02:00
Jiří Stachandaider dd613748c5 docs: add phased plan, roadmap, and suggestions to readme
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 15:19:22 +02:00
Jiří Stachandaider 4c29b3a10c docs: add readme describing kubectl gui plan
Co-authored-by: aider (openrouter/thinkingmachines/inkling-small:free) <aider@aider.chat>
2026-10-09 15:17:31 +02:00
8 changed files with 890 additions and 0 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
.env
.venv/
venv/
node_modules/
dist/
*.egg-info/
.aider.chat.history.md
.aider.input.history
.aider.tags.cache.v4/
backend/cache.json
+91
View File
@@ -0,0 +1,91 @@
# Kubectl Web GUI
A local web-based GUI for kubectl.
## Overview
This app provides a lightweight local interface to manage Kubernetes clusters via kubectl.
## Features
- Choose Kubernetes context and namespace
- Search resources: pods, nodes, PVCs, configmaps, and others
- Perform actions: describe, delete, view logs, exec (non-interactive), and more
## Tech Stack
- Backend: Python
- Frontend: Lightweight JavaScript
## Plan by Phases
### Phase 1: Prototype
- Basic Python backend with kubectl wrapper
- Lightweight frontend with context/namespace selectors
- List pods and nodes
- Basic describe and delete actions
### Phase 2: Search & Actions
- Search/filter pods, nodes, PVCs, and other resources
- Add logs viewer
- Expand actions: describe, delete, logs, and more
- Improve error handling and feedback
- Session persistence for context/namespace defaults
### Phase 3: More Resources & Actions
- Add configmaps and additional resource types
- Non-interactive exec into pods (with container selection)
- Additional kubectl actions and endpoints
### Phase 4: UI Polish & Documentation
- Better UI/UX and responsive layout
- Documentation and setup instructions
- Confirmation dialogs for destructive actions
### Phase 5: Error Handling & Advanced Features
- Improved error handling and feedback
- Possible features:
- Authentication / access controls
- Real-time updates / watch mode
- Multi-cluster dashboard
- Advanced filtering and sorting
- Export reports (JSON/CSV)
- Resource metrics / status indicators
- Custom kubectl plugin integration
## Roadmap / Checklist
- [x] Add readme describing kubectl gui plan
- [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: More resources (configmaps) and non-interactive exec
- [ ] Phase 4: UI polish and documentation
- [ ] Phase 5: Error handling and advanced features
## Suggestions
- Use a lightweight Python framework (e.g., Flask or FastAPI) for the backend
- Keep frontend vanilla JS or minimal framework to stay lightweight
- Consider running kubectl via subprocess with proper input validation
- Add a simple status indicator for kubectl connectivity
## Running Locally
To test the app locally:
1. Start the backend:
```bash
cd backend
pip install -r requirements.txt
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
2. Serve the frontend (from the repo root or frontend folder):
```bash
cd frontend
python -m http.server 8080
```
3. Open `http://localhost:8080` in your browser. The frontend connects to `http://localhost:8000` (CORS is enabled in the backend).
Make sure `kubectl` is installed and configured with access to your cluster.
+240
View File
@@ -0,0 +1,240 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import subprocess
import time
import json
import threading
import os
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
CACHE_FILE = "cache.json"
CACHE_TTL = 300 # 5 minutes
cache = {}
cache_lock = threading.Lock()
def load_persistent_cache():
global cache
if not os.path.exists(CACHE_FILE):
return
try:
with open(CACHE_FILE, "r") as f:
data = json.load(f)
loaded = {}
for k, v in data.items():
if isinstance(v, list) and len(v) == 2:
loaded[k] = (v[0], v[1])
with cache_lock:
cache = loaded
except Exception:
pass
def save_persistent_cache():
try:
with cache_lock:
data = {}
for k, v in cache.items():
data[k] = [v[0], v[1]]
with open(CACHE_FILE, "w") as f:
json.dump(data, f)
except Exception:
pass
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
def get_cache_key(resource, namespace, context):
return f"{resource}:{namespace}:{context or ''}"
def get_cached_items(resource, namespace, context):
key = get_cache_key(resource, namespace, context)
with cache_lock:
if key in cache:
ts, items = cache[key]
if time.time() - ts < CACHE_TTL:
return items
return None
def set_cached_items(resource, namespace, context, items):
key = get_cache_key(resource, namespace, context)
with cache_lock:
cache[key] = (time.time(), items)
save_persistent_cache()
def refresh_worker(resource, namespace, context, kubectl_args, item_mapper):
while True:
try:
result = run_kubectl(kubectl_args, context=context)
try:
obj = json.loads(result.stdout)
raw_items = obj.get("items", [])
except Exception:
raw_items = []
items = [item_mapper(i) for i in raw_items]
set_cached_items(resource, namespace, context, items)
except Exception:
pass
time.sleep(240) # 4 minutes background renewal
@app.on_event("startup")
def start_background_refresh():
load_persistent_cache()
workers = [
("pods", "default", None, ["get", "pods", "-n", "default", "-o", "json"], lambda i: {"name": i.get("metadata", {}).get("name", "unknown"), "namespace": i.get("metadata", {}).get("namespace", "default")}),
("nodes", "default", None, ["get", "nodes", "-o", "json"], lambda i: {"name": i.get("metadata", {}).get("name", "unknown"), "namespace": "-"}),
("pvcs", "default", None, ["get", "pvc", "-n", "default", "-o", "json"], lambda i: {"name": i.get("metadata", {}).get("name", "unknown"), "namespace": i.get("metadata", {}).get("namespace", "default")}),
("configmaps", "default", None, ["get", "configmaps", "-n", "default", "-o", "json"], lambda i: {"name": i.get("metadata", {}).get("name", "unknown"), "namespace": i.get("metadata", {}).get("namespace", "default")}),
]
for res, ns, ctx, args, mapper in workers:
t = threading.Thread(target=refresh_worker, args=(res, ns, ctx, args, mapper), daemon=True)
t.start()
@app.get("/")
def root():
return {"message": "Kubectl Web GUI backend"}
@app.get("/contexts")
def get_contexts():
result = run_kubectl(["config", "get-contexts", "-o", "name"])
contexts = [line for line in result.stdout.splitlines() if line]
current = ""
try:
cur = run_kubectl(["config", "current-context"])
current = cur.stdout.strip()
except Exception:
pass
return {"current": current, "contexts": contexts}
@app.get("/namespaces")
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}
def paginated_list(resource, namespace, context, search, page, page_size, bypass, kubectl_args, item_mapper):
key = get_cache_key(resource, namespace, context)
items = None
if not bypass:
items = get_cached_items(resource, namespace, context)
if items is None:
result = run_kubectl(kubectl_args, context=context)
try:
obj = json.loads(result.stdout)
raw_items = obj.get("items", [])
except Exception:
raw_items = []
items = [item_mapper(i) for i in raw_items]
set_cached_items(resource, namespace, context, items)
if search:
s = search.lower()
items = [i for i in items if s in (i.get("name") or "").lower()]
total = len(items)
start = (page - 1) * page_size
paginated = items[start:start + page_size]
return {
"items": paginated,
"total": total,
"page": page,
"page_size": page_size
}
@app.get("/pods")
def get_pods(namespace: str = "default", context: str = None, search: str = "", page: int = 1, page_size: int = 10, bypass: bool = False):
def mapper(i):
return {
"name": i.get("metadata", {}).get("name", "unknown"),
"namespace": i.get("metadata", {}).get("namespace", namespace)
}
return paginated_list("pods", namespace, context, search, page, page_size, bypass, ["get", "pods", "-n", namespace, "-o", "json"], mapper)
@app.get("/nodes")
def get_nodes(context: str = None, search: str = "", page: int = 1, page_size: int = 10, bypass: bool = False):
def mapper(i):
return {
"name": i.get("metadata", {}).get("name", "unknown"),
"namespace": "-"
}
return paginated_list("nodes", "default", context, search, page, page_size, bypass, ["get", "nodes", "-o", "json"], mapper)
@app.get("/pvcs")
def get_pvcs(namespace: str = "default", context: str = None, search: str = "", page: int = 1, page_size: int = 10, bypass: bool = False):
def mapper(i):
return {
"name": i.get("metadata", {}).get("name", "unknown"),
"namespace": i.get("metadata", {}).get("namespace", namespace)
}
return paginated_list("pvcs", namespace, context, search, page, page_size, bypass, ["get", "pvc", "-n", namespace, "-o", "json"], mapper)
@app.get("/configmaps")
def get_configmaps(namespace: str = "default", context: str = None, search: str = "", page: int = 1, page_size: int = 10, bypass: bool = False):
def mapper(i):
return {
"name": i.get("metadata", {}).get("name", "unknown"),
"namespace": i.get("metadata", {}).get("namespace", namespace)
}
return paginated_list("configmaps", namespace, context, search, page, page_size, bypass, ["get", "configmaps", "-n", namespace, "-o", "json"], mapper)
@app.get("/describe")
def describe(resource: str, name: str, namespace: str = "default", context: str = None):
allowed = {"pod", "node", "pvc", "service", "deployment", "configmap"}
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", "configmap"}
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}
@app.get("/exec")
def exec_pod(pod: str, namespace: str = "default", context: str = None, container: str = None, command: str = "ls"):
cmd = ["exec", pod, "-n", namespace]
if container:
cmd += ["-c", container]
cmd += ["--", "sh", "-c", command]
result = run_kubectl(cmd, context=context)
return {"output": result.stdout, "stderr": result.stderr, "returncode": result.returncode}
@app.get("/cordon")
def cordon(node: str, context: str = None):
result = run_kubectl(["cordon", node], context=context)
return {"output": result.stdout, "stderr": result.stderr, "returncode": result.returncode}
@app.get("/uncordon")
def uncordon(node: str, context: str = None):
result = run_kubectl(["uncordon", node], context=context)
return {"output": result.stdout, "stderr": result.stderr, "returncode": result.returncode}
+3
View File
@@ -0,0 +1,3 @@
fastapi
uvicorn[standard]
python-multipart
+39
View File
@@ -0,0 +1,39 @@
# Cache Proposal for Kubectl Web GUI
## Problem
With ~1600 pods, loading full lists from kubectl is slow. We need backend caching with background renewal so users are not blocked.
## Ideas
### 1. In-Memory TTL Cache with Background Refresh
- Store parsed item lists in a Python dict with 5-minute TTL.
- A background thread (threading.Timer or asyncio) refreshes each resource type every 4 minutes.
- Users always read from memory; refresh is invisible.
### 2. Stale-While-Revalidate Pattern
- Serve cached data immediately.
- If cache is older than 3 minutes, trigger a background kubectl call to update it.
- User gets fast response; data updates shortly after.
### 3. Per-Resource Background Workers
- One lightweight thread per resource (pods, nodes, pvcs, configmaps).
- Each thread runs kubectl on its own interval (e.g., 4 min).
- Cache is always warm; no user request triggers kubectl unless bypass is used.
### 4. Cache Invalidation on Actions
- After delete/cordon/uncordon, invalidate only that resource's cache.
- Next load will refresh just that resource, not all.
### 5. Partial / Chunked Cache
- Cache full list but paginate in backend.
- Background refresh updates the full list; pagination stays fast.
## Background Renewal Approach
- Use threading.Thread with a loop and time.sleep(240) per resource.
- Or use FastAPI startup event to spawn refresh tasks.
- Ensure thread-safe access to the shared cache dict (GIL helps, but use locks if needed).
## Trade-offs
- Memory usage: ~1600 pods is small; acceptable.
- Staleness: 4-5 minutes is okay for a local GUI.
- Refresh impact: background only, so app stays responsive.
+278
View File
@@ -0,0 +1,278 @@
document.addEventListener('DOMContentLoaded', () => {
const contextSelect = document.getElementById('context');
const namespaceSelect = document.getElementById('namespace');
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 refreshBtn = document.getElementById('refresh-btn');
const setDefaultBtn = document.getElementById('set-default');
const actionName = document.getElementById('action-name');
const actionOutput = document.getElementById('action-output');
let currentPage = 1;
const pageSize = 10;
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;
}
function updateActionVisibility() {
const isNode = resourceSelect.value === 'nodes';
const isConfigmap = resourceSelect.value === 'configmaps';
document.getElementById('btn-delete').style.display = (isNode || isConfigmap) ? 'none' : 'inline-block';
document.getElementById('btn-logs').style.display = (isNode || isConfigmap) ? 'none' : 'inline-block';
document.getElementById('btn-exec').style.display = (isNode || isConfigmap) ? 'none' : 'inline-block';
document.getElementById('btn-cordon').style.display = isNode ? 'inline-block' : 'none';
document.getElementById('btn-uncordon').style.display = isNode ? 'inline-block' : 'none';
}
async function loadContexts() {
try {
const res = await fetch('http://localhost:8000/contexts');
const data = await res.json();
contextSelect.innerHTML = '';
data.contexts.forEach(ctx => {
const opt = document.createElement('option');
opt.value = ctx;
opt.textContent = ctx;
if (ctx === data.current) opt.selected = true;
contextSelect.appendChild(opt);
});
loadSession();
loadNamespaces();
} catch (e) {
console.error('Failed to load contexts', e);
}
}
async function loadNamespaces() {
try {
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 => {
const opt = document.createElement('option');
opt.value = ns;
opt.textContent = ns;
namespaceSelect.appendChild(opt);
});
loadSession();
loadResources();
} catch (e) {
console.error('Failed to load namespaces', e);
}
}
async function loadResources(bypass = false, page = 1) {
const resType = resourceSelect.value;
const ns = namespaceSelect.value || 'default';
const ctx = contextSelect.value || null;
const search = searchInput.value.trim();
resourceTitle.textContent = resType.charAt(0).toUpperCase() + resType.slice(1);
resourceList.innerHTML = '<li>Loading...</li>';
currentPage = page;
try {
let url;
if (resType === 'pods') {
url = `http://localhost:8000/pods?namespace=${encodeURIComponent(ns)}&page=${page}&page_size=${pageSize}&bypass=${bypass}`;
} else if (resType === 'nodes') {
url = `http://localhost:8000/nodes?page=${page}&page_size=${pageSize}&bypass=${bypass}`;
} else if (resType === 'pvcs') {
url = `http://localhost:8000/pvcs?namespace=${encodeURIComponent(ns)}&page=${page}&page_size=${pageSize}&bypass=${bypass}`;
} else if (resType === 'configmaps') {
url = `http://localhost:8000/configmaps?namespace=${encodeURIComponent(ns)}&page=${page}&page_size=${pageSize}&bypass=${bypass}`;
} else {
url = `http://localhost:8000/pods?namespace=${encodeURIComponent(ns)}&page=${page}&page_size=${pageSize}&bypass=${bypass}`;
}
if (search) url += `&search=${encodeURIComponent(search)}`;
if (ctx) url += `&context=${encodeURIComponent(ctx)}`;
const res = await fetch(url);
const data = await res.json();
renderList(data);
updateActionVisibility();
} catch (e) {
console.error('Failed to load resources', e);
resourceList.innerHTML = '<li>Error loading resources</li>';
}
}
function renderList(data) {
const items = data.items || [];
const total = data.total || 0;
const page = data.page || 1;
const pageSize = data.page_size || 10;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
resourceList.innerHTML = '';
if (items.length === 0) {
resourceList.innerHTML = '<li>No results</li>';
} else {
items.forEach(item => {
const li = document.createElement('li');
li.style.cursor = 'pointer';
li.innerHTML = `<strong>${item.name}</strong> <span style="color:#666">(${item.namespace})</span>`;
li.addEventListener('click', () => {
actionName.value = item.name;
document.getElementById('exec-pod').value = item.name;
Array.from(resourceList.children).forEach(child => {
child.classList.remove('selected');
child.style.background = '';
});
li.classList.add('selected');
li.style.background = '#ebf8ff';
});
resourceList.appendChild(li);
});
}
const pageInfo = document.getElementById('page-info');
if (pageInfo) pageInfo.textContent = `Page ${page} of ${totalPages} (${total} total)`;
const prevBtn = document.getElementById('prev-page');
const nextBtn = document.getElementById('next-page');
if (prevBtn) prevBtn.disabled = page <= 1;
if (nextBtn) nextBtn.disabled = page >= totalPages;
}
contextSelect.addEventListener('change', () => { saveSession(); loadNamespaces(); });
namespaceSelect.addEventListener('change', () => { saveSession(); loadResources(); });
resourceSelect.addEventListener('change', () => { saveSession(); loadResources(); });
searchBtn.addEventListener('click', () => loadResources(false, 1));
refreshBtn.addEventListener('click', () => loadResources(true, 1));
setDefaultBtn.addEventListener('click', () => { saveSession(); alert('Defaults saved for session'); });
document.getElementById('prev-page').addEventListener('click', () => {
if (currentPage > 1) loadResources(false, currentPage - 1);
});
document.getElementById('next-page').addEventListener('click', () => {
loadResources(false, currentPage + 1);
});
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' : (resourceSelect.value === 'configmaps' ? 'configmap' : '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' : (resourceSelect.value === 'configmaps' ? 'configmap' : '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;
}
});
document.getElementById('btn-exec').addEventListener('click', async () => {
const pod = document.getElementById('exec-pod').value.trim();
if (!pod) return;
const ns = namespaceSelect.value || 'default';
const ctx = contextSelect.value || null;
const container = document.getElementById('exec-container').value.trim() || null;
const cmd = document.getElementById('exec-cmd').value.trim() || 'ls';
try {
const url = new URL('http://localhost:8000/exec');
url.searchParams.set('pod', pod);
url.searchParams.set('namespace', ns);
url.searchParams.set('command', cmd);
if (container) url.searchParams.set('container', container);
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-cordon').addEventListener('click', async () => {
const node = actionName.value.trim();
if (!node) return;
const ctx = contextSelect.value || null;
try {
const url = new URL('http://localhost:8000/cordon');
url.searchParams.set('node', node);
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-uncordon').addEventListener('click', async () => {
const node = actionName.value.trim();
if (!node) return;
const ctx = contextSelect.value || null;
try {
const url = new URL('http://localhost:8000/uncordon');
url.searchParams.set('node', node);
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;
}
});
loadContexts();
});
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kubectl Web GUI</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Kubectl Web GUI</h1>
</header>
<main>
<section id="selectors">
<label for="context">Context:</label>
<select id="context"><option>Loading...</option></select>
<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>
<option value="configmaps">ConfigMaps</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</button>
<button id="refresh-btn">Refresh</button>
</section>
<section id="resources">
<h2 id="resource-title">Pods</h2>
<ul id="resource-list"><li>Loading...</li></ul>
<div id="pagination" style="margin-top:1rem; display:flex; gap:0.5rem; align-items:center;">
<button id="prev-page">Prev</button>
<span id="page-info">Page 1</span>
<button id="next-page">Next</button>
</div>
</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>
<button id="btn-cordon" style="display:none;">Cordon</button>
<button id="btn-uncordon" style="display:none;">Uncordon</button>
</div>
<div style="margin-top:0.75rem;">
<label>Exec (non-interactive):</label>
<input type="text" id="exec-pod" placeholder="pod name" style="min-width:120px;" />
<input type="text" id="exec-container" placeholder="container (optional)" style="min-width:120px;" />
<input type="text" id="exec-cmd" placeholder="command (default: ls)" style="min-width:160px;" />
<button id="btn-exec">Exec</button>
</div>
<pre id="action-output"></pre>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
* {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 2rem 1rem;
background: linear-gradient(135deg, #f0f4f8 0%, #d9e2ec 100%);
color: #1a202c;
line-height: 1.5;
}
header h1 {
margin-bottom: 1.5rem;
font-size: 2rem;
letter-spacing: -0.02em;
color: #2d3748;
}
main {
max-width: 960px;
margin: 0 auto;
}
section {
background: #ffffff;
padding: 1.25rem;
border-radius: 12px;
margin-bottom: 1.25rem;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
}
#selectors {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
#selectors label {
font-weight: 600;
font-size: 0.875rem;
color: #4a5568;
display: block;
margin-bottom: 0.25rem;
}
select, input[type="text"] {
padding: 0.5rem 0.75rem;
border-radius: 8px;
border: 1px solid #cbd5e0;
background: #fff;
font-size: 0.95rem;
min-width: 160px;
}
button {
padding: 0.5rem 1rem;
border-radius: 8px;
border: none;
background: #3182ce;
color: #fff;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
button:hover {
background: #2b6cb0;
}
button.danger {
background: #e53e3e;
}
button.danger:hover {
background: #c53030;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#search {
display: flex;
gap: 0.5rem;
align-items: center;
}
#search input {
flex: 1;
min-width: 200px;
}
#resource-title {
margin-top: 0;
font-size: 1.25rem;
color: #2d3748;
}
#resource-list {
list-style: none;
padding: 0;
margin: 0;
}
#resource-list li {
padding: 0.75rem 0.5rem;
border-bottom: 1px solid #edf2f7;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.1s ease;
}
#resource-list li:hover {
background: #f7fafc;
}
#resource-list li.selected {
background: #ebf8ff;
border-left: 3px solid #3182ce;
}
#resource-list li:last-child {
border-bottom: none;
}
#pagination {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
#actions pre {
background: #1a202c;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-size: 0.85rem;
}
@media (max-width: 640px) {
#selectors {
flex-direction: column;
align-items: stretch;
}
select, input[type="text"] {
width: 100%;
}
}