Opruimen na installatie Gitea

This commit is contained in:
kodi
2026-02-18 10:52:08 +01:00
parent 62e195c59e
commit 3142e9fbd0
49 changed files with 0 additions and 20301 deletions
-36
View File
@@ -1,36 +0,0 @@
from fastapi import FastAPI, HTTPException
import requests_unixsocket
import os
import yaml
app = FastAPI(title="Podman MVP Control Plane")
SESSION = requests_unixsocket.Session()
# Podman API URL format voor unix sockets
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
@app.get("/workloads")
def list_workloads():
files = [f for f in os.listdir(WORKLOADS_DIR) if f.endswith('.yaml')]
return {"workloads": files}
@app.post("/workloads/deploy/{filename}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="YAML niet gevonden")
with open(path, 'r') as f:
# Podman Kube Play endpoint: /libpod/kube/play
# We versturen de content direct als body
url = f"{PODMAN_API_BASE}/libpod/kube/play"
response = SESSION.post(url, data=f.read())
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=response.json())
return response.json()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)
-68
View File
@@ -1,68 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
import requests_unixsocket
import yaml # Voor eventuele toekomstige validatie
app = FastAPI(title="Podman MVP Control Plane")
# Gebruik requests-unixsocket om met de Podman socket te praten
SESSION = requests_unixsocket.Session()
# Podman API URL: %2F is de slash '/' voor de unix socket
# We gebruiken v5.4.2 zoals gespecificeerd
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
@app.get("/workloads")
def list_workloads():
"""Scant recursief naar .yaml bestanden in de gemounte map."""
yaml_files = []
try:
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith(".yaml"):
# Maak een pad relatief aan de workloads map
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
"""Stuurt de inhoud van een YAML naar de Podman API (kube play)."""
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail=f"Bestand {filename} niet gevonden")
try:
with open(path, 'r') as f:
yaml_content = f.read()
# Podman Kube Play endpoint
url = f"{PODMAN_API_BASE}/libpod/kube/play"
# We versturen de platte tekst van het YAML bestand naar de API
response = SESSION.post(url, data=yaml_content)
if response.status_code >= 400:
return {
"status": "error",
"podman_status_code": response.status_code,
"details": response.json()
}
return {
"status": "success",
"message": f"Deployment van {filename} gestart",
"podman_response": response.json()
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
# Belangrijk: host 0.0.0.0 zodat hij bereikbaar is buiten de container
uvicorn.run(app, host="0.0.0.0", port=8000)
-59
View File
@@ -1,59 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane")
# Gebruik requests-unixsocket voor communicatie met de Podman socket
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
try:
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
try:
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
response = SESSION.post(url, data=yaml_content)
return {"status": "success", "podman_response": response.json()}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/workloads/create-folder/{foldername:path}")
def create_folder(foldername: str):
target_path = os.path.join(WORKLOADS_DIR, foldername)
os.makedirs(target_path, exist_ok=True)
return {"status": "success", "message": f"Map {foldername} aangemaakt"}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f:
f.write(file_data.content)
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-73
View File
@@ -1,73 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane")
# Gebruik requests-unixsocket voor communicatie met de Podman socket
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
try:
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
try:
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
response = SESSION.post(url, data=yaml_content)
return {"status": "success", "podman_response": response.json()}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/workloads/create-folder/{foldername:path}")
def create_folder(foldername: str):
target_path = os.path.join(WORKLOADS_DIR, foldername)
os.makedirs(target_path, exist_ok=True)
return {"status": "success", "message": f"Map {foldername} aangemaakt"}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f:
f.write(file_data.content)
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
@app.get("/pods")
def list_pods():
"""Haalt de lijst met pods en hun status op via de Podman API."""
try:
url = f"{PODMAN_API_BASE}/libpod/pods/json"
response = SESSION.get(url)
if response.status_code != 200:
return {"status": "error", "details": response.json()}
# We geven de ruwe lijst met pods terug
return response.json()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-87
View File
@@ -1,87 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
try:
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
"""Leest de inhoud van een specifiek YAML bestand."""
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
with open(path, 'r') as f:
return {"filename": filename, "content": f.read()}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
response = SESSION.post(url, data=yaml_content)
return {"status": "success", "podman_response": response.json()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f:
f.write(file_data.content)
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
@app.get("/pods")
def list_pods():
url = f"{PODMAN_API_BASE}/libpod/pods/json"
response = SESSION.get(url)
return response.json()
# --- NIEUWE MANAGEMENT ENDPOINTS ---
@app.post("/pods/stop/{name}")
def stop_pod(name: str):
"""Stopt alle containers in een pod."""
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
response = SESSION.post(url)
return {"status": "success" if response.status_code == 200 else "error", "code": response.status_code}
@app.post("/pods/start/{name}")
def start_pod(name: str):
"""Start alle containers in een pod."""
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
response = SESSION.post(url)
return {"status": "success" if response.status_code == 200 else "error", "code": response.status_code}
@app.post("/pods/rm/{name}")
def remove_pod(name: str):
"""Verwijdert een pod (geforceerd)."""
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
response = SESSION.delete(url)
return {"status": "success" if response.status_code == 201 or response.status_code == 200 else "error"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-90
View File
@@ -1,90 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
# We voegen 'root_path' toe zodat de /docs pagina werkt achter Nginx
app = FastAPI(
title="Podman MVP Control Plane",
root_path="/api"
)
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
with open(path, 'r') as f:
return {"filename": filename, "content": f.read()}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
response = SESSION.post(url, data=yaml_content)
return {"status": "success", "podman_response": response.json()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f:
f.write(file_data.content)
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
@app.get("/pods")
def list_pods():
url = f"{PODMAN_API_BASE}/libpod/pods/json"
response = SESSION.get(url)
return response.json()
@app.get("/pods/inspect/{name}")
def inspect_pod(name: str):
"""Geeft alle technische details van een pod (mounts, poorten, etc)."""
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/json"
response = SESSION.get(url)
if response.status_code != 200:
raise HTTPException(status_code=response.status_code, detail="Pod niet gevonden")
return response.json()
@app.post("/pods/stop/{name}")
def stop_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 204, 304] else "error"}
@app.post("/pods/start/{name}")
def start_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 304] else "error"}
@app.post("/pods/rm/{name}")
def remove_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
response = SESSION.delete(url)
return {"status": "success" if response.status_code in [200, 201] else "error"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-121
View File
@@ -1,121 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(
title="Podman MVP Control Plane",
root_path="/api"
)
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
# --- WORKLOAD BEHEER ---
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
with open(path, 'r') as f:
return {"filename": filename, "content": f.read()}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
response = SESSION.post(url, data=yaml_content)
return {"status": "success", "podman_response": response.json()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f:
f.write(file_data.content)
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
@app.post("/workloads/delete/{filename:path}")
def delete_workload(filename: str):
"""Verwijdert een YAML bestand fysiek van de schijf."""
path = os.path.join(WORKLOADS_DIR, filename)
if os.path.exists(path):
os.remove(path)
return {"status": "success", "message": f"{filename} verwijderd"}
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
# --- POD BEHEER ---
@app.get("/pods")
def list_pods():
url = f"{PODMAN_API_BASE}/libpod/pods/json"
response = SESSION.get(url)
return response.json()
@app.get("/pods/inspect/{name}")
def inspect_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/json"
response = SESSION.get(url)
return response.json()
@app.get("/pods/logs/{name}")
def get_pod_logs(name: str):
"""Haalt de logs op van de pod. Let op: dit kan veel tekst zijn."""
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/logs?stdout=true&stderr=true&tail=100"
response = SESSION.get(url)
return {"logs": response.text}
@app.post("/pods/stop/{name}")
def stop_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 204, 304] else "error"}
@app.post("/pods/start/{name}")
def start_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 304] else "error"}
@app.post("/pods/restart/{name}")
def restart_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/restart"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 204] else "error"}
@app.post("/pods/rm/{name}")
def remove_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
response = SESSION.delete(url)
return {"status": "success" if response.status_code in [200, 201, 204] else "error"}
# --- SYSTEEM ---
@app.get("/system/info")
def system_info():
"""Geeft algemene info over de Podman host."""
url = f"{PODMAN_API_BASE}/libpod/info"
response = SESSION.get(url)
return response.json()
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-131
View File
@@ -1,131 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(
title="Podman MVP Control Plane",
root_path="/api"
)
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
# --- WORKLOAD BEHEER ---
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
with open(path, 'r') as f:
return {"filename": filename, "content": f.read()}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
response = SESSION.post(url, data=yaml_content)
return {"status": "success", "podman_response": response.json()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f:
f.write(file_data.content)
return {"status": "success", "message": f"Bestand {file_data.path} opgeslagen"}
@app.post("/workloads/delete/{filename:path}")
def delete_workload(filename: str):
"""Verwijdert een YAML bestand fysiek van de schijf."""
path = os.path.join(WORKLOADS_DIR, filename)
if os.path.exists(path):
os.remove(path)
return {"status": "success", "message": f"{filename} verwijderd"}
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
# --- POD BEHEER ---
@app.get("/pods")
def list_pods():
url = f"{PODMAN_API_BASE}/libpod/pods/json"
response = SESSION.get(url)
return response.json()
@app.get("/pods/inspect/{name}")
def inspect_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/json"
response = SESSION.get(url)
return response.json()
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
"""Haalt de logs op van een specifieke container (ook als deze in een pod zit)."""
# We voegen 'follow=false' toe om te voorkomen dat de API blijft hangen
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=50&follow=false"
response = SESSION.get(url)
if response.status_code != 200:
return {"logs": f"Kon logs voor {name} niet vinden (Code {response.status_code})"}
return {"logs": response.text}
@app.post("/pods/stop/{name}")
def stop_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/stop"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 204, 304] else "error"}
@app.post("/pods/start/{name}")
def start_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/start"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 304] else "error"}
@app.post("/pods/restart/{name}")
def restart_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/restart"
response = SESSION.post(url)
return {"status": "success" if response.status_code in [200, 204] else "error"}
@app.post("/pods/rm/{name}")
def remove_pod(name: str):
url = f"{PODMAN_API_BASE}/libpod/pods/{name}?force=true"
response = SESSION.delete(url)
return {"status": "success" if response.status_code in [200, 201, 204] else "error"}
# --- SYSTEEM ---
@app.get("/system/info")
def system_info():
"""Geeft algemene info over de Podman host."""
url = f"{PODMAN_API_BASE}/libpod/info"
response = SESSION.get(url)
return response.json()
@app.get("/containers")
def list_containers():
"""Haalt alle containers op met details zoals status, poorten en pods."""
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
response = SESSION.get(url)
return response.json()
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-75
View File
@@ -1,75 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/pods/{action}/{name}")
def pod_action(action: str, name: str):
# Podman API pad: /libpod/pods/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-178
View File
@@ -1,178 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# 1. Haal alle containers op om te weten welke we moeten stoppen
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
matching = [c for c in api_containers if name in c['Names'][0]]
if action == "stop":
for c in matching:
# We gebruiken de ID om specifiek te zijn
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
return {"status": "stopped", "count": len(matching)}
elif action == "start":
# We zoeken het pad van het bestand dat bij deze naam hoort
# (Dit doen we door even snel de map te scannen)
target_path = None
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith(('.yaml', '.kube')):
target_path = os.path.join(root, f)
break
if target_path and target_path.endswith('.yaml'):
with open(target_path, 'r') as file:
yaml_content = file.read()
# Start via de Kube Play API
url = f"{PODMAN_API_BASE}/libpod/kube/play"
res = SESSION.post(url, data=yaml_content)
return res.json()
return {"status": "unknown action or file not found"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
"""Starten/Stoppen via de API."""
# Voor Quadlets/Kube gebruiken we 'play' om te starten en 'stop' op de container om te stoppen
if action == "start":
# Hier zouden we 'podman kube play' logica doen
pass
else:
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/stop"
SESSION.post(url)
return {"status": "request sent"}
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-191
View File
@@ -1,191 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# 1. Haal alle containers op
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
matching = [c for c in api_containers if name in c['Names'][0]]
# 2. Zoek het bestand op schijf om te bepalen wat voor type het is
target_path = None
file_ext = ""
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name):
target_path = os.path.join(root, f)
file_ext = f.split('.')[-1]
break
if action == "stop":
# Bij stop: probeer container te stoppen als die er is
if matching:
for c in matching:
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
return {"status": "stopped", "count": len(matching)}
return {"status": "already stopped or not found"}
if action == "start":
# TYPE 1: YAML (Kube Play)
if file_ext == "yaml":
with open(target_path, 'r') as file:
yaml_content = file.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
res = SESSION.post(url, data=yaml_content)
return res.json()
# TYPE 2: QUADLET (.container)
# Omdat Quadlets via systemd gaan en de container weg is,
# is de enige 'schone' weg via de API een 'prune' en herstart triggeren
# Voor nu proberen we de container her-aanmaak te forceren:
if file_ext == "container":
# Bij Quadlets op Debian is het vaak het beste om een 'generate'
# of een specifieke systeem-trigger te doen.
# TEST: We sturen een bericht dat we dit via een shell-commando moeten doen
return {"status": "error", "message": "Quadlets (.container) vereisen systemctl start op de host."}
return {"status": "unknown"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
"""Starten/Stoppen via de API."""
# Voor Quadlets/Kube gebruiken we 'play' om te starten en 'stop' op de container om te stoppen
if action == "start":
# Hier zouden we 'podman kube play' logica doen
pass
else:
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/stop"
SESSION.post(url)
return {"status": "request sent"}
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-238
View File
@@ -1,238 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
pod_name = f"pod{name}"
if action == "stop":
# Probeer de hele pod te stoppen
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}/stop")
if res.status_code == 204 or res.status_code == 200:
return {"status": "stopped", "target": pod_name}
# Fallback: als het geen pod is, stop individuele containers
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
matching = [c for c in api_containers if name in c['Names'][0]]
for c in matching:
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
return {"status": "stop-attempted", "count": len(matching)}
if action == "start":
# Altijd eerst geforceerd de oude pod verwijderen
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}?force=true")
target_path = None
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
res = SESSION.post(url, data=yaml_content)
return res.json()
return {"status": "unknown_action"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
"""Starten/Stoppen via de API."""
# Voor Quadlets/Kube gebruiken we 'play' om te starten en 'stop' op de container om te stoppen
if action == "start":
# Hier zouden we 'podman kube play' logica doen
pass
else:
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/stop"
SESSION.post(url)
return {"status": "request sent"}
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/api/files/tree")
def get_file_tree():
"""Geeft een lijst van alle mappen en bestanden in de workloads map."""
tree = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
relative_path = os.relpath(root, WORKLOADS_DIR)
if relative_path == ".":
relative_path = ""
tree.append({
"path": relative_path,
"dirs": dirs,
"files": [f for f in files if f.endswith(('.yaml', '.container'))]
})
return tree
@app.get("/api/files/read")
def read_file(path: str):
"""Leest de inhoud van een specifiek bestand op."""
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(str(WORKLOADS_DIR)):
raise HTTPException(status_code=403, detail="Toegang geweigerd")
if not os.path.exists(full_path):
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
with open(full_path, 'r') as f:
return {"content": f.read()}
@app.post("/api/files/save")
def save_file(path: str, data: FileContent):
"""Slaat de inhoud op naar een bestand (overschrijft bestaand)."""
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(str(WORKLOADS_DIR)):
raise HTTPException(status_code=403, detail="Toegang geweigerd")
# Maak mappen aan als ze niet bestaan
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/api/files/delete")
def delete_file(path: str):
"""Verwijdert een bestand."""
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(str(WORKLOADS_DIR)):
raise HTTPException(status_code=403, detail="Toegang geweigerd")
if os.path.exists(full_path):
os.remove(full_path)
return {"status": "deleted"}
raise HTTPException(status_code=404, detail="Bestand niet gevonden")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-248
View File
@@ -1,248 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
pod_name = f"pod{name}"
if action == "stop":
# Probeer de hele pod te stoppen
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}/stop")
if res.status_code == 204 or res.status_code == 200:
return {"status": "stopped", "target": pod_name}
# Fallback: als het geen pod is, stop individuele containers
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
matching = [c for c in api_containers if name in c['Names'][0]]
for c in matching:
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
return {"status": "stop-attempted", "count": len(matching)}
if action == "start":
# Altijd eerst geforceerd de oude pod verwijderen
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}?force=true")
target_path = None
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
res = SESSION.post(url, data=yaml_content)
return res.json()
return {"status": "unknown_action"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
#@app.delete("/files/delete")
#def delete_file_managed(path: str):
# full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
# if os.path.exists(full_path):
# os.remove(full_path)
# return {"status": "deleted"}
# raise HTTPException(status_code=404)
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
"""Maakt een nieuwe map aan binnen de workloads map."""
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-256
View File
@@ -1,256 +0,0 @@
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
pod_name = f"pod{name}"
if action == "stop":
# Probeer de hele pod te stoppen
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}/stop")
if res.status_code == 204 or res.status_code == 200:
return {"status": "stopped", "target": pod_name}
# Fallback: als het geen pod is, stop individuele containers
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
matching = [c for c in api_containers if name in c['Names'][0]]
for c in matching:
SESSION.post(f"{PODMAN_API_BASE}/libpod/containers/{c['Id']}/stop")
return {"status": "stop-attempted", "count": len(matching)}
if action == "start":
# Altijd eerst geforceerd de oude pod verwijderen
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{pod_name}?force=true")
target_path = None
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
res = SESSION.post(url, data=yaml_content)
return res.json()
return {"status": "unknown_action"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-410
View File
@@ -1,410 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
# JSON API
#@app.post("/api/daemon-reload")
#def api_daemon_reload():
# code, out = run(["systemctl", "--user", "daemon-reload"])
# return jsonify({"cmd": ["systemctl", "--user", "daemon-reload"], "exit": code, "output": out})
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-425
View File
@@ -1,425 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
@app.get("/systemd/allowlist")
def systemd_allowlist():
allowed, allow_mode = read_allowlist()
return {"allow_mode": allow_mode, "units": sorted(allowed)}
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in {"status", "start", "stop", "restart"}:
raise HTTPException(status_code=400, detail="Invalid action")
# allowlist check
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-488
View File
@@ -1,488 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
# helper blok
def podname_to_systemd_unit(podname: str) -> str | None:
"""
Jouw conventie:
podmediaserver -> mediaserver.service
"""
if podname.startswith("pod") and len(podname) > 3:
base = podname[3:]
return f"{base}.service"
return None
def try_systemd_pod_action(action: str, podname: str):
"""
Probeert systemd --user <action> <unit>.
Return dict met exit/output + unit, of None als geen mapping.
"""
unit = podname_to_systemd_unit(podname)
if not unit:
return None
code, out = run(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
# einde helper blok
# endpoint blok
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
"""
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
anders fallback naar bestaande Podman take_action().
"""
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# 1) Probeer systemd (alleen als mapping bestaat)
sys_res = try_systemd_pod_action(action, podname)
if sys_res is not None:
if sys_res["exit"] == 0:
return sys_res
# systemd faalde -> fallback naar Podman
return {
"method": "systemd_then_podman",
"systemd": sys_res,
"podman": take_action(action, podname),
"note": "systemd failed, fell back to podman",
}
# 2) Geen mapping -> fallback naar Podman
return {"method": "podman", "result": take_action(action, podname)}
# einde endpointblok
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
@app.get("/systemd/allowlist")
def systemd_allowlist():
allowed, allow_mode = read_allowlist()
return {"allow_mode": allow_mode, "units": sorted(allowed)}
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in {"status", "start", "stop", "restart"}:
raise HTTPException(status_code=400, detail="Invalid action")
# allowlist check
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-488
View File
@@ -1,488 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
# helper blok
def podname_to_systemd_unit(podname: str) -> str | None:
"""
Jouw conventie:
podmediaserver -> mediaserver.service
"""
if podname.startswith("pod") and len(podname) > 3:
base = podname[3:]
return f"{base}.service"
return None
def try_systemd_pod_action(action: str, podname: str):
"""
Probeert systemd --user <action> <unit>.
Return dict met exit/output + unit, of None als geen mapping.
"""
unit = podname_to_systemd_unit(podname)
if not unit:
return None
code, out = run(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
# einde helper blok
# endpoint blok
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
"""
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
anders fallback naar bestaande Podman take_action().
"""
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# 1) Probeer systemd (alleen als mapping bestaat)
sys_res = try_systemd_pod_action(action, podname)
if sys_res is not None:
if sys_res["exit"] == 0:
return sys_res
# systemd faalde -> fallback naar Podman
return {
"method": "systemd_then_podman",
"systemd": sys_res,
"podman": take_action(action, podname),
"note": "systemd failed, fell back to podman",
}
# 2) Geen mapping -> fallback naar Podman
return {"method": "podman", "result": take_action(action, podname)}
# einde endpointblok
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
@app.get("/systemd/allowlist")
def systemd_allowlist():
allowed, allow_mode = read_allowlist()
return {"allow_mode": allow_mode, "units": sorted(allowed)}
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in {"status", "start", "stop", "restart"}:
raise HTTPException(status_code=400, detail="Invalid action")
# allowlist check
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-547
View File
@@ -1,547 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
@app.get("/pods-dashboard")
def pods_dashboard():
"""
Combineer:
A) pods die echt bestaan (Podman API)
B) pods die gedefinieerd zijn via workloads/systemd (ook als ze niet bestaan)
"""
# A) echte pods
api_pods = SESSION.get(f"{PODMAN_API_BASE}/libpod/pods/json?all=true").json()
by_name = {}
for p in api_pods:
name = p.get("Name") or p.get("name")
if not name:
continue
by_name[name] = {
"Name": name,
"Status": p.get("Status") or p.get("status") or "unknown",
"Source": "podman",
"Unit": None
}
# B) gedefinieerde pods (flexibel): scan WORKLOADS_DIR naar *.yaml/*.kube
# Heuristiek: basename "mediaserver.yaml" => unit "mediaserver.service" => pod "podmediaserver"
# (jij gaf aan: systemd maakt naam met "pod" prefix)
managed = set()
for root, _, files in os.walk(WORKLOADS_DIR):
for fn in files:
if not fn.endswith((".yaml", ".kube")):
continue
base = os.path.splitext(fn)[0] # mediaserver
pod_name = f"pod{base}" # podmediaserver
unit_name = f"{base}.service" # mediaserver.service
managed.add((pod_name, unit_name))
for pod_name, unit_name in sorted(managed):
if pod_name not in by_name:
# bestaat niet als pod => toch tonen
# status halen uit systemd
code, out = run(["systemctl", "--user", "is-active", unit_name])
active = (out.strip() if out else "unknown")
status = "Running" if active == "active" else "Stopped (defined)"
by_name[pod_name] = {
"Name": pod_name,
"Status": status,
"Source": "systemd",
"Unit": unit_name
}
else:
# bestaat wel: verrijk met unit-info (handig voor UI)
by_name[pod_name]["Unit"] = unit_name
return list(by_name.values())
# helper blok
def podname_to_systemd_unit(podname: str) -> str | None:
"""
Jouw conventie:
podmediaserver -> mediaserver.service
"""
if podname.startswith("pod") and len(podname) > 3:
base = podname[3:]
return f"{base}.service"
return None
def try_systemd_pod_action(action: str, podname: str):
"""
Probeert systemd --user <action> <unit>.
Return dict met exit/output + unit, of None als geen mapping.
"""
unit = podname_to_systemd_unit(podname)
if not unit:
return None
code, out = run(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
# einde helper blok
# endpoint blok
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
"""
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
anders fallback naar bestaande Podman take_action().
"""
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# Als de podnaam begint met "pod" (bv podmediaserver), haal dan "pod" eraf voor fallback
base_name = podname[3:] if podname.startswith("pod") and len(podname) > 3 else podname
# 1) Probeer systemd (alleen als mapping bestaat)
sys_res = try_systemd_pod_action(action, podname)
if sys_res is not None:
if sys_res["exit"] == 0:
return sys_res
# systemd faalde -> fallback naar Podman
return {
"method": "systemd_then_podman",
"systemd": sys_res,
"podman": take_action(action, base_name),
"note": "systemd failed, fell back to podman",
}
# 2) Geen mapping -> fallback naar Podman
return {"method": "podman", "result": take_action(action, base_name)}
# einde endpointblok
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
@app.get("/systemd/allowlist")
def systemd_allowlist():
allowed, allow_mode = read_allowlist()
return {"allow_mode": allow_mode, "units": sorted(allowed)}
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in {"status", "start", "stop", "restart"}:
raise HTTPException(status_code=400, detail="Invalid action")
# allowlist check
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-654
View File
@@ -1,654 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
@app.get("/pods-dashboard")
def pods_dashboard():
"""
Combineer:
A) pods die echt bestaan (Podman API)
B) pods die gedefinieerd zijn via workloads/systemd (ook als ze niet bestaan)
"""
# A) echte pods
api_pods = SESSION.get(f"{PODMAN_API_BASE}/libpod/pods/json?all=true").json()
by_name = {}
for p in api_pods:
name = p.get("Name") or p.get("name")
if not name:
continue
by_name[name] = {
"Name": name,
"Status": p.get("Status") or p.get("status") or "unknown",
"Source": "podman",
"Unit": None
}
# B) gedefinieerde pods (flexibel): scan WORKLOADS_DIR naar *.yaml/*.kube
# Heuristiek: basename "mediaserver.yaml" => unit "mediaserver.service" => pod "podmediaserver"
# (jij gaf aan: systemd maakt naam met "pod" prefix)
managed = set()
for root, _, files in os.walk(WORKLOADS_DIR):
for fn in files:
if not fn.endswith((".yaml", ".kube")):
continue
base = os.path.splitext(fn)[0] # mediaserver
pod_name = f"pod{base}" # podmediaserver
unit_name = f"{base}.service" # mediaserver.service
managed.add((pod_name, unit_name))
for pod_name, unit_name in sorted(managed):
if pod_name not in by_name:
# bestaat niet als pod => toch tonen
# status halen uit systemd
code, out = run(["systemctl", "--user", "is-active", unit_name])
active = (out.strip() if out else "unknown")
status = "Running" if active == "active" else "Stopped (defined)"
by_name[pod_name] = {
"Name": pod_name,
"Status": status,
"Source": "systemd",
"Unit": unit_name
}
else:
# bestaat wel: verrijk met unit-info (handig voor UI)
by_name[pod_name]["Unit"] = unit_name
return list(by_name.values())
# helper blok
def podname_to_systemd_unit(podname: str) -> str | None:
"""
Jouw conventie:
podmediaserver -> mediaserver.service
"""
if podname.startswith("pod") and len(podname) > 3:
base = podname[3:]
return f"{base}.service"
return None
def try_systemd_pod_action(action: str, podname: str):
"""
Probeert systemd --user <action> <unit>.
Return dict met exit/output + unit, of None als geen mapping.
"""
unit = podname_to_systemd_unit(podname)
if not unit:
return None
code, out = run(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
def find_defined_containers():
"""
Zoekt naar *.container onder WORKLOADS_DIR/systemd (recursief).
Return: dict name -> relpath (bijv {"sonarr": "sonarr/sonarr.container"})
"""
base = os.path.join(WORKLOADS_DIR, "systemd")
defined = {}
if not os.path.isdir(base):
return defined
for root, _, files in os.walk(base):
for fn in files:
if not fn.endswith(".container"):
continue
name = fn[:-len(".container")] # sonarr
rel = os.path.relpath(os.path.join(root, fn), base)
defined[name] = rel
return defined
# einde helper blok
# endpoint blok
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
"""
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
anders fallback naar bestaande Podman take_action().
"""
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# Als de podnaam begint met "pod" (bv podmediaserver), haal dan "pod" eraf voor fallback
base_name = podname[3:] if podname.startswith("pod") and len(podname) > 3 else podname
# 1) Probeer systemd (alleen als mapping bestaat)
sys_res = try_systemd_pod_action(action, podname)
if sys_res is not None:
if sys_res["exit"] == 0:
return sys_res
# systemd faalde -> fallback naar Podman
return {
"method": "systemd_then_podman",
"systemd": sys_res,
"podman": take_action(action, base_name),
"note": "systemd failed, fell back to podman",
}
# 2) Geen mapping -> fallback naar Podman
return {"method": "podman", "result": take_action(action, base_name)}
@app.get("/containers-dashboard")
def containers_dashboard():
# A) echte containers
real = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
by_name = {}
for c in real:
# jouw UI pakt meestal Names[0]
names = c.get("Names") or []
name = names[0] if isinstance(names, list) and names else (c.get("Name") or c.get("name"))
if not name:
continue
# normalize: sommige APIs geven "/sonarr"
name = name.lstrip("/")
c["_dashboard_source"] = "podman"
labels = c.get("Labels") or {}
unit = labels.get("PODMAN_SYSTEMD_UNIT")
if unit:
c["_dashboard_source"] = "systemd(kube)"
c["_dashboard_unit"] = unit
if not c.get("Status"):
c["Status"] = c.get("State") or c.get("status") or ""
by_name[name] = c
# B) defined containers uit .container files
defined = find_defined_containers()
for name, relpath in defined.items():
if name in by_name:
# container bestaat al, verrijk met systemd info
by_name[name]["_dashboard_source"] = "podman+systemd"
by_name[name]["_dashboard_unit"] = name
by_name[name]["_dashboard_def_path"] = relpath
continue
# bestaat niet -> voeg defined entry toe
by_name[name] = {
"Names": [name],
"Status": "Stopped (defined)",
"State": "defined",
"Image": "",
"PodName": "",
"Ports": [],
"_dashboard_source": "systemd",
"_dashboard_unit": name,
"_dashboard_def_path": relpath,
}
return list(by_name.values())
# einde endpointblok
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# 1) Eerst: probeer Podman (voor bestaande containers)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
# 200/204/304 zien we als succes (304 = "was al in die state")
if res.status_code in (200, 204, 304):
return {
"method": "podman",
"name": name,
"cmd": f"POST {url}",
"status_code": res.status_code,
}
# 2) Als Podman faalt: check of dit een .container definition is => systemd fallback
defined = find_defined_containers() # deze helper moet je ook hebben
if name in defined:
code, out = run(["systemctl", "--user", action, name])
return {
"method": "systemd",
"name": name,
"unit": name,
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# 3) Anders: echte Podman error teruggeven
return {
"method": "podman",
"name": name,
"cmd": f"POST {url}",
"status_code": res.status_code,
"error": res.text,
}, res.status_code
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
@app.get("/systemd/allowlist")
def systemd_allowlist():
allowed, allow_mode = read_allowlist()
return {"allow_mode": allow_mode, "units": sorted(allowed)}
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in {"status", "start", "stop", "restart"}:
raise HTTPException(status_code=400, detail="Invalid action")
# allowlist check
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-730
View File
@@ -1,730 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
def _safe_workloads_path(rel_path: str) -> Path:
"""
rel_path is iets als: "systemd/demo-web"
Zorgt dat je niet buiten WORKLOADS_DIR kunt komen (geen ../)
"""
base = Path(WORKLOADS_DIR).resolve()
rel = (rel_path or "").lstrip("/")
# hard fence: alleen systemd subtree
if rel == "systemd" or rel.startswith("systemd/"):
pass
else:
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
# geen systeemd root verwijderen
if rel == "systemd":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
target = (base / rel).resolve()
# ensure target binnen base
if not str(target).startswith(str(base) + "/"):
raise HTTPException(status_code=400, detail="Invalid path")
return target
@app.delete("/files/rmdir")
def files_rmdir(path: str = Query(..., description="Directory path under systemd/")):
target = _safe_workloads_path(path)
if not target.exists():
raise HTTPException(status_code=404, detail="Directory not found")
if not target.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
# echte leeg-check (files én subdirs)
entries = list(target.iterdir())
if entries:
# geef wat context terug
files = [p.name for p in entries if p.is_file()]
dirs = [p.name for p in entries if p.is_dir()]
raise HTTPException(
status_code=409,
detail={"error": "directory not empty", "files": files, "dirs": dirs}
)
target.rmdir()
return {"deleted": True, "path": path}
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
@app.get("/pods-dashboard")
def pods_dashboard():
"""
Combineer:
A) pods die echt bestaan (Podman API)
B) pods die gedefinieerd zijn via workloads/systemd (ook als ze niet bestaan)
Extra:
- Voeg per pod een lijst "Containers" toe met container-namen.
"""
# 0) Bouw mapping: pod_name -> [container_names...]
containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
containers_by_pod = {}
for c in containers:
pod_name = c.get("PodName") or ""
if not pod_name:
continue
names = c.get("Names") or []
if isinstance(names, list) and names:
cname = names[0]
else:
cname = c.get("Name") or c.get("name") or ""
cname = (cname or "").lstrip("/")
if not cname:
continue
containers_by_pod.setdefault(pod_name, []).append(cname)
# 1) A) echte pods
api_pods = SESSION.get(f"{PODMAN_API_BASE}/libpod/pods/json?all=true").json()
by_name = {}
for p in api_pods:
name = p.get("Name") or p.get("name")
if not name:
continue
by_name[name] = {
"Name": name,
"Status": p.get("Status") or p.get("status") or "unknown",
"Source": "podman",
"Unit": None,
"Containers": sorted(containers_by_pod.get(name, [])),
}
# 2) B) gedefinieerde pods: scan WORKLOADS_DIR naar *.yaml/*.kube
managed = set()
for root, _, files in os.walk(WORKLOADS_DIR):
for fn in files:
if not fn.endswith((".yaml", ".kube")):
continue
base = os.path.splitext(fn)[0] # mediaserver
pod_name = f"pod{base}" # podmediaserver
unit_name = f"{base}.service" # mediaserver.service
managed.add((pod_name, unit_name))
for pod_name, unit_name in sorted(managed):
if pod_name not in by_name:
# bestaat niet als pod => toch tonen met systemd status
code, out = run(["systemctl", "--user", "is-active", unit_name])
active = (out.strip() if out else "unknown")
status = "Running" if active == "active" else "Stopped (defined)"
by_name[pod_name] = {
"Name": pod_name,
"Status": status,
"Source": "systemd",
"Unit": unit_name,
"Containers": sorted(containers_by_pod.get(pod_name, [])),
}
else:
# bestaat wel: verrijk met unit-info (handig voor UI)
by_name[pod_name]["Unit"] = unit_name
return list(by_name.values())
# helper blok
def podname_to_systemd_unit(podname: str) -> str | None:
"""
Jouw conventie:
podmediaserver -> mediaserver.service
"""
if podname.startswith("pod") and len(podname) > 3:
base = podname[3:]
return f"{base}.service"
return None
def try_systemd_pod_action(action: str, podname: str):
"""
Probeert systemd --user <action> <unit>.
Return dict met exit/output + unit, of None als geen mapping.
"""
unit = podname_to_systemd_unit(podname)
if not unit:
return None
code, out = run(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
def find_defined_containers():
"""
Zoekt naar *.container onder WORKLOADS_DIR/systemd (recursief).
Return: dict name -> relpath (bijv {"sonarr": "sonarr/sonarr.container"})
"""
base = os.path.join(WORKLOADS_DIR, "systemd")
defined = {}
if not os.path.isdir(base):
return defined
for root, _, files in os.walk(base):
for fn in files:
if not fn.endswith(".container"):
continue
name = fn[:-len(".container")] # sonarr
rel = os.path.relpath(os.path.join(root, fn), base)
defined[name] = rel
return defined
# einde helper blok
# endpoint blok
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
"""
Prefer systemd voor pods die beginnen met 'pod' (quadlet/kube-gegenereerd),
anders fallback naar bestaande Podman take_action().
"""
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# Als de podnaam begint met "pod" (bv podmediaserver), haal dan "pod" eraf voor fallback
base_name = podname[3:] if podname.startswith("pod") and len(podname) > 3 else podname
# 1) Probeer systemd (alleen als mapping bestaat)
sys_res = try_systemd_pod_action(action, podname)
if sys_res is not None:
if sys_res["exit"] == 0:
return sys_res
# systemd faalde -> fallback naar Podman
return {
"method": "systemd_then_podman",
"systemd": sys_res,
"podman": take_action(action, base_name),
"note": "systemd failed, fell back to podman",
}
# 2) Geen mapping -> fallback naar Podman
return {"method": "podman", "result": take_action(action, base_name)}
@app.get("/containers-dashboard")
def containers_dashboard():
# A) echte containers
real = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
by_name = {}
for c in real:
# jouw UI pakt meestal Names[0]
names = c.get("Names") or []
name = names[0] if isinstance(names, list) and names else (c.get("Name") or c.get("name"))
if not name:
continue
# normalize: sommige APIs geven "/sonarr"
name = name.lstrip("/")
c["_dashboard_source"] = "podman"
labels = c.get("Labels") or {}
unit = labels.get("PODMAN_SYSTEMD_UNIT")
if unit:
c["_dashboard_source"] = "systemd(kube)"
c["_dashboard_unit"] = unit
if not c.get("Status"):
c["Status"] = c.get("State") or c.get("status") or ""
by_name[name] = c
# B) defined containers uit .container files
defined = find_defined_containers()
for name, relpath in defined.items():
if name in by_name:
# container bestaat al, verrijk met systemd info
by_name[name]["_dashboard_source"] = "podman+systemd"
by_name[name]["_dashboard_unit"] = name
by_name[name]["_dashboard_def_path"] = relpath
continue
# bestaat niet -> voeg defined entry toe
by_name[name] = {
"Names": [name],
"Status": "Stopped (defined)",
"State": "defined",
"Image": "",
"PodName": "",
"Ports": [],
"_dashboard_source": "systemd",
"_dashboard_unit": name,
"_dashboard_def_path": relpath,
}
return list(by_name.values())
# einde endpointblok
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in {"start", "stop", "restart"}:
return {"error": "Invalid action"}, 400
# 1) Eerst: probeer Podman (voor bestaande containers)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
# 200/204/304 zien we als succes (304 = "was al in die state")
if res.status_code in (200, 204, 304):
return {
"method": "podman",
"name": name,
"cmd": f"POST {url}",
"status_code": res.status_code,
}
# 2) Als Podman faalt: check of dit een .container definition is => systemd fallback
defined = find_defined_containers() # deze helper moet je ook hebben
if name in defined:
code, out = run(["systemctl", "--user", action, name])
return {
"method": "systemd",
"name": name,
"unit": name,
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# 3) Anders: echte Podman error teruggeven
return {
"method": "podman",
"name": name,
"cmd": f"POST {url}",
"status_code": res.status_code,
"error": res.text,
}, res.status_code
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
@app.get("/systemd/allowlist")
def systemd_allowlist():
allowed, allow_mode = read_allowlist()
return {"allow_mode": allow_mode, "units": sorted(allowed)}
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in {"status", "start", "stop", "restart"}:
raise HTTPException(status_code=400, detail="Invalid action")
# allowlist check
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-644
View File
@@ -1,644 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = safe_join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = safe_join(WORKLOADS_DIR, path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = f"{name}.service" if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
# derive pod name from filename (legacy convention)
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = f"{pod_name}.service"
if pod_name not in by_name:
# bestaat niet als pod => toch tonen met systemd status
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
return dashboard
def _map_pod_to_unit(podname: str) -> str | None:
# Derive a likely systemd unit from pod name (legacy convention).
if not podname:
return None
# e.g. podmediaserver -> podmediaserver.service
return f"{podname}.service"
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# 1) try systemd path
systemd_res = try_systemd_pod_action(action, podname)
if systemd_res and systemd_res.get("exit", 1) == 0:
return systemd_res
# 2) if systemd tried but failed, fall back
if systemd_res:
note = "systemd failed; falling back to podman"
# Podman pod action endpoint
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
podman = _podman_post(url).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
# 3) direct podman
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
return {"method": "podman", "result": _podman_post(url).json()}
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
# ensure Status exists (legacy behavior)
if "Status" not in c:
c["Status"] = c.get("State", "")
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) defined containers from systemd/*.container
defined = find_defined_containers()
for name, relpath in defined.items():
dashboard.append({
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
})
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# 1) Eerst: probeer Podman (voor bestaande containers)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = _podman_post(url)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
# Podman error path (tuple-return style)
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
# 2) Als Podman faalt: kijk of het een defined container is
defined = find_defined_containers()
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# fallback
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append({
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
})
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-694
View File
@@ -1,694 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = safe_join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = safe_join(WORKLOADS_DIR, path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
def _build_pod_to_containers_map(containers: list):
# preserves original order of containers processing; no sorting added
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
return pod_to_containers
def _map_pod_to_unit(podname: str) -> str | None:
"""
HOTFIX 3.1 FIX 1:
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
Else: <podname>.service
"""
if not podname:
return None
if podname.startswith("pod"):
return f"{podname[3:]}.service"
return f"{podname}.service"
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
# preserves original api_pods iteration order
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = _map_pod_to_unit(name) if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
# preserves original os.walk order and file iteration order
for root, _, files in os.walk(root_dir):
for f in files:
if f.endswith((".yaml", ".yml")):
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = _map_pod_to_unit(pod_name)
if pod_name not in by_name:
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = _build_pod_to_containers_map(containers)
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
return dashboard
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# 1) try systemd path
systemd_res = try_systemd_pod_action(action, podname)
if systemd_res and systemd_res.get("exit", 1) == 0:
return systemd_res
# 2) if systemd tried but failed, fall back
if systemd_res:
note = "systemd failed; falling back to podman"
# Podman pod action endpoint
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
podman = _podman_post(url).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
# 3) direct podman
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
return {"method": "podman", "result": _podman_post(url).json()}
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) defined containers from systemd/*.container
defined = find_defined_containers()
for name, relpath in defined.items():
row = _make_defined_container_dashboard_row(name, relpath)
# HOTFIX 3.1 FIX 2: fill Status from systemd is-active, keep keyset identical
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# HOTFIX 3.2:
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
defined = find_defined_containers()
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# systemd failed -> fall back to podman (unchanged podman result behavior below)
# Podman path (primary for non-defined, fallback for defined if systemd failed)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = _podman_post(url)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
# If podman did not succeed and this is defined, we keep legacy behavior:
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-699
View File
@@ -1,699 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = safe_join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = safe_join(WORKLOADS_DIR, path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = safe_join(WORKLOADS_DIR, path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
def _build_pod_to_containers_map(containers: list):
# preserves original order of containers processing; no sorting added
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
return pod_to_containers
def _map_pod_to_unit(podname: str) -> str | None:
"""
HOTFIX 3.1 FIX 1:
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
Else: <podname>.service
"""
if not podname:
return None
if podname.startswith("pod"):
return f"{podname[3:]}.service"
return f"{podname}.service"
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
# preserves original api_pods iteration order
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = _map_pod_to_unit(name) if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
# preserves original os.walk order and file iteration order
for root, _, files in os.walk(root_dir):
for f in files:
if f.endswith((".yaml", ".yml")):
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = _map_pod_to_unit(pod_name)
if pod_name not in by_name:
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = _build_pod_to_containers_map(containers)
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
return dashboard
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# 1) try systemd path
systemd_res = try_systemd_pod_action(action, podname)
if systemd_res and systemd_res.get("exit", 1) == 0:
return systemd_res
# 2) if systemd tried but failed, fall back
if systemd_res:
note = "systemd failed; falling back to podman"
# Podman pod action endpoint
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
podman = _podman_post(url).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
# 3) direct podman
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
return {"method": "podman", "result": _podman_post(url).json()}
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers (UNCHANGED)
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
# C) defined containers from systemd/*.container (skip duplicates)
defined = find_defined_containers()
for name, relpath in defined.items():
if name in runtime_names:
continue
row = _make_defined_container_dashboard_row(name, relpath)
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# HOTFIX 3.2:
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
defined = find_defined_containers()
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# systemd failed -> fall back to podman (unchanged podman result behavior below)
# Podman path (primary for non-defined, fallback for defined if systemd failed)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = _podman_post(url)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
# If podman did not succeed and this is defined, we keep legacy behavior:
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-705
View File
@@ -1,705 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# STEP 4: Centralize WORKLOADS_DIR subtree enforcement via one helper.
# MUST be behavior-identical to previous safe_join(WORKLOADS_DIR, ...) calls.
def _files_safe_join(path: str) -> str:
return safe_join(WORKLOADS_DIR, path)
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = _files_safe_join(path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = _files_safe_join(path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
def _build_pod_to_containers_map(containers: list):
# preserves original order of containers processing; no sorting added
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
return pod_to_containers
def _map_pod_to_unit(podname: str) -> str | None:
"""
HOTFIX 3.1 FIX 1:
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
Else: <podname>.service
"""
if not podname:
return None
if podname.startswith("pod"):
return f"{podname[3:]}.service"
return f"{podname}.service"
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
# preserves original api_pods iteration order
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = _map_pod_to_unit(name) if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
# preserves original os.walk order and file iteration order
for root, _, files in os.walk(root_dir):
for f in files:
if f.endswith((".yaml", ".yml")):
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = _map_pod_to_unit(pod_name)
if pod_name not in by_name:
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = _build_pod_to_containers_map(containers)
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
return dashboard
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# 1) try systemd path
systemd_res = try_systemd_pod_action(action, podname)
if systemd_res and systemd_res.get("exit", 1) == 0:
return systemd_res
# 2) if systemd tried but failed, fall back
if systemd_res:
note = "systemd failed; falling back to podman"
# Podman pod action endpoint
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
podman = _podman_post(url).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
# 3) direct podman
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
return {"method": "podman", "result": _podman_post(url).json()}
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers (UNCHANGED)
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
# C) defined containers from systemd/*.container (skip duplicates)
defined = find_defined_containers()
for name, relpath in defined.items():
if name in runtime_names:
continue
row = _make_defined_container_dashboard_row(name, relpath)
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# HOTFIX 3.2:
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
defined = find_defined_containers()
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# systemd failed -> fall back to podman (unchanged podman result behavior below)
# Podman path (primary for non-defined, fallback for defined if systemd failed)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = _podman_post(url)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
# If podman did not succeed and this is defined, we keep legacy behavior:
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-420
View File
@@ -1,420 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- MODELS ---
class FileContent(BaseModel):
content: str
class FileCreate(BaseModel):
path: str
content: str
@app.get("/workloads")
def list_workloads():
yaml_files = []
for root, dirs, files in os.walk(WORKLOADS_DIR):
for file in files:
if file.endswith((".yaml", ".kube")):
rel_path = os.path.relpath(os.path.join(root, file), WORKLOADS_DIR)
yaml_files.append(rel_path)
return {"workloads": sorted(yaml_files)}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path): raise HTTPException(status_code=404)
with open(path, 'r') as f: return {"filename": filename, "content": f.read()}
@app.post("/workloads/save-file")
def save_file(file_data: FileCreate):
target_path = os.path.join(WORKLOADS_DIR, file_data.path)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, 'w') as f: f.write(file_data.content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f: yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return SESSION.post(url, data=yaml_content).json()
# --- PODS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return SESSION.get(url).json()
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Podman gebruikt vaak 'pod' prefix voor pods aangemaakt via kube play
possible_names = [f"pod{name}", name]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
# 204 = Succes, 304 = Draait al (ook succes)
if res.status_code in [204, 304, 200]:
print(f"DEBUG: {target} direct gestart (Status: {res.status_code})")
return {"status": "started", "method": "direct_pod_start", "target": target}
# STAP 2: Als direct starten niet kon (omdat de pod echt niet bestaat), gebruik YAML
print(f"DEBUG: Pod niet gevonden via API, probeer YAML play voor {name}")
target_path = None
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.startswith(name) and f.endswith('.yaml'):
target_path = os.path.join(root, f)
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# STAP 3: Laatste redmiddel - Als kube/play zegt dat hij al bestaat (jouw 500 error)
# Dan forceren we een delete en een herstart.
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
SESSION.delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = SESSION.post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
if action == "stop":
for target in possible_names:
res = SESSION.post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in [204, 304, 200]:
return {"status": "stopped", "target": target}
return {"status": "not_found_or_stopped"}
return {"status": "unknown_action"}
# --- CONTAINERS ---
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return SESSION.get(url).json()
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
# Podman API pad: /libpod/containers/{name}/{action}
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = SESSION.post(url)
return {"status": "success", "code": res.status_code}
@app.get("/dashboard")
def get_dashboard():
try:
api_containers = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true").json()
except:
api_containers = []
dashboard = {}
for root, dirs, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith(('.yaml', '.kube', '.container')):
rel_path = os.path.relpath(os.path.join(root, f), WORKLOADS_DIR)
# Gebruik de bestandsnaam zonder extensie als unieke sleutel
name_base = f.split('.')[0]
# Als we mediaserver.yaml EN mediaserver.kube hebben, zien we ze als 1 item
if name_base not in dashboard:
# Zoek containers die horen bij dit bestand (fuzzy match op naam)
matching = [c for c in api_containers if name_base in c['Names'][0]]
status = "stopped"
ip = "-"
container_count = len(matching)
if matching:
# Als er minstens één container draait, is de status 'running'
status = "running" if any(c['State'] == 'running' for c in matching) else "exited"
# Pak IP van de eerste container die een IP heeft
for c in matching:
nets = c.get('Networks', {})
first_net = next(iter(nets.values()), {}) if nets else {}
if first_net.get('IPAddress'):
ip = first_net['IPAddress']
break
dashboard[name_base] = {
"name": name_base,
"path": rel_path,
"status": status,
"ip": ip,
"containers": container_count
}
return list(dashboard.values())
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check bestanden op schijf
try:
files = os.listdir(WORKLOADS_DIR)
except Exception as e:
return {"error": f"Kan map {WORKLOADS_DIR} niet lezen: {str(e)}"}
# 2. Check Podman API
try:
api_res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
api_containers = api_res.json()
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": files,
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else 0,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and len(api_containers) > 0 else "Geen containers"
}
@app.get("/files/tree")
def get_file_tree():
tree = []
# os.walk gaat door alle mappen heen
for root, dirs, files in os.walk(WORKLOADS_DIR):
# JUISTE SYNTAX: os.path.relpath
relative_path = os.path.relpath(root, WORKLOADS_DIR) if root != WORKLOADS_DIR else ""
# Filter alleen de bestanden die we willen zien
valid_files = [f for f in files if f.endswith(('.yaml', '.container', '.kube'))]
# Voeg de map toe aan de lijst
tree.append({
"path": relative_path,
"dirs": dirs,
"files": valid_files
})
return tree
@app.get("/files/read")
def read_file_managed(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
if not os.path.exists(full_path): raise HTTPException(status_code=404)
with open(full_path, 'r') as f: return {"content": f.read()}
@app.post("/files/save")
def save_file_managed(path: str, data: FileContent):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)): raise HTTPException(status_code=403)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f: f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def delete_item(path: str):
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
if not os.path.exists(full_path):
raise HTTPException(status_code=404)
try:
if os.path.isdir(full_path):
# Verwijder een lege map
os.rmdir(full_path)
else:
# Verwijder een bestand
os.remove(full_path)
return {"status": "deleted", "type": "folder" if os.path.isdir(full_path) else "file"}
except OSError as e:
# Dit gebeurt o.a. als een map niet leeg is
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e.strerror}")
@app.post("/files/mkdir")
def create_directory(path: str):
# We dwingen af dat als het pad niet met systemd begint, we het toevoegen
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full_path = os.path.normpath(os.path.join(WORKLOADS_DIR, path))
# Veiligheidscheck blijft gelijk
if not full_path.startswith(os.path.abspath(WORKLOADS_DIR)):
raise HTTPException(status_code=403)
os.makedirs(full_path, exist_ok=True)
return {"status": "directory created", "path": path}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": res.text}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
res = SESSION.get(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
return res.json()
# SYSTEMCTL FUNCTIONS
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
return r.returncode, out.strip()
def read_allowlist():
"""
Return: (set_of_units, allow_mode_bool)
allow_mode_bool = True als er minstens 1 unit in allowlist staat.
"""
if not os.path.exists(ALLOWLIST_FILE):
return set(), False
allowed = set()
with open(ALLOWLIST_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# minimale sanity: alleen .service
if line.endswith(".service"):
allowed.add(line)
return allowed, (len(allowed) > 0)
def list_unit_files():
allowed, allow_mode = read_allowlist()
if allow_mode:
# whitelist = bron van waarheid
return sorted(allowed)
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = run(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("UNIT FILE") or line.startswith("UNIT"):
continue
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = run(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = run(["systemctl", "--user", "is-enabled", unit])
enabled_out = enabled_out.splitlines()[0].strip() if enabled_out else ""
if code != 0 or "Failed to get unit file state" in enabled_out:
enabled = "unknown"
else:
enabled = enabled_out
return active, enabled
def get_units_for_ui():
all_units = list_unit_files()
_, allow_mode = read_allowlist()
result = []
for u in all_units:
active, enabled = unit_state(u)
result.append({"name": u, "active": active, "enabled": enabled})
return result, allow_mode
def assert_allowed(unit):
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
abort(403, description="Unit not allowed by allowlist")
@app.get("/")
def index():
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=None,
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = run(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in {"status", "start", "stop", "restart"}:
raise HTTPException(status_code=400, detail="Invalid action")
# allowlist check
allowed, allow_mode = read_allowlist()
if allow_mode and unit not in allowed:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/action")
def action_form():
unit = request.form.get("unit", "")
action = request.form.get("action", "")
if action not in {"status", "start", "stop", "restart"}:
return "Invalid action", 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
units, allow_mode = get_units_for_ui()
return render_template_string(
HTML,
units=units,
output=f"$ {' '.join(cmd)}\n(exit {code})\n\n{out}",
allowfile=ALLOWLIST_FILE,
allow_mode=allow_mode
)
@app.post("/api/<action>/<unit>")
def api_action(action, unit):
if action not in {"status", "start", "stop", "restart"}:
return jsonify({"error": "Invalid action"}), 400
assert_allowed(unit)
cmd = ["systemctl", "--user", action, unit]
code, out = run(cmd)
return jsonify({"cmd": cmd, "exit": code, "output": out})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-709
View File
@@ -1,709 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
def _run_systemctl_action(action: str, unit: str):
cmd = ["systemctl", "--user", action, unit]
return _systemctl(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# STEP 4: Centralize WORKLOADS_DIR subtree enforcement via one helper.
# MUST be behavior-identical to previous safe_join(WORKLOADS_DIR, ...) calls.
def _files_safe_join(path: str) -> str:
return safe_join(WORKLOADS_DIR, path)
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = _files_safe_join(path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = _files_safe_join(path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
def _build_pod_to_containers_map(containers: list):
# preserves original order of containers processing; no sorting added
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
return pod_to_containers
def _map_pod_to_unit(podname: str) -> str | None:
"""
HOTFIX 3.1 FIX 1:
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
Else: <podname>.service
"""
if not podname:
return None
if podname.startswith("pod"):
return f"{podname[3:]}.service"
return f"{podname}.service"
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
# preserves original api_pods iteration order
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = _map_pod_to_unit(name) if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
# preserves original os.walk order and file iteration order
for root, _, files in os.walk(root_dir):
for f in files:
if f.endswith((".yaml", ".yml")):
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = _map_pod_to_unit(pod_name)
if pod_name not in by_name:
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = _build_pod_to_containers_map(containers)
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
return dashboard
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# 1) try systemd path
systemd_res = try_systemd_pod_action(action, podname)
if systemd_res and systemd_res.get("exit", 1) == 0:
return systemd_res
# 2) if systemd tried but failed, fall back
if systemd_res:
note = "systemd failed; falling back to podman"
# Podman pod action endpoint
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
podman = _podman_post(url).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
# 3) direct podman
url = f"{PODMAN_API_BASE}/libpod/pods/{podname}/{action}"
return {"method": "podman", "result": _podman_post(url).json()}
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers (UNCHANGED)
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
# C) defined containers from systemd/*.container (skip duplicates)
defined = find_defined_containers()
for name, relpath in defined.items():
if name in runtime_names:
continue
row = _make_defined_container_dashboard_row(name, relpath)
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# HOTFIX 3.2:
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
defined = find_defined_containers()
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# systemd failed -> fall back to podman (unchanged podman result behavior below)
# Podman path (primary for non-defined, fallback for defined if systemd failed)
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
res = _podman_post(url)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
# If podman did not succeed and this is defined, we keep legacy behavior:
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-713
View File
@@ -1,713 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_action_post(kind: str, name: str, action: str):
if kind == "pods":
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
else:
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
return _podman_post(url)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
def _run_systemctl_action(action: str, unit: str):
cmd = ["systemctl", "--user", action, unit]
return _systemctl(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# STEP 4: Centralize WORKLOADS_DIR subtree enforcement via one helper.
# MUST be behavior-identical to previous safe_join(WORKLOADS_DIR, ...) calls.
def _files_safe_join(path: str) -> str:
return safe_join(WORKLOADS_DIR, path)
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = _files_safe_join(path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = _files_safe_join(path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
def _build_pod_to_containers_map(containers: list):
# preserves original order of containers processing; no sorting added
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
return pod_to_containers
def _map_pod_to_unit(podname: str) -> str | None:
"""
HOTFIX 3.1 FIX 1:
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
Else: <podname>.service
"""
if not podname:
return None
if podname.startswith("pod"):
return f"{podname[3:]}.service"
return f"{podname}.service"
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
# preserves original api_pods iteration order
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = _map_pod_to_unit(name) if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
# preserves original os.walk order and file iteration order
for root, _, files in os.walk(root_dir):
for f in files:
if f.endswith((".yaml", ".yml")):
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = _map_pod_to_unit(pod_name)
if pod_name not in by_name:
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = _build_pod_to_containers_map(containers)
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
return dashboard
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# 1) try systemd path
systemd_res = try_systemd_pod_action(action, podname)
if systemd_res and systemd_res.get("exit", 1) == 0:
return systemd_res
# 2) if systemd tried but failed, fall back
if systemd_res:
note = "systemd failed; falling back to podman"
# Podman pod action endpoint
podman = _podman_action_post("pods", podname, action).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
# 3) direct podman
return {"method": "podman", "result": _podman_action_post("pods", podname, action).json()}
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers (UNCHANGED)
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
# C) defined containers from systemd/*.container (skip duplicates)
defined = find_defined_containers()
for name, relpath in defined.items():
if name in runtime_names:
continue
row = _make_defined_container_dashboard_row(name, relpath)
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
# HOTFIX 3.2:
# For defined (.container/quadlet) containers: ALWAYS try systemd first; podman only fallback.
defined = find_defined_containers()
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
# systemd failed -> fall back to podman (unchanged podman result behavior below)
# Podman path (primary for non-defined, fallback for defined if systemd failed)
res = _podman_action_post("containers", name, action)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
# If podman did not succeed and this is defined, we keep legacy behavior:
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-724
View File
@@ -1,724 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_action_post(kind: str, name: str, action: str):
if kind == "pods":
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
else:
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
return _podman_post(url)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
def _run_systemctl_action(action: str, unit: str):
cmd = ["systemctl", "--user", action, unit]
return _systemctl(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# STEP 4: Centralize WORKLOADS_DIR subtree enforcement via one helper.
# MUST be behavior-identical to previous safe_join(WORKLOADS_DIR, ...) calls.
def _files_safe_join(path: str) -> str:
return safe_join(WORKLOADS_DIR, path)
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = _files_safe_join(path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = _files_safe_join(path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
def _build_pod_to_containers_map(containers: list):
# preserves original order of containers processing; no sorting added
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
return pod_to_containers
def _map_pod_to_unit(podname: str) -> str | None:
"""
HOTFIX 3.1 FIX 1:
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
Else: <podname>.service
"""
if not podname:
return None
if podname.startswith("pod"):
return f"{podname[3:]}.service"
return f"{podname}.service"
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
# preserves original api_pods iteration order
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = _map_pod_to_unit(name) if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
# preserves original os.walk order and file iteration order
for root, _, files in os.walk(root_dir):
for f in files:
if f.endswith((".yaml", ".yml")):
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = _map_pod_to_unit(pod_name)
if pod_name not in by_name:
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = _build_pod_to_containers_map(containers)
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
return dashboard
def _systemd_then_podman(action_kind: str, action: str, name: str, systemd_callable, podman_callable):
systemd_res = systemd_callable()
if systemd_res is not None:
if isinstance(systemd_res, dict) and systemd_res.get("exit", 1) == 0:
return systemd_res
return podman_callable(systemd_res)
return podman_callable(None)
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
def _systemd_call():
return try_systemd_pod_action(action, podname)
def _podman_call(systemd_res):
if systemd_res:
note = "systemd failed; falling back to podman"
podman = _podman_action_post("pods", podname, action).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
return {"method": "podman", "result": _podman_action_post("pods", podname, action).json()}
return _systemd_then_podman("pods", action, podname, _systemd_call, _podman_call)
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers (UNCHANGED)
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
# C) defined containers from systemd/*.container (skip duplicates)
defined = find_defined_containers()
for name, relpath in defined.items():
if name in runtime_names:
continue
row = _make_defined_container_dashboard_row(name, relpath)
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
defined = find_defined_containers()
_sys = {"code": None, "out": None}
def _systemd_call():
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
_sys["code"] = code
_sys["out"] = out
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"exit": code, "output": out}
return None
def _podman_call(systemd_res):
res = _podman_action_post("containers", name, action)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": _sys["code"],
"output": _sys["out"],
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
return _systemd_then_podman("containers", action, name, _systemd_call, _podman_call)
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-724
View File
@@ -1,724 +0,0 @@
import os
import subprocess
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
import requests_unixsocket
import uvicorn
from pathlib import Path
app = FastAPI(title="Podman MVP Control Plane", root_path="/api")
SESSION = requests_unixsocket.Session()
PODMAN_API_BASE = "http+unix://%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ALLOWLIST_FILE = os.getenv("ALLOWLIST_FILE", os.path.join(BASE_DIR, "allowed_units.txt"))
WORKLOADS_DIR = "/app/workloads"
# --- ADAPTERS (contract-neutral helpers) ---
# Centralize Podman socket and systemctl invocation.
# MUST NOT change endpoint outputs, status codes, or side-effects.
def _podman_get_json(url: str):
return SESSION.get(url).json()
def _podman_get_text(url: str) -> str:
return SESSION.get(url).text
def _podman_post(url: str, **kwargs):
return SESSION.post(url, **kwargs)
def _podman_action_post(kind: str, name: str, action: str):
if kind == "pods":
url = f"{PODMAN_API_BASE}/libpod/pods/{name}/{action}"
else:
url = f"{PODMAN_API_BASE}/libpod/containers/{name}/{action}"
return _podman_post(url)
def _podman_delete(url: str):
return SESSION.delete(url)
def _systemctl(cmd):
# Proxy to existing run() to avoid behavioral changes.
return run(cmd)
def _run_systemctl_action(action: str, unit: str):
cmd = ["systemctl", "--user", action, unit]
return _systemctl(cmd)
# --- MODELS ---
class FileContent(BaseModel):
content: str
# --- WORKLOADS ---
@app.get("/workloads")
def list_workloads():
workloads = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
if f.endswith((".yaml", ".yml", ".json")):
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
workloads.append(rel)
return {"workloads": workloads}
@app.get("/workloads/read/{filename:path}")
def read_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404)
with open(path, 'r') as f:
content = f.read()
return {"filename": filename, "content": content}
@app.post("/workloads/save-file")
def save_workload_file(data: dict):
path = data.get("path")
content = data.get("content")
full_path = os.path.join(WORKLOADS_DIR, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return {"status": "success"}
@app.post("/workloads/deploy/{filename:path}")
def deploy_workload(filename: str):
path = os.path.join(WORKLOADS_DIR, filename)
with open(path, 'r') as f:
yaml_content = f.read()
url = f"{PODMAN_API_BASE}/libpod/kube/play"
return _podman_post(url, data=yaml_content).json()
# --- FILE RESTRICTIONS ---
def safe_join(base, path):
# prevent traversal
base = os.path.abspath(base)
final = os.path.abspath(os.path.join(base, path))
if not final.startswith(base):
raise HTTPException(status_code=403, detail="Forbidden path")
return final
# STEP 4: Centralize WORKLOADS_DIR subtree enforcement via one helper.
# MUST be behavior-identical to previous safe_join(WORKLOADS_DIR, ...) calls.
def _files_safe_join(path: str) -> str:
return safe_join(WORKLOADS_DIR, path)
# --- FILES API ---
@app.get("/files/tree")
def file_tree():
root = WORKLOADS_DIR
result = []
for dirpath, dirnames, filenames in os.walk(root):
rel = os.path.relpath(dirpath, root)
if rel == ".":
rel = ""
result.append({
"path": rel,
"dirs": sorted(dirnames),
"files": sorted(filenames),
})
return result
@app.get("/files/read")
def file_read(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=403, detail="Is a directory")
with open(full, "r") as f:
content = f.read()
return {"content": content}
@app.post("/files/save")
def file_save(path: str = Query(...), data: FileContent = None):
full = _files_safe_join(path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w") as f:
f.write(data.content)
return {"status": "success", "path": path}
@app.delete("/files/delete")
def file_delete(path: str = Query(...)):
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
if os.path.isdir(full):
raise HTTPException(status_code=400, detail="Kan niet verwijderen: is directory")
try:
os.remove(full)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Kan niet verwijderen: {e}")
return {"status": "deleted", "type": "file"}
@app.post("/files/mkdir")
def file_mkdir(path: str = Query(...)):
# UI expects operations under systemd/; enforce prefix if absent.
if not path.startswith("systemd"):
path = os.path.join("systemd", path)
full = _files_safe_join(path)
os.makedirs(full, exist_ok=True)
return {"status": "directory created", "path": path}
@app.delete("/files/rmdir")
def file_rmdir(path: str = Query(..., description="Directory path under systemd/")):
# Only allow deletion under systemd subtree
if not path or path == "systemd" or path == "systemd/":
raise HTTPException(status_code=400, detail="Refusing to delete systemd root")
if not path.startswith("systemd/") and path != "systemd":
raise HTTPException(status_code=400, detail="Only systemd subtree is allowed")
full = _files_safe_join(path)
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Directory not found")
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Path is not a directory")
# directory must be empty
try:
Path(full).rmdir()
except OSError:
# not empty
# build a stable detail payload
try:
dirs = []
files = []
for entry in os.listdir(full):
p = os.path.join(full, entry)
if os.path.isdir(p):
dirs.append(entry)
else:
files.append(entry)
except Exception:
dirs, files = [], []
raise HTTPException(status_code=409, detail={
"error": "directory not empty",
"dirs": sorted(dirs),
"files": sorted(files),
})
return {"deleted": True, "path": path}
# --- PODS / CONTAINERS ---
@app.get("/pods")
def list_pods():
# Cruciaal: ?all=true zorgt dat EXIT_STATE pods ook getoond worden
url = f"{PODMAN_API_BASE}/libpod/pods/json?all=true"
return _podman_get_json(url)
@app.post("/actions/{action}/{name}")
def take_action(action: str, name: str):
# Legacy endpoint (keep behavior)
possible_names = [name, f"pod{name}", f"pod-{name}"]
if action == "start":
# STAP 1: Probeer direct de pod te starten (de 'Cockpit' methode)
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/start")
if res.status_code in (200, 204):
return {"status": "started", "target": target, "method": "direct"}
# STAP 2: Als direct starten faalt, probeer dan YAML opnieuw te deployen
target_path = None
for ext in (".yaml", ".yml"):
cand = os.path.join(WORKLOADS_DIR, f"{name}{ext}")
if os.path.exists(cand):
target_path = cand
break
if target_path:
with open(target_path, 'r') as file:
yaml_content = file.read()
res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
# SPECIALE CASE: Pod bestaat al, forceer dan restart
if res.status_code == 500 and "already exists" in res.text:
print(f"DEBUG: Forceer herstart voor {name} wegens conflict")
for target in possible_names:
_podman_delete(f"{PODMAN_API_BASE}/libpod/pods/{target}?force=true")
# Probeer het nu opnieuw
retry_res = _podman_post(f"{PODMAN_API_BASE}/libpod/kube/play", data=yaml_content)
return retry_res.json()
return res.json()
return {"status": "unknown", "method": "no_yaml_found"}
if action == "stop":
for target in possible_names:
res = _podman_post(f"{PODMAN_API_BASE}/libpod/pods/{target}/stop")
if res.status_code in (200, 204):
return {"status": "stopped", "target": target}
return {"status": "not found"}
return {"status": "unknown"}
# --- DASHBOARD HELPERS (contract-neutral, no ordering/sorting changes) ---
def _build_pod_to_containers_map(containers: list):
# preserves original order of containers processing; no sorting added
pod_to_containers = {}
for c in containers:
pod_name = c.get("PodName") or ""
if pod_name:
pod_to_containers.setdefault(pod_name, []).append((c.get("Names") or ["?"])[0])
return pod_to_containers
def _map_pod_to_unit(podname: str) -> str | None:
"""
HOTFIX 3.1 FIX 1:
If podname starts with "pod", map to <rest>.service (e.g. podmediaserver -> mediaserver.service)
Else: <podname>.service
"""
if not podname:
return None
if podname.startswith("pod"):
return f"{podname[3:]}.service"
return f"{podname}.service"
def _append_podman_pods_dashboard_rows(dashboard: list, api_pods: list, pod_to_containers: dict):
# preserves original api_pods iteration order
for p in api_pods:
name = p.get("Name")
status = p.get("Status", "unknown")
unit = _map_pod_to_unit(name) if name else ""
dashboard.append({
"Name": name,
"Status": status,
"Containers": pod_to_containers.get(name, []),
"Unit": unit,
"Source": "podman",
})
def _append_defined_pods_dashboard_rows(dashboard: list, by_name: dict, root_dir: str):
# preserves original os.walk order and file iteration order
for root, _, files in os.walk(root_dir):
for f in files:
if f.endswith((".yaml", ".yml")):
base = os.path.splitext(os.path.basename(f))[0]
pod_name = f"pod{base}"
unit_name = _map_pod_to_unit(pod_name)
if pod_name not in by_name:
code, out = _systemctl(["systemctl", "--user", "is-active", unit_name])
status = (out or "").strip() or ("active" if code == 0 else "inactive")
dashboard.append({
"Name": pod_name,
"Status": status,
"Containers": [],
"Unit": unit_name,
"Source": "systemd",
})
def _ensure_container_status_field(container: dict):
# keep exact existing defaulting behavior
if "Status" not in container:
container["Status"] = container.get("State", "")
def _make_defined_container_dashboard_row(name: str, relpath: str):
# keep exact key set and default values as before
return {
"Names": [name],
"Image": "",
"State": "",
"Status": "",
"Ports": [],
"PodName": "",
"_dashboard_source": "systemd",
"_dashboard_unit": f"{name}.service",
"_dashboard_def_path": relpath,
}
def _legacy_dashboard_item_from_container(c: dict):
# Keep exact keys & defaults as before
return {
"name": (c.get("Names") or ["?"])[0],
"status": c.get("Status") or c.get("State") or "",
"path": "",
"ip": "",
"containers": [],
}
@app.get("/pods-dashboard")
def pods_dashboard():
dashboard = []
# 0) Bouw mapping: pod_name -> [container_names...]
containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
pod_to_containers = _build_pod_to_containers_map(containers)
# 1) A) echte pods
api_pods = _podman_get_json(f"{PODMAN_API_BASE}/libpod/pods/json?all=true")
by_name = {p.get("Name"): p for p in api_pods}
_append_podman_pods_dashboard_rows(dashboard, api_pods, pod_to_containers)
# 1) B) defined pods via workloads scan
# Based on YAML files in WORKLOADS_DIR; show even if not running.
_append_defined_pods_dashboard_rows(dashboard, by_name, WORKLOADS_DIR)
return dashboard
def _systemd_then_podman(systemd_callable, podman_callable):
systemd_res = systemd_callable()
if systemd_res is not None:
if isinstance(systemd_res, dict) and systemd_res.get("exit", 1) == 0:
return systemd_res
return podman_callable(systemd_res)
return podman_callable(None)
def try_systemd_pod_action(action: str, podname: str):
# If systemd unit exists/allowed, prefer it.
unit = _map_pod_to_unit(podname)
if not unit:
return None
code, out = _systemctl(["systemctl", "--user", action, unit])
return {
"method": "systemd",
"pod": podname,
"unit": unit,
"cmd": f"systemctl --user {action} {unit}",
"exit": code,
"output": out,
}
@app.post("/pods/actions/{action}/{podname}")
def pod_action_prefer_systemd(action: str, podname: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
def _systemd_call():
return try_systemd_pod_action(action, podname)
def _podman_call(systemd_res):
if systemd_res:
note = "systemd failed; falling back to podman"
podman = _podman_action_post("pods", podname, action).json()
return {"method": "systemd_then_podman", "note": note, "systemd": systemd_res, "podman": podman}
return {"method": "podman", "result": _podman_action_post("pods", podname, action).json()}
return _systemd_then_podman(_systemd_call, _podman_call)
def find_defined_containers():
defined = {}
for root, _, files in os.walk(os.path.join(WORKLOADS_DIR, "systemd")):
for f in files:
if f.endswith(".container"):
name = os.path.splitext(f)[0]
full = os.path.join(root, f)
rel = os.path.relpath(full, WORKLOADS_DIR)
defined[name] = rel
return defined
@app.get("/containers-dashboard")
def containers_dashboard():
dashboard = []
# A) echte containers (UNCHANGED)
real = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
for c in real:
_ensure_container_status_field(c)
c["_dashboard_source"] = "podman"
dashboard.append(c)
# B) Dedup set (HOTFIX 3.3) — exact extraction, no sorting
runtime_names = set((c.get("Names") or ["?"])[0] for c in real)
# C) defined containers from systemd/*.container (skip duplicates)
defined = find_defined_containers()
for name, relpath in defined.items():
if name in runtime_names:
continue
row = _make_defined_container_dashboard_row(name, relpath)
# fill Status from systemd is-active (existing hotfix 3.1 behavior)
code, out = _systemctl(["systemctl", "--user", "is-active", f"{name}.service"])
row["Status"] = (out or "").strip()
dashboard.append(row)
return dashboard
@app.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
url = f"{PODMAN_API_BASE}/libpod/containers/json?all=true"
return _podman_get_json(url)
@app.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
defined = find_defined_containers()
_sys = {"code": None, "out": None}
def _systemd_call():
if name in defined:
code, out = _systemctl(["systemctl", "--user", action, name])
_sys["code"] = code
_sys["out"] = out
if code == 0:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": code,
"output": out,
}
return {"exit": code, "output": out}
return None
def _podman_call(systemd_res):
res = _podman_action_post("containers", name, action)
if res.status_code in (200, 204):
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
if res.status_code >= 400:
return {
"method": "podman",
"name": name,
"cmd": f"podman {action} {name}",
"status_code": res.status_code,
"error": getattr(res, "text", "") or "",
}, res.status_code
if name in defined:
return {
"method": "systemd",
"name": name,
"unit": f"{name}.service",
"definition": defined[name],
"cmd": f"systemctl --user {action} {name}",
"exit": _sys["code"],
"output": _sys["out"],
}
return {"method": "podman", "name": name, "cmd": f"podman {action} {name}", "status_code": res.status_code}
return _systemd_then_podman(_systemd_call, _podman_call)
@app.get("/debug/defined-containers")
def debug_defined_containers():
return find_defined_containers()
@app.get("/dashboard")
def get_dashboard():
# Legacy dashboard view (keep shape)
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except:
api_containers = []
items = []
for c in api_containers:
items.append(_legacy_dashboard_item_from_container(c))
return items
@app.get("/test-hybrid")
def test_hybrid():
# 1. Check filesystem
try:
bestanden = []
for root, _, files in os.walk(WORKLOADS_DIR):
for f in files:
bestanden.append(os.path.join(root, f))
except Exception as e:
bestanden = f"FS Fout: {str(e)}"
# 2. Check Podman API
try:
api_containers = _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/json?all=true")
except Exception as e:
api_containers = f"API Fout: {str(e)}"
return {
"bestanden_gevonden": bestanden if isinstance(bestanden, list) else [],
"api_containers_aantal": len(api_containers) if isinstance(api_containers, list) else -1,
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
}
@app.get("/containers/logs/{name}")
def get_container_logs(name: str):
# We vragen de laatste 100 regels op (tail=100)
txt = _podman_get_text(f"{PODMAN_API_BASE}/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=100")
# Podman logs komen vaak met wat binaire metadata, we decoden dit als tekst
return {"logs": txt}
@app.get("/containers/inspect/{name}")
def inspect_container(name: str):
return _podman_get_json(f"{PODMAN_API_BASE}/libpod/containers/{name}/json")
# --- SYSTEMD allowlist ---
def read_allowlist():
units = []
if os.path.exists(ALLOWLIST_FILE):
with open(ALLOWLIST_FILE, "r") as f:
for line in f:
u = line.strip()
if u and u.endswith(".service"):
units.append(u)
return sorted(set(units))
def list_unit_files():
# fallback (als allowlist leeg is): probeer systemctl list-unit-files
code, out = _systemctl(["systemctl", "--user", "list-unit-files", "--type=service", "--no-pager"])
if code != 0:
return []
units = []
for line in out.splitlines():
parts = line.split()
if parts and parts[0].endswith(".service"):
units.append(parts[0])
return sorted(set(units))
def unit_state(unit):
# active state
_, active = _systemctl(["systemctl", "--user", "is-active", unit])
active = active.splitlines()[0].strip() if active else "unknown"
# enabled state (kan falen in container-context)
code, enabled_out = _systemctl(["systemctl", "--user", "is-enabled", unit])
enabled = enabled_out.splitlines()[0].strip() if (enabled_out and code == 0) else "unknown"
return active, enabled
@app.get("/systemd/allowlist")
def systemd_allowlist():
units = read_allowlist()
allow_mode = len(units) > 0
if not units:
units = list_unit_files()
return {"allow_mode": allow_mode, "units": units}
@app.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
"output": out,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/{action}/{unit}")
def api_action(action: str, unit: str):
if action not in ("status", "start", "stop", "restart"):
raise HTTPException(status_code=400, detail="Invalid action")
units = read_allowlist()
allow_mode = len(units) > 0
if allow_mode and unit not in units:
raise HTTPException(status_code=403, detail="Unit not allowed by allowlist")
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
# --- HTML ROOT (legacy) ---
HTML = """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>User Systemctl WebUI</title></head>
<body>
<h1>User Systemctl WebUI</h1>
<form method="post" action="/daemon-reload" style="margin-bottom: 16px;">
<button type="submit">systemctl --user daemon-reload</button>
</form>
<form method="post" action="/action">
<label>Unit:
<input name="unit" value="example.service">
</label>
<select name="action">
<option value="status">status</option>
<option value="start">start</option>
<option value="stop">stop</option>
<option value="restart">restart</option>
</select>
<button type="submit">Run</button>
</form>
</body></html>
"""
@app.get("/")
def root():
# legacy html
from fastapi.responses import HTMLResponse
return HTMLResponse(HTML)
@app.post("/action")
def html_action(unit: str = Query(None), action: str = Query(None)):
# legacy handler; minimal behavior
if not unit or not action:
return "Invalid action", 400
cmd = ["systemctl", "--user", action, unit]
code, out = _systemctl(cmd)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
@app.post("/api/<action>/<unit>")
def legacy_api_action(action: str, unit: str):
# legacy flask-like path; keep behavior (even if not used by index.html)
if action not in ("status", "start", "stop", "restart"):
return {"error": "Invalid action"}, 400
cmd = ["systemctl", "--user", action, unit]
code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out}
def run(cmd):
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = (result.stdout or "") + (result.stderr or "")
return result.returncode, output.strip()
except Exception as e:
return 1, str(e)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://127.0.0.1:8080/api"
ALLOWLIST_FILE="$HOME/systemctl-webui/allowed_units.txt"
LOG_FILE="./systemd_api_test_$(date +%F_%H%M%S).log"
# Helper: log zowel naar scherm als bestand
log() {
echo "$*" | tee -a "$LOG_FILE"
}
# Helper: POST en log response
post() {
local path="$1"
log ""
log "### POST $BASE_URL$path"
# -sS: silent maar errors tonen, -i: headers ook, voor debugging
curl -sS -i -X POST "$BASE_URL$path" | tee -a "$LOG_FILE"
log ""
}
# URL-encode alleen het @ teken (meest voorkomende issue)
# (Als je later meer nodig hebt, kunnen we dat uitbreiden)
encode_unit() {
echo "$1" | sed 's/@/%40/g'
}
log "== Systemd API test gestart =="
log "Base URL: $BASE_URL"
log "Allowlist: $ALLOWLIST_FILE"
log "Log file: $LOG_FILE"
if [[ ! -f "$ALLOWLIST_FILE" ]]; then
log "ERROR: allowlist bestand niet gevonden: $ALLOWLIST_FILE"
exit 1
fi
log ""
log "== 1) daemon-reload =="
post "/daemon-reload"
log ""
log "== 2) status van allowlisted units =="
while IFS= read -r unit; do
unit="$(echo "$unit" | sed 's/#.*//; s/[[:space:]]//g')"
[[ -z "$unit" ]] && continue
[[ "$unit" != *.service ]] && continue
enc_unit="$(encode_unit "$unit")"
post "/status/$enc_unit"
done < "$ALLOWLIST_FILE"
log ""
log "== 3) start sonarr.service (als in allowlist) =="
if grep -qE '^[[:space:]]*sonarr\.service[[:space:]]*$' "$ALLOWLIST_FILE"; then
post "/start/sonarr.service"
log "== 4) status sonarr.service na start =="
post "/status/sonarr.service"
else
log "SKIP: sonarr.service staat niet in allowlist"
fi
log ""
log "== Klaar. Output staat in: $LOG_FILE =="
-168
View File
@@ -1,168 +0,0 @@
import os
import sys
import types
import importlib
import pytest
from fastapi.testclient import TestClient
class FakeResponse:
def __init__(self, status_code: int = 200, json_data=None, text_data: str | None = None):
self.status_code = status_code
self._json_data = json_data
self._text_data = text_data
def json(self):
return self._json_data
@property
def text(self):
if self._text_data is not None:
return self._text_data
# Best-effort string
return ""
class FakeSession:
"""
Test-only Podman session stub.
Routes by URL substring to return deterministic JSON.
"""
def __init__(self):
self.calls = []
def get(self, url: str):
self.calls.append(("GET", url))
# Pods list (DYNAMIC)
if "/libpod/pods/json" in url:
return FakeResponse(200, json_data=[])
# Containers list (DYNAMIC)
if "/libpod/containers/json" in url:
return FakeResponse(200, json_data=[])
# Container inspect (DYNAMIC)
if "/libpod/containers/" in url and url.endswith("/json"):
# Return some dict to prove JSON
return FakeResponse(200, json_data={"Id": "dummy"})
# Container logs (FIXED endpoint expects {"logs": "..."} from wrapper endpoint, not Podman directly,
# but app.py fetches Podman logs and then wraps into {"logs": text}.
if "/libpod/containers/" in url and "/logs?" in url:
return FakeResponse(200, json_data=None, text_data="line1\nline2\n")
# Fallback: return JSON dict
return FakeResponse(200, json_data={})
def post(self, url: str, **kwargs):
self.calls.append(("POST", url, kwargs))
# kube/play passthrough (workloads deploy)
if "/libpod/kube/play" in url:
# app.py gebruikt SESSION.post(url, data=yaml_content).json()
return FakeResponse(200, json_data={"kube": "played"})
# Container start/stop/restart: treat as success
if "/libpod/containers/" in url:
return FakeResponse(204, json_data={})
# Pod start/stop etc: treat as success
if "/libpod/pods/" in url:
return FakeResponse(200, json_data={"ok": True})
return FakeResponse(200, json_data={})
def delete(self, url: str):
self.calls.append(("DELETE", url))
return FakeResponse(200, json_data={"deleted": True})
@pytest.fixture(scope="session")
def app_module():
"""
Import app.py as module `app` from /app, while ensuring requests_unixsocket import is safe.
No runtime changes to app.py; only test-time monkeypatching later.
"""
# Ensure /app is importable
if "/app" not in sys.path:
sys.path.insert(0, "/app")
# If requests_unixsocket isn't installed or to avoid real socket usage, provide a minimal stub.
if "requests_unixsocket" not in sys.modules:
mod = types.ModuleType("requests_unixsocket")
mod.Session = lambda: FakeSession()
sys.modules["requests_unixsocket"] = mod
app = importlib.import_module("app")
return app
@pytest.fixture()
def client(app_module, monkeypatch, tmp_path):
"""
TestClient with all external side-effects stubbed:
- Podman socket calls via app.SESSION
- systemctl calls via app.run
- filesystem roots via WORKLOADS_DIR + ALLOWLIST_FILE
"""
# Prepare temp workload tree under tmp_path
workloads_dir = tmp_path / "workloads"
systemd_dir = workloads_dir / "systemd"
systemd_dir.mkdir(parents=True, exist_ok=True)
# A defined container (*.container) for /containers-dashboard defined entry
(systemd_dir / "sonarr.container").write_text("[Container]\nImage=dummy\n", encoding="utf-8")
# Files endpoints operate under WORKLOADS_DIR; UI focuses on systemd subtree
# Create a file for /files/read/save/delete
(systemd_dir / "test.txt").write_text("hello\n", encoding="utf-8")
# Create a non-empty dir for /files/rmdir 409 scenario
nonempty_dir = systemd_dir / "nonempty"
nonempty_dir.mkdir(parents=True, exist_ok=True)
(nonempty_dir / "keep.txt").write_text("x", encoding="utf-8")
# Create a yaml to create a defined pod entry for /pods-dashboard
(workloads_dir / "mediaserver.yaml").write_text("kind: Pod\nmetadata:\n name: mediaserver\n", encoding="utf-8")
# Allowlist file for /systemd/allowlist and allow-mode enforcement
allowlist_file = tmp_path / "allowed_units.txt"
allowlist_file.write_text("sonarr.service\nmediaserver.service\n", encoding="utf-8")
# Patch module globals to point at tmp filesystem (test-only)
monkeypatch.setattr(app_module, "WORKLOADS_DIR", str(workloads_dir))
monkeypatch.setattr(app_module, "ALLOWLIST_FILE", str(allowlist_file))
# Stub Podman session object
monkeypatch.setattr(app_module, "SESSION", FakeSession())
# Stub systemctl runner
def fake_run(cmd):
# cmd is a list, e.g. ["systemctl","--user","is-active","mediaserver.service"]
cmd_str = " ".join(cmd)
if "is-active mediaserver.service" in cmd_str:
return 0, "active"
if "is-active sonarr.service" in cmd_str:
return 0, "inactive"
# For POST /{action}/{unit}, return something stable
if cmd[:3] == ["systemctl", "--user", "status"]:
return 0, "Active: active (running)"
if cmd[:3] == ["systemctl", "--user", "daemon-reload"]:
return 0, "ok"
if cmd[:3] == ["systemctl", "--user", "start"]:
return 0, "started"
if cmd[:3] == ["systemctl", "--user", "stop"]:
return 0, "stopped"
if cmd[:3] == ["systemctl", "--user", "restart"]:
return 0, "restarted"
# fallback
return 0, "ok"
monkeypatch.setattr(app_module, "run", fake_run)
return TestClient(app_module.app)
-208
View File
@@ -1,208 +0,0 @@
import pytest
# ---- LOCKED BASELINE: paths + methods ----
BASELINE_ROUTES = {
("GET", "/workloads"),
("GET", "/workloads/read/{filename:path}"),
("POST", "/workloads/save-file"),
("POST", "/workloads/deploy/{filename:path}"),
("DELETE", "/files/rmdir"),
("GET", "/pods"),
("POST", "/actions/{action}/{name}"),
("GET", "/pods-dashboard"),
("POST", "/pods/actions/{action}/{podname}"),
("GET", "/containers-dashboard"),
("GET", "/containers"),
("POST", "/containers/{action}/{name}"),
("GET", "/debug/defined-containers"),
("GET", "/dashboard"),
("GET", "/test-hybrid"),
("GET", "/files/tree"),
("GET", "/files/read"),
("POST", "/files/save"),
("DELETE", "/files/delete"),
("POST", "/files/mkdir"),
("GET", "/containers/logs/{name}"),
("GET", "/containers/inspect/{name}"),
("GET", "/systemd/allowlist"),
("GET", "/"),
("POST", "/daemon-reload"),
("POST", "/{action}/{unit}"),
("POST", "/action"),
("POST", "/api/<action>/<unit>"),
}
def _route_tuples_from_app(app):
found = set()
for r in app.routes:
methods = getattr(r, "methods", None)
path = getattr(r, "path", None)
if not methods or not path:
continue
for m in methods:
# ignore implicit HEAD/OPTIONS etc; baseline is explicit
if m in {"HEAD", "OPTIONS"}:
continue
found.add((m, path))
return found
def _assert_keys_exact(obj: dict, expected_keys: list[str]):
assert isinstance(obj, dict), f"Expected dict, got {type(obj)}"
assert sorted(list(obj.keys())) == sorted(expected_keys), f"Keys mismatch: {sorted(obj.keys())} != {sorted(expected_keys)}"
# ---- 1) Route existence: all 28 paths/methods ----
def test_baseline_routes_exist(app_module):
found = _route_tuples_from_app(app_module.app)
missing = sorted(BASELINE_ROUTES - found)
assert not missing, f"Missing baseline routes: {missing}"
# ---- 2) FIXED/VARIANT keyset verification ----
def test_files_tree_fixed_shape(client):
r = client.get("/files/tree")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
if data:
_assert_keys_exact(data[0], ["dirs", "files", "path"])
def test_files_read_fixed_shape(client):
r = client.get("/files/read", params={"path": "systemd/test.txt"})
assert r.status_code == 200
_assert_keys_exact(r.json(), ["content"])
def test_files_save_fixed_shape(client):
r = client.post("/files/save", params={"path": "systemd/test.txt"}, json={"content": "updated\n"})
assert r.status_code == 200
_assert_keys_exact(r.json(), ["path", "status"])
def test_files_delete_fixed_shape(client):
# create a file first (via save)
client.post("/files/save", params={"path": "systemd/delete-me.txt"}, json={"content": "x"})
r = client.delete("/files/delete", params={"path": "systemd/delete-me.txt"})
assert r.status_code == 200
_assert_keys_exact(r.json(), ["status", "type"])
def test_files_mkdir_fixed_shape(client):
r = client.post("/files/mkdir", params={"path": "systemd/newdir"})
assert r.status_code == 200
_assert_keys_exact(r.json(), ["path", "status"])
def test_files_rmdir_variant_shapes(client):
# 409: directory not empty -> FastAPI HTTPException detail object
r = client.delete("/files/rmdir", params={"path": "systemd/nonempty"})
assert r.status_code == 409
body = r.json()
_assert_keys_exact(body, ["detail"])
assert isinstance(body["detail"], dict)
_assert_keys_exact(body["detail"], ["dirs", "error", "files"])
# 200: empty directory -> success keys
client.post("/files/mkdir", params={"path": "systemd/emptydir"})
r2 = client.delete("/files/rmdir", params={"path": "systemd/emptydir"})
assert r2.status_code == 200
_assert_keys_exact(r2.json(), ["deleted", "path"])
def test_systemd_allowlist_fixed_shape(client):
r = client.get("/systemd/allowlist")
assert r.status_code == 200
_assert_keys_exact(r.json(), ["allow_mode", "units"])
def test_daemon_reload_fixed_shape(client):
r = client.post("/daemon-reload")
assert r.status_code == 200
_assert_keys_exact(r.json(), ["cmd", "exit", "output"])
def test_systemd_action_fixed_shape_and_allow_enforcement(client):
# allowed unit
r = client.post("/status/sonarr.service")
assert r.status_code == 200
_assert_keys_exact(r.json(), ["cmd", "exit", "output"])
# not allowed unit -> 403 with {"detail": ...}
r2 = client.post("/status/notallowed.service")
assert r2.status_code == 403
_assert_keys_exact(r2.json(), ["detail"])
def test_pods_dashboard_fixed_shape(client):
r = client.get("/pods-dashboard")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
# Expect at least the defined pod from mediaserver.yaml -> "podmediaserver"
# Keys per item must be fixed
if data:
_assert_keys_exact(data[0], ["Containers", "Name", "Source", "Status", "Unit"])
def test_containers_dashboard_variant_shape(client):
r = client.get("/containers-dashboard")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
# Because we created systemd/sonarr.container, we expect a defined entry
# in fixed defined-shape.
defined_shape = ["Image", "Names", "PodName", "Ports", "State", "Status",
"_dashboard_def_path", "_dashboard_source", "_dashboard_unit"]
found_defined = False
for item in data:
if isinstance(item, dict) and item.get("_dashboard_source") == "systemd":
_assert_keys_exact(item, defined_shape)
found_defined = True
break
assert found_defined, "Expected at least one defined container entry with fixed defined-shape"
def test_containers_logs_fixed_shape(client):
r = client.get("/containers/logs/testcontainer")
assert r.status_code == 200
_assert_keys_exact(r.json(), ["logs"])
# NOTE: tuple-return endpoints (invalid action) behave as HTTP 200 with JSON list [dict, status]
def test_tuple_return_invalid_action_shapes(client):
r = client.post("/containers/foo/bar")
assert r.status_code == 200
body = r.json()
assert isinstance(body, list) and len(body) == 2
assert isinstance(body[0], dict) and "error" in body[0]
r2 = client.post("/pods/actions/foo/podx")
assert r2.status_code == 200
body2 = r2.json()
assert isinstance(body2, list) and len(body2) == 2
assert isinstance(body2[0], dict) and "error" in body2[0]
# ---- 3) DYNAMIC endpoints: statuscode + is JSON only ----
@pytest.mark.parametrize(
"method,path,kwargs",
[
("GET", "/pods", {}),
("GET", "/containers", {}),
("GET", "/containers/inspect/testcontainer", {}),
("POST", "/workloads/deploy/mediaserver.yaml", {}),
("GET", "/debug/defined-containers", {}),
],
)
def test_dynamic_endpoints_only_json_and_200(client, method, path, kwargs):
req = getattr(client, method.lower())
r = req(path, **kwargs)
assert r.status_code == 200
data = r.json()
assert isinstance(data, (dict, list)), f"Expected JSON (dict/list), got {type(data)}"
-168
View File
@@ -1,168 +0,0 @@
import os
import sys
import types
import importlib
import pytest
from fastapi.testclient import TestClient
class FakeResponse:
def __init__(self, status_code: int = 200, json_data=None, text_data: str | None = None):
self.status_code = status_code
self._json_data = json_data
self._text_data = text_data
def json(self):
return self._json_data
@property
def text(self):
if self._text_data is not None:
return self._text_data
# Best-effort string
return ""
class FakeSession:
"""
Test-only Podman session stub.
Routes by URL substring to return deterministic JSON.
"""
def __init__(self):
self.calls = []
def get(self, url: str):
self.calls.append(("GET", url))
# Pods list (DYNAMIC)
if "/libpod/pods/json" in url:
return FakeResponse(200, json_data=[])
# Containers list (DYNAMIC)
if "/libpod/containers/json" in url:
return FakeResponse(200, json_data=[])
# Container inspect (DYNAMIC)
if "/libpod/containers/" in url and url.endswith("/json"):
# Return some dict to prove JSON
return FakeResponse(200, json_data={"Id": "dummy"})
# Container logs (FIXED endpoint expects {"logs": "..."} from wrapper endpoint, not Podman directly,
# but app.py fetches Podman logs and then wraps into {"logs": text}.
if "/libpod/containers/" in url and "/logs?" in url:
return FakeResponse(200, json_data=None, text_data="line1\nline2\n")
# Fallback: return JSON dict
return FakeResponse(200, json_data={})
def post(self, url: str, **kwargs):
self.calls.append(("POST", url, kwargs))
# kube/play passthrough (workloads deploy)
if "/libpod/kube/play" in url:
# app.py gebruikt SESSION.post(url, data=yaml_content).json()
return FakeResponse(200, json_data={"kube": "played"})
# Container start/stop/restart: treat as success
if "/libpod/containers/" in url:
return FakeResponse(204, json_data={})
# Pod start/stop etc: treat as success
if "/libpod/pods/" in url:
return FakeResponse(200, json_data={"ok": True})
return FakeResponse(200, json_data={})
def delete(self, url: str):
self.calls.append(("DELETE", url))
return FakeResponse(200, json_data={"deleted": True})
@pytest.fixture(scope="session")
def app_module():
"""
Import app.py as module `app` from /app, while ensuring requests_unixsocket import is safe.
No runtime changes to app.py; only test-time monkeypatching later.
"""
# Ensure /app is importable
if "/app" not in sys.path:
sys.path.insert(0, "/app")
# If requests_unixsocket isn't installed or to avoid real socket usage, provide a minimal stub.
if "requests_unixsocket" not in sys.modules:
mod = types.ModuleType("requests_unixsocket")
mod.Session = lambda: FakeSession()
sys.modules["requests_unixsocket"] = mod
app = importlib.import_module("app")
return app
@pytest.fixture()
def client(app_module, monkeypatch, tmp_path):
"""
TestClient with all external side-effects stubbed:
- Podman socket calls via app.SESSION
- systemctl calls via app.run
- filesystem roots via WORKLOADS_DIR + ALLOWLIST_FILE
"""
# Prepare temp workload tree under tmp_path
workloads_dir = tmp_path / "workloads"
systemd_dir = workloads_dir / "systemd"
systemd_dir.mkdir(parents=True, exist_ok=True)
# A defined container (*.container) for /containers-dashboard defined entry
(systemd_dir / "sonarr.container").write_text("[Container]\nImage=dummy\n", encoding="utf-8")
# Files endpoints operate under WORKLOADS_DIR; UI focuses on systemd subtree
# Create a file for /files/read/save/delete
(systemd_dir / "test.txt").write_text("hello\n", encoding="utf-8")
# Create a non-empty dir for /files/rmdir 409 scenario
nonempty_dir = systemd_dir / "nonempty"
nonempty_dir.mkdir(parents=True, exist_ok=True)
(nonempty_dir / "keep.txt").write_text("x", encoding="utf-8")
# Create a yaml to create a defined pod entry for /pods-dashboard
(workloads_dir / "mediaserver.yaml").write_text("kind: Pod\nmetadata:\n name: mediaserver\n", encoding="utf-8")
# Allowlist file for /systemd/allowlist and allow-mode enforcement
allowlist_file = tmp_path / "allowed_units.txt"
allowlist_file.write_text("sonarr.service\nmediaserver.service\n", encoding="utf-8")
# Patch module globals to point at tmp filesystem (test-only)
monkeypatch.setattr(app_module, "WORKLOADS_DIR", str(workloads_dir))
monkeypatch.setattr(app_module, "ALLOWLIST_FILE", str(allowlist_file))
# Stub Podman session object
monkeypatch.setattr(app_module, "SESSION", FakeSession())
# Stub systemctl runner
def fake_run(cmd):
# cmd is a list, e.g. ["systemctl","--user","is-active","mediaserver.service"]
cmd_str = " ".join(cmd)
if "is-active mediaserver.service" in cmd_str:
return 0, "active"
if "is-active sonarr.service" in cmd_str:
return 0, "inactive"
# For POST /{action}/{unit}, return something stable
if cmd[:3] == ["systemctl", "--user", "status"]:
return 0, "Active: active (running)"
if cmd[:3] == ["systemctl", "--user", "daemon-reload"]:
return 0, "ok"
if cmd[:3] == ["systemctl", "--user", "start"]:
return 0, "started"
if cmd[:3] == ["systemctl", "--user", "stop"]:
return 0, "stopped"
if cmd[:3] == ["systemctl", "--user", "restart"]:
return 0, "restarted"
# fallback
return 0, "ok"
monkeypatch.setattr(app_module, "run", fake_run)
return TestClient(app_module.app)
@@ -1,165 +0,0 @@
import importlib
import importlib.util
from pathlib import Path
from fastapi.testclient import TestClient
def load_app_module():
try:
return importlib.import_module("app")
except ModuleNotFoundError:
app_path = Path("/app/app.py")
spec = importlib.util.spec_from_file_location("app", app_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ============================================================
# /containers/{action}/{name}
# ============================================================
def test_container_action_invalid_action_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/containers/invalid/dummy")
# Freeze whatever baseline currently is
body = r.json()
# Must be JSON and deterministic structure
assert isinstance(body, (dict, list))
# If dict variant
if isinstance(body, dict):
assert set(body.keys()) == {"error"}
assert body["error"] == "Invalid action"
def test_container_action_failure_shape_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/containers/start/nonexistent_container_12345")
body = r.json()
# Freeze exact variant structure
if isinstance(body, dict) and body.get("method") == "podman":
assert "name" in body
assert "cmd" in body
assert "status_code" in body
else:
# Any other baseline variant must still be JSON
assert isinstance(body, (dict, list))
# ============================================================
# /actions/{action}/{name}
# ============================================================
def test_legacy_actions_invalid_action_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/actions/invalid/dummy")
body = r.json()
assert isinstance(body, dict)
assert set(body.keys()) == {"status"}
assert body["status"] == "unknown"
# ============================================================
# /pods/actions/{action}/{podname}
# ============================================================
def test_pods_action_invalid_action_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/pods/actions/invalid/dummy")
body = r.json()
# Baseline may return tuple-style 400 or JSON
assert isinstance(body, (dict, list))
if isinstance(body, dict) and "error" in body:
assert body["error"] == "Invalid action"
def test_pods_action_variant_shape_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/pods/actions/start/nonexistent_pod_12345")
body = r.json()
# Freeze possible baseline variants
if isinstance(body, dict):
if body.get("method") == "systemd_then_podman":
assert set(body.keys()) == {"method", "note", "systemd", "podman"}
assert body["note"] == "systemd failed; falling back to podman"
elif body.get("method") == "podman":
assert set(body.keys()) == {"method", "result"}
else:
assert isinstance(body, list)
# ============================================================
# /{action}/{unit}
# ============================================================
def test_systemctl_wrapper_invalid_action_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/invalid/dummy.service")
body = r.json()
assert set(body.keys()) == {"detail"}
assert body["detail"] == "Invalid action"
def test_systemctl_wrapper_status_shape_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/status/nonexistent.service")
body = r.json()
# allowlist may cause 403
if r.status_code == 403:
assert set(body.keys()) == {"detail"}
else:
assert set(body.keys()) == {"cmd", "exit", "output"}
# ============================================================
# /api/<action>/<unit>
# ============================================================
def test_legacy_api_invalid_action_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/api/invalid/dummy.service")
body = r.json()
# Baseline: HTTPException 400 with {"detail": "..."}
assert r.status_code == 400
assert set(body.keys()) == {"detail"}
def test_legacy_api_status_shape_contract():
app_mod = load_app_module()
client = TestClient(app_mod.app)
r = client.post("/api/status/nonexistent.service")
body = r.json()
if r.status_code == 403:
assert set(body.keys()) == {"detail"}
else:
assert set(body.keys()) == {"cmd", "exit", "output"}
-69
View File
@@ -1,69 +0,0 @@
import importlib.util
import os
def load_app_module():
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
app_path = os.path.join(repo_root, "app.py")
spec = importlib.util.spec_from_file_location("app", app_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _iter_routes(app):
for r in getattr(app, "routes", []):
path = getattr(r, "path", None)
methods = getattr(r, "methods", None)
if not path or not methods:
continue
for m in methods:
if m in ("HEAD", "OPTIONS"):
continue
yield (m, path)
def test_contract_routes_exist_and_match_locked_baseline():
app_mod = load_app_module()
routes = set(_iter_routes(app_mod.app))
# Endpoint count exact (locked baseline)
assert len(routes) == 30
expected = {
# FastAPI built-ins (default)
("GET", "/openapi.json"),
("GET", "/docs"),
("GET", "/docs/oauth2-redirect"),
("GET", "/redoc"),
# App routes
("GET", "/workloads"),
("GET", "/workloads/read/{filename:path}"),
("POST", "/workloads/save-file"),
("POST", "/workloads/deploy/{filename:path}"),
("GET", "/files/tree"),
("GET", "/files/read"),
("POST", "/files/save"),
("DELETE", "/files/delete"),
("POST", "/files/mkdir"),
("DELETE", "/files/rmdir"),
("GET", "/pods"),
("POST", "/actions/{action}/{name}"),
("GET", "/pods-dashboard"),
("POST", "/pods/actions/{action}/{podname}"),
("GET", "/containers-dashboard"),
("GET", "/containers"),
("POST", "/containers/{action}/{name}"),
("GET", "/debug/defined-containers"),
("GET", "/dashboard"),
("GET", "/test-hybrid"),
("GET", "/containers/logs/{name}"),
("GET", "/containers/inspect/{name}"),
("GET", "/systemd/allowlist"),
("POST", "/daemon-reload"),
("POST", "/{action}/{unit}"),
("POST", "/api/<action>/<unit>"),
}
assert routes == expected
-124
View File
@@ -1,124 +0,0 @@
import importlib
import importlib.util
from pathlib import Path
from fastapi.testclient import TestClient
def load_app_module():
try:
return importlib.import_module("app")
except ModuleNotFoundError:
app_path = Path("/app/app.py")
spec = importlib.util.spec_from_file_location("app", app_path)
if spec is None or spec.loader is None:
raise
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_files_read_not_found_detail_string(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
workloads.mkdir()
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.get("/files/read", params={"path": "systemd/nope.txt"})
assert r.status_code == 404
assert r.json()["detail"] == "Not found"
def test_files_read_is_directory_detail_string(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
(workloads / "systemd").mkdir(parents=True)
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.get("/files/read", params={"path": "systemd"})
assert r.status_code == 403
assert r.json()["detail"] == "Is a directory"
def test_files_delete_directory_error_string(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
(workloads / "systemd").mkdir(parents=True)
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.delete("/files/delete", params={"path": "systemd"})
assert r.status_code == 400
assert r.json()["detail"] == "Kan niet verwijderen: is directory"
def test_files_mkdir_prefix_systemd_and_keyset(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
workloads.mkdir()
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.post("/files/mkdir", params={"path": "abc"})
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {"status", "path"}
assert body["path"] == "systemd/abc"
def test_files_rmdir_refuse_root_detail_string(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
workloads.mkdir()
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.delete("/files/rmdir", params={"path": "systemd"})
assert r.status_code == 400
assert r.json()["detail"] == "Refusing to delete systemd root"
def test_files_rmdir_only_systemd_subtree_detail_string(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
workloads.mkdir()
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.delete("/files/rmdir", params={"path": "not_systemd/x"})
assert r.status_code == 400
assert r.json()["detail"] == "Only systemd subtree is allowed"
def test_files_rmdir_nonempty_409_detail_shape_exact(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
d = workloads / "systemd" / "dir1"
d.mkdir(parents=True)
(d / "a.txt").write_text("x", encoding="utf-8")
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.delete("/files/rmdir", params={"path": "systemd/dir1"})
assert r.status_code == 409
body = r.json()
assert set(body.keys()) == {"detail"}
detail = body["detail"]
assert set(detail.keys()) == {"error", "dirs", "files"}
assert detail["error"] == "directory not empty"
assert "a.txt" in detail["files"]
def test_files_safe_join_forbidden_path_detail_string(tmp_path, monkeypatch):
app_mod = load_app_module()
workloads = tmp_path / "workloads"
workloads.mkdir()
monkeypatch.setattr(app_mod, "WORKLOADS_DIR", str(workloads))
client = TestClient(app_mod.app)
r = client.get("/files/read", params={"path": "../escape.txt"})
assert r.status_code == 403
assert r.json()["detail"] == "Forbidden path"
-733
View File
@@ -1,733 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MVP Control UI</title>
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
</div>
</div>
</header>
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Image</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
refreshActive();
}
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = getSystemdUnitsFromUI();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(c => {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const image = c.Image || c.image || '';
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(image)}</td>
<td>
<div class="flex">
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
function getSystemdUnitsFromUI() {
let units = [];
try { units = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch {}
if (!Array.isArray(units) || units.length === 0) units = ["demo1.service","demo2.service","sonarr.service"];
// sync textarea
const ta = document.getElementById('systemdUnits');
if (ta && ta.value.trim().length === 0) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = getSystemdUnitsFromUI();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = getSystemdUnitsFromUI();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// ---- Init ----
(function init(){
// preload systemd units UI
const units = getSystemdUnitsFromUI();
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// first refresh
refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>
-739
View File
@@ -1,739 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MVP Control UI</title>
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
</div>
</div>
</header>
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>Image</th>
<th>Published port</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
refreshActive();
}
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = await getSystemdUnitsFromServer();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(c => {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const ports = (c.Ports || []).map(p =>
`${p.host_port}:${p.container_port}`
).join(", ");
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
async function getSystemdUnitsFromServer() {
const data = await api('/systemd/allowlist', 'GET');
const units = Array.isArray(data.units) ? data.units : [];
// vul textarea ook
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = await getSystemdUnitsFromServer();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// ---- Init ----
(function init(){
// preload systemd units UI
const units = getSystemdUnitsFromServer();
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// first refresh
refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>
-738
View File
@@ -1,738 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MVP Control UI</title>
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
</div>
</div>
</header>
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>Image</th>
<th>Published port</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
refreshActive();
}
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = await getSystemdUnitsFromServer();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(c => {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const ports = (c.Ports || []).map(p =>
`${p.host_port}:${p.container_port}`
).join(", ");
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
async function getSystemdUnitsFromServer() {
const data = await api('/systemd/allowlist', 'GET');
const units = Array.isArray(data.units) ? data.units : [];
// vul textarea ook
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = await getSystemdUnitsFromServer();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// ---- Init ----
(async function init(){
// preload systemd units UI
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// first refresh
await refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>
-738
View File
@@ -1,738 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MVP Control UI</title>
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
</div>
</div>
</header>
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>Image</th>
<th>Published port</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
refreshActive();
}
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = await getSystemdUnitsFromServer();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(c => {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const ports = (c.Ports || []).map(p =>
`${p.host_port}:${p.container_port}`
).join(", ");
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
async function getSystemdUnitsFromServer() {
const data = await api('/systemd/allowlist', 'GET');
const units = Array.isArray(data.units) ? data.units : [];
// vul textarea ook
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = await getSystemdUnitsFromServer();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// ---- Init ----
(async function init(){
// preload systemd units UI
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// first refresh
await refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>
-738
View File
@@ -1,738 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MVP Control UI</title>
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
</div>
</div>
</header>
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>Image</th>
<th>Published port</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
refreshActive();
}
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods-dashboard','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = await getSystemdUnitsFromServer();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods-dashboard','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers-dashboard', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(c => {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const ports = (c.Ports || []).map(p =>
`${p.host_port}:${p.container_port}`
).join(", ");
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
async function getSystemdUnitsFromServer() {
const data = await api('/systemd/allowlist', 'GET');
const units = Array.isArray(data.units) ? data.units : [];
// vul textarea ook
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = await getSystemdUnitsFromServer();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// ---- Init ----
(async function init(){
// preload systemd units UI
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// first refresh
await refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>
-741
View File
@@ -1,741 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MVP Control UI</title>
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
</div>
</div>
</header>
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>Image</th>
<th>Managed</th>
<th>Published port</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
refreshActive();
}
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods-dashboard','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = await getSystemdUnitsFromServer();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods-dashboard','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers-dashboard', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(c => {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const managed = c._dashboard_source || 'podman';
const ports = (c.Ports || []).map(p =>
`${p.host_port}:${p.container_port}`
).join(", ");
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${badgeFromStatus(managed)}</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
async function getSystemdUnitsFromServer() {
const data = await api('/systemd/allowlist', 'GET');
const units = Array.isArray(data.units) ? data.units : [];
// vul textarea ook
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = await getSystemdUnitsFromServer();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// ---- Init ----
(async function init(){
// preload systemd units UI
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// first refresh
await refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>
-915
View File
@@ -1,915 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MVP Control UI</title>
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
<div class="tab" id="tab-files" onclick="setTab('files')">Files</div>
</div>
</div>
</header>
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>Image</th>
<th>Managed</th>
<th>Published port</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="view-files" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Files (systemd)</div>
<div class="flex">
<button class="btn" onclick="filesRefresh()">Ververs</button>
<button class="btn" onclick="filesNewFolder()">Nieuwe map</button>
<button class="btn ok" onclick="filesNewFile()">Nieuw bestand</button>
<button class="btn ok" onclick="filesSave()">Opslaan</button>
<button class="btn bad" onclick="filesDelete()">Verwijderen</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<!-- Links: tree -->
<div>
<div class="muted" style="margin-bottom:8px">
Alleen onder <span class="mono">~/.config/containers/systemd</span> (systemd wordt niet getoond in paden).
</div>
<div id="filesTree" class="input" style="min-height:360px; overflow:auto; padding:12px"></div>
<div class="hint">Klik op een bestand om te openen.</div>
</div>
<!-- Rechts: editor -->
<div>
<div class="muted" style="margin-bottom:8px">
Huidig bestand: <span class="mono" id="filesCurrent">-</span>
</div>
<textarea id="filesEditor" class="textarea mono" spellcheck="false" placeholder="Selecteer links een bestand..."></textarea>
<div class="hint">
Na wijzigen van <span class="mono">*.container</span> moet je meestal <span class="mono">daemon-reload</span> doen (kan via Systemd-tab of dashboard-knop).
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
if (tab === 'files') {
filesRefresh();
}
refreshActive();
}
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods-dashboard','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = await getSystemdUnitsFromServer();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods-dashboard','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers-dashboard', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(c => {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const managed = c._dashboard_source || 'podman';
const ports = (c.Ports || []).map(p =>
`${p.host_port}:${p.container_port}`
).join(", ");
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${badgeFromStatus(managed)}</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
async function getSystemdUnitsFromServer() {
const data = await api('/systemd/allowlist', 'GET');
const units = Array.isArray(data.units) ? data.units : [];
// vul textarea ook
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = await getSystemdUnitsFromServer();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// =========================
// Files tab (systemd subtree)
// =========================
const FILES_ROOT = 'systemd'; // API-root binnen WORKLOADS_DIR
let filesCurrentUiPath = ''; // zonder "systemd/"
let filesCurrentApiPath = ''; // met "systemd/"
function filesToApiPath(uiPath) {
let p = (uiPath || '').trim().replace(/^\/+/, '');
if (!p) return FILES_ROOT;
if (p === FILES_ROOT || p.startsWith(FILES_ROOT + '/')) return p;
return `${FILES_ROOT}/${p}`;
}
function filesToUiPath(apiPath) {
const p = (apiPath || '').trim().replace(/^\/+/, '');
return p.replace(new RegExp('^' + FILES_ROOT + '/?'), '');
}
function filesSetCurrent(uiPath) {
filesCurrentUiPath = (uiPath || '').trim().replace(/^\/+/, '');
filesCurrentApiPath = filesToApiPath(filesCurrentUiPath);
document.getElementById('filesCurrent').textContent = filesCurrentUiPath || '-';
}
async function filesRefresh() {
const treeEl = document.getElementById('filesTree');
treeEl.textContent = 'Laden...';
const data = await api('/files/tree', 'GET');
// Filter alleen systemd subtree
const scoped = (data || []).filter(folder => {
const p = (folder.path || '').replace(/^\/+/, '');
return p === FILES_ROOT || p.startsWith(FILES_ROOT + '/');
});
if (!scoped.length) {
treeEl.textContent = 'Geen bestanden gevonden onder systemd.';
return;
}
// Render simpel: folders met files eronder
const parts = [];
for (const folder of scoped) {
const apiFolderPath = (folder.path || '').replace(/^\/+/, '');
const uiFolderPath = filesToUiPath(apiFolderPath); // zonder systemd/
const folderLabel = uiFolderPath || 'root';
parts.push(`<div class="mono" style="margin:8px 0 6px 0; font-weight:600;">📂 ${esc(folderLabel)}</div>`);
const files = folder.files || [];
if (!files.length) {
parts.push(`<div class="muted" style="margin-left:18px;">(leeg)</div>`);
continue;
}
for (const f of files) {
const fullUi = uiFolderPath ? `${uiFolderPath}/${f}` : f;
parts.push(`
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-left:10px;padding:4px 0;border-bottom:1px dashed rgba(36,52,95,.35)">
<span class="mono" style="cursor:pointer" onclick="filesOpen('${esc(fullUi)}')">📄 ${esc(f)}</span>
</div>
`);
}
}
treeEl.innerHTML = parts.join('');
}
async function filesOpen(uiPath) {
const editor = document.getElementById('filesEditor');
filesSetCurrent(uiPath);
const res = await api(`/files/read?path=${encodeURIComponent(filesCurrentApiPath)}`, 'GET');
editor.value = res.content || '';
}
async function filesSave() {
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
return showModal('Files', 'Selecteer eerst een bestand.');
}
const editor = document.getElementById('filesEditor');
const content = editor.value ?? '';
const res = await api(`/files/save?path=${encodeURIComponent(filesCurrentApiPath)}`, 'POST', { content });
showModal('Opgeslagen', JSON.stringify(res, null, 2));
await filesRefresh();
}
async function filesDelete() {
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
return showModal('Files', 'Selecteer eerst een bestand om te verwijderen.');
}
if (!confirm(`Verwijderen: ${filesCurrentUiPath}?`)) return;
const res = await api(`/files/delete?path=${encodeURIComponent(filesCurrentApiPath)}`, 'DELETE');
showModal('Verwijderd', JSON.stringify(res, null, 2));
// reset current
filesSetCurrent('');
document.getElementById('filesEditor').value = '';
await filesRefresh();
}
async function filesNewFolder() {
const ui = prompt('Nieuwe map (onder systemd):\nVoorbeeld: mediaserver', '');
if (!ui) return;
const apiPath = filesToApiPath(ui);
const res = await api(`/files/mkdir?path=${encodeURIComponent(apiPath)}`, 'POST');
showModal('Map aangemaakt', JSON.stringify(res, null, 2));
await filesRefresh();
}
async function filesNewFile() {
const ui = prompt('Nieuw bestand (onder systemd):\nVoorbeeld: demo-web/demo-web.container', '');
if (!ui) return;
const apiPath = filesToApiPath(ui);
// Maak het bestand aan met lege content (of minimale comment)
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "# nieuw bestand\n" });
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
// Open direct
filesSetCurrent(ui);
document.getElementById('filesEditor').value = "# nieuw bestand\n";
await filesRefresh();
}
// ---- Init ----
(async function init(){
// preload systemd units UI
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// first refresh
await refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-329
View File
@@ -1,329 +0,0 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Podman MVP | Dashboard V4.5</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/theme/material-darker.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/mode/yaml/yaml.min.js"></script>
<style>
:root {
--sidebar-width: 250px;
--portainer-blue: #00b3e6;
--bg-dark: #2c3e50;
--bg-light: #f4f6f9;
--border-color: #dce1e5;
}
body { font-family: 'Segoe UI', sans-serif; margin: 0; display: flex; background: var(--bg-light); height: 100vh; overflow: hidden; }
.sidebar { width: var(--sidebar-width); background: var(--bg-dark); color: white; display: flex; flex-direction: column; }
.sidebar-header { padding: 15px 20px; background: #1a252f; font-weight: bold; display: flex; align-items: center; gap: 10px; }
.sidebar-header img { width: 30px; }
.nav-item { padding: 15px 20px; cursor: pointer; border-left: 4px solid transparent; transition: 0.2s; }
.nav-item:hover { background: #34495e; }
.nav-item.active { background: #34495e; border-left-color: var(--portainer-blue); color: var(--portainer-blue); }
.main-content { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
.topbar { background: white; padding: 15px 30px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
/* Navigatie logica */
.view-container { padding: 25px; display: none; }
.view-container.active { display: block; }
.card { background: white; border-radius: 4px; border: 1px solid var(--border-color); margin-bottom: 20px; }
.card-header { padding: 12px 20px; border-bottom: 1px solid var(--border-color); font-weight: bold; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 15px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 12px; border-bottom: 2px solid var(--border-color); font-size: 0.8rem; text-transform: uppercase; color: #7f8c8d; }
td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 0.9rem; }
.badge { padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; }
.badge-running { background: #d4edda; color: #155724; }
.badge-stopped { background: #f8d7da; color: #721c24; }
.btn { padding: 5px 10px; border-radius: 3px; border: 1px solid #ccc; cursor: pointer; font-size: 0.8rem; background: white; transition: 0.2s; }
.btn:hover { background: #f8f9fa; border-color: #bbb; }
.btn-primary { background: var(--portainer-blue); color: white; border: none; padding: 8px 15px; font-weight: bold; }
/* Modal Styles */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; }
.modal-content { background: white; width: 80%; max-height: 80%; border-radius: 8px; display: flex; flex-direction: column; position: relative; }
.modal-header { padding: 15px 20px; border-bottom: 1px solid #ddd; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
.log-container { background: #1e1e1e; color: #d4d4d4; padding: 15px; font-family: 'Consolas', monospace; font-size: 0.85rem; border-radius: 4px; white-space: pre-wrap; }
.inspect-container { background: #f8f9fa; padding: 15px; font-family: monospace; font-size: 0.85rem; border: 1px solid #ddd; border-radius: 4px; }
/* Studio Layout */
.studio-layout { display: flex; gap: 20px; height: calc(100vh - 280px); }
.file-tree { width: 280px; border-right: 1px solid #eee; overflow-y: auto; padding-right: 10px; }
.editor-container { flex: 1; display: flex; flex-direction: column; }
.CodeMirror { flex: 1; border: 1px solid #ddd; font-size: 13px; }
.tree-item { display: flex; justify-content: space-between; align-items: center; padding: 5px 8px; border-radius: 4px; margin-bottom: 2px; }
.tree-item:hover { background: #f0f2f5; }
.tree-folder { font-weight: bold; color: #2c3e50; background: #eaeff2; margin-top: 10px; }
.tree-file { cursor: pointer; color: #2980b9; }
.icon-btn { cursor: pointer; opacity: 0.6; transition: 0.2s; font-style: normal; }
.icon-btn:hover { opacity: 1; transform: scale(1.1); }
</style>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<img src="https://raw.githubusercontent.com/containers/common/main/logos/podman-logo.svg" alt="Podman">
<span>Podman MVP</span>
</div>
<div class="nav-item active" id="nav-dashboard" onclick="showView('dashboard')">Dashboard</div>
<div class="nav-item" id="nav-containers" onclick="showView('containers')">Containers</div>
<div class="nav-item" id="nav-pods" onclick="showView('pods')">Pods</div>
<div class="nav-item" id="nav-workloads" onclick="showView('workloads')">Workload Studio</div>
</div>
<div class="main-content">
<div class="topbar">
<span id="view-title" style="font-weight: bold; color: #34495e;">DASHBOARD</span>
<button class="btn" onclick="refresh()">🔄 Ververs Data</button>
</div>
<div id="dashboard-view" class="view-container active">
<div class="card">
<div class="card-header">Beheerde Workloads</div>
<div class="card-body">
<table>
<thead><tr><th>Naam</th><th>Status</th><th>IP</th><th>Acties</th></tr></thead>
<tbody id="dashboard-list"></tbody>
</table>
</div>
</div>
</div>
<div id="containers-view" class="view-container">
<div class="card">
<div class="card-header">Live Containers</div>
<div class="card-body">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>IP</th>
<th>Poorten</th>
<th>Quick Actions</th>
</tr>
</thead>
<tbody id="live-container-list"></tbody>
</table>
</div>
</div>
</div>
<div id="pods-view" class="view-container">
<div class="card">
<div class="card-header">Active Pods</div>
<div class="card-body">
<table>
<thead><tr><th>Pod Naam</th><th>Status</th><th>Containers</th></tr></thead>
<tbody id="pod-list"></tbody>
</table>
</div>
</div>
</div>
<div id="workloads-view" class="view-container">
<div class="card">
<div class="card-header"><span>Bestandsbeheer</span><button class="btn" onclick="createNewMap()">📁 Nieuwe Hoofdmap</button></div>
<div class="card-body studio-layout">
<div id="file-tree" class="file-tree">Laden...</div>
<div class="editor-container">
<div id="editor-header" style="padding: 10px; background: #34495e; color: white; font-family: monospace; font-size: 0.8rem; border-radius: 4px 4px 0 0;">Selecteer een bestand...</div>
<textarea id="edit-content"></textarea>
<div style="margin-top:15px; display: flex; justify-content: flex-end;"><button class="btn btn-primary" onclick="saveFile()">💾 Bestand Opslaan</button></div>
</div>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-overlay" onclick="closeModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<span id="modal-title">Info</span>
<button class="btn" onclick="closeModal()">✖ Sluiten</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<script>
let editor;
let currentPath = '';
window.onload = () => {
editor = CodeMirror.fromTextArea(document.getElementById("edit-content"), {
mode: "yaml", theme: "material-darker", lineNumbers: true, tabSize: 2, indentWithTabs: false
});
refresh();
};
async function api(path, method = 'GET', body = null) {
const options = { method, headers: body ? {'Content-Type': 'application/json'} : {} };
if (body) options.body = JSON.stringify(body);
const res = await fetch('/api' + path, options);
return await res.json();
}
// --- NAVIGATIE HERSTELD ---
function showView(view) {
document.querySelectorAll('.view-container').forEach(v => {
v.style.display = 'none';
v.classList.remove('active');
});
const selected = document.getElementById(view + '-view');
if (selected) {
selected.style.display = 'block';
selected.classList.add('active');
}
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const nav = document.getElementById('nav-' + view);
if (nav) nav.classList.add('active');
document.getElementById('view-title').innerText = view.toUpperCase();
if(view === 'workloads') fetchTree();
}
async function fetchDashboard() {
const data = await api('/dashboard');
document.getElementById('dashboard-list').innerHTML = data.map(w => `
<tr><td><strong>${w.name}</strong></td>
<td><span class="badge ${w.status === 'running' ? 'badge-running' : 'badge-stopped'}">${w.status}</span></td>
<td>${w.ip || '-'}</td>
<td><button class="btn" onclick="runAction('start', '${w.name}')">▶</button> <button class="btn" onclick="runAction('stop', '${w.name}')">⏹</button></td></tr>`).join('');
}
// --- CONTAINERS MET POORTEN EN ACTIONS ---
async function fetchContainers() {
const data = await api('/containers');
const list = document.getElementById('live-container-list');
if (!data) return;
list.innerHTML = '';
const realContainers = data.filter(c => !c.IsInfra);
realContainers.forEach(c => {
const name = c.Names[0].replace('/', '');
const ip = c.Networks ? Object.values(c.Networks)[0]?.IPAddress || '-' : '-';
let portMapping = '-';
if (c.Ports && c.Ports.length > 0) {
portMapping = c.Ports.map(p => {
const host = p.host_port;
const container = p.container_port;
return host ? `${host}:${container}` : `${container}/${p.protocol}`;
}).join(', ');
}
list.innerHTML += `
<tr>
<td><strong>${name}</strong></td>
<td><span class="badge ${c.State==='running'?'badge-running':'badge-stopped'}">${c.State}</span></td>
<td>${c.PodName || '-'}</td>
<td>${ip}</td>
<td style="font-family:monospace; font-size:0.8rem;">${portMapping}</td>
<td>
<button class="btn" title="Logs" onclick="showLogs('${name}')">📜</button>
<button class="btn" title="Inspect" onclick="showInspect('${name}')">🔍</button>
</td>
</tr>`;
});
}
async function showLogs(name) {
document.getElementById('modal-title').innerText = `Logs: ${name}`;
document.getElementById('modal-body').innerHTML = '<div class="log-container">Laden...</div>';
document.getElementById('modal').style.display = 'flex';
const data = await api(`/containers/logs/${name}`);
const cleanLogs = data.logs.replace(/[\u0000-\u0008]/g, "");
document.getElementById('modal-body').innerHTML = `<div class="log-container">${cleanLogs || 'Geen logs gevonden.'}</div>`;
}
async function showInspect(name) {
document.getElementById('modal-title').innerText = `Inspect: ${name}`;
document.getElementById('modal-body').innerHTML = '<div class="inspect-container">Laden...</div>';
document.getElementById('modal').style.display = 'flex';
const data = await api(`/containers/inspect/${name}`);
document.getElementById('modal-body').innerHTML = `<pre class="inspect-container">${JSON.stringify(data, null, 2)}</pre>`;
}
function closeModal() { document.getElementById('modal').style.display = 'none'; }
async function fetchTree() {
const data = await api('/files/tree');
const tree = document.getElementById('file-tree');
tree.innerHTML = '';
data.forEach(folder => {
const folderName = folder.path || "root";
tree.innerHTML += `<div class="tree-item tree-folder"><span>📂 ${folderName}</span><div style="display:flex;gap:8px;"><i class="icon-btn" onclick="createFileIn('${folder.path}')"></i><i class="icon-btn" style="color:red" onclick="deleteItem('${folder.path}')">🗑</i></div></div>`;
folder.files.forEach(f => {
const full = folder.path ? `${folder.path}/${f}` : f;
tree.innerHTML += `<div class="tree-item ml-4"><span class="tree-file" onclick="loadFile('${full}')">📄 ${f}</span><i class="icon-btn" style="color:red" onclick="deleteItem('${full}')">🗑</i></div>`;
});
});
}
async function loadFile(path) {
currentPath = path;
const data = await api(`/files/read?path=${encodeURIComponent(path)}`);
editor.setValue(data.content);
document.getElementById('editor-header').innerText = `BESTAND: ${path}`;
}
async function saveFile() {
if(!currentPath) return alert("Selecteer bestand!");
await api(`/files/save?path=${encodeURIComponent(currentPath)}`, 'POST', {content: editor.getValue()});
alert("Opgeslagen!");
fetchTree();
}
async function createFileIn(folderPath) {
const name = prompt(`Naam in ${folderPath || 'root'}:`, "service.yaml");
if(name) {
const full = folderPath ? `${folderPath}/${name}` : name;
const template = "version: '1.0'\nservices:\n ";
await api(`/files/save?path=${encodeURIComponent(full)}`, 'POST', {content: template});
fetchTree();
}
}
async function createNewMap() {
const path = prompt("Nieuwe hoofdmap:");
if(path) { await api(`/files/mkdir?path=${encodeURIComponent(path)}`, 'POST'); fetchTree(); }
}
async function deleteItem(path) {
if(!path || !confirm(`Verwijderen: ${path}?`)) return;
const res = await api(`/files/delete?path=${encodeURIComponent(path)}`, 'DELETE');
if(res.status === 'deleted') fetchTree();
else if(res.detail) alert(res.detail);
}
async function fetchPods() {
const data = await api('/pods');
document.getElementById('pod-list').innerHTML = data.map(p => `<tr><td>${p.Name}</td><td><span class="badge ${p.Status==='Running'?'badge-running':'badge-stopped'}">${p.Status}</span></td><td>${p.Containers.length}</td></tr>`).join('');
}
async function runAction(action, name) {
await api(`/actions/${action}/${name}`, 'POST');
setTimeout(refresh, 1200);
}
function refresh() {
fetchDashboard();
fetchContainers();
fetchPods();
if(document.getElementById('workloads-view').classList.contains('active')) fetchTree();
}
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long