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