Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94a2f4586a | |||
| 7d2f19f81f | |||
| fba9b59445 | |||
| 2dfe53895b | |||
| 5f6719464d | |||
| 249d24721c | |||
| f8bbb783b0 | |||
| 4404c02967 | |||
| bae6fd8b9f | |||
| ed94ee31f4 | |||
| 5196e7840f | |||
| a05d79ae2c | |||
| 5e7d1b887c | |||
| e469508570 | |||
| c338955320 | |||
| f016c2bae0 |
@@ -93,9 +93,8 @@ podman run -d --pod mvp-pod \
|
||||
--ipc=host \
|
||||
--pid=host \
|
||||
-e XDG_RUNTIME_DIR=/run/user/1000 \
|
||||
-e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
|
||||
-v /run/user/1000:/run/user/1000:rw \
|
||||
-v /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock:rw \
|
||||
-v /run/user/1000/podman-mvp:/run/podman-mvp \
|
||||
-v /home/kodi/.config/containers:/app/workloads:rw \
|
||||
mvp-control:latest
|
||||
|
||||
@@ -103,7 +102,7 @@ podman run -d --pod mvp-pod \
|
||||
Important notes:
|
||||
- Backend communicates with Podman through unix socket.
|
||||
- User-session Podman is used (not root).
|
||||
- DBus access is required.
|
||||
- D-Bus is NOT required — alle systemctl-acties gaan via podman-helper.
|
||||
- Host PID/IPC namespaces are intentional.
|
||||
|
||||
Do NOT change these assumptions without proposal.
|
||||
|
||||
+3
-1
@@ -44,6 +44,7 @@ Single deployable backend service, split into modules (routers) by domain.
|
||||
- `control/app_networks.py` — networks tab endpoints
|
||||
- `control/app_files.py` — files/workloads endpoints (tree/read/save/etc.)
|
||||
- `control/app_images.py` — images endpoints
|
||||
- `control/app_volumes.py` — volumes endpoints (list/create/delete/prune/exists)
|
||||
|
||||
4. **Shared Infrastructure Layer**
|
||||
- `control/common.py`
|
||||
@@ -73,10 +74,11 @@ After any change affecting backend routing or shared helpers, run:
|
||||
```bash
|
||||
python3 -m py_compile control/app.py control/common.py control/app_system.py \
|
||||
control/app_containers.py control/app_pods.py control/app_networks.py \
|
||||
control/app_files.py control/app_images.py
|
||||
control/app_files.py control/app_images.py control/app_volumes.py
|
||||
|
||||
curl -fsS http://127.0.0.1:8081/api/health | jq
|
||||
curl -fsS http://127.0.0.1:8081/api/pods-dashboard >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/containers-dashboard >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/files/tree >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/volumes >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/networks/meta | jq
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
podman-mvp is a Portainer-like web dashboard for managing rootless user-session Podman containers. It runs as a two-container Podman pod: a FastAPI backend (`mvp-backend`) that talks to Podman over a Unix socket, and a static Apache frontend (`mvp-webui`) that reverse-proxies `/api/` to the backend.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend — FastAPI modular monolith (`control/`)
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `app.py` | Bootstrap only — creates FastAPI app, wires routers, no feature logic |
|
||||
| `common.py` | Shared helpers: Podman HTTP, systemctl, utilities |
|
||||
| `app_system.py` | System/platform router: `/health`, `/daemon-reload`, systemctl unit actions |
|
||||
| `app_containers.py` | Containers router: dashboard, inspect, logs, stats stream, exec sessions |
|
||||
| `app_pods.py` | Pods router: dashboard, pod actions |
|
||||
| `app_networks.py` | Networks router |
|
||||
| `app_images.py` | Images router |
|
||||
| `app_volumes.py` | Volumes router: list, create, delete, prune, exists |
|
||||
| `app_files.py` | Files/workloads router: tree, read, save |
|
||||
|
||||
Backend communicates with Podman through the Unix socket at `/run/user/1000/podman/podman.sock` using `requests_unixsocket`. Podman API base: `http+unix://%2Frun%2Frun%2Fuser%2F1000%2Fpodman%2Fpodman.sock/v5.4.2`.
|
||||
|
||||
### Frontend — Static Apache (`webui/`)
|
||||
|
||||
- `webui/html/index.html` — single-page app shell
|
||||
- `webui/html/assets/js/tabs/` — per-tab JavaScript modules (containers, networks, images, volumes, files)
|
||||
- `webui/conf/httpd.conf` — Apache config, proxies `/api/` → `http://127.0.0.1:8000/api/`
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```bash
|
||||
# Build backend image
|
||||
podman build -t mvp-control:latest control/
|
||||
|
||||
# Create pod
|
||||
podman pod create --name mvp-pod -p 8080:8000 -p 8081:8081 --userns=keep-id
|
||||
|
||||
# Run backend
|
||||
podman run -d --pod mvp-pod --name mvp-backend \
|
||||
--ipc=host --pid=host \
|
||||
-e XDG_RUNTIME_DIR=/run/user/1000 \
|
||||
-v /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock:rw \
|
||||
-v /run/user/1000/podman-mvp:/run/podman-mvp \
|
||||
-v /home/kodi/.config/containers:/app/workloads:rw \
|
||||
mvp-control:latest
|
||||
|
||||
# Run frontend
|
||||
podman run -d --pod mvp-pod --name mvp-webui \
|
||||
-v $HOME/.config/podman-mvp/webui/html:/usr/local/apache2/htdocs:ro \
|
||||
-v $HOME/.config/podman-mvp/webui/conf/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro \
|
||||
docker.io/library/httpd:2.4
|
||||
```
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Syntax check all backend modules
|
||||
python3 -m py_compile control/app.py control/common.py control/app_system.py \
|
||||
control/app_containers.py control/app_pods.py control/app_networks.py \
|
||||
control/app_files.py control/app_images.py control/app_volumes.py
|
||||
|
||||
# Smoke test key endpoints (all via proxy on :8081)
|
||||
curl -fsS http://127.0.0.1:8081/api/health | jq
|
||||
curl -fsS http://127.0.0.1:8081/api/containers-dashboard >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/pods-dashboard >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/files/tree >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/volumes >/dev/null && echo OK
|
||||
curl -fsS http://127.0.0.1:8081/api/networks/meta | jq
|
||||
```
|
||||
|
||||
All test/verification URLs must target `127.0.0.1:8081` (the proxy), not port 8000 directly.
|
||||
|
||||
## Health Check (`/api/health`)
|
||||
|
||||
`GET /api/health` geeft drie deelresultaten terug:
|
||||
|
||||
| Veld | Wat het meet | Techniek |
|
||||
|---|---|---|
|
||||
| `podman.ok` | Podman API bereikbaar | HTTP GET `/libpod/info` op Unix socket |
|
||||
| `helper.ok` | podman-helper socket bereikbaar | TCP connect op `/run/podman-mvp/podman-helper.sock` |
|
||||
| `systemd_user.reachable` | Afgeleid van `helper.ok` | Identiek — helper draait als host-user en voert `systemctl --user` uit, dus bereikbaarheid van helper impliceert bereikbaarheid van systemd |
|
||||
|
||||
`ok` (toplevel) is `true` als én `podman.ok` én `helper.ok` waar zijn.
|
||||
|
||||
De container voert zelf **geen** `systemctl --user` of D-Bus aanroepen uit. Alle systemctl-acties (start/stop/restart/daemon-reload) gaan via de helper-socket. D-Bus en `/run/user/1000/bus` zijn niet gemount.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
### Module placement
|
||||
- `app.py` is bootstrap-only — no endpoints, no feature logic, no Podman/systemctl calls.
|
||||
- New system/platform endpoints → `app_system.py`.
|
||||
- New domain feature endpoints → the corresponding `app_<domain>.py`.
|
||||
- Shared helpers → `common.py`, never duplicated into routers.
|
||||
- `allow_list` / `allowed_units.txt` has been removed and must NOT be reintroduced.
|
||||
- `app_system.py` broad wildcard routes (`/{action}/{unit}`) must be defined **last**.
|
||||
|
||||
### API contract (`contracts/API_GOLDEN.md`)
|
||||
- Never remove or rename existing JSON response keys.
|
||||
- Never change existing key data types.
|
||||
- Extend via new optional fields or new endpoints only.
|
||||
- UI-critical endpoints requiring pre-approval before any change: `/containers-dashboard`, `/pods-dashboard`, `/images`, `/networks/meta`.
|
||||
|
||||
### Security
|
||||
- No `shell=True` in subprocess calls.
|
||||
- All subprocess commands must be explicit lists.
|
||||
|
||||
### Infrastructure (propose before changing)
|
||||
- Pod name, port mappings, `userns=keep-id`.
|
||||
- DBus/XDG_RUNTIME_DIR mounts, Podman socket path, host PID/IPC namespaces.
|
||||
- `control/Containerfile`, `webui/conf/httpd.conf`.
|
||||
|
||||
## Change Workflow
|
||||
|
||||
For non-trivial changes, follow PR_RULES.md:
|
||||
1. Analyse existing behaviour with curl.
|
||||
2. Propose minimal plan identifying affected files.
|
||||
3. Confirm API contract safety.
|
||||
4. Provide curl validation commands showing expected output change.
|
||||
5. Implement after agreement.
|
||||
|
||||
Minimize diff size. Do not reformat unrelated code. No large rewrites or hidden refactors.
|
||||
+6
-5
@@ -19,15 +19,16 @@ Do not change without agreement:
|
||||
|
||||
Backend runtime assumptions:
|
||||
|
||||
- DBUS_SESSION_BUS_ADDRESS usage
|
||||
- XDG_RUNTIME_DIR mounts
|
||||
- Podman unix socket access
|
||||
- /run/user/1000 mounts
|
||||
- XDG_RUNTIME_DIR=/run/user/1000 (env var voor Podman socket pad)
|
||||
- Podman unix socket: /run/user/1000/podman/podman.sock
|
||||
- Helper socket directory: /run/user/1000/podman-mvp → /run/podman-mvp
|
||||
- host PID namespace
|
||||
- host IPC namespace
|
||||
|
||||
Reason:
|
||||
Backend communicates with user-session Podman and systemd.
|
||||
Backend communicates with user-session Podman via unix socket.
|
||||
Alle systemctl-acties (start/stop/restart/daemon-reload) gaan via
|
||||
podman-helper. D-Bus is niet gemount.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -149,6 +149,74 @@ Golden example:
|
||||
}
|
||||
|
||||
|
||||
==================================================
|
||||
GET /api/volumes
|
||||
==================================================
|
||||
|
||||
Curl:
|
||||
curl -s http://127.0.0.1:8081/api/volumes
|
||||
|
||||
Response type:
|
||||
Array of Podman volume objects (raw Podman passthrough).
|
||||
|
||||
Golden keys per item:
|
||||
- Name
|
||||
- Driver
|
||||
- Mountpoint
|
||||
- CreatedAt
|
||||
- Labels
|
||||
|
||||
Golden example:
|
||||
[
|
||||
{
|
||||
"Name": "my-volume",
|
||||
"Driver": "local",
|
||||
"Mountpoint": "/home/kodi/.local/share/containers/storage/volumes/my-volume/_data",
|
||||
"CreatedAt": "2026-03-01T12:00:00Z",
|
||||
"Labels": {}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
==================================================
|
||||
POST /api/volumes
|
||||
==================================================
|
||||
|
||||
Request body (JSON):
|
||||
- name (string, required)
|
||||
- driver (string, optional, default "local")
|
||||
- labels (object, optional)
|
||||
- driverOpts (object, optional)
|
||||
|
||||
Response: created volume object (raw Podman passthrough).
|
||||
|
||||
|
||||
==================================================
|
||||
DELETE /api/volumes/{name}
|
||||
==================================================
|
||||
|
||||
Response on success (204 from Podman):
|
||||
{"ok": true}
|
||||
|
||||
Error responses forwarded from Podman (e.g. 409 if in use).
|
||||
|
||||
|
||||
==================================================
|
||||
POST /api/volumes/prune
|
||||
==================================================
|
||||
|
||||
Response: array of pruned volume names (raw Podman passthrough).
|
||||
|
||||
|
||||
==================================================
|
||||
GET /api/volumes/{name}/exists
|
||||
==================================================
|
||||
|
||||
Response:
|
||||
{"exists": true} — volume bestaat (Podman 204)
|
||||
{"exists": false} — volume niet gevonden (Podman 404)
|
||||
|
||||
|
||||
==================================================
|
||||
GET /api/openapi.json
|
||||
==================================================
|
||||
|
||||
@@ -4,6 +4,7 @@ RUN apt-get update && apt-get install -y curl systemd && rm -rf /var/lib/apt/lis
|
||||
RUN pip install fastapi uvicorn requests-unixsocket pyyaml pytest httpx
|
||||
COPY app.py .
|
||||
COPY app_images.py .
|
||||
COPY app_volumes.py .
|
||||
COPY app_files.py .
|
||||
COPY app_networks.py .
|
||||
COPY app_pods.py .
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app_images import init_images_router
|
||||
from app_volumes import init_volumes_router
|
||||
from app_files import init_files_router
|
||||
from app_pods import init_pods_router
|
||||
from app_containers import init_containers_router, start_stats_poller
|
||||
@@ -27,6 +28,7 @@ def _systemctl(cmd):
|
||||
# --- ROUTERS ---
|
||||
# Images API lives in dedicated modules to keep this file from growing further.
|
||||
app.include_router(init_images_router(SESSION, PODMAN_API_BASE))
|
||||
app.include_router(init_volumes_router(SESSION, PODMAN_API_BASE))
|
||||
app.include_router(init_files_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR))
|
||||
app.include_router(init_networks_router(SESSION, PODMAN_API_BASE))
|
||||
app.include_router(init_containers_router(
|
||||
|
||||
+33
-27
@@ -14,7 +14,6 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from common import (
|
||||
_helper_call,
|
||||
_map_pod_to_unit,
|
||||
_podman_action_post,
|
||||
_podman_get_json,
|
||||
_podman_get_text,
|
||||
@@ -30,6 +29,7 @@ _PODMAN_API_BASE = None
|
||||
_STATS_CACHE_BY_NAME = {} # name -> {"cpu": float|None, "mem_usage": float|None, "mem_perc": float|None}
|
||||
_STATS_CACHE_TS = None
|
||||
_STATS_POLLER_TASK = None
|
||||
_STATS_SHOWN_NAMES: set = set() # namen van alle dashboard-containers uit laatste dashboard call
|
||||
|
||||
# --- EXEC SESSION CACHE (in-memory) ---
|
||||
_EXEC_SESSIONS = {} # session_id -> _ExecSessionState
|
||||
@@ -411,21 +411,8 @@ def init_containers_router(
|
||||
dashboard = []
|
||||
defined = find_defined_containers()
|
||||
|
||||
# Cache zodat we niet voor elke container opnieuw systemctl doen
|
||||
unit_active_cache = {}
|
||||
|
||||
stats_by_name = _STATS_CACHE_BY_NAME
|
||||
|
||||
def _unit_is_active(unit):
|
||||
if not unit:
|
||||
return False
|
||||
if unit in unit_active_cache:
|
||||
return unit_active_cache[unit]
|
||||
code, out = systemctl_func(["systemctl", "--user", "is-active", unit])
|
||||
ok = (code == 0) or ((out or "").strip() == "active")
|
||||
unit_active_cache[unit] = ok
|
||||
return ok
|
||||
|
||||
# A) echte containers (runtime)
|
||||
real = _podman_get_json(session, f"{podman_api_base}/libpod/containers/json?all=true")
|
||||
for c in real:
|
||||
@@ -447,22 +434,18 @@ def init_containers_router(
|
||||
c["_dashboard_mem_usage"] = st.get("mem_usage")
|
||||
c["_dashboard_mem_perc"] = st.get("mem_perc")
|
||||
|
||||
# 1) Managed: systemd als er een .container definitie bestaat
|
||||
if rname in defined:
|
||||
# Classificatie: PODMAN_SYSTEMD_UNIT label is ground truth
|
||||
labels = c.get("Labels") or {}
|
||||
podman_unit = labels.get("PODMAN_SYSTEMD_UNIT") or ""
|
||||
if podman_unit:
|
||||
c["_dashboard_source"] = "systemd"
|
||||
c["_dashboard_unit"] = f"{rname}.service"
|
||||
c["_dashboard_def_path"] = defined[rname]
|
||||
c["_dashboard_unit"] = podman_unit
|
||||
else:
|
||||
# 2) Extra: zit container in een pod die via systemd (kube/quadlet) draait?
|
||||
podname = (c.get("PodName") or "").strip()
|
||||
pod_unit = _map_pod_to_unit(podname) if podname else None
|
||||
c["_dashboard_source"] = "podman"
|
||||
|
||||
if pod_unit and _unit_is_active(pod_unit):
|
||||
c["_dashboard_source"] = "systemd"
|
||||
c["_dashboard_unit"] = pod_unit
|
||||
# geen _dashboard_def_path, want dit is geen .container definitie
|
||||
else:
|
||||
c["_dashboard_source"] = "podman"
|
||||
# Definitiepad: onafhankelijk van classificatie
|
||||
if rname in defined:
|
||||
c["_dashboard_def_path"] = defined[rname]
|
||||
|
||||
dashboard.append(c)
|
||||
|
||||
@@ -478,8 +461,22 @@ def init_containers_router(
|
||||
row["Status"] = (out or "").strip()
|
||||
dashboard.append(row)
|
||||
|
||||
# Bijwerken welke containernamen in het dashboard staan (voor /stats filter)
|
||||
global _STATS_SHOWN_NAMES
|
||||
_STATS_SHOWN_NAMES = {
|
||||
_norm_container_name((c.get("Names") or ["?"])[0])
|
||||
for c in dashboard
|
||||
} - {"?", ""}
|
||||
|
||||
return dashboard
|
||||
|
||||
@router.get("/stats")
|
||||
def stats_snapshot():
|
||||
cache = _STATS_CACHE_BY_NAME
|
||||
if _STATS_SHOWN_NAMES:
|
||||
return {k: v for k, v in cache.items() if k in _STATS_SHOWN_NAMES}
|
||||
return cache
|
||||
|
||||
@router.get("/containers")
|
||||
def list_containers():
|
||||
# Ook hier ?all=true voor gestopte containers
|
||||
@@ -556,6 +553,15 @@ def init_containers_router(
|
||||
|
||||
@router.post("/containers/{action}/{name}")
|
||||
def container_action(action: str, name: str):
|
||||
"""
|
||||
Voer een actie uit op een container.
|
||||
|
||||
- **start** — Start de container (of bijbehorende systemd-unit).
|
||||
- **stop** — ⚠️ Destructief: stopt de container direct.
|
||||
- **restart** — ⚠️ Destructief: herstart de container direct.
|
||||
|
||||
Gebruikt systemd als de container een beheerde unit heeft; anders Podman API direct.
|
||||
"""
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "Invalid action"}, 400
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
|
||||
# --- STAP 2: remove selected (batch) ---
|
||||
@router.post("/remove")
|
||||
def remove_images(req: ImageRemoveRequest):
|
||||
"""⚠️ Destructief: verwijdert één of meerdere images permanent. Niet terug te draaien."""
|
||||
# Libpod heeft batch remove via query params (images=...).
|
||||
url = f"{podman_api_base}/libpod/images/remove"
|
||||
params = {
|
||||
@@ -91,6 +92,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
|
||||
force: bool = Query(False),
|
||||
ignore: bool = Query(False),
|
||||
):
|
||||
"""⚠️ Destructief: verwijdert één image permanent op basis van naam of ID."""
|
||||
url = f"{podman_api_base}/libpod/images/remove"
|
||||
params = {
|
||||
"images": [image_ref],
|
||||
@@ -104,6 +106,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
|
||||
# --- STAP 2: prune (dangling default, all=true => unused) ---
|
||||
@router.post("/prune")
|
||||
def prune_images(all: bool = Query(False)):
|
||||
"""⚠️ Destructief: verwijdert dangling images (standaard) of alle ongebruikte images (`all=true`)."""
|
||||
url = f"{podman_api_base}/libpod/images/prune"
|
||||
params = {"all": str(all).lower()}
|
||||
resp = session.post(url, params=params)
|
||||
|
||||
+86
-319
@@ -82,27 +82,25 @@ def init_networks_router(session, podman_api_base: str) -> APIRouter:
|
||||
def networks_usage():
|
||||
"""
|
||||
Bouwt mapping netwerk -> containers/pods, en container -> netwerken.
|
||||
Werkt betrouwbaar ook als network inspect geen containers toont.
|
||||
Ground truth: NetworkSettings.Networks uit container inspect.
|
||||
Infra containers (IsInfra=true) worden gefilterd.
|
||||
"""
|
||||
# 1) containers list (all=true)
|
||||
url = f"{podman_api_base}/libpod/containers/json?all=true"
|
||||
containers = _podman_get_json_checked(url) or []
|
||||
# 1) Containers ophalen
|
||||
containers = _podman_get_json_checked(
|
||||
f"{podman_api_base}/libpod/containers/json?all=true"
|
||||
) or []
|
||||
|
||||
by_network: dict[str, dict] = {}
|
||||
by_container: dict[str, list[str]] = {}
|
||||
by_container_meta: dict[str, dict] = {}
|
||||
|
||||
def _norm_name(c: dict) -> str:
|
||||
# Podman kan Names (list) of Name (string) geven.
|
||||
n = c.get("Name")
|
||||
if isinstance(n, str) and n:
|
||||
return n
|
||||
names = c.get("Names")
|
||||
if isinstance(names, list) and names:
|
||||
# vaak begint dit met "/name"
|
||||
nm = str(names[0]).lstrip("/")
|
||||
return nm
|
||||
# fallback id
|
||||
return str(names[0]).lstrip("/")
|
||||
cid = c.get("Id") or c.get("id") or ""
|
||||
return cid[:12] if cid else "(unknown)"
|
||||
|
||||
@@ -110,380 +108,149 @@ def init_networks_router(session, podman_api_base: str) -> APIRouter:
|
||||
return c.get("Id") or c.get("id") or ""
|
||||
|
||||
def _pod_name(c: dict) -> str | None:
|
||||
# Verschilt per output; we proberen een paar logische keys
|
||||
for k in ("PodName", "pod", "Pod", "PodID", "PodId"):
|
||||
for k in ("PodName", "pod", "Pod"):
|
||||
v = c.get(k)
|
||||
if isinstance(v, str) and v and v != "": # PodID is geen naam, maar beter dan niks
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
return None
|
||||
|
||||
def _extract_networks_from_summary(c: dict) -> list[str] | None:
|
||||
# Mogelijke structuren in list output
|
||||
nets = c.get("Networks")
|
||||
if isinstance(nets, dict):
|
||||
return list(nets.keys())
|
||||
if isinstance(nets, list):
|
||||
return [str(x) for x in nets if x]
|
||||
|
||||
ns = c.get("NetworkSettings")
|
||||
if isinstance(ns, dict):
|
||||
nets2 = ns.get("Networks")
|
||||
if isinstance(nets2, dict):
|
||||
return list(nets2.keys())
|
||||
|
||||
# Sommige builds hebben NetworkNames
|
||||
nn = c.get("NetworkNames")
|
||||
if isinstance(nn, list):
|
||||
return [str(x) for x in nn if x]
|
||||
|
||||
return None
|
||||
|
||||
def _extract_networks_from_inspect_obj(insp: dict) -> list[str]:
|
||||
def _ns_networks(insp: dict) -> dict:
|
||||
"""Haal NetworkSettings.Networks dict op uit inspect — de ground truth."""
|
||||
ns = insp.get("NetworkSettings") if isinstance(insp, dict) else None
|
||||
nets = ns.get("Networks") if isinstance(ns, dict) else None
|
||||
return nets if isinstance(nets, dict) else {}
|
||||
|
||||
def _extract_from_inspect(cid: str) -> tuple[list[str], dict, dict]:
|
||||
"""
|
||||
Probeert netwerk-namen uit een container inspect te halen.
|
||||
Ondersteunt varianten/casing die per Podman/driver kunnen verschillen.
|
||||
"""
|
||||
if not isinstance(insp, dict):
|
||||
return []
|
||||
|
||||
candidates = []
|
||||
|
||||
# 1) meest voorkomend
|
||||
ns = insp.get("NetworkSettings")
|
||||
if isinstance(ns, dict):
|
||||
candidates.append(ns.get("Networks"))
|
||||
candidates.append(ns.get("networks"))
|
||||
|
||||
# 2) sommige outputs hebben Networks top-level
|
||||
candidates.append(insp.get("Networks"))
|
||||
candidates.append(insp.get("networks"))
|
||||
|
||||
# 3) extra varianten
|
||||
n2 = insp.get("networkSettings")
|
||||
if isinstance(n2, dict):
|
||||
candidates.append(n2.get("Networks"))
|
||||
candidates.append(n2.get("networks"))
|
||||
|
||||
n3 = insp.get("Network")
|
||||
if isinstance(n3, dict):
|
||||
candidates.append(n3.get("Networks"))
|
||||
candidates.append(n3.get("networks"))
|
||||
|
||||
cfg = insp.get("Config")
|
||||
if isinstance(cfg, dict):
|
||||
candidates.append(cfg.get("Networks"))
|
||||
candidates.append(cfg.get("networks"))
|
||||
|
||||
# Normaliseer candidates naar lijst[str]
|
||||
out: list[str] = []
|
||||
for val in candidates:
|
||||
if isinstance(val, dict):
|
||||
out.extend([str(k) for k in val.keys() if k])
|
||||
elif isinstance(val, list):
|
||||
for x in val:
|
||||
if isinstance(x, str) and x:
|
||||
out.append(x)
|
||||
elif isinstance(x, dict):
|
||||
# Best-effort: soms bevat list entries met Name
|
||||
nm = x.get("Name") or x.get("name")
|
||||
if isinstance(nm, str) and nm:
|
||||
out.append(nm)
|
||||
|
||||
# uniq + stable sort
|
||||
return sorted(set([n for n in out if isinstance(n, str) and n]))
|
||||
|
||||
def _extract_networks_from_inspect(cid: str) -> tuple[list[str], dict]:
|
||||
"""
|
||||
Returns: (networks, extra_info)
|
||||
extra_info kan bv. networkMode/containerOwner bevatten.
|
||||
Returns: (net_names, extra, net_details)
|
||||
- net_names: lijst van netwerknamen
|
||||
- extra: {networkMode, networkOwnerId, networkOwnerName} voor container: mode
|
||||
- net_details: {net_name: {ip, aliases}} voor bridge-netwerken
|
||||
"""
|
||||
if not cid:
|
||||
return ([], {})
|
||||
return [], {}, {}
|
||||
|
||||
insp = _podman_get_json_checked(f"{podman_api_base}/libpod/containers/{cid}/json")
|
||||
insp = _podman_get_json_checked(
|
||||
f"{podman_api_base}/libpod/containers/{cid}/json"
|
||||
)
|
||||
extra: dict = {}
|
||||
|
||||
# 1) normale inspect: probeer meerdere paden
|
||||
nets0 = _extract_networks_from_inspect_obj(insp)
|
||||
if nets0:
|
||||
return (nets0, extra)
|
||||
# 1) NetworkSettings.Networks is de ground truth voor bridge-containers
|
||||
nets_dict = _ns_networks(insp)
|
||||
if nets_dict:
|
||||
net_details = {}
|
||||
for net_name, net_info in nets_dict.items():
|
||||
if isinstance(net_info, dict):
|
||||
ip = net_info.get("IPAddress") or ""
|
||||
aliases = [
|
||||
a for a in (net_info.get("Aliases") or [])
|
||||
if isinstance(a, str)
|
||||
]
|
||||
net_details[net_name] = {"ip": ip, "aliases": aliases}
|
||||
else:
|
||||
net_details[net_name] = {"ip": "", "aliases": []}
|
||||
return sorted(nets_dict.keys()), extra, net_details
|
||||
|
||||
# 2) container network namespace mode: HostConfig.NetworkMode = "container:<id>"
|
||||
# 2) Shared network namespace: NetworkMode = "container:<id>"
|
||||
hc = insp.get("HostConfig") if isinstance(insp, dict) else None
|
||||
if isinstance(hc, dict):
|
||||
nm = hc.get("NetworkMode")
|
||||
if isinstance(nm, str) and nm.startswith("container:"):
|
||||
owner_id = nm.split("container:", 1)[1]
|
||||
extra["networkMode"] = nm
|
||||
extra["networkOwnerId"] = owner_id
|
||||
# Inspect owner container en pak diens netwerken
|
||||
owner = _podman_get_json_checked(f"{podman_api_base}/libpod/containers/{owner_id}/json")
|
||||
nm = hc.get("NetworkMode") if isinstance(hc, dict) else None
|
||||
|
||||
# 1) netwerken van owner vinden (meerdere varianten)
|
||||
owner_nets_list = _extract_networks_from_inspect_obj(owner)
|
||||
if isinstance(nm, str) and nm.startswith("container:"):
|
||||
owner_id = nm.split("container:", 1)[1]
|
||||
extra["networkMode"] = nm
|
||||
extra["networkOwnerId"] = owner_id
|
||||
|
||||
# 2) owner naam vinden (meerdere varianten)
|
||||
owner_name = None
|
||||
owner = _podman_get_json_checked(
|
||||
f"{podman_api_base}/libpod/containers/{owner_id}/json"
|
||||
)
|
||||
owner_name = str(owner.get("Name") or owner_id[:12]).lstrip("/")
|
||||
extra["networkOwnerName"] = owner_name
|
||||
|
||||
# meest voorkomend bij inspect
|
||||
if isinstance(owner.get("Name"), str) and owner.get("Name"):
|
||||
owner_name = owner.get("Name")
|
||||
owner_nets = _ns_networks(owner)
|
||||
if owner_nets:
|
||||
return sorted(owner_nets.keys()), extra, {}
|
||||
|
||||
# fallback: soms staat het in Config.Name
|
||||
if not owner_name:
|
||||
cfg = owner.get("Config") or {}
|
||||
if isinstance(cfg.get("Name"), str) and cfg.get("Name"):
|
||||
owner_name = cfg.get("Name")
|
||||
# Owner gebruikt pasta/host/none
|
||||
owner_nm = (owner.get("HostConfig") or {}).get("NetworkMode") or ""
|
||||
if owner_nm in ("pasta", "host", "none"):
|
||||
return [owner_nm], extra, {}
|
||||
|
||||
# fallback: soms in ContainerConfig
|
||||
if not owner_name:
|
||||
ccfg = owner.get("ContainerConfig") or {}
|
||||
if isinstance(ccfg.get("Name"), str) and ccfg.get("Name"):
|
||||
owner_name = ccfg.get("Name")
|
||||
return [], extra, {}
|
||||
|
||||
# fallback: als niets werkt, toon korte id
|
||||
if not owner_name:
|
||||
owner_name = owner_id[:12]
|
||||
# 3) Pseudo-netwerken: pasta / host / none
|
||||
if isinstance(nm, str) and nm in ("pasta", "host", "none"):
|
||||
extra["networkMode"] = nm
|
||||
return [nm], extra, {}
|
||||
|
||||
extra["networkOwnerName"] = str(owner_name).lstrip("/")
|
||||
return [], {}, {}
|
||||
|
||||
# 3) netwerken returnen (als we ze gevonden hebben)
|
||||
if owner_nets_list:
|
||||
return (owner_nets_list, extra)
|
||||
import re
|
||||
_INFRA_NAME_RE = re.compile(r"^[0-9a-f]+-infra$")
|
||||
_PSEUDO_NETS = {"pasta", "host", "none"}
|
||||
|
||||
# Extra fallback: probeer inspect via ownerName (soms werkt naam beter dan id)
|
||||
try:
|
||||
owner_name_for_lookup = extra.get("networkOwnerName")
|
||||
if owner_name_for_lookup and owner_name_for_lookup != owner_id:
|
||||
owner2 = _podman_get_json_checked(f"{podman_api_base}/libpod/containers/{owner_name_for_lookup}/json")
|
||||
owner2_nets = _extract_networks_from_inspect_obj(owner2)
|
||||
if owner2_nets:
|
||||
return (owner2_nets, extra)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Owner fallback: sommige infra containers gebruiken pasta/host/none
|
||||
try:
|
||||
ohc = owner.get("HostConfig") if isinstance(owner, dict) else None
|
||||
if isinstance(ohc, dict):
|
||||
onm = ohc.get("NetworkMode")
|
||||
if isinstance(onm, str) and onm in ("pasta", "host", "none"):
|
||||
# owner gebruikt geen Podman-netwerk; behandel als pseudo-netwerk
|
||||
return ([onm], extra)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ([], extra)
|
||||
|
||||
# 3) Special networking modes: pasta/host/none
|
||||
# In deze modes bestaat vaak geen NetworkSettings.Networks map.
|
||||
if isinstance(hc, dict):
|
||||
nm2 = hc.get("NetworkMode")
|
||||
if isinstance(nm2, str) and nm2 in ("pasta", "host", "none"):
|
||||
extra["networkMode"] = nm2
|
||||
return ([nm2], extra)
|
||||
|
||||
return ([], extra)
|
||||
|
||||
# 2) Loop containers: verzamel netwerken
|
||||
# 2) Loop over alle containers
|
||||
for c in containers:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cname_pre = _norm_name(c)
|
||||
if c.get("IsInfra") or _INFRA_NAME_RE.match(cname_pre):
|
||||
continue # pod infra containers overslaan
|
||||
|
||||
cid = _norm_id(c)
|
||||
cname = _norm_name(c)
|
||||
cname = cname_pre
|
||||
pod = _pod_name(c)
|
||||
|
||||
nets = _extract_networks_from_summary(c)
|
||||
extra = {}
|
||||
extra: dict = {}
|
||||
net_details: dict = {}
|
||||
|
||||
if not nets:
|
||||
nets, extra = _extract_networks_from_inspect(cid)
|
||||
nets, extra, net_details = _extract_from_inspect(cid)
|
||||
elif any(n not in _PSEUDO_NETS for n in nets):
|
||||
# Bridge-container: inspect voor IP/aliases
|
||||
_, extra, net_details = _extract_from_inspect(cid)
|
||||
|
||||
by_container_meta[cname] = extra
|
||||
|
||||
nets = [n for n in (nets or []) if isinstance(n, str) and n]
|
||||
|
||||
# byContainer blijft lijst (contract simpel houden)
|
||||
by_container[cname] = sorted(set(nets))
|
||||
|
||||
for n in nets:
|
||||
slot = by_network.setdefault(n, {"containers": [], "pods": []})
|
||||
nd = net_details.get(n, {})
|
||||
slot["containers"].append({
|
||||
"id": cid,
|
||||
"name": cname,
|
||||
"pod": pod,
|
||||
**extra, # voegt networkMode/owner info toe indien van toepassing
|
||||
"ip": nd.get("ip", ""),
|
||||
"aliases": nd.get("aliases", []),
|
||||
**extra,
|
||||
})
|
||||
|
||||
# 3) Pods afleiden (lightweight) via containers
|
||||
# 3) Pods afleiden via containers
|
||||
for n, slot in by_network.items():
|
||||
pods = sorted({c.get("pod") for c in slot["containers"] if isinstance(c.get("pod"), str) and c.get("pod")})
|
||||
pods = sorted({
|
||||
c.get("pod") for c in slot["containers"]
|
||||
if isinstance(c.get("pod"), str) and c.get("pod")
|
||||
})
|
||||
slot["pods"] = [{"name": p} for p in pods]
|
||||
|
||||
# --- FALLBACK: derive owner networks via network-inspect (works for pod infra/shared netns) ---
|
||||
# We look at shared netns containers (networkMode=container:...) and map their owner-id to networks
|
||||
owner_ids: set[str] = set()
|
||||
owner_names: dict[str, str] = {} # ownerId -> ownerName
|
||||
|
||||
for cname, meta in by_container_meta.items():
|
||||
try:
|
||||
mode = str((meta or {}).get("networkMode") or "")
|
||||
except Exception:
|
||||
mode = ""
|
||||
if not mode.startswith("container:"):
|
||||
continue
|
||||
|
||||
owner_id = (meta or {}).get("networkOwnerId")
|
||||
owner_name = (meta or {}).get("networkOwnerName")
|
||||
if isinstance(owner_id, str) and owner_id:
|
||||
owner_ids.add(owner_id)
|
||||
if isinstance(owner_name, str) and owner_name:
|
||||
owner_names[owner_id] = owner_name
|
||||
|
||||
def _collect_container_ids_from_network_inspect(net_inspect) -> set[str]:
|
||||
"""
|
||||
Key-agnostic: scan alle strings in network inspect en verzamel hex IDs (12..64 chars).
|
||||
Dit is robuust tegen schema-verschillen tussen netavark/cni/podman versies.
|
||||
"""
|
||||
ids: set[str] = set()
|
||||
|
||||
def looks_like_hex_id(s: str) -> bool:
|
||||
if not isinstance(s, str):
|
||||
return False
|
||||
s = s.strip()
|
||||
if len(s) < 12 or len(s) > 64:
|
||||
return False
|
||||
# alleen hex chars
|
||||
for ch in s:
|
||||
if ch not in "0123456789abcdef":
|
||||
return False
|
||||
return True
|
||||
|
||||
def walk(obj):
|
||||
if obj is None:
|
||||
return
|
||||
if isinstance(obj, str):
|
||||
# soms staat id als "container:<id>"
|
||||
if obj.startswith("container:"):
|
||||
cand = obj.split("container:", 1)[1]
|
||||
if looks_like_hex_id(cand):
|
||||
ids.add(cand)
|
||||
elif looks_like_hex_id(obj):
|
||||
ids.add(obj)
|
||||
return
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
# keys kunnen ook ids zijn
|
||||
if isinstance(k, str) and looks_like_hex_id(k):
|
||||
ids.add(k)
|
||||
walk(v)
|
||||
return
|
||||
if isinstance(obj, list):
|
||||
for it in obj:
|
||||
walk(it)
|
||||
return
|
||||
|
||||
walk(net_inspect)
|
||||
return ids
|
||||
|
||||
owner_networks_by_id: dict[str, set[str]] = {oid: set() for oid in owner_ids}
|
||||
|
||||
# List networks
|
||||
try:
|
||||
nets_list = _podman_get_json_checked(f"{podman_api_base}/libpod/networks/json")
|
||||
except Exception:
|
||||
nets_list = []
|
||||
|
||||
net_names: list[str] = []
|
||||
if isinstance(nets_list, list):
|
||||
for n in nets_list:
|
||||
if isinstance(n, dict):
|
||||
nm = n.get("name") or n.get("Name")
|
||||
if isinstance(nm, str) and nm:
|
||||
net_names.append(nm)
|
||||
|
||||
# Inspect each network and see if it contains any owner_id
|
||||
for net_name in sorted(set(net_names)):
|
||||
try:
|
||||
net_inspect = _podman_get_json_checked(f"{podman_api_base}/libpod/networks/{net_name}/json")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
attached_ids = _collect_container_ids_from_network_inspect(net_inspect)
|
||||
if not attached_ids:
|
||||
continue
|
||||
|
||||
for oid in owner_ids:
|
||||
short = oid[:12]
|
||||
for aid in attached_ids:
|
||||
if not isinstance(aid, str) or not aid:
|
||||
continue
|
||||
# match exact / short / prefix
|
||||
if aid == oid or aid == short or oid.startswith(aid) or aid.startswith(short):
|
||||
owner_networks_by_id.setdefault(oid, set()).add(net_name)
|
||||
break
|
||||
|
||||
# Apply: if shared container or owner container has empty by_container[], fill it with owner's networks
|
||||
for cname, meta in by_container_meta.items():
|
||||
try:
|
||||
mode = str((meta or {}).get("networkMode") or "")
|
||||
except Exception:
|
||||
mode = ""
|
||||
if not mode.startswith("container:"):
|
||||
continue
|
||||
|
||||
owner_id = (meta or {}).get("networkOwnerId")
|
||||
if not (isinstance(owner_id, str) and owner_id):
|
||||
continue
|
||||
|
||||
owner_nets = sorted(owner_networks_by_id.get(owner_id, set()))
|
||||
if not owner_nets:
|
||||
continue
|
||||
|
||||
# 1) fill owner-name entry (if known)
|
||||
owner_name = (meta or {}).get("networkOwnerName") or owner_names.get(owner_id)
|
||||
if isinstance(owner_name, str) and owner_name and not by_container.get(owner_name):
|
||||
by_container[owner_name] = owner_nets
|
||||
|
||||
# 2) fill shared container entry
|
||||
if not by_container.get(cname):
|
||||
by_container[cname] = owner_nets
|
||||
|
||||
# --- FINALIZE: derive by_container from by_network (robust for pods/shared netns) ---
|
||||
by_container_derived: dict[str, list[str]] = {}
|
||||
|
||||
for net_name, info in (by_network or {}).items():
|
||||
containers2 = (info or {}).get("containers") or []
|
||||
for c2 in containers2:
|
||||
if not isinstance(c2, dict):
|
||||
continue
|
||||
cname2 = c2.get("name") or c2.get("Name")
|
||||
if not cname2:
|
||||
continue
|
||||
by_container_derived.setdefault(cname2, []).append(net_name)
|
||||
|
||||
# dedupe + stable sort
|
||||
for k, v in by_container_derived.items():
|
||||
by_container_derived[k] = sorted(set(v))
|
||||
|
||||
# merge: vul lege items in by_container, maar breek niks
|
||||
for k, v in by_container_derived.items():
|
||||
if not by_container.get(k):
|
||||
by_container[k] = v
|
||||
|
||||
# --- shared netns: shared containers erven owner-netwerken (als owner bekend is) ---
|
||||
for cname, meta in by_container_meta.items():
|
||||
try:
|
||||
mode = str((meta or {}).get("networkMode") or "")
|
||||
except Exception:
|
||||
mode = ""
|
||||
if not mode.startswith("container:"):
|
||||
continue
|
||||
owner = (meta or {}).get("networkOwnerName") or (meta or {}).get("networkOwnerId")
|
||||
if owner and by_container.get(owner) and not by_container.get(cname):
|
||||
by_container[cname] = by_container[owner]
|
||||
|
||||
return {"byNetwork": by_network, "byContainer": by_container, "byContainerMeta": by_container_meta}
|
||||
|
||||
@router.get("/networks/{name}")
|
||||
|
||||
+15
-13
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
import subprocess
|
||||
import socket
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from common import (
|
||||
HELPER_SOCKET,
|
||||
_helper_call,
|
||||
_podman_get_json as _common_podman_get_json,
|
||||
_systemctl as _common_systemctl,
|
||||
@@ -27,24 +28,25 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
|
||||
except Exception:
|
||||
podman_ok = False
|
||||
|
||||
systemd_reachable = False
|
||||
helper_ok = False
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["systemctl", "--user", "list-units", "--no-pager", "--no-legend"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=2,
|
||||
)
|
||||
systemd_reachable = (res.returncode == 0)
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(2)
|
||||
s.connect(HELPER_SOCKET)
|
||||
helper_ok = True
|
||||
except Exception:
|
||||
systemd_reachable = False
|
||||
helper_ok = False
|
||||
|
||||
ok = podman_ok and systemd_reachable
|
||||
# Helper draait op de host als de kodi-user en voert systemctl --user uit.
|
||||
# Als de helper bereikbaar is, is systemd ook bereikbaar.
|
||||
systemd_reachable = helper_ok
|
||||
|
||||
ok = podman_ok and helper_ok
|
||||
return {
|
||||
"ok": ok,
|
||||
"podman": {"ok": podman_ok},
|
||||
"systemd_user": {"reachable": systemd_reachable},
|
||||
"helper": {"ok": helper_ok},
|
||||
}
|
||||
|
||||
@router.get("/test-hybrid")
|
||||
@@ -80,7 +82,7 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
|
||||
@router.post("/daemon-reload")
|
||||
def api_daemon_reload():
|
||||
try:
|
||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
||||
code, out = _helper_call("daemon-reload", "")
|
||||
return {
|
||||
"cmd": "systemctl --user daemon-reload",
|
||||
"exit": code,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def _normalize_filters(filters: str) -> str:
|
||||
"""Zet key=value formaat om naar {"key":["value"]} JSON dat Libpod verwacht.
|
||||
Als de waarde al met '{' begint, wordt hij ongewijzigd doorgegeven."""
|
||||
if filters.startswith("{"):
|
||||
return filters
|
||||
# key=value → {"key": ["value"]}
|
||||
if "=" in filters:
|
||||
key, _, value = filters.partition("=")
|
||||
return json.dumps({key.strip(): [value.strip()]})
|
||||
# Alleen een key zonder waarde → {"key": ["true"]}
|
||||
return json.dumps({filters.strip(): ["true"]})
|
||||
|
||||
|
||||
class VolumeCreateRequest(BaseModel):
|
||||
name: str
|
||||
driver: str = "local"
|
||||
driverOpts: Optional[Dict[str, str]] = None
|
||||
labels: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
def _raise_on_error(resp):
|
||||
if 200 <= resp.status_code < 300:
|
||||
return
|
||||
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
||||
|
||||
|
||||
def init_volumes_router(session, podman_api_base: str) -> APIRouter:
|
||||
router = APIRouter(prefix="/volumes", tags=["volumes"])
|
||||
|
||||
@router.get("")
|
||||
def list_volumes(filters: Optional[str] = Query(None)):
|
||||
url = f"{podman_api_base}/libpod/volumes/json"
|
||||
params = {}
|
||||
if filters is not None:
|
||||
params["filters"] = _normalize_filters(filters)
|
||||
resp = session.get(url, params=params)
|
||||
_raise_on_error(resp)
|
||||
return resp.json()
|
||||
|
||||
@router.post("")
|
||||
def create_volume(req: VolumeCreateRequest):
|
||||
url = f"{podman_api_base}/libpod/volumes/create"
|
||||
body: dict = {"name": req.name, "driver": req.driver}
|
||||
if req.driverOpts:
|
||||
body["driverOpts"] = req.driverOpts
|
||||
if req.labels:
|
||||
body["labels"] = req.labels
|
||||
resp = session.post(url, json=body)
|
||||
_raise_on_error(resp)
|
||||
return resp.json()
|
||||
|
||||
@router.post("/prune")
|
||||
def prune_volumes():
|
||||
"""⚠️ Destructief: verwijdert alle ongebruikte volumes permanent. Niet terug te draaien."""
|
||||
url = f"{podman_api_base}/libpod/volumes/prune"
|
||||
resp = session.post(url)
|
||||
_raise_on_error(resp)
|
||||
return resp.json()
|
||||
|
||||
@router.get("/{name}/exists")
|
||||
def volume_exists(name: str):
|
||||
url = f"{podman_api_base}/libpod/volumes/{name}/exists"
|
||||
resp = session.get(url)
|
||||
if resp.status_code == 204:
|
||||
return {"exists": True}
|
||||
if resp.status_code == 404:
|
||||
return {"exists": False}
|
||||
_raise_on_error(resp)
|
||||
|
||||
@router.get("/{name}")
|
||||
def get_volume(name: str):
|
||||
url = f"{podman_api_base}/libpod/volumes/{name}/json"
|
||||
resp = session.get(url)
|
||||
_raise_on_error(resp)
|
||||
return resp.json()
|
||||
|
||||
@router.delete("/{name}")
|
||||
def remove_volume(name: str, force: bool = Query(False)):
|
||||
"""⚠️ Destructief: verwijdert een volume permanent. Niet terug te draaien als het volume data bevat."""
|
||||
url = f"{podman_api_base}/libpod/volumes/{name}"
|
||||
params = {"force": str(force).lower()}
|
||||
resp = session.delete(url, params=params)
|
||||
if resp.status_code == 204:
|
||||
return {"ok": True}
|
||||
_raise_on_error(resp)
|
||||
|
||||
return router
|
||||
+1
-1
@@ -4,7 +4,7 @@ import subprocess
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
HELPER_SOCKET = "/run/podman-helper.sock"
|
||||
HELPER_SOCKET = "/run/podman-mvp/podman-helper.sock"
|
||||
|
||||
|
||||
def _helper_call(action: str, unit: str) -> tuple[int, str]:
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
# De podman-helper: waarom, hoe en wat het oplost
|
||||
|
||||
## Inleiding
|
||||
|
||||
Dit document beschrijft de technische achtergrond en motivatie voor de `podman-helper` service in het Podman MVP project. Het legt uit welk fundamenteel probleem er was, waarom meerdere eerdere oplossingen faalden, en hoe de helper dit definitief oplost.
|
||||
|
||||
---
|
||||
|
||||
## Het probleem: systemd units beheren vanuit een rootless container
|
||||
|
||||
### Wat we wilden
|
||||
|
||||
Een gebruiker moet vanuit de webui een quadlet service kunnen starten, stoppen en herstarten. Een quadlet service is een systemd user service die gegenereerd wordt door Podman's quadlet generator op basis van een `.container` of `.kube` bestand. Voorbeelden:
|
||||
|
||||
- `sonarr.container` → systemd genereert `sonarr.service`
|
||||
- `test-web.container` → systemd genereert `test-web.service`
|
||||
|
||||
De bedoeling was:
|
||||
```
|
||||
Gebruiker klikt "stop" in de webui
|
||||
→ webui stuurt POST /api/system/unit/sonarr.service/stop
|
||||
→ API container voert de stop uit
|
||||
→ sonarr stopt
|
||||
```
|
||||
|
||||
### Waarom dit niet triviaal is
|
||||
|
||||
De API container is een rootless Podman container. Hij draait als gewone gebruiker (kodi, UID 1000) maar hij draait **in een geïsoleerde namespace**. Systemd user services draaien in de **host user session** van UID 1000. Die twee werelden zijn niet zomaar uitwisselbaar.
|
||||
|
||||
---
|
||||
|
||||
## Poging 1: D-Bus StopUnit vanuit de container
|
||||
|
||||
### Aanpak
|
||||
|
||||
Systemd is bereikbaar via D-Bus. De rootless D-Bus session bus is beschikbaar op:
|
||||
```
|
||||
unix:path=/run/user/1000/bus
|
||||
```
|
||||
|
||||
Door deze socket als volume te mounten in de container en de `DBUS_SESSION_BUS_ADDRESS` omgevingsvariabele in te stellen, leek het mogelijk om via `dbus-send` systemd commando's te sturen:
|
||||
|
||||
```python
|
||||
_dbus_call("StopUnit", f"string:{unit_name}", "string:replace")
|
||||
```
|
||||
|
||||
### Waarom dit faalde
|
||||
|
||||
**D-Bus StopUnit werkt anders dan systemctl stop.**
|
||||
|
||||
Wanneer `StopUnit` via D-Bus wordt aangeroepen:
|
||||
1. Systemd stuurt een stopsignaal naar de container
|
||||
2. De container ontvangt SIGTERM maar de applicatie reageert niet snel genoeg
|
||||
3. Na de TimeoutStopSec (standaard 10 seconden) stuurt systemd SIGKILL
|
||||
4. De container sterft met **exit code 137** (128 + 9 = SIGKILL)
|
||||
|
||||
Het probleem zit in wat er daarna gebeurt. De quadlet-gegenereerde service heeft standaard `Restart=on-failure` in de gegenereerde unit. Exit code 137 wordt door systemd beschouwd als een **failure** — en systemd herstart de service automatisch.
|
||||
|
||||
**`systemctl --user stop` werkt wel** omdat het intern een `prevent_restart` flag zet die de automatische herstart onderdrukt. D-Bus `StopUnit` heeft deze flag niet.
|
||||
|
||||
**Resultaat:** De service lijkt gestopt maar herstart zichzelf binnen enkele seconden. Vanuit de webui is dit onzichtbaar — de gebruiker ziet "gestopt" maar de service draait gewoon door.
|
||||
|
||||
### Bewijs
|
||||
|
||||
```bash
|
||||
# Handmatige test toonde dit gedrag:
|
||||
curl -s -X POST http://api/system/unit/sonarr.service/stop
|
||||
# Response: {"message": "sonarr.service gestopt"} ← misleidend
|
||||
|
||||
sleep 5
|
||||
systemctl --user is-active sonarr.service
|
||||
# Output: active ← service draait gewoon door
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Poging 2: podman stop + D-Bus StopUnit
|
||||
|
||||
### Aanpak
|
||||
|
||||
Het idee: als we eerst `podman stop` aanroepen (wat SIGTERM stuurt en de container netjes laat stoppen met exit 0), en daarna D-Bus StopUnit aanroepen voor de systemd cleanup, zou de combinatie moeten werken.
|
||||
|
||||
```python
|
||||
# Stap 1: container netjes stoppen
|
||||
await _request("POST", f"/containers/{container_name}/stop")
|
||||
|
||||
# Stap 2: systemd opruimen
|
||||
_dbus_call("StopUnit", f"string:{unit_name}", "string:replace")
|
||||
```
|
||||
|
||||
### Waarom dit faalde
|
||||
|
||||
De `StopUnit` D-Bus call na `podman stop` **blokkeerde de opvolgende StartUnit**. Wanneer we daarna probeerden de service te starten:
|
||||
|
||||
```python
|
||||
_dbus_call("StartUnit", f"string:{unit_name}", "string:replace")
|
||||
```
|
||||
|
||||
Returde D-Bus wel een job pad (wat suggereert dat het gelukt is), maar systemd voerde de job niet daadwerkelijk uit. De service bleef op `inactive`.
|
||||
|
||||
De oorzaak: de extra `StopUnit` call zette de unit in een transitie state waaruit systemd de `StartUnit` job annuleerde.
|
||||
|
||||
---
|
||||
|
||||
## Poging 3: podman stop + wachten op inactive + StartUnit
|
||||
|
||||
### Aanpak
|
||||
|
||||
Misschien was het timing. We voegden een wachtfunctie toe die wacht totdat de unit echt `inactive` is voordat we `StartUnit` aanroepen:
|
||||
|
||||
```python
|
||||
async def _wait_for_unit_state(unit_name, target="inactive", timeout=15):
|
||||
for _ in range(timeout):
|
||||
state = _get_unit_active_state(unit_name)
|
||||
if state == target:
|
||||
return True
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
# Gebruik:
|
||||
await _wait_for_unit_state(unit_name, "inactive")
|
||||
await asyncio.sleep(2) # extra buffer
|
||||
_dbus_call("StartUnit", ...)
|
||||
```
|
||||
|
||||
### Waarom dit ook faalde
|
||||
|
||||
Zelfs als `ActiveState == inactive` is, is systemd intern nog bezig met de deactivatie cleanup. De `StartUnit` job wordt aangemaakt (D-Bus geeft een job pad terug) maar systemd annuleert hem intern omdat de unit nog niet volledig gestopt is.
|
||||
|
||||
Uitgebreide tests toonden aan:
|
||||
- `ActiveState` was al `inactive`
|
||||
- `SubState` was al `dead`
|
||||
- Maar `StartUnit` via D-Bus vanuit de container resulteerde niet in een daadwerkelijke start
|
||||
|
||||
**Hetzelfde commando direct op de host werkte wél:**
|
||||
```bash
|
||||
# Op de host:
|
||||
dbus-send --session --print-reply \
|
||||
--dest=org.freedesktop.systemd1 \
|
||||
/org/freedesktop/systemd1 \
|
||||
org.freedesktop.systemd1.Manager.StartUnit \
|
||||
string:test-web.service string:replace
|
||||
# → active ✓
|
||||
|
||||
# Vanuit de container (zelfde commando):
|
||||
podman exec podman-api dbus-send --session --print-reply \
|
||||
--dest=org.freedesktop.systemd1 \
|
||||
/org/freedesktop/systemd1 \
|
||||
org.freedesktop.systemd1.Manager.StartUnit \
|
||||
string:test-web.service string:replace
|
||||
# → D-Bus geeft OK maar service blijft inactive ✗
|
||||
```
|
||||
|
||||
Dit bevestigde dat het probleem fundamenteel is: D-Bus vanuit een container context en D-Bus vanuit de host user session zijn niet equivalent, ook al communiceren ze over dezelfde socket.
|
||||
|
||||
---
|
||||
|
||||
## De root cause: container namespace isolatie
|
||||
|
||||
De kern van het probleem is dat een Podman rootless container draait in een **user namespace**. Hoewel de D-Bus socket gemount is en communicatie technisch mogelijk is, heeft systemd een interne controle op de **peer credentials** van de D-Bus verbinding.
|
||||
|
||||
Wanneer een D-Bus bericht aankomt van een process in een andere user namespace (de container), ziet systemd dit als een andere security context dan een bericht van de host user session. Bepaalde operaties — met name het daadwerkelijk uitvoeren van StartUnit/StopUnit jobs — worden door systemd intern anders behandeld of afgekapt.
|
||||
|
||||
Dit is geen bug maar een bewuste beveiligingsboundary in Linux: een process in een user namespace mag niet zomaar services beheren in de parent namespace.
|
||||
|
||||
**`systemctl --user stop` werkt** omdat systemctl de D-Bus verbinding opbouwt vanuit de host user session met de juiste credentials. Het zet ook de `prevent_restart` flag die D-Bus `StopUnit` mist.
|
||||
|
||||
---
|
||||
|
||||
## De oplossing: podman-helper
|
||||
|
||||
### Ontwerp
|
||||
|
||||
De `podman-helper` is een kleine Python service die **direct op de host** draait als de `kodi` gebruiker. Hij luistert op een Unix socket en voert `systemctl --user` commando's uit namens de API container.
|
||||
|
||||
```
|
||||
API container
|
||||
│
|
||||
│ Unix socket: /run/user/1000/podman-helper.sock
|
||||
│ (gemount in de container als /run/podman-helper.sock)
|
||||
▼
|
||||
podman-helper.py (draait op de HOST als kodi user)
|
||||
│
|
||||
│ subprocess: systemctl --user start|stop|restart <unit>
|
||||
▼
|
||||
systemd user session (host)
|
||||
```
|
||||
|
||||
### Protocol
|
||||
|
||||
Eenvoudig JSON over de Unix socket:
|
||||
|
||||
**Verzoek:**
|
||||
```json
|
||||
{"action": "start", "unit": "test-web.service"}
|
||||
```
|
||||
|
||||
**Antwoord bij succes:**
|
||||
```json
|
||||
{"ok": true, "output": "test-web.service start geslaagd"}
|
||||
```
|
||||
|
||||
**Antwoord bij fout:**
|
||||
```json
|
||||
{"ok": false, "error": "Actie 'kill' niet toegestaan. Gebruik: restart, start, stop"}
|
||||
```
|
||||
|
||||
### Beveiliging
|
||||
|
||||
De helper heeft een strikte whitelist:
|
||||
|
||||
```python
|
||||
ALLOWED_ACTIONS = {"start", "stop", "restart"}
|
||||
UNIT_PATTERN = re.compile(r'^[a-zA-Z0-9._\-]+\.service$')
|
||||
```
|
||||
|
||||
- Alleen `start`, `stop` en `restart` zijn toegestaan
|
||||
- Unit namen mogen alleen veilige tekens bevatten
|
||||
- Geen shell injection mogelijk — `systemctl` wordt direct aangeroepen via `subprocess`, niet via een shell
|
||||
- De Unix socket heeft permissie `0o600` — alleen de eigenaar (kodi) kan ermee communiceren
|
||||
|
||||
### Gelijktijdige verbindingen
|
||||
|
||||
De helper gebruikt Python `asyncio` en `asyncio.start_unix_server`. Dit betekent dat meerdere gelijktijdige verzoeken zonder problemen verwerkt worden — de event loop handelt ze af zonder blocking.
|
||||
|
||||
Test bewees dit:
|
||||
```bash
|
||||
# 5 gelijktijdige restart verzoeken
|
||||
for i in {1..5}; do
|
||||
echo '{"action": "restart", "unit": "test-web.service"}' | \
|
||||
socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/podman-helper.sock &
|
||||
done
|
||||
wait
|
||||
# Resultaat: alle 5 geslaagd, service actief ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wat nu wél werkt
|
||||
|
||||
### Stop
|
||||
|
||||
```
|
||||
webui klikt stop
|
||||
→ POST /api/system/unit/test-web.service/stop
|
||||
→ API verbindt met helper socket
|
||||
→ helper voert: systemctl --user stop test-web.service
|
||||
→ systemd stopt de service met prevent_restart flag
|
||||
→ container verdwijnt, cidfile opgeruimd
|
||||
→ service is inactive ✓
|
||||
```
|
||||
|
||||
### Start
|
||||
|
||||
```
|
||||
webui klikt start
|
||||
→ POST /api/system/unit/test-web.service/start
|
||||
→ API verbindt met helper socket
|
||||
→ helper voert: systemctl --user start test-web.service
|
||||
→ quadlet generator heeft al een .service gegenereerd
|
||||
→ systemd start de container
|
||||
→ service is active ✓
|
||||
```
|
||||
|
||||
### Restart
|
||||
|
||||
```
|
||||
webui klikt restart
|
||||
→ POST /api/system/unit/test-web.service/restart
|
||||
→ API verbindt met helper socket
|
||||
→ helper voert: systemctl --user restart test-web.service
|
||||
→ systemd stopt en herstart de container
|
||||
→ service is active (na ~8-10 seconden) ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## daemon-reload via de helper
|
||||
|
||||
`daemon-reload` gaat inmiddels ook via de helper. Oorspronkelijk werkte `Manager.Reload` via D-Bus vanuit de container, maar om de D-Bus socket en `DBUS_SESSION_BUS_ADDRESS` mount volledig te kunnen verwijderen is daemon-reload als actie toegevoegd aan de helper.
|
||||
|
||||
De helper bouwt het commando zonder unit-argument: `systemctl --user daemon-reload`.
|
||||
|
||||
---
|
||||
|
||||
## Samenvatting
|
||||
|
||||
| Operatie | Via D-Bus vanuit container | Via helper op host |
|
||||
|---|---|---|
|
||||
| daemon-reload | ❌ Niet meer via D-Bus | ✅ Werkt |
|
||||
| Unit status opvragen | ✅ Werkt (read-only) | — |
|
||||
| Unit stoppen | ❌ Herstart zichzelf | ✅ Werkt |
|
||||
| Unit starten | ❌ Job wordt genegeerd | ✅ Werkt |
|
||||
| Unit herstarten | ❌ Blijft inactive | ✅ Werkt |
|
||||
|
||||
De helper is de minimale, veilige oplossing voor het fundamentele probleem dat een process in een user namespace niet dezelfde rechten heeft als een process in de host user session — ook niet als ze via dezelfde D-Bus socket communiceren.
|
||||
@@ -6,13 +6,14 @@ Unix socket helper die op de HOST draait als de gewone gebruiker.
|
||||
Ontvangt JSON verzoeken van de API container en voert systemctl --user uit.
|
||||
|
||||
Beveiligingsmodel:
|
||||
- Alleen start / stop / restart toegestaan
|
||||
- Alleen .service units
|
||||
- Unit naam mag alleen letters, cijfers, punt, koppelteken en underscore bevatten
|
||||
- Alleen start / stop / restart / daemon-reload toegestaan
|
||||
- start/stop/restart: alleen .service units met veilige tekens
|
||||
- daemon-reload: geen unit naam, wordt genegeerd
|
||||
- Meerdere gelijktijdige verbindingen worden afgehandeld via asyncio
|
||||
|
||||
Protocol:
|
||||
Inkomend: {"action": "start"|"stop"|"restart", "unit": "naam.service"}
|
||||
{"action": "daemon-reload", "unit": ""}
|
||||
Uitkomend: {"ok": true, "output": "..."} of {"ok": false, "error": "..."}
|
||||
"""
|
||||
|
||||
@@ -27,7 +28,7 @@ import sys
|
||||
# ── Configuratie ─────────────────────────────────────────────────────────────
|
||||
SOCKET_PATH = os.getenv(
|
||||
"HELPER_SOCKET",
|
||||
os.path.join(os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"), "podman-helper.sock")
|
||||
os.path.join(os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"), "podman-mvp", "podman-helper.sock")
|
||||
)
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
TIMEOUT = 30 # seconden maximaal per systemctl aanroep
|
||||
@@ -42,22 +43,26 @@ logging.basicConfig(
|
||||
log = logging.getLogger("podman-helper")
|
||||
|
||||
# ── Whitelist ─────────────────────────────────────────────────────────────────
|
||||
ALLOWED_ACTIONS = {"start", "stop", "restart"}
|
||||
UNIT_PATTERN = re.compile(r'^[a-zA-Z0-9._\-]+\.service$')
|
||||
ALLOWED_ACTIONS = {"start", "stop", "restart", "daemon-reload"}
|
||||
UNIT_PATTERN = re.compile(r'^[a-zA-Z0-9._\-]+\.service$')
|
||||
NO_UNIT_ACTIONS = {"daemon-reload"}
|
||||
|
||||
|
||||
def validate(action: str, unit: str) -> str | None:
|
||||
"""Geeft een foutmelding terug als het verzoek niet toegestaan is, anders None."""
|
||||
if action not in ALLOWED_ACTIONS:
|
||||
return f"Actie '{action}' niet toegestaan. Gebruik: {', '.join(sorted(ALLOWED_ACTIONS))}"
|
||||
if not UNIT_PATTERN.match(unit):
|
||||
if action not in NO_UNIT_ACTIONS and not UNIT_PATTERN.match(unit):
|
||||
return f"Ongeldige unit naam '{unit}'. Alleen .service units met veilige tekens."
|
||||
return None
|
||||
|
||||
|
||||
async def run_systemctl(action: str, unit: str) -> dict:
|
||||
"""Voert systemctl --user <action> <unit> uit en geeft het resultaat terug."""
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
"""Voert systemctl --user <action> [unit] uit en geeft het resultaat terug."""
|
||||
if action in NO_UNIT_ACTIONS:
|
||||
cmd = ["systemctl", "--user", action]
|
||||
else:
|
||||
cmd = ["systemctl", "--user", action, unit]
|
||||
log.info("Uitvoeren: %s", " ".join(cmd))
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,6 @@ Environment=XDG_RUNTIME_DIR=/run/user/%U
|
||||
Environment=LOG_LEVEL=INFO
|
||||
|
||||
ExecStart=/usr/bin/python3 %h/.config/podman-mvp/podman-helper/podman-helper.py
|
||||
ExecStopPost=-/bin/rm -f ${XDG_RUNTIME_DIR}/podman-helper.sock
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM docker.io/library/httpd:2.4
|
||||
COPY html/ /usr/local/apache2/htdocs/
|
||||
COPY conf/httpd.conf /usr/local/apache2/conf/httpd.conf
|
||||
@@ -250,6 +250,7 @@ header{
|
||||
.btn:hover{background: var(--btn2)}
|
||||
.btn:active{transform: translateY(1px)}
|
||||
.btn.small{padding:7px 9px; border-radius: 10px}
|
||||
.btn.tiny{padding:1px 5px; border-radius: 4px; font-size:11px; line-height:16px;}
|
||||
.btn.ghost{background: transparent}
|
||||
.btn.ok{border-color: rgba(45,212,191,.6)}
|
||||
.btn.bad{border-color: rgba(251,113,133,.6)}
|
||||
@@ -643,29 +644,30 @@ pre{
|
||||
.sidebar .navLabel { display: none; }
|
||||
.sidebar .tab { justify-content: center; }
|
||||
}
|
||||
/* Files tree (Portainer-ish) */
|
||||
/* Files tree (IDE sidebar stijl) */
|
||||
.file-folder-row{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
gap:6px;
|
||||
cursor:pointer;
|
||||
user-select:none;
|
||||
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
background: var(--folder-bg);
|
||||
transition: background .12s ease, border-color .12s ease, transform .06s ease;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
transition: background .1s ease;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-folder-row:hover{
|
||||
background: var(--folder-hover);
|
||||
border-color: rgba(96,165,250,.35);
|
||||
}
|
||||
|
||||
.file-folder-row:active{
|
||||
transform: translateY(1px);
|
||||
.file-entry:hover{
|
||||
background: var(--folder-hover);
|
||||
}
|
||||
|
||||
.file-folder-left{
|
||||
@@ -697,19 +699,19 @@ pre{
|
||||
|
||||
.file-folder-files{
|
||||
margin-left: 0;
|
||||
margin-top: 6px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px dashed var(--soft-line);
|
||||
margin-top: 0;
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.file-entry{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
padding:4px 0;
|
||||
border-bottom:1px dashed var(--soft-line);
|
||||
border-radius: 8px;
|
||||
gap:6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.file-entry-name{
|
||||
cursor:pointer;
|
||||
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -261,27 +261,19 @@ async function pollContainersDashboardStatsOnce() {
|
||||
if (containersDashboardStatsInFlight) return;
|
||||
containersDashboardStatsInFlight = true;
|
||||
try {
|
||||
const containers = await api('/containers-dashboard', 'GET');
|
||||
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
|
||||
const stats = await api('/stats', 'GET');
|
||||
|
||||
// totals per pod voor deze poll tick
|
||||
const podCpu = new Map(); // podName -> cpuPct sum
|
||||
const podMem = new Map(); // podName -> memBytes sum
|
||||
const podMemPct = new Map(); // podName -> memPct sum
|
||||
|
||||
for (const c of (list || [])) {
|
||||
const cname = normalizeContainerName((c?.Names && c.Names[0]) ? c.Names[0] : (c?.Names || c?.Name || c?.name || ''));
|
||||
if (!cname) continue;
|
||||
|
||||
for (const [cname, s] of Object.entries(stats || {})) {
|
||||
const key = cssSafeId(cname);
|
||||
|
||||
const cpuRaw = c?._dashboard_cpu;
|
||||
const memBytesRaw = c?._dashboard_mem_usage;
|
||||
const memPctRaw = c?._dashboard_mem_perc;
|
||||
|
||||
const cpuPct = Number(cpuRaw);
|
||||
const memBytes = Number(memBytesRaw);
|
||||
const memPct = Number(memPctRaw);
|
||||
const cpuPct = Number(s?.cpu);
|
||||
const memBytes = Number(s?.mem_usage);
|
||||
const memPct = Number(s?.mem_perc);
|
||||
|
||||
const pod = containersC2P.get(cname);
|
||||
if (pod) {
|
||||
|
||||
@@ -21,9 +21,10 @@ function filesSetEditorTheme(themeName) {
|
||||
|
||||
window.filesSetEditorTheme = filesSetEditorTheme;
|
||||
|
||||
function _isFolderCollapsed(folderKey) {
|
||||
return localStorage.getItem('files_folder_collapsed:' + folderKey) !== '0';
|
||||
// default collapsed = true
|
||||
function _isFolderCollapsed(folderKey, level) {
|
||||
const stored = localStorage.getItem('files_folder_collapsed:' + folderKey);
|
||||
if (stored !== null) return stored !== '0';
|
||||
return true; // standaard alles ingeklapt
|
||||
}
|
||||
|
||||
function _setFolderCollapsed(folderKey, v) {
|
||||
@@ -246,37 +247,26 @@ async function filesRefresh() {
|
||||
|
||||
function renderNode(node, level) {
|
||||
const folderKey = node.apiPath;
|
||||
const collapsed = _isFolderCollapsed(folderKey);
|
||||
const collapsed = _isFolderCollapsed(folderKey, level);
|
||||
const label = node.uiPath || 'root';
|
||||
const indent = Math.max(0, level) * 14;
|
||||
|
||||
const folder = folderByPath.get(folderKey);
|
||||
const files = (folder && folder.files) ? folder.files : [];
|
||||
|
||||
// subfolders (NU AL beschikbaar voor de badges)
|
||||
const childNames = Array.from(node.children.keys()).sort((a,b) => a.localeCompare(b));
|
||||
|
||||
// files (NU AL beschikbaar voor de badges)
|
||||
const sortedFiles = (files || []).slice().sort((a,b) => a.localeCompare(b));
|
||||
|
||||
const out = [];
|
||||
out.push(`
|
||||
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:${level === 0 ? '8px' : '6px'} 0 6px 0; padding-left:${indent}px; font-weight:600;">
|
||||
<span class="file-folder-left">
|
||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||
<span>📂 ${esc(label)}</span>
|
||||
</span>
|
||||
<span class="file-folder-meta" onclick="event.stopPropagation();">
|
||||
<span class="file-badge" title="Subfolders in deze map">📁 ${childNames.length}</span>
|
||||
<span class="file-badge" title="Bestanden in deze map">📄 ${sortedFiles.length}</span>
|
||||
|
||||
<span class="flex file-folder-actions">
|
||||
<button class="btn small ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
|
||||
<button class="btn small bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">🗑️</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
out.push(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="padding-left:${indent}px;">
|
||||
<span class="file-folder-left">
|
||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||
<span>📂 ${esc(label)}</span>
|
||||
</span>
|
||||
<span class="file-folder-actions" onclick="event.stopPropagation();">
|
||||
<button class="btn tiny ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
|
||||
<button class="btn tiny bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">✕</button>
|
||||
</span>
|
||||
</div>`);
|
||||
|
||||
out.push(`<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">`);
|
||||
|
||||
@@ -287,16 +277,14 @@ async function filesRefresh() {
|
||||
for (const f of sortedFiles) {
|
||||
const fullUi = node.uiPath ? `${node.uiPath}/${f}` : f;
|
||||
const fileKey = encodeURIComponent(fullUi);
|
||||
out.push(`
|
||||
<div class="file-entry" data-file="${fileKey}" style="padding-left:${indent + 18}px;">
|
||||
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
|
||||
<span class="file-entry-state"></span>
|
||||
</div>
|
||||
`);
|
||||
out.push(`<div class="file-entry" data-file="${fileKey}" style="padding-left:${indent + 16}px;">
|
||||
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
|
||||
<span class="file-entry-state"></span>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
if (!childNames.length && !sortedFiles.length) {
|
||||
out.push(`<div class="muted" style="padding-left:${indent + 18}px;">(leeg)</div>`);
|
||||
out.push(`<div class="muted" style="padding-left:${indent + 16}px; font-size:0.85em;">(leeg)</div>`);
|
||||
}
|
||||
|
||||
out.push(`</div>`);
|
||||
@@ -311,30 +299,25 @@ async function filesRefresh() {
|
||||
const rootFolder = folderByPath.get(FILES_ROOT);
|
||||
if (rootFolder && (rootFolder.files || []).length) {
|
||||
const folderKey = FILES_ROOT;
|
||||
const collapsed = _isFolderCollapsed(folderKey);
|
||||
parts.unshift(`
|
||||
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:8px 0 6px 0; font-weight:600;">
|
||||
<span class="file-folder-left">
|
||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||
<span>📂 root</span>
|
||||
</span>
|
||||
<span class="flex" onclick="event.stopPropagation();">
|
||||
<button class="btn small ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">
|
||||
${(rootFolder.files || []).slice().sort((a,b)=>a.localeCompare(b)).map(f => {
|
||||
const fullUi = f;
|
||||
const fileKey = encodeURIComponent(fullUi);
|
||||
return `
|
||||
<div class="file-entry" data-file="${fileKey}" style="padding-left:18px;">
|
||||
<span class="mono file-entry-name" style="cursor:pointer" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
|
||||
<span class="file-entry-state"></span>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`);
|
||||
const collapsed = _isFolderCollapsed(folderKey, 0);
|
||||
parts.unshift(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}">
|
||||
<span class="file-folder-left">
|
||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||
<span>📂 root</span>
|
||||
</span>
|
||||
<span class="file-folder-actions" onclick="event.stopPropagation();">
|
||||
<button class="btn tiny ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">
|
||||
${(rootFolder.files || []).slice().sort((a,b)=>a.localeCompare(b)).map(f => {
|
||||
const fileKey = encodeURIComponent(f);
|
||||
return `<div class="file-entry" data-file="${fileKey}" style="padding-left:16px;">
|
||||
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
|
||||
<span class="file-entry-state"></span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
treeEl.innerHTML = parts.join('');
|
||||
|
||||
@@ -620,34 +620,27 @@
|
||||
}
|
||||
|
||||
let graphCtx = null;
|
||||
let modalGraphCtx = null;
|
||||
let _escBound = false;
|
||||
|
||||
function renderGraph(model, opts = {}) {
|
||||
const host = document.getElementById('networksMapHost');
|
||||
if (!host) return;
|
||||
|
||||
// leeg host (placeholder weg)
|
||||
host.innerHTML = '';
|
||||
function _renderGraphInHost(hostEl, ctx, model, opts = {}) {
|
||||
hostEl.innerHTML = '';
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'mapTooltip';
|
||||
tooltip.style.display = 'none';
|
||||
host.appendChild(tooltip);
|
||||
hostEl.appendChild(tooltip);
|
||||
|
||||
const w = Math.max(600, host.clientWidth || 600);
|
||||
const h = Math.max(420, host.clientHeight || 420);
|
||||
|
||||
const svg = d3.select(host).append('svg')
|
||||
.attr('viewBox', `0 0 ${w} ${h}`);
|
||||
const w = Math.max(600, hostEl.clientWidth || 600);
|
||||
const h = Math.max(420, hostEl.clientHeight || 420);
|
||||
|
||||
const svg = d3.select(hostEl).append('svg').attr('viewBox', `0 0 ${w} ${h}`);
|
||||
const g = svg.append('g');
|
||||
|
||||
// zoom/pan
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.2, 2.5])
|
||||
.on('zoom', (ev) => g.attr('transform', ev.transform));
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(model.links)
|
||||
@@ -655,7 +648,6 @@
|
||||
.append('line')
|
||||
.attr('class', d => d.type === 'shared' ? 'graphLink shared' : 'graphLink');
|
||||
|
||||
// nodes
|
||||
const node = g.append('g')
|
||||
.selectAll('g')
|
||||
.data(model.nodes)
|
||||
@@ -663,49 +655,38 @@
|
||||
.append('g')
|
||||
.attr('class', d => `graphNode ${d.type}`);
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', d => d.type === 'network' ? 14 : 9);
|
||||
|
||||
node.append('circle').attr('r', d => d.type === 'network' ? 14 : 9);
|
||||
node.append('text')
|
||||
.attr('class', 'graphLabel')
|
||||
.attr('x', d => d.type === 'network' ? 18 : 12)
|
||||
.attr('y', 4)
|
||||
.text(d => d.label || d.key);
|
||||
|
||||
// drag
|
||||
const drag = d3.drag()
|
||||
.on('start', (ev, d) => {
|
||||
if (!ev.active) graphCtx.sim.alphaTarget(0.2).restart();
|
||||
if (!ev.active) ctx.sim.alphaTarget(0.2).restart();
|
||||
d.fx = d.x; d.fy = d.y;
|
||||
})
|
||||
.on('drag', (ev, d) => {
|
||||
d.fx = ev.x; d.fy = ev.y;
|
||||
})
|
||||
.on('drag', (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
|
||||
.on('end', (ev, d) => {
|
||||
if (!ev.active) graphCtx.sim.alphaTarget(0);
|
||||
if (!ev.active) ctx.sim.alphaTarget(0);
|
||||
d.fx = null; d.fy = null;
|
||||
});
|
||||
|
||||
node.call(drag);
|
||||
|
||||
// hover highlight (connected)
|
||||
node.on('mouseenter', (ev, d) => {
|
||||
node.classed('graphDim', true);
|
||||
link.classed('graphDim', true);
|
||||
|
||||
d3.select(ev.currentTarget).classed('graphDim', false);
|
||||
|
||||
link.each(function(l) {
|
||||
const sid = l.source?.id || l.source;
|
||||
const tid = l.target?.id || l.target;
|
||||
const hit = (sid === d.id || tid === d.id);
|
||||
if (hit) {
|
||||
if (sid === d.id || tid === d.id) {
|
||||
d3.select(this).classed('graphDim', false).classed('graphActive', true);
|
||||
} else {
|
||||
d3.select(this).classed('graphActive', false);
|
||||
}
|
||||
});
|
||||
|
||||
const typeLabel = d.type === 'network' ? 'Netwerk' : 'Container';
|
||||
const extra = d.type === 'network'
|
||||
? `Driver: ${d?.meta?.driver || 'onbekend'}`
|
||||
@@ -713,15 +694,11 @@
|
||||
tooltip.innerHTML = `<strong>${typeLabel}</strong><br>${d.label || d.key}<br>${extra}`;
|
||||
tooltip.style.display = 'block';
|
||||
});
|
||||
|
||||
node.on('mousemove', (ev) => {
|
||||
const rect = host.getBoundingClientRect();
|
||||
const x = (ev.clientX - rect.left) + 14;
|
||||
const y = (ev.clientY - rect.top) + 14;
|
||||
tooltip.style.left = `${x}px`;
|
||||
tooltip.style.top = `${y}px`;
|
||||
const rect = hostEl.getBoundingClientRect();
|
||||
tooltip.style.left = `${(ev.clientX - rect.left) + 14}px`;
|
||||
tooltip.style.top = `${(ev.clientY - rect.top) + 14}px`;
|
||||
});
|
||||
|
||||
node.on('mouseleave', () => {
|
||||
node.classed('graphDim', false);
|
||||
link.classed('graphDim', false).classed('graphActive', false);
|
||||
@@ -730,15 +707,14 @@
|
||||
|
||||
node.on('click', (ev, d) => {
|
||||
if (d.type === 'network') {
|
||||
openNetworkDetail(d.key);
|
||||
if (opts.onNetworkClick) opts.onNetworkClick(d.key);
|
||||
else openNetworkDetail(d.key);
|
||||
return;
|
||||
}
|
||||
|
||||
const s = document.getElementById('networksMapStatus');
|
||||
if (s) s.textContent = `Geselecteerd: ${d.type} ${d.key}`;
|
||||
const statusEl = opts.statusEl || document.getElementById('networksMapStatus');
|
||||
if (statusEl) statusEl.textContent = `Geselecteerd: ${d.type} ${d.key}`;
|
||||
});
|
||||
|
||||
// simulation
|
||||
const sim = d3.forceSimulation(model.nodes)
|
||||
.force('link', d3.forceLink(model.links).id(d => d.id).distance(80))
|
||||
.force('charge', d3.forceManyBody().strength(-30))
|
||||
@@ -746,23 +722,24 @@
|
||||
.force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16))
|
||||
.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
// detail-mode: pin netwerk in het midden
|
||||
if (opts.pinNetwork) {
|
||||
const pinId = opts.pinNetwork;
|
||||
const pinned = model.nodes.find(n => n.id === pinId);
|
||||
if (pinned) {
|
||||
pinned.fx = w / 2;
|
||||
pinned.fy = h / 2;
|
||||
}
|
||||
}
|
||||
graphCtx = { svg, g, sim, model, zoom };
|
||||
|
||||
if (opts.pinNetwork) {
|
||||
const pinned = model.nodes.find(n => n.id === opts.pinNetwork);
|
||||
if (pinned) { pinned.fx = w / 2; pinned.fy = h / 2; }
|
||||
}
|
||||
|
||||
ctx.svg = svg; ctx.g = g; ctx.sim = sim; ctx.model = model; ctx.zoom = zoom;
|
||||
}
|
||||
|
||||
function renderGraph(model, opts = {}) {
|
||||
const host = document.getElementById('networksMapHost');
|
||||
if (!host) return;
|
||||
graphCtx = {};
|
||||
_renderGraphInHost(host, graphCtx, model, opts);
|
||||
}
|
||||
|
||||
function openNetworkDetail(networkName) {
|
||||
@@ -839,6 +816,79 @@
|
||||
if (s) s.textContent = buildMapStatus('Global', model.nodes.length, model.links.length);
|
||||
}
|
||||
|
||||
// ---- Vergroot modal ----
|
||||
|
||||
function openModalNetworkDetail(networkName) {
|
||||
const host = document.getElementById('networksMapModalHost');
|
||||
if (!host) return;
|
||||
if (!modalGraphCtx) modalGraphCtx = {};
|
||||
|
||||
const model = buildNetworkDetailGraphModel(networkName);
|
||||
_renderGraphInHost(host, modalGraphCtx, model, {
|
||||
pinNetwork: `net:${networkName}`,
|
||||
onNetworkClick: openModalNetworkDetail,
|
||||
statusEl: document.getElementById('networksMapModalStatus'),
|
||||
});
|
||||
|
||||
const s = document.getElementById('networksMapModalStatus');
|
||||
if (s) s.textContent = buildMapStatus(`Detail: ${networkName}`, model.nodes.length, model.links.length);
|
||||
|
||||
const list = Array.isArray(state.list?.networks) ? state.list.networks : [];
|
||||
const meta = list.find(n => (n?.name || n?.Name) === networkName) || { name: networkName };
|
||||
const usage = state.usage?.byNetwork?.[networkName];
|
||||
const containers = Array.isArray(usage?.containers) ? usage.containers : [];
|
||||
const driver = String(meta?.driver || meta?.Driver || '');
|
||||
const subnets = fmtSubnets(meta).join(', ');
|
||||
const listHtml = containers.length
|
||||
? `<ul class="mapDetailList">${containers.map(c => `<li>${esc(c.name || c.id.slice(0, 12))}</li>`).join('')}</ul>`
|
||||
: `<div class="muted">Geen containers op dit netwerk.</div>`;
|
||||
|
||||
const title = document.getElementById('networksMapModalDetailTitle');
|
||||
const body = document.getElementById('networksMapModalDetailBody');
|
||||
const side = document.getElementById('networksMapModalSide');
|
||||
if (title) title.textContent = networkName;
|
||||
if (body) body.innerHTML = `
|
||||
<div class="mapDetailGrid">
|
||||
<div class="mapDetailKey">Driver</div><div>${esc(driver || '—')}</div>
|
||||
<div class="mapDetailKey">Subnets</div><div>${esc(subnets || '—')}</div>
|
||||
<div class="mapDetailKey">Containers</div><div>${containers.length}</div>
|
||||
</div>${listHtml}`;
|
||||
if (side) side.style.display = '';
|
||||
}
|
||||
|
||||
function _openModalGlobalMap() {
|
||||
const host = document.getElementById('networksMapModalHost');
|
||||
if (!host) return;
|
||||
modalGraphCtx = {};
|
||||
const model = buildGlobalGraphModel();
|
||||
_renderGraphInHost(host, modalGraphCtx, model, {
|
||||
onNetworkClick: openModalNetworkDetail,
|
||||
statusEl: document.getElementById('networksMapModalStatus'),
|
||||
});
|
||||
const s = document.getElementById('networksMapModalStatus');
|
||||
if (s) s.textContent = buildMapStatus('Global', model.nodes.length, model.links.length);
|
||||
const side = document.getElementById('networksMapModalSide');
|
||||
if (side) side.style.display = 'none';
|
||||
}
|
||||
|
||||
function openMapModal() {
|
||||
const modal = document.getElementById('networksMapModal');
|
||||
if (!modal) return;
|
||||
modal.style.display = 'flex';
|
||||
requestAnimationFrame(() => { _openModalGlobalMap(); });
|
||||
}
|
||||
|
||||
function closeMapModal() {
|
||||
const modal = document.getElementById('networksMapModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
if (modalGraphCtx && modalGraphCtx.sim) modalGraphCtx.sim.stop();
|
||||
modalGraphCtx = {};
|
||||
const host = document.getElementById('networksMapModalHost');
|
||||
if (host) host.innerHTML = '';
|
||||
const side = document.getElementById('networksMapModalSide');
|
||||
if (side) side.style.display = 'none';
|
||||
}
|
||||
|
||||
function renderNetworks() {
|
||||
const tbody = document.getElementById('networksTbody');
|
||||
const rel = document.getElementById('networksRelations');
|
||||
@@ -1019,6 +1069,44 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Modal: expand knop
|
||||
const expandBtn = document.getElementById('networksMapExpandBtn');
|
||||
if (expandBtn && !expandBtn.dataset.bound) {
|
||||
expandBtn.dataset.bound = '1';
|
||||
expandBtn.addEventListener('click', openMapModal);
|
||||
}
|
||||
|
||||
// Modal: sluitknop
|
||||
const modalCloseBtn = document.getElementById('networksMapModalClose');
|
||||
if (modalCloseBtn && !modalCloseBtn.dataset.bound) {
|
||||
modalCloseBtn.dataset.bound = '1';
|
||||
modalCloseBtn.addEventListener('click', closeMapModal);
|
||||
}
|
||||
|
||||
// Modal: overlay klik
|
||||
const mapModal = document.getElementById('networksMapModal');
|
||||
if (mapModal && !mapModal.dataset.bound) {
|
||||
mapModal.dataset.bound = '1';
|
||||
mapModal.addEventListener('click', (ev) => { if (ev.target === mapModal) closeMapModal(); });
|
||||
}
|
||||
|
||||
// Modal: terug naar global
|
||||
const modalBackBtn = document.getElementById('networksMapModalBackBtn');
|
||||
if (modalBackBtn && !modalBackBtn.dataset.bound) {
|
||||
modalBackBtn.dataset.bound = '1';
|
||||
modalBackBtn.addEventListener('click', _openModalGlobalMap);
|
||||
}
|
||||
|
||||
// ESC sluit modal (éénmalig binden)
|
||||
if (!_escBound) {
|
||||
_escBound = true;
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
const m = document.getElementById('networksMapModal');
|
||||
if (m && m.style.display !== 'none') closeMapModal();
|
||||
});
|
||||
}
|
||||
|
||||
const detailBody = document.getElementById('networksDetailBody');
|
||||
if (detailBody && !detailBody.dataset.bound) {
|
||||
detailBody.dataset.bound = '1';
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
let volumesData = [];
|
||||
let volumeContainersMap = {};
|
||||
|
||||
async function loadVolumes() {
|
||||
const tbody = document.getElementById("volumes-tbody");
|
||||
try {
|
||||
const [volumes, containers] = await Promise.all([
|
||||
fetch("/api/volumes").then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }),
|
||||
fetch("/api/containers-dashboard").then(r => r.ok ? r.json() : []).catch(() => [])
|
||||
]);
|
||||
|
||||
volumesData = Array.isArray(volumes) ? volumes : [];
|
||||
|
||||
// containers-dashboard geeft Mounts als strings (destination paden).
|
||||
// Volledige mount-info (Type + Name) zit alleen in de inspect endpoint.
|
||||
// Haal inspect op voor alle containers met niet-lege Mounts, parallel.
|
||||
const containerList = Array.isArray(containers) ? containers : [];
|
||||
const withMounts = containerList.filter(c => (c.Mounts || []).length > 0);
|
||||
const inspectResults = await Promise.all(
|
||||
withMounts.map(c => {
|
||||
const name = (c.Names && c.Names[0]) || "";
|
||||
if (!name) return Promise.resolve(null);
|
||||
return fetch("/api/containers/inspect/" + encodeURIComponent(name))
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.catch(() => null);
|
||||
})
|
||||
);
|
||||
|
||||
// Bouw volume → containers mapping: filter op Type === "volume"
|
||||
volumeContainersMap = {};
|
||||
for (let i = 0; i < withMounts.length; i++) {
|
||||
const inspect = inspectResults[i];
|
||||
if (!inspect) continue;
|
||||
const cname = (withMounts[i].Names && withMounts[i].Names[0]) || "";
|
||||
for (const m of (inspect.Mounts || [])) {
|
||||
if (m.Type === "volume" && m.Name) {
|
||||
(volumeContainersMap[m.Name] = volumeContainersMap[m.Name] || []).push(cname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.updateNavCount === "function") {
|
||||
window.updateNavCount("countNavVolumes", volumesData.length);
|
||||
}
|
||||
renderVolumes(volumesData);
|
||||
} catch (e) {
|
||||
volumesData = [];
|
||||
if (typeof window.updateNavCount === "function") window.updateNavCount("countNavVolumes", 0);
|
||||
if (tbody) {
|
||||
const box = typeof window.renderStateBox === "function"
|
||||
? window.renderStateBox("error", "Volumes laden mislukt", e.message || String(e))
|
||||
: "Volumes laden mislukt.";
|
||||
tbody.innerHTML = `<tr><td colspan="7">${box}</td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _volRelTime(isoStr) {
|
||||
if (!isoStr) return "-";
|
||||
const d = new Date(isoStr);
|
||||
if (isNaN(d)) return String(isoStr);
|
||||
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s geleden`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m geleden`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}u geleden`;
|
||||
return `${Math.floor(s / 86400)} dagen geleden`;
|
||||
}
|
||||
|
||||
function _volEsc(s) {
|
||||
return String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function renderVolumes(volumes) {
|
||||
const tbody = document.getElementById("volumes-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (!volumes.length) {
|
||||
const box = typeof window.renderStateBox === "function"
|
||||
? window.renderStateBox("empty", "Geen volumes", "Er zijn momenteel geen volumes gevonden.")
|
||||
: "Geen volumes gevonden.";
|
||||
tbody.innerHTML = `<tr><td colspan="7">${box}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
volumes.forEach(vol => {
|
||||
const name = vol.Name || "-";
|
||||
const driver = vol.Driver || "-";
|
||||
const mp = vol.Mountpoint || "";
|
||||
const mpShort = mp.length > 45 ? mp.slice(0, 42) + "…" : mp;
|
||||
const created = _volRelTime(vol.CreatedAt);
|
||||
const labels = vol.Labels || {};
|
||||
const cNames = volumeContainersMap[name] || [];
|
||||
const inUse = cNames.length > 0;
|
||||
|
||||
const labelHtml = Object.keys(labels).length
|
||||
? Object.keys(labels).map(k =>
|
||||
`<span class="badge muted" title="${_volEsc(k + "=" + labels[k])}">${_volEsc(k)}</span>`
|
||||
).join(" ")
|
||||
: `<span class="muted">-</span>`;
|
||||
|
||||
const containersHtml = cNames.length
|
||||
? cNames.map(n => `<span class="badge ok">${_volEsc(n)}</span>`).join(" ")
|
||||
: `<span class="muted">-</span>`;
|
||||
|
||||
const nameEnc = encodeURIComponent(name);
|
||||
const disabledAttr = inUse ? `disabled title="In gebruik door een container"` : "";
|
||||
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><strong>${_volEsc(name)}</strong></td>
|
||||
<td class="muted">${_volEsc(driver)}</td>
|
||||
<td class="muted mono" title="${_volEsc(mp)}">${_volEsc(mpShort)}</td>
|
||||
<td class="muted">${created}</td>
|
||||
<td>${labelHtml}</td>
|
||||
<td>${containersHtml}</td>
|
||||
<td>
|
||||
<button class="btn small bad" onclick="removeVolume(decodeURIComponent('${nameEnc}'))" ${disabledAttr}>
|
||||
Verwijder
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function removeVolume(name) {
|
||||
if (!confirm(`Volume '${name}' verwijderen?\nDit kan niet ongedaan worden gemaakt.`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/volumes/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
alert(`Verwijderen mislukt (${res.status}): ${body}`);
|
||||
return;
|
||||
}
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
alert(`Fout: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneVolumes() {
|
||||
if (!confirm(
|
||||
"Prune volumes\n\n" +
|
||||
"Dit verwijdert alle volumes die niet aan een container gekoppeld zijn.\n" +
|
||||
"Dit kan niet ongedaan worden gemaakt.\n\n" +
|
||||
"Doorgaan?"
|
||||
)) return;
|
||||
try {
|
||||
const res = await fetch("/api/volumes/prune", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
alert(`Prune mislukt (${res.status}): ${body}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const removed = Array.isArray(data) ? data.length : 0;
|
||||
alert(`Prune voltooid. ${removed} volume(s) verwijderd.`);
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
alert(`Fout: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Create Volume Modal ----
|
||||
|
||||
function openCreateVolumeModal() {
|
||||
document.getElementById("createVolumeModalBack").style.display = "flex";
|
||||
document.getElementById("createVolumeName").value = "";
|
||||
document.getElementById("createVolumeLabels").value = "";
|
||||
}
|
||||
|
||||
function hideCreateVolumeModal() {
|
||||
document.getElementById("createVolumeModalBack").style.display = "none";
|
||||
}
|
||||
|
||||
function closeCreateVolumeModal(e) {
|
||||
if (e.target.id === "createVolumeModalBack") hideCreateVolumeModal();
|
||||
}
|
||||
|
||||
async function createVolume() {
|
||||
const name = document.getElementById("createVolumeName").value.trim();
|
||||
if (!name) { alert("Naam is verplicht."); return; }
|
||||
|
||||
const labelsRaw = document.getElementById("createVolumeLabels").value.trim();
|
||||
const labels = {};
|
||||
if (labelsRaw) {
|
||||
for (const line of labelsRaw.split(/\r?\n/)) {
|
||||
const l = line.trim();
|
||||
if (!l) continue;
|
||||
const idx = l.indexOf("=");
|
||||
if (idx > 0) labels[l.slice(0, idx).trim()] = l.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const body = { name };
|
||||
if (Object.keys(labels).length) body.labels = labels;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/volumes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
alert(`Aanmaken mislukt (${res.status}): ${err}`);
|
||||
return;
|
||||
}
|
||||
hideCreateVolumeModal();
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
alert(`Fout: ${e.message}`);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>API Documentatie — Podman MVP</title>
|
||||
<link rel="stylesheet" href="../assets/swagger-ui/swagger-ui.css" />
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
.topbar { background: #1a1a2e; padding: 12px 20px; display: flex; align-items: center; gap: 16px; }
|
||||
.topbar a { color: #ccc; text-decoration: none; font-size: 0.85rem; }
|
||||
.topbar a:hover { color: #fff; }
|
||||
.topbar-title { color: #fff; font-weight: 600; font-size: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<span class="topbar-title">Podman MVP — API Documentatie</span>
|
||||
<a href="/">← Terug naar UI</a>
|
||||
</div>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="../assets/swagger-ui/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
url: '/api/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
||||
layout: 'BaseLayout',
|
||||
tryItOutEnabled: true,
|
||||
requestInterceptor: (req) => {
|
||||
// Zorg dat Try it out via dezelfde origin gaat (geen CORS issues)
|
||||
req.url = req.url.replace(/^https?:\/\/[^/]+/, '');
|
||||
return req;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+107
-18
@@ -29,9 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button class="btn ghost" onclick="pingApi()">◉ Ping</button>
|
||||
<button class="btn" onclick="refreshActive()">↻ Ververs</button>
|
||||
<button class="btn ghost" id="themeToggleBtn" title="Schakel light/dark mode">◐ Theme</button>
|
||||
<button class="btn ghost" id="themeToggleBtn" title="Schakel thema">🌙</button>
|
||||
<span class="statusline headerMeta" id="lastRefreshHeader">Laatste refresh: -</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,6 +55,9 @@
|
||||
<div class="tab" id="tab-images" onclick="setTab('images')" title="Images">
|
||||
<span class="navIcon">📦</span><span class="navLabel">Images</span><span class="navCount" id="countNavImages">-</span>
|
||||
</div>
|
||||
<div class="tab" id="tab-volumes" onclick="setTab('volumes')" title="Volumes">
|
||||
<span class="navIcon">🗄️</span><span class="navLabel">Volumes</span><span class="navCount" id="countNavVolumes">-</span>
|
||||
</div>
|
||||
<div class="tab" id="tab-files" onclick="setTab('files')" title="Files">
|
||||
<span class="navIcon">📁</span><span class="navLabel">Files</span><span class="navCount" id="countNavFiles">-</span>
|
||||
</div>
|
||||
@@ -110,6 +111,7 @@
|
||||
<button class="btn" onclick="setTab('networks')">Ga naar netwerken</button>
|
||||
<button class="btn" onclick="setTab('images')">Ga naar images</button>
|
||||
<button class="btn" onclick="setTab('files')">Ga naar files</button>
|
||||
<a class="btn ghost" href="/docs/" target="_blank">API docs ↗</a>
|
||||
</div>
|
||||
<div class="hint">Gebruik de zijbalk voor detailbeheer; deze acties geven snelle toegang tot de hoofdsecties.</div>
|
||||
</div>
|
||||
@@ -201,7 +203,8 @@
|
||||
<input type="checkbox" id="networksMapConnectedOnly">
|
||||
<span class="muted">Alleen verbonden</span>
|
||||
</label>
|
||||
<span class="muted" id="networksMapStatus" style="margin-left:auto;">Kaartweergave (placeholder)</span>
|
||||
<span class="muted" id="networksMapStatus" style="margin-left:auto;">Kaartweergave (placeholder)</span>
|
||||
<button class="btn small ghost" type="button" id="networksMapExpandBtn" title="Vergroot naar volledig scherm">⛶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -304,6 +307,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-volumes" class="grid" style="display:none">
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="cardHeader">
|
||||
<div class="cardTitle">Volumes</div>
|
||||
<div class="flex">
|
||||
<button class="btn" onclick="loadVolumes()">Ververs</button>
|
||||
<button class="btn ok" onclick="openCreateVolumeModal()">+ Volume</button>
|
||||
<button class="btn bad" onclick="pruneVolumes()">Prune</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Driver</th>
|
||||
<th>Mountpoint</th>
|
||||
<th>Aangemaakt</th>
|
||||
<th>Labels</th>
|
||||
<th>Containers</th>
|
||||
<th style="width:90px;">Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="volumes-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-files" class="grid" style="display:none">
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="cardHeader">
|
||||
@@ -394,6 +426,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Volume Modal -->
|
||||
<div class="modalBack" id="createVolumeModalBack" style="display:none;" onclick="closeCreateVolumeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()" style="width:460px;">
|
||||
<div class="modalHeader">
|
||||
<div class="modalTitle">Volume aanmaken</div>
|
||||
<button class="btn small ghost" onclick="hideCreateVolumeModal()">Sluiten</button>
|
||||
</div>
|
||||
<div class="modalBody">
|
||||
<div style="margin-bottom:12px;">
|
||||
<label class="label">Naam <span style="color:var(--bad)">*</span></label>
|
||||
<input id="createVolumeName" class="input" type="text" placeholder="mijn-volume" style="width:100%;" />
|
||||
</div>
|
||||
<div style="margin-bottom:16px;">
|
||||
<label class="label">Labels <span class="muted">(optioneel, één key=value per regel)</span></label>
|
||||
<textarea id="createVolumeLabels" class="textarea mono" rows="3" placeholder="app=myapp env=production" style="width:100%;"></textarea>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button class="btn ok" onclick="createVolume()">Aanmaken</button>
|
||||
<button class="btn ghost" onclick="hideCreateVolumeModal()">Annuleren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build Modal -->
|
||||
<div class="modalBack" id="buildModalBack" style="display:none;" onclick="closeBuildModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()" style="width:700px;">
|
||||
@@ -589,6 +645,9 @@
|
||||
if (tab === "images") {
|
||||
loadImages();
|
||||
}
|
||||
if (tab === "volumes") {
|
||||
loadVolumes();
|
||||
}
|
||||
// Start/stop live stats alleen in Containers tab (polling via /containers-dashboard)
|
||||
if (tab === 'containers') startContainersDashboardStatsPoll();
|
||||
else stopContainersDashboardStatsPoll();
|
||||
@@ -623,21 +682,30 @@
|
||||
// ---- Health / Ping ----
|
||||
async function pingApi() {
|
||||
try {
|
||||
// simpele ping: pods ophalen
|
||||
await api('/pods-dashboard', 'GET');
|
||||
setApiState(true, 'API: OK');
|
||||
const h = await api('/health', 'GET');
|
||||
const helperOk = h?.helper?.ok === true;
|
||||
if (!h?.ok) {
|
||||
const detail = !h?.podman?.ok ? 'podman' : !h?.systemd_user?.reachable ? 'systemd' : 'onbekend';
|
||||
setApiState('error', `API: fout (${detail})`);
|
||||
} else if (!helperOk) {
|
||||
setApiState('warn', 'API: OK | ⚠️ helper');
|
||||
} else {
|
||||
setApiState('ok', 'API: OK');
|
||||
}
|
||||
} catch (e) {
|
||||
setApiState(false, 'API: fout (' + e.message + ')');
|
||||
setApiState('error', 'API: fout (' + e.message + ')');
|
||||
showModal('API fout', e.stack || e.message);
|
||||
}
|
||||
}
|
||||
function setApiState(ok, msg) {
|
||||
function setApiState(state, 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)';
|
||||
const ok = state === 'ok';
|
||||
const warn = state === 'warn';
|
||||
dot.style.background = ok ? 'var(--ok)' : warn ? 'var(--warn, #f59e0b)' : 'var(--bad)';
|
||||
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : warn ? '0 0 0 6px rgba(245,158,11,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
|
||||
document.getElementById('statusLine').textContent = msg;
|
||||
const apiStat = document.getElementById('dashboardApiState');
|
||||
if (apiStat) apiStat.textContent = ok ? 'OK' : 'Fout';
|
||||
if (apiStat) apiStat.textContent = ok ? 'OK' : warn ? 'Waarschuwing' : 'Fout';
|
||||
}
|
||||
|
||||
function currentClockText() {
|
||||
@@ -671,10 +739,10 @@
|
||||
const nCount = Array.isArray(networks?.networks) ? networks.networks.length : 0;
|
||||
updateNavCount('countNavNetworks', nCount);
|
||||
}
|
||||
setApiState(true, 'API: OK');
|
||||
setLastRefreshNow();
|
||||
pingApi();
|
||||
} catch (e) {
|
||||
setApiState(false, 'API: fout (' + e.message + ')');
|
||||
setApiState('error', 'API: fout (' + e.message + ')');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,9 +776,9 @@
|
||||
function updateThemeToggleUi(theme) {
|
||||
const btn = document.getElementById('themeToggleBtn');
|
||||
if (!btn) return;
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
btn.textContent = `Theme: ${theme === 'dark' ? 'Dark' : 'Light'}`;
|
||||
btn.title = `Schakel naar ${next === 'dark' ? 'dark' : 'light'} mode`;
|
||||
const goingTo = theme === 'dark' ? 'light' : 'dark';
|
||||
btn.textContent = goingTo === 'light' ? '☀️' : '🌙';
|
||||
btn.title = goingTo === 'light' ? 'Schakel naar licht thema' : 'Schakel naar donker thema';
|
||||
}
|
||||
|
||||
function applyTheme(theme, persist = false) {
|
||||
@@ -759,8 +827,29 @@
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
|
||||
<script src="assets/js/d3.min.js"></script>
|
||||
<script src="assets/js/tabs/networks.js"></script>
|
||||
<script src="assets/js/tabs/images.js"></script>
|
||||
<script src="assets/js/tabs/volumes.js"></script>
|
||||
|
||||
<!-- Netwerktopologie vergroot modal -->
|
||||
<div id="networksMapModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:1000; align-items:center; justify-content:center;">
|
||||
<div style="position:relative; width:90vw; height:90vh; background:var(--card-bg); border:1px solid var(--card-border); border-radius:16px; display:flex; flex-direction:column; overflow:hidden;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 16px; border-bottom:1px solid var(--card-border); flex:0 0 auto;">
|
||||
<span class="muted mono" id="networksMapModalStatus">Netwerktopologie</span>
|
||||
<button class="btn ghost" id="networksMapModalClose" title="Sluiten (Esc)">✕</button>
|
||||
</div>
|
||||
<div style="display:flex; flex:1; min-height:0;">
|
||||
<div id="networksMapModalHost" style="flex:1; min-width:0; min-height:0; position:relative; overflow:hidden; background:var(--map-bg);"></div>
|
||||
<div id="networksMapModalSide" style="display:none; width:280px; flex:0 0 280px; border-left:1px solid var(--card-border); overflow-y:auto; padding:12px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
|
||||
<strong id="networksMapModalDetailTitle">Netwerk</strong>
|
||||
<button class="btn small ghost" id="networksMapModalBackBtn">← Terug</button>
|
||||
</div>
|
||||
<div id="networksMapModalDetailBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user