Compare commits

...

21 Commits

Author SHA1 Message Date
kodi 94a2f4586a fix: cpu/mem container view 2026-03-27 18:23:16 +01:00
kodi 7d2f19f81f fix (containers): gebruik PODMAN_SYSTEMD_UNIT label als ground truth voor Managed By
De oude logica miste .kube quadlets volledig: het zocht alleen naar .container
bestanden op naam en gebruikte een fragiele pod-naam heuristiek als fallback.
Containers gestart via mediaserver.kube en bookstack.kube werden daardoor
als 'podman' geclassificeerd terwijl ze systemd-beheerd zijn.

PODMAN_SYSTEMD_UNIT label wordt door Podman/systemd automatisch gezet op elke
container gestart via een quadlet (.container, .kube, .pod). Dit is de enige
betrouwbare bron.

Verwijderd: _unit_is_active(), unit_active_cache, _map_pod_to_unit import.
Behouden: find_defined_containers() voor section C (offline containers) en
action routing (start/stop/restart via systemd unit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:14:50 +01:00
kodi fba9b59445 docs: update documentatie voor app_volumes en tabs tree view
- CLAUDE.md: app_volumes.py in module-tabel, frontend tabs lijst, py_compile en smoke tests
- ARCHITECTURE.md: app_volumes.py in feature routers, py_compile en smoke tests
- API_GOLDEN.md: volumes endpoints gedocumenteerd (GET/POST/DELETE/prune/exists)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:26:08 +01:00
kodi 2dfe53895b feat (ui): compacte IDE-sidebar tree view voor Files tabblad
- Vervang kaart-stijl folder-rijen door compacte, platte rijen (2px padding, geen border)
- Verwijder badge-tellers (📁 N, 📄 N) uit folder-rijen
- Voeg .btn.tiny toe voor kleine actieknoppen (+/✕) in boom
- Alle mappen standaard ingeklapt; localStorage behoudt uitgeklapte staat
- file-entry hover highlight; verwijder bottom-border per rij

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:04 +01:00
kodi 5f6719464d fix (ui/volumes): herstel container-koppeling via inspect endpoint
containers-dashboard geeft Mounts als strings (destination paden).
Volledige mount-info (Type + Name) zit alleen in /containers/inspect/{name}.

Fix: voor containers met niet-lege Mounts parallel inspect ophalen,
daarna filteren op Type === "volume" voor named volume koppeling.

Getest: postgresdb_data → postgres-db, n8n_data → n8n.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:51:37 +01:00
kodi 249d24721c feat (ui): voeg Volumes tabblad toe aan webui
Nieuw tabblad na Images met:
- Tabel: Naam, Driver, Mountpoint (afgekapt + tooltip), Aangemaakt
  (relatieve tijd), Labels (pills), Containers (pills via Mounts koppeling)
- Toolbar: Ververs, + Volume, Prune (met bevestigingsdialoog)
- Verwijder knop per rij (uitgeschakeld als volume in gebruik)
- Create Volume modal: naam (verplicht) + labels (key=value per regel)
- Lege staat via renderStateBox

volumes.js: _volEsc() voor XSS-safe rendering, encodeURIComponent
voor onclick-handlers, parallel fetch volumes + containers-dashboard
voor container-koppeling via Mounts[].Name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:34:29 +01:00
kodi f8bbb783b0 feat (volumes): voeg volumes router toe aan backend
Nieuw bestand control/app_volumes.py met Libpod volume operaties:
- GET  /volumes          — lijst alle volumes (optioneel ?filters=key=value)
- POST /volumes          — volume aanmaken (name, driver, labels, driverOpts)
- GET  /volumes/{name}   — details van één volume
- GET  /volumes/{name}/exists — bestaanskontrolle (204 → true, 404 → false)
- DELETE /volumes/{name} — volume verwijderen (?force=true optioneel)
- POST /volumes/prune    — ⚠️ verwijdert alle ongebruikte volumes

Filters: key=value formaat wordt automatisch omgezet naar
{"key":["value"]} JSON dat de Libpod API verwacht.

Containerfile: COPY app_volumes.py toegevoegd.
app.py: init_volumes_router geregistreerd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 13:25:58 +01:00
kodi 4404c02967 docs: update AGENTS/SAFE_FILES/rationale na D-Bus verwijdering
- AGENTS.md: run-commando bijgewerkt (verwijder brede /run/user/1000
  mount en DBUS_SESSION_BUS_ADDRESS); notitie D-Bus niet meer vereist
- SAFE_FILES.md: verwijder DBUS_SESSION_BUS_ADDRESS; beschrijf
  concrete mounts (Podman socket + helper directory)
- podman-helper-rationale.md: daemon-reload sectie bijgewerkt —
  gaat nu via helper ipv D-Bus; samenvattingstabel gecorrigeerd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:28:22 +01:00
kodi bae6fd8b9f docs (CLAUDE.md): documenteer health check gedrag en helper architectuur
Beschrijf dat systemd_user.reachable afgeleid is van helper.ok,
dat de container zelf geen D-Bus/systemctl aanroepen doet, en dat
alle systemctl-acties (incl. daemon-reload) via de helper-socket lopen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:54:43 +01:00
kodi ed94ee31f4 feat (helper): daemon-reload via helper; verwijder D-Bus afhankelijkheid
- podman-helper: voeg daemon-reload toe aan ALLOWED_ACTIONS; actions
  in NO_UNIT_ACTIONS slaan unit-validatie over en bouwen cmd zonder
  unit argument
- app_system: /daemon-reload endpoint gebruikt nu _helper_call in
  plaats van directe subprocess; verwijder subprocess import
- app_system: health check legt systemd_reachable af van helper_ok
  in plaats van systemctl --user list-units — de helper draait als
  host-user en impliceert systemd bereikbaarheid
- CLAUDE.md: verwijder DBUS_SESSION_BUS_ADDRESS env var; D-Bus mount
  is niet meer nodig

Deploy: kopieer podman-helper.py naar host, daemon-reload, restart
helper, rebuild backend image, herstart container zonder bus mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 11:39:10 +01:00
kodi 5196e7840f fix (helper): verplaats socket naar dedicated submap /run/podman-mvp/
Vervangt file bind-mount door directory mount om stale inode probleem
op te lossen: bij file bind-mounts bindt Podman de inode op run-tijd;
als podman-helper stopt en de socket verwijdert, wijst de container
nog steeds naar de verwijderde inode. Een directory mount lost altijd
op naar de huidige mapinhoud inclusief nieuwe inodes.

Wijzigingen:
- podman-helper.py: SOCKET_PATH → XDG_RUNTIME_DIR/podman-mvp/podman-helper.sock
- common.py: HELPER_SOCKET → /run/podman-mvp/podman-helper.sock
- CLAUDE.md: run-commando gebruikt -v /run/user/1000/podman-mvp:/run/podman-mvp

Deploy: kopieer podman-helper.py naar host, daemon-reload, restart helper,
rebuild backend image, herstart container met nieuwe mount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 09:50:31 +01:00
kodi a05d79ae2c fix (helper): verwijder ExecStopPost socket cleanup
ExecStopPost=-/bin/rm -f ${XDG_RUNTIME_DIR}/podman-helper.sock verwijderde
het socketbestand bij stoppen. Hierdoor werd bij herstart een nieuw inode
aangemaakt, terwijl de container-bind-mount nog het oude inode vasthield
(stale mount). Gevolg: health check en _helper_call faalden na herstart
ook al was de helper running.

De cleanup is overbodig: podman-helper.py doet os.unlink() bij opstarten
(regel 153) en bij afsluiten via finally-block (regel 178).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 08:31:38 +01:00
kodi 5e7d1b887c feat (health): voeg helper socket check toe, drie visuele states
Backend (/api/health):
- Importeer HELPER_SOCKET uit common.py
- Voeg helper-check toe: connect() op /run/podman-helper.sock, timeout=2s
- ok blijft true als alleen de helper ontbreekt (waarschuwing, geen fout)
- Nieuwe response key: "helper": {"ok": bool}

Frontend (pingApi / setApiState):
- pingApi() roept nu /api/health aan i.p.v. /pods-dashboard
- setApiState(state, msg) accepteert 'ok' / 'warn' / 'error'
- Gele dot met --warn kleur als helper.ok=false maar core OK
- refreshActive() delegeert statusupdate aan pingApi()
- Detailbericht bij fout: toont welk component (podman/systemd) faalt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 08:06:38 +01:00
kodi e469508570 feat (docs): voeg Swagger UI toe op /docs, lokaal gebundeld
- Swagger UI v5.32.1 lokaal in assets/swagger-ui/ (geen CDN, offline bruikbaar)
- webui/html/docs/index.html: custom pagina die /api/openapi.json laadt
  met requestInterceptor zodat Try it out via same-origin werkt
- Link toegevoegd aan dashboard "Snel acties": API docs ↗ (opent in nieuw tabblad)
- Docstrings toegevoegd aan destructieve endpoints (app_containers, app_images):
  container stop/restart, image remove (batch + single), image prune
  geven nu ⚠️-waarschuwingen in de Swagger UI beschrijving
- Backend rebuild nodig voor docstrings zichtbaar in spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 07:30:51 +01:00
kodi c338955320 refactor (networks): herschrijf networks_usage, bundel D3 lokaal
- Verwijder phase-3 hex-ID fallback (~160 regels): NetworkSettings.Networks
  uit container inspect is de ground truth, niet netwerk-inspect + scannen
- Filter infra containers via IsInfra flag + naam-regex ^[0-9a-f]+-infra$
- Voeg IP en aliases toe aan byNetwork container entries (via inspect)
- Bridge containers krijgen altijd een inspect-call voor IP/aliases;
  pasta/host/none containers worden overgeslagen
- D3 v7.9.0 lokaal gebundeld (assets/js/d3.min.js, CDN-afhankelijkheid weg)
- Nieuw webui/Containerfile voor reproduceerbare webui image builds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 16:28:39 +01:00
kodi f016c2bae0 perf: stats poll via lichtgewicht /api/stats i.p.v. /containers-dashboard
De frontend haalde CPU/mem stats op via het zware /containers-dashboard
endpoint (Podman call + os.walk + systemctl subprocesses per container).
Nu gaat de stats poll via een nieuw /api/stats endpoint dat alleen de
bestaande in-memory cache teruggeeft (<5ms vs ~400ms).

- app_containers.py: /api/stats endpoint toegevoegd (cache direct return)
- app_containers.py: _STATS_SHOWN_NAMES bijgehouden per dashboard call
  (filtert infra/management containers eruit op basis van _dashboard_source)
- containers.js: pollContainersDashboardStatsOnce() gebruikt /api/stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:09:53 +01:00
kodi e922cea167 added helper 2026-03-22 14:39:38 +01:00
kodi 7d2c205930 feat: systemd unit acties via podman-helper Unix socket
start/stop/restart van systemd units gaan nu via de host-helper
(/run/podman-helper.sock) in plaats van directe systemctl subprocess
vanuit de container. Hiermee wordt de user namespace isolatie omzeild
die D-Bus calls vanuit de container onbetrouwbaar maakt.

- common.py: _helper_call(action, unit) toegevoegd
- app_system.py: /{action}/{unit} route gebruikt helper voor start/stop/restart
- app_containers.py: container_action() gebruikt helper
- daemon-reload en is-active blijven subprocess (read-only, werkt al)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 11:24:25 +01:00
kodi 580c301718 refactor: verplaats run() duplicaat naar common.py
run() stond identiek in app.py en app_system.py. Verplaatst naar
common.py als single source of truth; beide modules importeren
nu de centrale versie.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 10:26:34 +01:00
kodi 1c61854143 fix: verwijder dode Flask-stijl legacy route in app_system.py
De route @router.post("/api/<action>/<unit>") gebruikte Flask-syntaxis
die nooit matcht in FastAPI. Dead code verwijderd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 10:22:25 +01:00
kodi bacab3b20a fix (security): sluit path traversal in legacy /workloads/ endpoints
Drie endpoints gebruikten os.path.join zonder validatie, waardoor een
aanvaller buiten WORKLOADS_DIR kon lezen/schrijven. Vervangen door de
bestaande _files_safe_join() helper die al door alle /files/ endpoints
werd gebruikt.

Endpoints: /workloads/read/, /workloads/save-file, /workloads/deploy/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 09:52:27 +01:00
30 changed files with 1660 additions and 705 deletions
+2 -3
View File
@@ -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
View File
@@ -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
+125
View File
@@ -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
View File
@@ -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.
--- ---
-143
View File
@@ -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_ids 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.
+68
View File
@@ -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
================================================== ==================================================
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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,23 +434,19 @@ 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:
# 2) Extra: zit container in een pod die via systemd (kube/quadlet) draait?
podname = (c.get("PodName") or "").strip()
pod_unit = _map_pod_to_unit(podname) if podname else None
if pod_unit and _unit_is_active(pod_unit):
c["_dashboard_source"] = "systemd"
c["_dashboard_unit"] = pod_unit
# geen _dashboard_def_path, want dit is geen .container definitie
else: else:
c["_dashboard_source"] = "podman" c["_dashboard_source"] = "podman"
# Definitiepad: onafhankelijk van classificatie
if rname in defined:
c["_dashboard_def_path"] = defined[rname]
dashboard.append(c) dashboard.append(c)
# B) Dedup set: ook genormaliseerd (voorkomt /name vs name doublures) # B) Dedup set: ook genormaliseerd (voorkomt /name vs name doublures)
@@ -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:
+3 -3
View File
@@ -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"
+3
View File
@@ -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)
+83 -316
View File
@@ -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:"): if isinstance(nm, str) and nm.startswith("container:"):
owner_id = nm.split("container:", 1)[1] owner_id = nm.split("container:", 1)[1]
extra["networkMode"] = nm extra["networkMode"] = nm
extra["networkOwnerId"] = owner_id 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) owner = _podman_get_json_checked(
owner_nets_list = _extract_networks_from_inspect_obj(owner) f"{podman_api_base}/libpod/containers/{owner_id}/json"
)
owner_name = str(owner.get("Name") or owner_id[:12]).lstrip("/")
extra["networkOwnerName"] = owner_name
# 2) owner naam vinden (meerdere varianten) owner_nets = _ns_networks(owner)
owner_name = None if owner_nets:
return sorted(owner_nets.keys()), extra, {}
# meest voorkomend bij inspect # Owner gebruikt pasta/host/none
if isinstance(owner.get("Name"), str) and owner.get("Name"): owner_nm = (owner.get("HostConfig") or {}).get("NetworkMode") or ""
owner_name = owner.get("Name") if owner_nm in ("pasta", "host", "none"):
return [owner_nm], extra, {}
# fallback: soms staat het in Config.Name return [], extra, {}
if not owner_name:
cfg = owner.get("Config") or {}
if isinstance(cfg.get("Name"), str) and cfg.get("Name"):
owner_name = cfg.get("Name")
# fallback: soms in ContainerConfig # 3) Pseudo-netwerken: pasta / host / none
if not owner_name: if isinstance(nm, str) and nm in ("pasta", "host", "none"):
ccfg = owner.get("ContainerConfig") or {} extra["networkMode"] = nm
if isinstance(ccfg.get("Name"), str) and ccfg.get("Name"): return [nm], extra, {}
owner_name = ccfg.get("Name")
# fallback: als niets werkt, toon korte id return [], {}, {}
if not owner_name:
owner_name = owner_id[:12]
extra["networkOwnerName"] = str(owner_name).lstrip("/") import re
_INFRA_NAME_RE = re.compile(r"^[0-9a-f]+-infra$")
_PSEUDO_NETS = {"pasta", "host", "none"}
# 3) netwerken returnen (als we ze gevonden hebben) # 2) Loop over alle containers
if owner_nets_list:
return (owner_nets_list, extra)
# Extra fallback: probeer inspect via ownerName (soms werkt naam beter dan id)
try:
owner_name_for_lookup = extra.get("networkOwnerName")
if owner_name_for_lookup and owner_name_for_lookup != owner_id:
owner2 = _podman_get_json_checked(f"{podman_api_base}/libpod/containers/{owner_name_for_lookup}/json")
owner2_nets = _extract_networks_from_inspect_obj(owner2)
if owner2_nets:
return (owner2_nets, extra)
except Exception:
pass
# Owner fallback: sommige infra containers gebruiken pasta/host/none
try:
ohc = owner.get("HostConfig") if isinstance(owner, dict) else None
if isinstance(ohc, dict):
onm = ohc.get("NetworkMode")
if isinstance(onm, str) and onm in ("pasta", "host", "none"):
# owner gebruikt geen Podman-netwerk; behandel als pseudo-netwerk
return ([onm], extra)
except Exception:
pass
return ([], extra)
# 3) Special networking modes: pasta/host/none
# In deze modes bestaat vaak geen NetworkSettings.Networks map.
if isinstance(hc, dict):
nm2 = hc.get("NetworkMode")
if isinstance(nm2, str) and nm2 in ("pasta", "host", "none"):
extra["networkMode"] = nm2
return ([nm2], extra)
return ([], extra)
# 2) Loop containers: verzamel netwerken
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}")
+20 -30
View File
@@ -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,20 +91,14 @@ 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]
if action in ("start", "stop", "restart"):
code, out = _helper_call(action, unit)
else:
code, out = _run_systemctl_action(action, unit) code, out = _run_systemctl_action(action, unit)
return {"cmd": " ".join(cmd), "exit": code, "output": out} return {"cmd": " ".join(cmd), "exit": code, "output": out}
+96
View File
@@ -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
+39
View File
@@ -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)
+296
View File
@@ -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.
+184
View File
@@ -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)
+20
View File
@@ -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
+84
View File
@@ -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."
+3
View File
@@ -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
+19 -17
View File
@@ -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;
File diff suppressed because one or more lines are too long
+5 -13
View File
@@ -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) {
+22 -39
View File
@@ -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-meta" onclick="event.stopPropagation();"> <span class="file-folder-actions" onclick="event.stopPropagation();">
<span class="file-badge" title="Subfolders in deze map">📁 ${childNames.length}</span> <button class="btn tiny ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
<span class="file-badge" title="Bestanden in deze map">📄 ${sortedFiles.length}</span> <button class="btn tiny bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">✕</button>
<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>
</span> </div>`);
</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="flex" onclick="event.stopPropagation();"> <span class="file-folder-actions" onclick="event.stopPropagation();">
<button class="btn small ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button> <button class="btn tiny 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 fullUi = f; const fileKey = encodeURIComponent(f);
const fileKey = encodeURIComponent(fullUi); return `<div class="file-entry" data-file="${fileKey}" style="padding-left:16px;">
return ` <span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
<div class="file-entry" data-file="${fileKey}" style="padding-left:18px;">
<span class="mono file-entry-name" style="cursor:pointer" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
<span class="file-entry-state"></span> <span class="file-entry-state"></span>
</div> </div>`;
`;
}).join('')} }).join('')}
</div> </div>`);
`);
} }
treeEl.innerHTML = parts.join(''); treeEl.innerHTML = parts.join('');
+144 -56
View File
@@ -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';
+219
View File
@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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
+39
View File
@@ -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>
+106 -17
View File
@@ -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>
@@ -202,6 +204,7 @@
<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&#10;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>