Compare commits
17
Commits
b4f87c7b3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77bd79f892 | ||
|
|
ebc8196c02 | ||
|
|
5d10b660d6 | ||
|
|
ea86194ce8 | ||
|
|
77febb993e | ||
|
|
f8422404d7 | ||
|
|
c47bf0ea16 | ||
|
|
508cdff893 | ||
|
|
cdd3e3818c | ||
|
|
a0bf943601 | ||
|
|
5f42b89be7 | ||
|
|
82ee3aa6c2 | ||
|
|
18ca7b5885 | ||
|
|
82a0839004 | ||
|
|
e67d1f3316 | ||
|
|
dd613748c5 | ||
|
|
4c29b3a10c |
No files matched your search
+13
@@ -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
|
||||
@@ -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
@@ -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}
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-multipart
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user