Compare commits
21 Commits
2c5cb07cdb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94a2f4586a | |||
| 7d2f19f81f | |||
| fba9b59445 | |||
| 2dfe53895b | |||
| 5f6719464d | |||
| 249d24721c | |||
| f8bbb783b0 | |||
| 4404c02967 | |||
| bae6fd8b9f | |||
| ed94ee31f4 | |||
| 5196e7840f | |||
| a05d79ae2c | |||
| 5e7d1b887c | |||
| e469508570 | |||
| c338955320 | |||
| f016c2bae0 | |||
| e922cea167 | |||
| 7d2c205930 | |||
| 580c301718 | |||
| 1c61854143 | |||
| bacab3b20a |
@@ -93,9 +93,8 @@ podman run -d --pod mvp-pod \
|
|||||||
--ipc=host \
|
--ipc=host \
|
||||||
--pid=host \
|
--pid=host \
|
||||||
-e XDG_RUNTIME_DIR=/run/user/1000 \
|
-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/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 \
|
-v /home/kodi/.config/containers:/app/workloads:rw \
|
||||||
mvp-control:latest
|
mvp-control:latest
|
||||||
|
|
||||||
@@ -103,7 +102,7 @@ podman run -d --pod mvp-pod \
|
|||||||
Important notes:
|
Important notes:
|
||||||
- Backend communicates with Podman through unix socket.
|
- Backend communicates with Podman through unix socket.
|
||||||
- User-session Podman is used (not root).
|
- 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.
|
- Host PID/IPC namespaces are intentional.
|
||||||
|
|
||||||
Do NOT change these assumptions without proposal.
|
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_networks.py` — networks tab endpoints
|
||||||
- `control/app_files.py` — files/workloads endpoints (tree/read/save/etc.)
|
- `control/app_files.py` — files/workloads endpoints (tree/read/save/etc.)
|
||||||
- `control/app_images.py` — images endpoints
|
- `control/app_images.py` — images endpoints
|
||||||
|
- `control/app_volumes.py` — volumes endpoints (list/create/delete/prune/exists)
|
||||||
|
|
||||||
4. **Shared Infrastructure Layer**
|
4. **Shared Infrastructure Layer**
|
||||||
- `control/common.py`
|
- `control/common.py`
|
||||||
@@ -73,10 +74,11 @@ After any change affecting backend routing or shared helpers, run:
|
|||||||
```bash
|
```bash
|
||||||
python3 -m py_compile control/app.py control/common.py control/app_system.py \
|
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_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/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/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/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/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
|
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:
|
Backend runtime assumptions:
|
||||||
|
|
||||||
- DBUS_SESSION_BUS_ADDRESS usage
|
- XDG_RUNTIME_DIR=/run/user/1000 (env var voor Podman socket pad)
|
||||||
- XDG_RUNTIME_DIR mounts
|
- Podman unix socket: /run/user/1000/podman/podman.sock
|
||||||
- Podman unix socket access
|
- Helper socket directory: /run/user/1000/podman-mvp → /run/podman-mvp
|
||||||
- /run/user/1000 mounts
|
|
||||||
- host PID namespace
|
- host PID namespace
|
||||||
- host IPC namespace
|
- host IPC namespace
|
||||||
|
|
||||||
Reason:
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
Chat export (zichtbare user-assistant conversatie)
|
|
||||||
Datum: 2026-03-04
|
|
||||||
|
|
||||||
User:
|
|
||||||
Onderzoek de mogelikhein om in het container overzicht in het three-dot menu naast elke container(dus niet pod) De podman exec -it functionaliteit toe te voegen. Je hebt mijn toestemming om zelf curls op http://host.containers.internal:8081 uit te voeren met escalated execution
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Ik ga eerst de huidige backend- en WebUI-implementatie van het container-overzicht en het three-dot menu in kaart brengen, daarna test ik met curl welke API-capaciteiten al beschikbaar zijn voor exec zodat ik een minimale, backward-compatible aanpak kan voorstellen.
|
|
||||||
|
|
||||||
Assistant (samenvatting):
|
|
||||||
- Relevante code gevonden in control/app_containers.py en webui/html/assets/js/tabs/containers.js.
|
|
||||||
- OpenAPI en live endpoints gecontroleerd via curl.
|
|
||||||
- Bevestigd dat er nog geen exec-endpoint bestond.
|
|
||||||
|
|
||||||
User:
|
|
||||||
Ik wil eerst backend-sessie + curl-werkend, doen nog helemaal niets aan de ui. Maak een kort implementatie plan en toen welke bestande je gaat aanpassen. Nog geen apply, nog niet doorvoeren
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Korte plan + bestanden:
|
|
||||||
1. Scope backend-only.
|
|
||||||
2. Nieuwe endpoints voor exec start/stream/input/resize/stop.
|
|
||||||
3. In-memory sessiemanager.
|
|
||||||
4. Foutafhandeling/guardrails.
|
|
||||||
5. Curl-validatie.
|
|
||||||
Bestanden: control/app_containers.py (primair), control/common.py (alleen indien nodig), control/app.py (liefst niet).
|
|
||||||
|
|
||||||
User:
|
|
||||||
Doe nog een keer goed odnerzoek of het wel haalbaar is want "podman auto-update --dry-run" werkt bijvoorbeeld niet in de api container
|
|
||||||
|
|
||||||
Assistant (samenvatting onderzoek):
|
|
||||||
- podman CLI ontbreekt in backend image/runtime (command not found).
|
|
||||||
- Backend kan wel met Podman REST via socket praten (health/test-hybrid/containers endpoints).
|
|
||||||
- Conclusie: CLI-route niet haalbaar zonder imagewijziging; REST-gebaseerde exec-sessie wel haalbaar.
|
|
||||||
|
|
||||||
User:
|
|
||||||
Nog geen akkoord, vertel eerst wat je gaat doen met de REST-gebaseerde exec-sessie (backend + curl)
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Plan REST-gebaseerd:
|
|
||||||
1. In-memory sessies.
|
|
||||||
2. POST /containers/{name}/exec/start.
|
|
||||||
3. GET /containers/exec/{session_id}/stream (SSE), POST input, POST resize.
|
|
||||||
4. POST stop.
|
|
||||||
5. Strikte validatie + backward compatibility.
|
|
||||||
6. Curl tests.
|
|
||||||
|
|
||||||
User:
|
|
||||||
Doe eerst de validatie nog geen implementatie akkoord
|
|
||||||
|
|
||||||
Assistant (samenvatting validatie):
|
|
||||||
- Socketpad en runtime bevestigd via inspect.
|
|
||||||
- Directe host-socket test vanuit agent-namespace beperkt.
|
|
||||||
- Conclusie bleef: REST-route haalbaar, maar laatste 100%-check via host-curl gewenst.
|
|
||||||
|
|
||||||
User:
|
|
||||||
"Als je wilt, doe ik nu alleen die laatste read-only socket-validatie (nog steeds zonder implementatie)." Ja
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Read-only socket-curl set gegeven voor host:
|
|
||||||
- _ping/version/info
|
|
||||||
- containers list
|
|
||||||
- exec create
|
|
||||||
- exec inspect
|
|
||||||
- exec resize
|
|
||||||
(optioneel exec start)
|
|
||||||
|
|
||||||
User (host outputs):
|
|
||||||
- Exec create gaf Id.
|
|
||||||
- Exec inspect werkte.
|
|
||||||
- Resize vóór start gaf expected 500 state improper.
|
|
||||||
- Exec start gaf 200 raw-stream.
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Conclusie: haalbaarheid bevestigd. Start->resize volgorde belangrijk. Klaar voor implementatie.
|
|
||||||
|
|
||||||
User:
|
|
||||||
akkoord
|
|
||||||
|
|
||||||
Assistant (implementatie):
|
|
||||||
- Backend-only exec sessie toegevoegd in control/app_containers.py.
|
|
||||||
- Nieuwe endpoints:
|
|
||||||
- POST /containers/{name}/exec/start
|
|
||||||
- GET /containers/exec/{session_id}
|
|
||||||
- GET /containers/exec/{session_id}/stream
|
|
||||||
- POST /containers/exec/{session_id}/input
|
|
||||||
- POST /containers/exec/{session_id}/resize
|
|
||||||
- POST /containers/exec/{session_id}/stop
|
|
||||||
- Compile checks OK.
|
|
||||||
- Op actieve runtime eerst 404 tot backend redeploy (verwacht).
|
|
||||||
|
|
||||||
User:
|
|
||||||
ja
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Deploy/run stappen gegeven (build image, restart backend container, curl tests).
|
|
||||||
|
|
||||||
User (testresultaten):
|
|
||||||
- OpenAPI toont nieuwe exec routes.
|
|
||||||
- start/stream/input/resize/stop werken.
|
|
||||||
- Ping bleef doorlopen in terminal 1.
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Uitleg: twee verschillende session_id’s gebruikt; stop de juiste sessie vanuit andere terminal.
|
|
||||||
|
|
||||||
User:
|
|
||||||
Kan niet in terminal 1 want ik kan niets invoeren, ping zit in soort loop
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Gebruik andere terminal of Ctrl+C in stream-terminal.
|
|
||||||
|
|
||||||
User:
|
|
||||||
Ja curl .../stop werkt in een andere terminal :)
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Bevestigd. Volgende stap: UI (1 menu-item, 2 modal terminal, 3 resize).
|
|
||||||
|
|
||||||
User:
|
|
||||||
Als 1, 2 en drie klaar zijn kan ik dan in een terminal windows vanuit de webui? (nog niets implementeren)
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Ja, met 1/2/3 heb je functionele interactieve web-terminal (basis), met optionele latere polish.
|
|
||||||
|
|
||||||
User:
|
|
||||||
Aplly 1, 2 en 3
|
|
||||||
|
|
||||||
Assistant (UI implementatie):
|
|
||||||
- Exec shell toegevoegd aan container three-dot menu (niet pod).
|
|
||||||
- Terminal modal toegevoegd in index.html.
|
|
||||||
- Frontend logic toegevoegd in containers.js:
|
|
||||||
- session start
|
|
||||||
- SSE stream
|
|
||||||
- input verzenden
|
|
||||||
- stop bij modal sluiten
|
|
||||||
- resize via ResizeObserver
|
|
||||||
- CSS styling toegevoegd in app.css.
|
|
||||||
- JS syntax check OK.
|
|
||||||
|
|
||||||
User:
|
|
||||||
Kun je deze gehel chat in en text bestand zetten, zodat ik deze kan bewaren?
|
|
||||||
|
|
||||||
Assistant:
|
|
||||||
Ik zet een export in een tekstbestand.
|
|
||||||
|
|
||||||
@@ -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
|
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
|
RUN pip install fastapi uvicorn requests-unixsocket pyyaml pytest httpx
|
||||||
COPY app.py .
|
COPY app.py .
|
||||||
COPY app_images.py .
|
COPY app_images.py .
|
||||||
|
COPY app_volumes.py .
|
||||||
COPY app_files.py .
|
COPY app_files.py .
|
||||||
COPY app_networks.py .
|
COPY app_networks.py .
|
||||||
COPY app_pods.py .
|
COPY app_pods.py .
|
||||||
|
|||||||
+3
-9
@@ -1,5 +1,5 @@
|
|||||||
import subprocess
|
|
||||||
from app_images import init_images_router
|
from app_images import init_images_router
|
||||||
|
from app_volumes import init_volumes_router
|
||||||
from app_files import init_files_router
|
from app_files import init_files_router
|
||||||
from app_pods import init_pods_router
|
from app_pods import init_pods_router
|
||||||
from app_containers import init_containers_router, start_stats_poller
|
from app_containers import init_containers_router, start_stats_poller
|
||||||
@@ -9,6 +9,7 @@ from fastapi import FastAPI
|
|||||||
import requests_unixsocket
|
import requests_unixsocket
|
||||||
from common import (
|
from common import (
|
||||||
_systemctl as _common_systemctl,
|
_systemctl as _common_systemctl,
|
||||||
|
run,
|
||||||
)
|
)
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ def _systemctl(cmd):
|
|||||||
# --- ROUTERS ---
|
# --- ROUTERS ---
|
||||||
# Images API lives in dedicated modules to keep this file from growing further.
|
# 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_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_files_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR))
|
||||||
app.include_router(init_networks_router(SESSION, PODMAN_API_BASE))
|
app.include_router(init_networks_router(SESSION, PODMAN_API_BASE))
|
||||||
app.include_router(init_containers_router(
|
app.include_router(init_containers_router(
|
||||||
@@ -44,13 +46,5 @@ app.include_router(init_pods_router(
|
|||||||
app.include_router(init_system_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR))
|
app.include_router(init_system_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR))
|
||||||
|
|
||||||
|
|
||||||
def run(cmd):
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
||||||
output = (result.stdout or "") + (result.stderr or "")
|
|
||||||
return result.returncode, output.strip()
|
|
||||||
except Exception as e:
|
|
||||||
return 1, str(e)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
|||||||
+35
-28
@@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from common import (
|
from common import (
|
||||||
_map_pod_to_unit,
|
_helper_call,
|
||||||
_podman_action_post,
|
_podman_action_post,
|
||||||
_podman_get_json,
|
_podman_get_json,
|
||||||
_podman_get_text,
|
_podman_get_text,
|
||||||
@@ -29,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_BY_NAME = {} # name -> {"cpu": float|None, "mem_usage": float|None, "mem_perc": float|None}
|
||||||
_STATS_CACHE_TS = None
|
_STATS_CACHE_TS = None
|
||||||
_STATS_POLLER_TASK = 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 SESSION CACHE (in-memory) ---
|
||||||
_EXEC_SESSIONS = {} # session_id -> _ExecSessionState
|
_EXEC_SESSIONS = {} # session_id -> _ExecSessionState
|
||||||
@@ -410,21 +411,8 @@ def init_containers_router(
|
|||||||
dashboard = []
|
dashboard = []
|
||||||
defined = find_defined_containers()
|
defined = find_defined_containers()
|
||||||
|
|
||||||
# Cache zodat we niet voor elke container opnieuw systemctl doen
|
|
||||||
unit_active_cache = {}
|
|
||||||
|
|
||||||
stats_by_name = _STATS_CACHE_BY_NAME
|
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)
|
# A) echte containers (runtime)
|
||||||
real = _podman_get_json(session, f"{podman_api_base}/libpod/containers/json?all=true")
|
real = _podman_get_json(session, f"{podman_api_base}/libpod/containers/json?all=true")
|
||||||
for c in real:
|
for c in real:
|
||||||
@@ -446,22 +434,18 @@ def init_containers_router(
|
|||||||
c["_dashboard_mem_usage"] = st.get("mem_usage")
|
c["_dashboard_mem_usage"] = st.get("mem_usage")
|
||||||
c["_dashboard_mem_perc"] = st.get("mem_perc")
|
c["_dashboard_mem_perc"] = st.get("mem_perc")
|
||||||
|
|
||||||
# 1) Managed: systemd als er een .container definitie bestaat
|
# Classificatie: PODMAN_SYSTEMD_UNIT label is ground truth
|
||||||
if rname in defined:
|
labels = c.get("Labels") or {}
|
||||||
|
podman_unit = labels.get("PODMAN_SYSTEMD_UNIT") or ""
|
||||||
|
if podman_unit:
|
||||||
c["_dashboard_source"] = "systemd"
|
c["_dashboard_source"] = "systemd"
|
||||||
c["_dashboard_unit"] = f"{rname}.service"
|
c["_dashboard_unit"] = podman_unit
|
||||||
c["_dashboard_def_path"] = defined[rname]
|
|
||||||
else:
|
else:
|
||||||
# 2) Extra: zit container in een pod die via systemd (kube/quadlet) draait?
|
c["_dashboard_source"] = "podman"
|
||||||
podname = (c.get("PodName") or "").strip()
|
|
||||||
pod_unit = _map_pod_to_unit(podname) if podname else None
|
|
||||||
|
|
||||||
if pod_unit and _unit_is_active(pod_unit):
|
# Definitiepad: onafhankelijk van classificatie
|
||||||
c["_dashboard_source"] = "systemd"
|
if rname in defined:
|
||||||
c["_dashboard_unit"] = pod_unit
|
c["_dashboard_def_path"] = defined[rname]
|
||||||
# geen _dashboard_def_path, want dit is geen .container definitie
|
|
||||||
else:
|
|
||||||
c["_dashboard_source"] = "podman"
|
|
||||||
|
|
||||||
dashboard.append(c)
|
dashboard.append(c)
|
||||||
|
|
||||||
@@ -477,8 +461,22 @@ def init_containers_router(
|
|||||||
row["Status"] = (out or "").strip()
|
row["Status"] = (out or "").strip()
|
||||||
dashboard.append(row)
|
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
|
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")
|
@router.get("/containers")
|
||||||
def list_containers():
|
def list_containers():
|
||||||
# Ook hier ?all=true voor gestopte containers
|
# Ook hier ?all=true voor gestopte containers
|
||||||
@@ -555,6 +553,15 @@ def init_containers_router(
|
|||||||
|
|
||||||
@router.post("/containers/{action}/{name}")
|
@router.post("/containers/{action}/{name}")
|
||||||
def container_action(action: str, name: str):
|
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"):
|
if action not in ("start", "stop", "restart"):
|
||||||
return {"error": "Invalid action"}, 400
|
return {"error": "Invalid action"}, 400
|
||||||
|
|
||||||
@@ -563,7 +570,7 @@ def init_containers_router(
|
|||||||
|
|
||||||
def _systemd_call():
|
def _systemd_call():
|
||||||
if name in defined:
|
if name in defined:
|
||||||
code, out = systemctl_func(["systemctl", "--user", action, name])
|
code, out = _helper_call(action, f"{name}.service")
|
||||||
_sys["code"] = code
|
_sys["code"] = code
|
||||||
_sys["out"] = out
|
_sys["out"] = out
|
||||||
if code == 0:
|
if code == 0:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def init_files_router(session, podman_api_base: str, workloads_dir: str) -> APIR
|
|||||||
|
|
||||||
@router.get("/workloads/read/{filename:path}")
|
@router.get("/workloads/read/{filename:path}")
|
||||||
def read_workload(filename: str):
|
def read_workload(filename: str):
|
||||||
path = os.path.join(workloads_dir, filename)
|
path = _files_safe_join(filename)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
with open(path, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
@@ -55,7 +55,7 @@ def init_files_router(session, podman_api_base: str, workloads_dir: str) -> APIR
|
|||||||
def save_workload_file(data: dict):
|
def save_workload_file(data: dict):
|
||||||
path = data.get("path")
|
path = data.get("path")
|
||||||
content = data.get("content")
|
content = data.get("content")
|
||||||
full_path = os.path.join(workloads_dir, path)
|
full_path = _files_safe_join(path)
|
||||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
with open(full_path, "w") as f:
|
with open(full_path, "w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
@@ -63,7 +63,7 @@ def init_files_router(session, podman_api_base: str, workloads_dir: str) -> APIR
|
|||||||
|
|
||||||
@router.post("/workloads/deploy/{filename:path}")
|
@router.post("/workloads/deploy/{filename:path}")
|
||||||
def deploy_workload(filename: str):
|
def deploy_workload(filename: str):
|
||||||
path = os.path.join(workloads_dir, filename)
|
path = _files_safe_join(filename)
|
||||||
with open(path, 'r') as f:
|
with open(path, 'r') as f:
|
||||||
yaml_content = f.read()
|
yaml_content = f.read()
|
||||||
url = f"{podman_api_base}/libpod/kube/play"
|
url = f"{podman_api_base}/libpod/kube/play"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
|
|||||||
# --- STAP 2: remove selected (batch) ---
|
# --- STAP 2: remove selected (batch) ---
|
||||||
@router.post("/remove")
|
@router.post("/remove")
|
||||||
def remove_images(req: ImageRemoveRequest):
|
def remove_images(req: ImageRemoveRequest):
|
||||||
|
"""⚠️ Destructief: verwijdert één of meerdere images permanent. Niet terug te draaien."""
|
||||||
# Libpod heeft batch remove via query params (images=...).
|
# Libpod heeft batch remove via query params (images=...).
|
||||||
url = f"{podman_api_base}/libpod/images/remove"
|
url = f"{podman_api_base}/libpod/images/remove"
|
||||||
params = {
|
params = {
|
||||||
@@ -91,6 +92,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
|
|||||||
force: bool = Query(False),
|
force: bool = Query(False),
|
||||||
ignore: 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"
|
url = f"{podman_api_base}/libpod/images/remove"
|
||||||
params = {
|
params = {
|
||||||
"images": [image_ref],
|
"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) ---
|
# --- STAP 2: prune (dangling default, all=true => unused) ---
|
||||||
@router.post("/prune")
|
@router.post("/prune")
|
||||||
def prune_images(all: bool = Query(False)):
|
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"
|
url = f"{podman_api_base}/libpod/images/prune"
|
||||||
params = {"all": str(all).lower()}
|
params = {"all": str(all).lower()}
|
||||||
resp = session.post(url, params=params)
|
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():
|
def networks_usage():
|
||||||
"""
|
"""
|
||||||
Bouwt mapping netwerk -> containers/pods, en container -> netwerken.
|
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)
|
# 1) Containers ophalen
|
||||||
url = f"{podman_api_base}/libpod/containers/json?all=true"
|
containers = _podman_get_json_checked(
|
||||||
containers = _podman_get_json_checked(url) or []
|
f"{podman_api_base}/libpod/containers/json?all=true"
|
||||||
|
) or []
|
||||||
|
|
||||||
by_network: dict[str, dict] = {}
|
by_network: dict[str, dict] = {}
|
||||||
by_container: dict[str, list[str]] = {}
|
by_container: dict[str, list[str]] = {}
|
||||||
by_container_meta: dict[str, dict] = {}
|
by_container_meta: dict[str, dict] = {}
|
||||||
|
|
||||||
def _norm_name(c: dict) -> str:
|
def _norm_name(c: dict) -> str:
|
||||||
# Podman kan Names (list) of Name (string) geven.
|
|
||||||
n = c.get("Name")
|
n = c.get("Name")
|
||||||
if isinstance(n, str) and n:
|
if isinstance(n, str) and n:
|
||||||
return n
|
return n
|
||||||
names = c.get("Names")
|
names = c.get("Names")
|
||||||
if isinstance(names, list) and names:
|
if isinstance(names, list) and names:
|
||||||
# vaak begint dit met "/name"
|
return str(names[0]).lstrip("/")
|
||||||
nm = str(names[0]).lstrip("/")
|
|
||||||
return nm
|
|
||||||
# fallback id
|
|
||||||
cid = c.get("Id") or c.get("id") or ""
|
cid = c.get("Id") or c.get("id") or ""
|
||||||
return cid[:12] if cid else "(unknown)"
|
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 ""
|
return c.get("Id") or c.get("id") or ""
|
||||||
|
|
||||||
def _pod_name(c: dict) -> str | None:
|
def _pod_name(c: dict) -> str | None:
|
||||||
# Verschilt per output; we proberen een paar logische keys
|
for k in ("PodName", "pod", "Pod"):
|
||||||
for k in ("PodName", "pod", "Pod", "PodID", "PodId"):
|
|
||||||
v = c.get(k)
|
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 v
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_networks_from_summary(c: dict) -> list[str] | None:
|
def _extract_networks_from_summary(c: dict) -> list[str] | None:
|
||||||
# Mogelijke structuren in list output
|
|
||||||
nets = c.get("Networks")
|
nets = c.get("Networks")
|
||||||
if isinstance(nets, dict):
|
if isinstance(nets, dict):
|
||||||
return list(nets.keys())
|
return list(nets.keys())
|
||||||
if isinstance(nets, list):
|
if isinstance(nets, list):
|
||||||
return [str(x) for x in nets if x]
|
return [str(x) for x in nets if x]
|
||||||
|
|
||||||
ns = c.get("NetworkSettings")
|
ns = c.get("NetworkSettings")
|
||||||
if isinstance(ns, dict):
|
if isinstance(ns, dict):
|
||||||
nets2 = ns.get("Networks")
|
nets2 = ns.get("Networks")
|
||||||
if isinstance(nets2, dict):
|
if isinstance(nets2, dict):
|
||||||
return list(nets2.keys())
|
return list(nets2.keys())
|
||||||
|
|
||||||
# Sommige builds hebben NetworkNames
|
|
||||||
nn = c.get("NetworkNames")
|
nn = c.get("NetworkNames")
|
||||||
if isinstance(nn, list):
|
if isinstance(nn, list):
|
||||||
return [str(x) for x in nn if x]
|
return [str(x) for x in nn if x]
|
||||||
|
|
||||||
return None
|
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.
|
Returns: (net_names, extra, net_details)
|
||||||
Ondersteunt varianten/casing die per Podman/driver kunnen verschillen.
|
- net_names: lijst van netwerknamen
|
||||||
"""
|
- extra: {networkMode, networkOwnerId, networkOwnerName} voor container: mode
|
||||||
if not isinstance(insp, dict):
|
- net_details: {net_name: {ip, aliases}} voor bridge-netwerken
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
if not cid:
|
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 = {}
|
extra: dict = {}
|
||||||
|
|
||||||
# 1) normale inspect: probeer meerdere paden
|
# 1) NetworkSettings.Networks is de ground truth voor bridge-containers
|
||||||
nets0 = _extract_networks_from_inspect_obj(insp)
|
nets_dict = _ns_networks(insp)
|
||||||
if nets0:
|
if nets_dict:
|
||||||
return (nets0, extra)
|
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
|
hc = insp.get("HostConfig") if isinstance(insp, dict) else None
|
||||||
if isinstance(hc, dict):
|
nm = hc.get("NetworkMode") if isinstance(hc, dict) else None
|
||||||
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")
|
|
||||||
|
|
||||||
# 1) netwerken van owner vinden (meerdere varianten)
|
if isinstance(nm, str) and nm.startswith("container:"):
|
||||||
owner_nets_list = _extract_networks_from_inspect_obj(owner)
|
owner_id = nm.split("container:", 1)[1]
|
||||||
|
extra["networkMode"] = nm
|
||||||
|
extra["networkOwnerId"] = owner_id
|
||||||
|
|
||||||
# 2) owner naam vinden (meerdere varianten)
|
owner = _podman_get_json_checked(
|
||||||
owner_name = None
|
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
|
owner_nets = _ns_networks(owner)
|
||||||
if isinstance(owner.get("Name"), str) and owner.get("Name"):
|
if owner_nets:
|
||||||
owner_name = owner.get("Name")
|
return sorted(owner_nets.keys()), extra, {}
|
||||||
|
|
||||||
# fallback: soms staat het in Config.Name
|
# Owner gebruikt pasta/host/none
|
||||||
if not owner_name:
|
owner_nm = (owner.get("HostConfig") or {}).get("NetworkMode") or ""
|
||||||
cfg = owner.get("Config") or {}
|
if owner_nm in ("pasta", "host", "none"):
|
||||||
if isinstance(cfg.get("Name"), str) and cfg.get("Name"):
|
return [owner_nm], extra, {}
|
||||||
owner_name = cfg.get("Name")
|
|
||||||
|
|
||||||
# fallback: soms in ContainerConfig
|
return [], extra, {}
|
||||||
if not owner_name:
|
|
||||||
ccfg = owner.get("ContainerConfig") or {}
|
|
||||||
if isinstance(ccfg.get("Name"), str) and ccfg.get("Name"):
|
|
||||||
owner_name = ccfg.get("Name")
|
|
||||||
|
|
||||||
# fallback: als niets werkt, toon korte id
|
# 3) Pseudo-netwerken: pasta / host / none
|
||||||
if not owner_name:
|
if isinstance(nm, str) and nm in ("pasta", "host", "none"):
|
||||||
owner_name = owner_id[:12]
|
extra["networkMode"] = nm
|
||||||
|
return [nm], extra, {}
|
||||||
|
|
||||||
extra["networkOwnerName"] = str(owner_name).lstrip("/")
|
return [], {}, {}
|
||||||
|
|
||||||
# 3) netwerken returnen (als we ze gevonden hebben)
|
import re
|
||||||
if owner_nets_list:
|
_INFRA_NAME_RE = re.compile(r"^[0-9a-f]+-infra$")
|
||||||
return (owner_nets_list, extra)
|
_PSEUDO_NETS = {"pasta", "host", "none"}
|
||||||
|
|
||||||
# Extra fallback: probeer inspect via ownerName (soms werkt naam beter dan id)
|
# 2) Loop over alle containers
|
||||||
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
|
|
||||||
for c in containers:
|
for c in containers:
|
||||||
if not isinstance(c, dict):
|
if not isinstance(c, dict):
|
||||||
continue
|
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)
|
cid = _norm_id(c)
|
||||||
cname = _norm_name(c)
|
cname = cname_pre
|
||||||
pod = _pod_name(c)
|
pod = _pod_name(c)
|
||||||
|
|
||||||
nets = _extract_networks_from_summary(c)
|
nets = _extract_networks_from_summary(c)
|
||||||
extra = {}
|
extra: dict = {}
|
||||||
|
net_details: dict = {}
|
||||||
|
|
||||||
if not nets:
|
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
|
by_container_meta[cname] = extra
|
||||||
|
|
||||||
nets = [n for n in (nets or []) if isinstance(n, str) and n]
|
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))
|
by_container[cname] = sorted(set(nets))
|
||||||
|
|
||||||
for n in nets:
|
for n in nets:
|
||||||
slot = by_network.setdefault(n, {"containers": [], "pods": []})
|
slot = by_network.setdefault(n, {"containers": [], "pods": []})
|
||||||
|
nd = net_details.get(n, {})
|
||||||
slot["containers"].append({
|
slot["containers"].append({
|
||||||
"id": cid,
|
"id": cid,
|
||||||
"name": cname,
|
"name": cname,
|
||||||
"pod": pod,
|
"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():
|
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]
|
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}
|
return {"byNetwork": by_network, "byContainer": by_container, "byContainerMeta": by_container_meta}
|
||||||
|
|
||||||
@router.get("/networks/{name}")
|
@router.get("/networks/{name}")
|
||||||
|
|||||||
+21
-31
@@ -1,10 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import socket
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from common import (
|
from common import (
|
||||||
|
HELPER_SOCKET,
|
||||||
|
_helper_call,
|
||||||
_podman_get_json as _common_podman_get_json,
|
_podman_get_json as _common_podman_get_json,
|
||||||
_systemctl as _common_systemctl,
|
_systemctl as _common_systemctl,
|
||||||
|
run,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -25,24 +28,25 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
|
|||||||
except Exception:
|
except Exception:
|
||||||
podman_ok = False
|
podman_ok = False
|
||||||
|
|
||||||
systemd_reachable = False
|
helper_ok = False
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||||
["systemctl", "--user", "list-units", "--no-pager", "--no-legend"],
|
s.settimeout(2)
|
||||||
capture_output=True,
|
s.connect(HELPER_SOCKET)
|
||||||
text=True,
|
helper_ok = True
|
||||||
check=False,
|
|
||||||
timeout=2,
|
|
||||||
)
|
|
||||||
systemd_reachable = (res.returncode == 0)
|
|
||||||
except Exception:
|
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 {
|
return {
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
"podman": {"ok": podman_ok},
|
"podman": {"ok": podman_ok},
|
||||||
"systemd_user": {"reachable": systemd_reachable},
|
"systemd_user": {"reachable": systemd_reachable},
|
||||||
|
"helper": {"ok": helper_ok},
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/test-hybrid")
|
@router.get("/test-hybrid")
|
||||||
@@ -68,14 +72,6 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
|
|||||||
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
"api_raw_sample": api_containers[0] if isinstance(api_containers, list) and api_containers else api_containers,
|
||||||
}
|
}
|
||||||
|
|
||||||
def run(cmd):
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
||||||
output = (result.stdout or "") + (result.stderr or "")
|
|
||||||
return result.returncode, output.strip()
|
|
||||||
except Exception as e:
|
|
||||||
return 1, str(e)
|
|
||||||
|
|
||||||
def _systemctl(cmd):
|
def _systemctl(cmd):
|
||||||
return _common_systemctl(cmd, run)
|
return _common_systemctl(cmd, run)
|
||||||
|
|
||||||
@@ -86,7 +82,7 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
|
|||||||
@router.post("/daemon-reload")
|
@router.post("/daemon-reload")
|
||||||
def api_daemon_reload():
|
def api_daemon_reload():
|
||||||
try:
|
try:
|
||||||
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
|
code, out = _helper_call("daemon-reload", "")
|
||||||
return {
|
return {
|
||||||
"cmd": "systemctl --user daemon-reload",
|
"cmd": "systemctl --user daemon-reload",
|
||||||
"exit": code,
|
"exit": code,
|
||||||
@@ -95,21 +91,15 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.post("/api/<action>/<unit>")
|
|
||||||
def legacy_api_action(action: str, unit: str):
|
|
||||||
# legacy flask-like path; keep behavior (even if not used by index.html)
|
|
||||||
if action not in ("status", "start", "stop", "restart"):
|
|
||||||
return {"error": "Invalid action"}, 400
|
|
||||||
cmd = ["systemctl", "--user", action, unit]
|
|
||||||
code, out = _run_systemctl_action(action, unit)
|
|
||||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
|
||||||
|
|
||||||
@router.post("/{action}/{unit}")
|
@router.post("/{action}/{unit}")
|
||||||
def api_action(action: str, unit: str):
|
def api_action(action: str, unit: str):
|
||||||
if action not in ("status", "start", "stop", "restart"):
|
if action not in ("status", "start", "stop", "restart"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid action")
|
raise HTTPException(status_code=400, detail="Invalid action")
|
||||||
cmd = ["systemctl", "--user", action, unit]
|
cmd = ["systemctl", "--user", action, unit]
|
||||||
code, out = _run_systemctl_action(action, unit)
|
if action in ("start", "stop", "restart"):
|
||||||
|
code, out = _helper_call(action, unit)
|
||||||
|
else:
|
||||||
|
code, out = _run_systemctl_action(action, unit)
|
||||||
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
return {"cmd": " ".join(cmd), "exit": code, "output": out}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -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,5 +1,44 @@
|
|||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
HELPER_SOCKET = "/run/podman-mvp/podman-helper.sock"
|
||||||
|
|
||||||
|
|
||||||
|
def _helper_call(action: str, unit: str) -> tuple[int, str]:
|
||||||
|
"""Stuur start/stop/restart naar de host-helper via Unix socket.
|
||||||
|
Returntype identiek aan run(): (returncode, output)."""
|
||||||
|
payload = json.dumps({"action": action, "unit": unit}).encode()
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(35)
|
||||||
|
s.connect(HELPER_SOCKET)
|
||||||
|
s.sendall(payload)
|
||||||
|
s.shutdown(socket.SHUT_WR)
|
||||||
|
data = b""
|
||||||
|
while True:
|
||||||
|
chunk = s.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
resp = json.loads(data.decode())
|
||||||
|
if resp.get("ok"):
|
||||||
|
return 0, resp.get("output", "")
|
||||||
|
return 1, resp.get("error", "mislukt")
|
||||||
|
except Exception as e:
|
||||||
|
return 1, f"helper niet bereikbaar: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
output = (result.stdout or "") + (result.stderr or "")
|
||||||
|
return result.returncode, output.strip()
|
||||||
|
except Exception as e:
|
||||||
|
return 1, str(e)
|
||||||
|
|
||||||
|
|
||||||
def _podman_get_json_checked(session, url: str):
|
def _podman_get_json_checked(session, url: str):
|
||||||
r = session.get(url)
|
r = session.get(url)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
podman-helper.py
|
||||||
|
----------------
|
||||||
|
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 / 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": "..."}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ── Configuratie ─────────────────────────────────────────────────────────────
|
||||||
|
SOCKET_PATH = os.getenv(
|
||||||
|
"HELPER_SOCKET",
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||||
|
logging.basicConfig(
|
||||||
|
level = getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||||
|
format = "%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt = "%Y-%m-%d %H:%M:%S",
|
||||||
|
stream = sys.stdout,
|
||||||
|
)
|
||||||
|
log = logging.getLogger("podman-helper")
|
||||||
|
|
||||||
|
# ── Whitelist ─────────────────────────────────────────────────────────────────
|
||||||
|
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 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."""
|
||||||
|
if action in NO_UNIT_ACTIONS:
|
||||||
|
cmd = ["systemctl", "--user", action]
|
||||||
|
else:
|
||||||
|
cmd = ["systemctl", "--user", action, unit]
|
||||||
|
log.info("Uitvoeren: %s", " ".join(cmd))
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout = asyncio.subprocess.PIPE,
|
||||||
|
stderr = asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT)
|
||||||
|
rc = proc.returncode
|
||||||
|
|
||||||
|
output = stdout.decode().strip() or stderr.decode().strip()
|
||||||
|
|
||||||
|
if rc == 0:
|
||||||
|
log.info("OK: %s %s (rc=0)", action, unit)
|
||||||
|
return {"ok": True, "output": output or f"{unit} {action} geslaagd"}
|
||||||
|
else:
|
||||||
|
log.warning("Mislukt: %s %s (rc=%d) %s", action, unit, rc, output)
|
||||||
|
return {"ok": False, "error": output or f"{unit} {action} mislukt (rc={rc})"}
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.error("Timeout na %ds: %s %s", TIMEOUT, action, unit)
|
||||||
|
return {"ok": False, "error": f"Timeout na {TIMEOUT} seconden"}
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Onverwachte fout: %s", e)
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
|
"""Verwerkt één client verbinding."""
|
||||||
|
peer = writer.get_extra_info("peername") or "onbekend"
|
||||||
|
log.debug("Verbinding van: %s", peer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Lees tot maximaal 4KB (meer dan genoeg voor een JSON verzoek)
|
||||||
|
data = await asyncio.wait_for(reader.read(4096), timeout=10)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# JSON parsen
|
||||||
|
try:
|
||||||
|
request = json.loads(data.decode())
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
log.warning("Ongeldige JSON: %s", e)
|
||||||
|
response = {"ok": False, "error": f"Ongeldige JSON: {e}"}
|
||||||
|
writer.write(json.dumps(response).encode())
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
action = str(request.get("action", "")).strip().lower()
|
||||||
|
unit = str(request.get("unit", "")).strip()
|
||||||
|
|
||||||
|
# Valideren
|
||||||
|
error = validate(action, unit)
|
||||||
|
if error:
|
||||||
|
log.warning("Afgewezen: %s", error)
|
||||||
|
response = {"ok": False, "error": error}
|
||||||
|
writer.write(json.dumps(response).encode())
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Uitvoeren
|
||||||
|
response = await run_systemctl(action, unit)
|
||||||
|
writer.write(json.dumps(response).encode())
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning("Client timeout bij lezen")
|
||||||
|
response = {"ok": False, "error": "Timeout bij lezen verzoek"}
|
||||||
|
try:
|
||||||
|
writer.write(json.dumps(response).encode())
|
||||||
|
await writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Fout bij verwerken verbinding: %s", e)
|
||||||
|
try:
|
||||||
|
response = {"ok": False, "error": str(e)}
|
||||||
|
writer.write(json.dumps(response).encode())
|
||||||
|
await writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
# Ruim oude socket op als die nog bestaat
|
||||||
|
if os.path.exists(SOCKET_PATH):
|
||||||
|
os.unlink(SOCKET_PATH)
|
||||||
|
log.info("Oude socket verwijderd: %s", SOCKET_PATH)
|
||||||
|
|
||||||
|
# Zorg dat de map bestaat
|
||||||
|
os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True)
|
||||||
|
|
||||||
|
server = await asyncio.start_unix_server(handle_client, path=SOCKET_PATH)
|
||||||
|
|
||||||
|
# Socket alleen leesbaar voor eigenaar (de kodi user)
|
||||||
|
os.chmod(SOCKET_PATH, 0o600)
|
||||||
|
|
||||||
|
log.info("podman-helper gestart op %s", SOCKET_PATH)
|
||||||
|
log.info("Toegestane acties: %s", ", ".join(sorted(ALLOWED_ACTIONS)))
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("Gestopt")
|
||||||
|
finally:
|
||||||
|
if os.path.exists(SOCKET_PATH):
|
||||||
|
os.unlink(SOCKET_PATH)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Podman systemctl helper socket service
|
||||||
|
Documentation=man:systemctl(1)
|
||||||
|
After=default.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3s
|
||||||
|
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/%U
|
||||||
|
Environment=LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
ExecStart=/usr/bin/python3 %h/.config/podman-mvp/podman-helper/podman-helper.py
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-helper.sh — Test de podman-helper direct op de host
|
||||||
|
# Gebruik: ./test-helper.sh <unit> (standaard: test-web.service)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
UNIT="${1:-test-web.service}"
|
||||||
|
SOCKET="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman-helper.sock"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'; RED='\033[0;31m'; NC='\033[0m'
|
||||||
|
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
||||||
|
fail() { echo -e "${RED}✗${NC} $*"; }
|
||||||
|
|
||||||
|
send() {
|
||||||
|
local action="$1"
|
||||||
|
local result
|
||||||
|
result=$(echo "{\"action\": \"$action\", \"unit\": \"$UNIT\"}" | \
|
||||||
|
socat - UNIX-CONNECT:"$SOCKET" 2>/dev/null)
|
||||||
|
echo "$result"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Socket: $SOCKET"
|
||||||
|
echo "Unit: $UNIT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check socat
|
||||||
|
command -v socat &>/dev/null || { echo "socat niet gevonden — installeer: sudo apt install socat"; exit 1; }
|
||||||
|
|
||||||
|
# Check socket
|
||||||
|
[[ -S "$SOCKET" ]] || { fail "Socket niet gevonden. Is podman-helper.service actief?"; exit 1; }
|
||||||
|
|
||||||
|
# ── Test 1: stop ──────────────────────────────────────────────────────────────
|
||||||
|
echo "Test 1: stop"
|
||||||
|
systemctl --user start "$UNIT" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
RESULT=$(send "stop")
|
||||||
|
echo " Response: $RESULT"
|
||||||
|
sleep 5
|
||||||
|
STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true)
|
||||||
|
echo " State na stop: $STATE"
|
||||||
|
[[ "$STATE" == "inactive" ]] && ok "Stop werkt" || fail "Stop mislukt (state: $STATE)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Test 2: start ─────────────────────────────────────────────────────────────
|
||||||
|
echo "Test 2: start"
|
||||||
|
RESULT=$(send "start")
|
||||||
|
echo " Response: $RESULT"
|
||||||
|
sleep 5
|
||||||
|
STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true)
|
||||||
|
echo " State na start: $STATE"
|
||||||
|
[[ "$STATE" == "active" ]] && ok "Start werkt" || fail "Start mislukt (state: $STATE)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Test 3: restart ───────────────────────────────────────────────────────────
|
||||||
|
echo "Test 3: restart"
|
||||||
|
RESULT=$(send "restart")
|
||||||
|
echo " Response: $RESULT"
|
||||||
|
sleep 5
|
||||||
|
STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true)
|
||||||
|
echo " State na restart: $STATE"
|
||||||
|
[[ "$STATE" == "active" ]] && ok "Restart werkt" || fail "Restart mislukt (state: $STATE)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Test 4: ongeldige actie (whitelist check) ─────────────────────────────────
|
||||||
|
echo "Test 4: ongeldige actie (whitelist)"
|
||||||
|
RESULT=$(echo '{"action": "kill", "unit": "'"$UNIT"'"}' | \
|
||||||
|
socat - UNIX-CONNECT:"$SOCKET" 2>/dev/null)
|
||||||
|
echo " Response: $RESULT"
|
||||||
|
echo "$RESULT" | grep -q '"ok": false' && ok "Whitelist werkt" || fail "Whitelist werkt NIET"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Test 5: gelijktijdige aanvragen ───────────────────────────────────────────
|
||||||
|
echo "Test 5: gelijktijdig (5 status aanvragen)"
|
||||||
|
for i in {1..5}; do
|
||||||
|
echo '{"action": "restart", "unit": "'"$UNIT"'"}' | \
|
||||||
|
socat - UNIX-CONNECT:"$SOCKET" 2>/dev/null &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
sleep 5
|
||||||
|
STATE=$(systemctl --user is-active "$UNIT" 2>/dev/null || true)
|
||||||
|
echo " State na gelijktijdige aanvragen: $STATE"
|
||||||
|
[[ "$STATE" == "active" ]] && ok "Gelijktijdig werkt" || fail "Gelijktijdig mislukt (state: $STATE)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Tests klaar."
|
||||||
@@ -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:hover{background: var(--btn2)}
|
||||||
.btn:active{transform: translateY(1px)}
|
.btn:active{transform: translateY(1px)}
|
||||||
.btn.small{padding:7px 9px; border-radius: 10px}
|
.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.ghost{background: transparent}
|
||||||
.btn.ok{border-color: rgba(45,212,191,.6)}
|
.btn.ok{border-color: rgba(45,212,191,.6)}
|
||||||
.btn.bad{border-color: rgba(251,113,133,.6)}
|
.btn.bad{border-color: rgba(251,113,133,.6)}
|
||||||
@@ -643,29 +644,30 @@ pre{
|
|||||||
.sidebar .navLabel { display: none; }
|
.sidebar .navLabel { display: none; }
|
||||||
.sidebar .tab { justify-content: center; }
|
.sidebar .tab { justify-content: center; }
|
||||||
}
|
}
|
||||||
/* Files tree (Portainer-ish) */
|
/* Files tree (IDE sidebar stijl) */
|
||||||
.file-folder-row{
|
.file-folder-row{
|
||||||
display:flex;
|
display:flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
justify-content:space-between;
|
justify-content:space-between;
|
||||||
gap:10px;
|
gap:6px;
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
user-select:none;
|
user-select:none;
|
||||||
|
|
||||||
padding: 10px 12px;
|
padding: 2px 6px;
|
||||||
border: 1px solid var(--card-border);
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
background: var(--folder-bg);
|
background: transparent;
|
||||||
transition: background .12s ease, border-color .12s ease, transform .06s ease;
|
transition: background .1s ease;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-folder-row:hover{
|
.file-folder-row:hover{
|
||||||
background: var(--folder-hover);
|
background: var(--folder-hover);
|
||||||
border-color: rgba(96,165,250,.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-folder-row:active{
|
.file-entry:hover{
|
||||||
transform: translateY(1px);
|
background: var(--folder-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-folder-left{
|
.file-folder-left{
|
||||||
@@ -697,19 +699,19 @@ pre{
|
|||||||
|
|
||||||
.file-folder-files{
|
.file-folder-files{
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-top: 6px;
|
margin-top: 0;
|
||||||
padding-left: 12px;
|
padding-left: 0;
|
||||||
border-left: 1px dashed var(--soft-line);
|
border-left: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-entry{
|
.file-entry{
|
||||||
display:flex;
|
display:flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
justify-content:space-between;
|
justify-content:space-between;
|
||||||
gap:10px;
|
gap:6px;
|
||||||
padding:4px 0;
|
padding: 2px 6px;
|
||||||
border-bottom:1px dashed var(--soft-line);
|
border-radius: 4px;
|
||||||
border-radius: 8px;
|
line-height: 22px;
|
||||||
}
|
}
|
||||||
.file-entry-name{
|
.file-entry-name{
|
||||||
cursor:pointer;
|
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;
|
if (containersDashboardStatsInFlight) return;
|
||||||
containersDashboardStatsInFlight = true;
|
containersDashboardStatsInFlight = true;
|
||||||
try {
|
try {
|
||||||
const containers = await api('/containers-dashboard', 'GET');
|
const stats = await api('/stats', 'GET');
|
||||||
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
|
|
||||||
|
|
||||||
// totals per pod voor deze poll tick
|
// totals per pod voor deze poll tick
|
||||||
const podCpu = new Map(); // podName -> cpuPct sum
|
const podCpu = new Map(); // podName -> cpuPct sum
|
||||||
const podMem = new Map(); // podName -> memBytes sum
|
const podMem = new Map(); // podName -> memBytes sum
|
||||||
const podMemPct = new Map(); // podName -> memPct sum
|
const podMemPct = new Map(); // podName -> memPct sum
|
||||||
|
|
||||||
for (const c of (list || [])) {
|
for (const [cname, s] of Object.entries(stats || {})) {
|
||||||
const cname = normalizeContainerName((c?.Names && c.Names[0]) ? c.Names[0] : (c?.Names || c?.Name || c?.name || ''));
|
|
||||||
if (!cname) continue;
|
|
||||||
|
|
||||||
const key = cssSafeId(cname);
|
const key = cssSafeId(cname);
|
||||||
|
|
||||||
const cpuRaw = c?._dashboard_cpu;
|
const cpuPct = Number(s?.cpu);
|
||||||
const memBytesRaw = c?._dashboard_mem_usage;
|
const memBytes = Number(s?.mem_usage);
|
||||||
const memPctRaw = c?._dashboard_mem_perc;
|
const memPct = Number(s?.mem_perc);
|
||||||
|
|
||||||
const cpuPct = Number(cpuRaw);
|
|
||||||
const memBytes = Number(memBytesRaw);
|
|
||||||
const memPct = Number(memPctRaw);
|
|
||||||
|
|
||||||
const pod = containersC2P.get(cname);
|
const pod = containersC2P.get(cname);
|
||||||
if (pod) {
|
if (pod) {
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ function filesSetEditorTheme(themeName) {
|
|||||||
|
|
||||||
window.filesSetEditorTheme = filesSetEditorTheme;
|
window.filesSetEditorTheme = filesSetEditorTheme;
|
||||||
|
|
||||||
function _isFolderCollapsed(folderKey) {
|
function _isFolderCollapsed(folderKey, level) {
|
||||||
return localStorage.getItem('files_folder_collapsed:' + folderKey) !== '0';
|
const stored = localStorage.getItem('files_folder_collapsed:' + folderKey);
|
||||||
// default collapsed = true
|
if (stored !== null) return stored !== '0';
|
||||||
|
return true; // standaard alles ingeklapt
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setFolderCollapsed(folderKey, v) {
|
function _setFolderCollapsed(folderKey, v) {
|
||||||
@@ -246,37 +247,26 @@ async function filesRefresh() {
|
|||||||
|
|
||||||
function renderNode(node, level) {
|
function renderNode(node, level) {
|
||||||
const folderKey = node.apiPath;
|
const folderKey = node.apiPath;
|
||||||
const collapsed = _isFolderCollapsed(folderKey);
|
const collapsed = _isFolderCollapsed(folderKey, level);
|
||||||
const label = node.uiPath || 'root';
|
const label = node.uiPath || 'root';
|
||||||
const indent = Math.max(0, level) * 14;
|
const indent = Math.max(0, level) * 14;
|
||||||
|
|
||||||
const folder = folderByPath.get(folderKey);
|
const folder = folderByPath.get(folderKey);
|
||||||
const files = (folder && folder.files) ? folder.files : [];
|
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));
|
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 sortedFiles = (files || []).slice().sort((a,b) => a.localeCompare(b));
|
||||||
|
|
||||||
const out = [];
|
const out = [];
|
||||||
out.push(`
|
out.push(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="padding-left:${indent}px;">
|
||||||
<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="file-folder-left">
|
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
<span>📂 ${esc(label)}</span>
|
||||||
<span>📂 ${esc(label)}</span>
|
</span>
|
||||||
</span>
|
<span class="file-folder-actions" onclick="event.stopPropagation();">
|
||||||
<span class="file-folder-meta" onclick="event.stopPropagation();">
|
<button class="btn tiny ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
|
||||||
<span class="file-badge" title="Subfolders in deze map">📁 ${childNames.length}</span>
|
<button class="btn tiny bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">✕</button>
|
||||||
<span class="file-badge" title="Bestanden in deze map">📄 ${sortedFiles.length}</span>
|
</span>
|
||||||
|
</div>`);
|
||||||
<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="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">`);
|
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) {
|
for (const f of sortedFiles) {
|
||||||
const fullUi = node.uiPath ? `${node.uiPath}/${f}` : f;
|
const fullUi = node.uiPath ? `${node.uiPath}/${f}` : f;
|
||||||
const fileKey = encodeURIComponent(fullUi);
|
const fileKey = encodeURIComponent(fullUi);
|
||||||
out.push(`
|
out.push(`<div class="file-entry" data-file="${fileKey}" style="padding-left:${indent + 16}px;">
|
||||||
<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="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
|
<span class="file-entry-state"></span>
|
||||||
<span class="file-entry-state"></span>
|
</div>`);
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!childNames.length && !sortedFiles.length) {
|
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>`);
|
out.push(`</div>`);
|
||||||
@@ -311,30 +299,25 @@ async function filesRefresh() {
|
|||||||
const rootFolder = folderByPath.get(FILES_ROOT);
|
const rootFolder = folderByPath.get(FILES_ROOT);
|
||||||
if (rootFolder && (rootFolder.files || []).length) {
|
if (rootFolder && (rootFolder.files || []).length) {
|
||||||
const folderKey = FILES_ROOT;
|
const folderKey = FILES_ROOT;
|
||||||
const collapsed = _isFolderCollapsed(folderKey);
|
const collapsed = _isFolderCollapsed(folderKey, 0);
|
||||||
parts.unshift(`
|
parts.unshift(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}">
|
||||||
<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="file-folder-left">
|
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
<span>📂 root</span>
|
||||||
<span>📂 root</span>
|
</span>
|
||||||
</span>
|
<span class="file-folder-actions" onclick="event.stopPropagation();">
|
||||||
<span class="flex" onclick="event.stopPropagation();">
|
<button class="btn tiny ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
|
||||||
<button class="btn small ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">
|
||||||
<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 => {
|
||||||
${(rootFolder.files || []).slice().sort((a,b)=>a.localeCompare(b)).map(f => {
|
const fileKey = encodeURIComponent(f);
|
||||||
const fullUi = f;
|
return `<div class="file-entry" data-file="${fileKey}" style="padding-left:16px;">
|
||||||
const fileKey = encodeURIComponent(fullUi);
|
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
|
||||||
return `
|
<span class="file-entry-state"></span>
|
||||||
<div class="file-entry" data-file="${fileKey}" style="padding-left:18px;">
|
</div>`;
|
||||||
<span class="mono file-entry-name" style="cursor:pointer" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
|
}).join('')}
|
||||||
<span class="file-entry-state"></span>
|
</div>`);
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
treeEl.innerHTML = parts.join('');
|
treeEl.innerHTML = parts.join('');
|
||||||
|
|||||||
@@ -620,34 +620,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let graphCtx = null;
|
let graphCtx = null;
|
||||||
|
let modalGraphCtx = null;
|
||||||
|
let _escBound = false;
|
||||||
|
|
||||||
function renderGraph(model, opts = {}) {
|
function _renderGraphInHost(hostEl, ctx, model, opts = {}) {
|
||||||
const host = document.getElementById('networksMapHost');
|
hostEl.innerHTML = '';
|
||||||
if (!host) return;
|
|
||||||
|
|
||||||
// leeg host (placeholder weg)
|
|
||||||
host.innerHTML = '';
|
|
||||||
const tooltip = document.createElement('div');
|
const tooltip = document.createElement('div');
|
||||||
tooltip.className = 'mapTooltip';
|
tooltip.className = 'mapTooltip';
|
||||||
tooltip.style.display = 'none';
|
tooltip.style.display = 'none';
|
||||||
host.appendChild(tooltip);
|
hostEl.appendChild(tooltip);
|
||||||
|
|
||||||
const w = Math.max(600, host.clientWidth || 600);
|
const w = Math.max(600, hostEl.clientWidth || 600);
|
||||||
const h = Math.max(420, host.clientHeight || 420);
|
const h = Math.max(420, hostEl.clientHeight || 420);
|
||||||
|
|
||||||
const svg = d3.select(host).append('svg')
|
|
||||||
.attr('viewBox', `0 0 ${w} ${h}`);
|
|
||||||
|
|
||||||
|
const svg = d3.select(hostEl).append('svg').attr('viewBox', `0 0 ${w} ${h}`);
|
||||||
const g = svg.append('g');
|
const g = svg.append('g');
|
||||||
|
|
||||||
// zoom/pan
|
|
||||||
const zoom = d3.zoom()
|
const zoom = d3.zoom()
|
||||||
.scaleExtent([0.2, 2.5])
|
.scaleExtent([0.2, 2.5])
|
||||||
.on('zoom', (ev) => g.attr('transform', ev.transform));
|
.on('zoom', (ev) => g.attr('transform', ev.transform));
|
||||||
|
|
||||||
svg.call(zoom);
|
svg.call(zoom);
|
||||||
|
|
||||||
// links
|
|
||||||
const link = g.append('g')
|
const link = g.append('g')
|
||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.data(model.links)
|
.data(model.links)
|
||||||
@@ -655,7 +648,6 @@
|
|||||||
.append('line')
|
.append('line')
|
||||||
.attr('class', d => d.type === 'shared' ? 'graphLink shared' : 'graphLink');
|
.attr('class', d => d.type === 'shared' ? 'graphLink shared' : 'graphLink');
|
||||||
|
|
||||||
// nodes
|
|
||||||
const node = g.append('g')
|
const node = g.append('g')
|
||||||
.selectAll('g')
|
.selectAll('g')
|
||||||
.data(model.nodes)
|
.data(model.nodes)
|
||||||
@@ -663,49 +655,38 @@
|
|||||||
.append('g')
|
.append('g')
|
||||||
.attr('class', d => `graphNode ${d.type}`);
|
.attr('class', d => `graphNode ${d.type}`);
|
||||||
|
|
||||||
node.append('circle')
|
node.append('circle').attr('r', d => d.type === 'network' ? 14 : 9);
|
||||||
.attr('r', d => d.type === 'network' ? 14 : 9);
|
|
||||||
|
|
||||||
node.append('text')
|
node.append('text')
|
||||||
.attr('class', 'graphLabel')
|
.attr('class', 'graphLabel')
|
||||||
.attr('x', d => d.type === 'network' ? 18 : 12)
|
.attr('x', d => d.type === 'network' ? 18 : 12)
|
||||||
.attr('y', 4)
|
.attr('y', 4)
|
||||||
.text(d => d.label || d.key);
|
.text(d => d.label || d.key);
|
||||||
|
|
||||||
// drag
|
|
||||||
const drag = d3.drag()
|
const drag = d3.drag()
|
||||||
.on('start', (ev, d) => {
|
.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;
|
d.fx = d.x; d.fy = d.y;
|
||||||
})
|
})
|
||||||
.on('drag', (ev, d) => {
|
.on('drag', (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
|
||||||
d.fx = ev.x; d.fy = ev.y;
|
|
||||||
})
|
|
||||||
.on('end', (ev, d) => {
|
.on('end', (ev, d) => {
|
||||||
if (!ev.active) graphCtx.sim.alphaTarget(0);
|
if (!ev.active) ctx.sim.alphaTarget(0);
|
||||||
d.fx = null; d.fy = null;
|
d.fx = null; d.fy = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
node.call(drag);
|
node.call(drag);
|
||||||
|
|
||||||
// hover highlight (connected)
|
|
||||||
node.on('mouseenter', (ev, d) => {
|
node.on('mouseenter', (ev, d) => {
|
||||||
node.classed('graphDim', true);
|
node.classed('graphDim', true);
|
||||||
link.classed('graphDim', true);
|
link.classed('graphDim', true);
|
||||||
|
|
||||||
d3.select(ev.currentTarget).classed('graphDim', false);
|
d3.select(ev.currentTarget).classed('graphDim', false);
|
||||||
|
|
||||||
link.each(function(l) {
|
link.each(function(l) {
|
||||||
const sid = l.source?.id || l.source;
|
const sid = l.source?.id || l.source;
|
||||||
const tid = l.target?.id || l.target;
|
const tid = l.target?.id || l.target;
|
||||||
const hit = (sid === d.id || tid === d.id);
|
if (sid === d.id || tid === d.id) {
|
||||||
if (hit) {
|
|
||||||
d3.select(this).classed('graphDim', false).classed('graphActive', true);
|
d3.select(this).classed('graphDim', false).classed('graphActive', true);
|
||||||
} else {
|
} else {
|
||||||
d3.select(this).classed('graphActive', false);
|
d3.select(this).classed('graphActive', false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const typeLabel = d.type === 'network' ? 'Netwerk' : 'Container';
|
const typeLabel = d.type === 'network' ? 'Netwerk' : 'Container';
|
||||||
const extra = d.type === 'network'
|
const extra = d.type === 'network'
|
||||||
? `Driver: ${d?.meta?.driver || 'onbekend'}`
|
? `Driver: ${d?.meta?.driver || 'onbekend'}`
|
||||||
@@ -713,15 +694,11 @@
|
|||||||
tooltip.innerHTML = `<strong>${typeLabel}</strong><br>${d.label || d.key}<br>${extra}`;
|
tooltip.innerHTML = `<strong>${typeLabel}</strong><br>${d.label || d.key}<br>${extra}`;
|
||||||
tooltip.style.display = 'block';
|
tooltip.style.display = 'block';
|
||||||
});
|
});
|
||||||
|
|
||||||
node.on('mousemove', (ev) => {
|
node.on('mousemove', (ev) => {
|
||||||
const rect = host.getBoundingClientRect();
|
const rect = hostEl.getBoundingClientRect();
|
||||||
const x = (ev.clientX - rect.left) + 14;
|
tooltip.style.left = `${(ev.clientX - rect.left) + 14}px`;
|
||||||
const y = (ev.clientY - rect.top) + 14;
|
tooltip.style.top = `${(ev.clientY - rect.top) + 14}px`;
|
||||||
tooltip.style.left = `${x}px`;
|
|
||||||
tooltip.style.top = `${y}px`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
node.on('mouseleave', () => {
|
node.on('mouseleave', () => {
|
||||||
node.classed('graphDim', false);
|
node.classed('graphDim', false);
|
||||||
link.classed('graphDim', false).classed('graphActive', false);
|
link.classed('graphDim', false).classed('graphActive', false);
|
||||||
@@ -730,15 +707,14 @@
|
|||||||
|
|
||||||
node.on('click', (ev, d) => {
|
node.on('click', (ev, d) => {
|
||||||
if (d.type === 'network') {
|
if (d.type === 'network') {
|
||||||
openNetworkDetail(d.key);
|
if (opts.onNetworkClick) opts.onNetworkClick(d.key);
|
||||||
|
else openNetworkDetail(d.key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const statusEl = opts.statusEl || document.getElementById('networksMapStatus');
|
||||||
const s = document.getElementById('networksMapStatus');
|
if (statusEl) statusEl.textContent = `Geselecteerd: ${d.type} ${d.key}`;
|
||||||
if (s) s.textContent = `Geselecteerd: ${d.type} ${d.key}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// simulation
|
|
||||||
const sim = d3.forceSimulation(model.nodes)
|
const sim = d3.forceSimulation(model.nodes)
|
||||||
.force('link', d3.forceLink(model.links).id(d => d.id).distance(80))
|
.force('link', d3.forceLink(model.links).id(d => d.id).distance(80))
|
||||||
.force('charge', d3.forceManyBody().strength(-30))
|
.force('charge', d3.forceManyBody().strength(-30))
|
||||||
@@ -746,23 +722,24 @@
|
|||||||
.force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16))
|
.force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16))
|
||||||
.on('tick', () => {
|
.on('tick', () => {
|
||||||
link
|
link
|
||||||
.attr('x1', d => d.source.x)
|
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||||||
.attr('y1', d => d.source.y)
|
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||||
.attr('x2', d => d.target.x)
|
|
||||||
.attr('y2', d => d.target.y);
|
|
||||||
|
|
||||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||||
});
|
});
|
||||||
// detail-mode: pin netwerk in het midden
|
|
||||||
if (opts.pinNetwork) {
|
if (opts.pinNetwork) {
|
||||||
const pinId = opts.pinNetwork;
|
const pinned = model.nodes.find(n => n.id === opts.pinNetwork);
|
||||||
const pinned = model.nodes.find(n => n.id === pinId);
|
if (pinned) { pinned.fx = w / 2; pinned.fy = h / 2; }
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
graphCtx = { svg, g, sim, model, zoom };
|
function renderGraph(model, opts = {}) {
|
||||||
|
const host = document.getElementById('networksMapHost');
|
||||||
|
if (!host) return;
|
||||||
|
graphCtx = {};
|
||||||
|
_renderGraphInHost(host, graphCtx, model, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNetworkDetail(networkName) {
|
function openNetworkDetail(networkName) {
|
||||||
@@ -839,6 +816,79 @@
|
|||||||
if (s) s.textContent = buildMapStatus('Global', model.nodes.length, model.links.length);
|
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() {
|
function renderNetworks() {
|
||||||
const tbody = document.getElementById('networksTbody');
|
const tbody = document.getElementById('networksTbody');
|
||||||
const rel = document.getElementById('networksRelations');
|
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');
|
const detailBody = document.getElementById('networksDetailBody');
|
||||||
if (detailBody && !detailBody.dataset.bound) {
|
if (detailBody && !detailBody.dataset.bound) {
|
||||||
detailBody.dataset.bound = '1';
|
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>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<button class="btn ghost" onclick="pingApi()">◉ Ping</button>
|
<button class="btn ghost" id="themeToggleBtn" title="Schakel thema">🌙</button>
|
||||||
<button class="btn" onclick="refreshActive()">↻ Ververs</button>
|
|
||||||
<button class="btn ghost" id="themeToggleBtn" title="Schakel light/dark mode">◐ Theme</button>
|
|
||||||
<span class="statusline headerMeta" id="lastRefreshHeader">Laatste refresh: -</span>
|
<span class="statusline headerMeta" id="lastRefreshHeader">Laatste refresh: -</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +55,9 @@
|
|||||||
<div class="tab" id="tab-images" onclick="setTab('images')" title="Images">
|
<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>
|
<span class="navIcon">📦</span><span class="navLabel">Images</span><span class="navCount" id="countNavImages">-</span>
|
||||||
</div>
|
</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">
|
<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>
|
<span class="navIcon">📁</span><span class="navLabel">Files</span><span class="navCount" id="countNavFiles">-</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
<button class="btn" onclick="setTab('networks')">Ga naar netwerken</button>
|
<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('images')">Ga naar images</button>
|
||||||
<button class="btn" onclick="setTab('files')">Ga naar files</button>
|
<button class="btn" onclick="setTab('files')">Ga naar files</button>
|
||||||
|
<a class="btn ghost" href="/docs/" target="_blank">API docs ↗</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Gebruik de zijbalk voor detailbeheer; deze acties geven snelle toegang tot de hoofdsecties.</div>
|
<div class="hint">Gebruik de zijbalk voor detailbeheer; deze acties geven snelle toegang tot de hoofdsecties.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +203,8 @@
|
|||||||
<input type="checkbox" id="networksMapConnectedOnly">
|
<input type="checkbox" id="networksMapConnectedOnly">
|
||||||
<span class="muted">Alleen verbonden</span>
|
<span class="muted">Alleen verbonden</span>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,6 +307,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="view-files" class="grid" style="display:none">
|
||||||
<div class="card" style="grid-column: 1 / -1;">
|
<div class="card" style="grid-column: 1 / -1;">
|
||||||
<div class="cardHeader">
|
<div class="cardHeader">
|
||||||
@@ -394,6 +426,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Build Modal -->
|
||||||
<div class="modalBack" id="buildModalBack" style="display:none;" onclick="closeBuildModal(event)">
|
<div class="modalBack" id="buildModalBack" style="display:none;" onclick="closeBuildModal(event)">
|
||||||
<div class="modal" onclick="event.stopPropagation()" style="width:700px;">
|
<div class="modal" onclick="event.stopPropagation()" style="width:700px;">
|
||||||
@@ -589,6 +645,9 @@
|
|||||||
if (tab === "images") {
|
if (tab === "images") {
|
||||||
loadImages();
|
loadImages();
|
||||||
}
|
}
|
||||||
|
if (tab === "volumes") {
|
||||||
|
loadVolumes();
|
||||||
|
}
|
||||||
// Start/stop live stats alleen in Containers tab (polling via /containers-dashboard)
|
// Start/stop live stats alleen in Containers tab (polling via /containers-dashboard)
|
||||||
if (tab === 'containers') startContainersDashboardStatsPoll();
|
if (tab === 'containers') startContainersDashboardStatsPoll();
|
||||||
else stopContainersDashboardStatsPoll();
|
else stopContainersDashboardStatsPoll();
|
||||||
@@ -623,21 +682,30 @@
|
|||||||
// ---- Health / Ping ----
|
// ---- Health / Ping ----
|
||||||
async function pingApi() {
|
async function pingApi() {
|
||||||
try {
|
try {
|
||||||
// simpele ping: pods ophalen
|
const h = await api('/health', 'GET');
|
||||||
await api('/pods-dashboard', 'GET');
|
const helperOk = h?.helper?.ok === true;
|
||||||
setApiState(true, 'API: OK');
|
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) {
|
} catch (e) {
|
||||||
setApiState(false, 'API: fout (' + e.message + ')');
|
setApiState('error', 'API: fout (' + e.message + ')');
|
||||||
showModal('API fout', e.stack || e.message);
|
showModal('API fout', e.stack || e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setApiState(ok, msg) {
|
function setApiState(state, msg) {
|
||||||
const dot = document.getElementById('apiDot');
|
const dot = document.getElementById('apiDot');
|
||||||
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
|
const ok = state === 'ok';
|
||||||
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
|
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;
|
document.getElementById('statusLine').textContent = msg;
|
||||||
const apiStat = document.getElementById('dashboardApiState');
|
const apiStat = document.getElementById('dashboardApiState');
|
||||||
if (apiStat) apiStat.textContent = ok ? 'OK' : 'Fout';
|
if (apiStat) apiStat.textContent = ok ? 'OK' : warn ? 'Waarschuwing' : 'Fout';
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentClockText() {
|
function currentClockText() {
|
||||||
@@ -671,10 +739,10 @@
|
|||||||
const nCount = Array.isArray(networks?.networks) ? networks.networks.length : 0;
|
const nCount = Array.isArray(networks?.networks) ? networks.networks.length : 0;
|
||||||
updateNavCount('countNavNetworks', nCount);
|
updateNavCount('countNavNetworks', nCount);
|
||||||
}
|
}
|
||||||
setApiState(true, 'API: OK');
|
|
||||||
setLastRefreshNow();
|
setLastRefreshNow();
|
||||||
|
pingApi();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setApiState(false, 'API: fout (' + e.message + ')');
|
setApiState('error', 'API: fout (' + e.message + ')');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,9 +776,9 @@
|
|||||||
function updateThemeToggleUi(theme) {
|
function updateThemeToggleUi(theme) {
|
||||||
const btn = document.getElementById('themeToggleBtn');
|
const btn = document.getElementById('themeToggleBtn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const next = theme === 'dark' ? 'light' : 'dark';
|
const goingTo = theme === 'dark' ? 'light' : 'dark';
|
||||||
btn.textContent = `Theme: ${theme === 'dark' ? 'Dark' : 'Light'}`;
|
btn.textContent = goingTo === 'light' ? '☀️' : '🌙';
|
||||||
btn.title = `Schakel naar ${next === 'dark' ? 'dark' : 'light'} mode`;
|
btn.title = goingTo === 'light' ? 'Schakel naar licht thema' : 'Schakel naar donker thema';
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme, persist = false) {
|
function applyTheme(theme, persist = false) {
|
||||||
@@ -759,8 +827,29 @@
|
|||||||
setInterval(() => { pingApi(); }, 20000);
|
setInterval(() => { pingApi(); }, 20000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</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/networks.js"></script>
|
||||||
<script src="assets/js/tabs/images.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user