Compare commits

...

16 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
27 changed files with 1336 additions and 542 deletions
+2 -3
View File
@@ -93,9 +93,8 @@ podman run -d --pod mvp-pod \
--ipc=host \
--pid=host \
-e XDG_RUNTIME_DIR=/run/user/1000 \
-e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
-v /run/user/1000:/run/user/1000:rw \
-v /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock:rw \
-v /run/user/1000/podman-mvp:/run/podman-mvp \
-v /home/kodi/.config/containers:/app/workloads:rw \
mvp-control:latest
@@ -103,7 +102,7 @@ podman run -d --pod mvp-pod \
Important notes:
- Backend communicates with Podman through unix socket.
- User-session Podman is used (not root).
- DBus access is required.
- D-Bus is NOT required — alle systemctl-acties gaan via podman-helper.
- Host PID/IPC namespaces are intentional.
Do NOT change these assumptions without proposal.
+3 -1
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_files.py` — files/workloads endpoints (tree/read/save/etc.)
- `control/app_images.py` — images endpoints
- `control/app_volumes.py` — volumes endpoints (list/create/delete/prune/exists)
4. **Shared Infrastructure Layer**
- `control/common.py`
@@ -73,10 +74,11 @@ After any change affecting backend routing or shared helpers, run:
```bash
python3 -m py_compile control/app.py control/common.py control/app_system.py \
control/app_containers.py control/app_pods.py control/app_networks.py \
control/app_files.py control/app_images.py
control/app_files.py control/app_images.py control/app_volumes.py
curl -fsS http://127.0.0.1:8081/api/health | jq
curl -fsS http://127.0.0.1:8081/api/pods-dashboard >/dev/null && echo OK
curl -fsS http://127.0.0.1:8081/api/containers-dashboard >/dev/null && echo OK
curl -fsS http://127.0.0.1:8081/api/files/tree >/dev/null && echo OK
curl -fsS http://127.0.0.1:8081/api/volumes >/dev/null && echo OK
curl -fsS http://127.0.0.1:8081/api/networks/meta | jq
+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:
- DBUS_SESSION_BUS_ADDRESS usage
- XDG_RUNTIME_DIR mounts
- Podman unix socket access
- /run/user/1000 mounts
- XDG_RUNTIME_DIR=/run/user/1000 (env var voor Podman socket pad)
- Podman unix socket: /run/user/1000/podman/podman.sock
- Helper socket directory: /run/user/1000/podman-mvp → /run/podman-mvp
- host PID namespace
- host IPC namespace
Reason:
Backend communicates with user-session Podman and systemd.
Backend communicates with user-session Podman via unix socket.
Alle systemctl-acties (start/stop/restart/daemon-reload) gaan via
podman-helper. D-Bus is niet gemount.
---
+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
==================================================
+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
COPY app.py .
COPY app_images.py .
COPY app_volumes.py .
COPY app_files.py .
COPY app_networks.py .
COPY app_pods.py .
+2
View File
@@ -1,4 +1,5 @@
from app_images import init_images_router
from app_volumes import init_volumes_router
from app_files import init_files_router
from app_pods import init_pods_router
from app_containers import init_containers_router, start_stats_poller
@@ -27,6 +28,7 @@ def _systemctl(cmd):
# --- ROUTERS ---
# Images API lives in dedicated modules to keep this file from growing further.
app.include_router(init_images_router(SESSION, PODMAN_API_BASE))
app.include_router(init_volumes_router(SESSION, PODMAN_API_BASE))
app.include_router(init_files_router(SESSION, PODMAN_API_BASE, WORKLOADS_DIR))
app.include_router(init_networks_router(SESSION, PODMAN_API_BASE))
app.include_router(init_containers_router(
+33 -27
View File
@@ -14,7 +14,6 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from common import (
_helper_call,
_map_pod_to_unit,
_podman_action_post,
_podman_get_json,
_podman_get_text,
@@ -30,6 +29,7 @@ _PODMAN_API_BASE = None
_STATS_CACHE_BY_NAME = {} # name -> {"cpu": float|None, "mem_usage": float|None, "mem_perc": float|None}
_STATS_CACHE_TS = None
_STATS_POLLER_TASK = None
_STATS_SHOWN_NAMES: set = set() # namen van alle dashboard-containers uit laatste dashboard call
# --- EXEC SESSION CACHE (in-memory) ---
_EXEC_SESSIONS = {} # session_id -> _ExecSessionState
@@ -411,21 +411,8 @@ def init_containers_router(
dashboard = []
defined = find_defined_containers()
# Cache zodat we niet voor elke container opnieuw systemctl doen
unit_active_cache = {}
stats_by_name = _STATS_CACHE_BY_NAME
def _unit_is_active(unit):
if not unit:
return False
if unit in unit_active_cache:
return unit_active_cache[unit]
code, out = systemctl_func(["systemctl", "--user", "is-active", unit])
ok = (code == 0) or ((out or "").strip() == "active")
unit_active_cache[unit] = ok
return ok
# A) echte containers (runtime)
real = _podman_get_json(session, f"{podman_api_base}/libpod/containers/json?all=true")
for c in real:
@@ -447,23 +434,19 @@ def init_containers_router(
c["_dashboard_mem_usage"] = st.get("mem_usage")
c["_dashboard_mem_perc"] = st.get("mem_perc")
# 1) Managed: systemd als er een .container definitie bestaat
if rname in defined:
# Classificatie: PODMAN_SYSTEMD_UNIT label is ground truth
labels = c.get("Labels") or {}
podman_unit = labels.get("PODMAN_SYSTEMD_UNIT") or ""
if podman_unit:
c["_dashboard_source"] = "systemd"
c["_dashboard_unit"] = f"{rname}.service"
c["_dashboard_def_path"] = defined[rname]
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
c["_dashboard_unit"] = podman_unit
else:
c["_dashboard_source"] = "podman"
# Definitiepad: onafhankelijk van classificatie
if rname in defined:
c["_dashboard_def_path"] = defined[rname]
dashboard.append(c)
# B) Dedup set: ook genormaliseerd (voorkomt /name vs name doublures)
@@ -478,8 +461,22 @@ def init_containers_router(
row["Status"] = (out or "").strip()
dashboard.append(row)
# Bijwerken welke containernamen in het dashboard staan (voor /stats filter)
global _STATS_SHOWN_NAMES
_STATS_SHOWN_NAMES = {
_norm_container_name((c.get("Names") or ["?"])[0])
for c in dashboard
} - {"?", ""}
return dashboard
@router.get("/stats")
def stats_snapshot():
cache = _STATS_CACHE_BY_NAME
if _STATS_SHOWN_NAMES:
return {k: v for k, v in cache.items() if k in _STATS_SHOWN_NAMES}
return cache
@router.get("/containers")
def list_containers():
# Ook hier ?all=true voor gestopte containers
@@ -556,6 +553,15 @@ def init_containers_router(
@router.post("/containers/{action}/{name}")
def container_action(action: str, name: str):
"""
Voer een actie uit op een container.
- **start** — Start de container (of bijbehorende systemd-unit).
- **stop** — ⚠️ Destructief: stopt de container direct.
- **restart** — ⚠️ Destructief: herstart de container direct.
Gebruikt systemd als de container een beheerde unit heeft; anders Podman API direct.
"""
if action not in ("start", "stop", "restart"):
return {"error": "Invalid action"}, 400
+3
View File
@@ -73,6 +73,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
# --- STAP 2: remove selected (batch) ---
@router.post("/remove")
def remove_images(req: ImageRemoveRequest):
"""⚠️ Destructief: verwijdert één of meerdere images permanent. Niet terug te draaien."""
# Libpod heeft batch remove via query params (images=...).
url = f"{podman_api_base}/libpod/images/remove"
params = {
@@ -91,6 +92,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
force: bool = Query(False),
ignore: bool = Query(False),
):
"""⚠️ Destructief: verwijdert één image permanent op basis van naam of ID."""
url = f"{podman_api_base}/libpod/images/remove"
params = {
"images": [image_ref],
@@ -104,6 +106,7 @@ def init_images_router(session, podman_api_base: str) -> APIRouter:
# --- STAP 2: prune (dangling default, all=true => unused) ---
@router.post("/prune")
def prune_images(all: bool = Query(False)):
"""⚠️ Destructief: verwijdert dangling images (standaard) of alle ongebruikte images (`all=true`)."""
url = f"{podman_api_base}/libpod/images/prune"
params = {"all": str(all).lower()}
resp = session.post(url, params=params)
+83 -316
View File
@@ -82,27 +82,25 @@ def init_networks_router(session, podman_api_base: str) -> APIRouter:
def networks_usage():
"""
Bouwt mapping netwerk -> containers/pods, en container -> netwerken.
Werkt betrouwbaar ook als network inspect geen containers toont.
Ground truth: NetworkSettings.Networks uit container inspect.
Infra containers (IsInfra=true) worden gefilterd.
"""
# 1) containers list (all=true)
url = f"{podman_api_base}/libpod/containers/json?all=true"
containers = _podman_get_json_checked(url) or []
# 1) Containers ophalen
containers = _podman_get_json_checked(
f"{podman_api_base}/libpod/containers/json?all=true"
) or []
by_network: dict[str, dict] = {}
by_container: dict[str, list[str]] = {}
by_container_meta: dict[str, dict] = {}
def _norm_name(c: dict) -> str:
# Podman kan Names (list) of Name (string) geven.
n = c.get("Name")
if isinstance(n, str) and n:
return n
names = c.get("Names")
if isinstance(names, list) and names:
# vaak begint dit met "/name"
nm = str(names[0]).lstrip("/")
return nm
# fallback id
return str(names[0]).lstrip("/")
cid = c.get("Id") or c.get("id") or ""
return cid[:12] if cid else "(unknown)"
@@ -110,380 +108,149 @@ def init_networks_router(session, podman_api_base: str) -> APIRouter:
return c.get("Id") or c.get("id") or ""
def _pod_name(c: dict) -> str | None:
# Verschilt per output; we proberen een paar logische keys
for k in ("PodName", "pod", "Pod", "PodID", "PodId"):
for k in ("PodName", "pod", "Pod"):
v = c.get(k)
if isinstance(v, str) and v and v != "": # PodID is geen naam, maar beter dan niks
if isinstance(v, str) and v:
return v
return None
def _extract_networks_from_summary(c: dict) -> list[str] | None:
# Mogelijke structuren in list output
nets = c.get("Networks")
if isinstance(nets, dict):
return list(nets.keys())
if isinstance(nets, list):
return [str(x) for x in nets if x]
ns = c.get("NetworkSettings")
if isinstance(ns, dict):
nets2 = ns.get("Networks")
if isinstance(nets2, dict):
return list(nets2.keys())
# Sommige builds hebben NetworkNames
nn = c.get("NetworkNames")
if isinstance(nn, list):
return [str(x) for x in nn if x]
return None
def _extract_networks_from_inspect_obj(insp: dict) -> list[str]:
def _ns_networks(insp: dict) -> dict:
"""Haal NetworkSettings.Networks dict op uit inspect — de ground truth."""
ns = insp.get("NetworkSettings") if isinstance(insp, dict) else None
nets = ns.get("Networks") if isinstance(ns, dict) else None
return nets if isinstance(nets, dict) else {}
def _extract_from_inspect(cid: str) -> tuple[list[str], dict, dict]:
"""
Probeert netwerk-namen uit een container inspect te halen.
Ondersteunt varianten/casing die per Podman/driver kunnen verschillen.
"""
if not isinstance(insp, dict):
return []
candidates = []
# 1) meest voorkomend
ns = insp.get("NetworkSettings")
if isinstance(ns, dict):
candidates.append(ns.get("Networks"))
candidates.append(ns.get("networks"))
# 2) sommige outputs hebben Networks top-level
candidates.append(insp.get("Networks"))
candidates.append(insp.get("networks"))
# 3) extra varianten
n2 = insp.get("networkSettings")
if isinstance(n2, dict):
candidates.append(n2.get("Networks"))
candidates.append(n2.get("networks"))
n3 = insp.get("Network")
if isinstance(n3, dict):
candidates.append(n3.get("Networks"))
candidates.append(n3.get("networks"))
cfg = insp.get("Config")
if isinstance(cfg, dict):
candidates.append(cfg.get("Networks"))
candidates.append(cfg.get("networks"))
# Normaliseer candidates naar lijst[str]
out: list[str] = []
for val in candidates:
if isinstance(val, dict):
out.extend([str(k) for k in val.keys() if k])
elif isinstance(val, list):
for x in val:
if isinstance(x, str) and x:
out.append(x)
elif isinstance(x, dict):
# Best-effort: soms bevat list entries met Name
nm = x.get("Name") or x.get("name")
if isinstance(nm, str) and nm:
out.append(nm)
# uniq + stable sort
return sorted(set([n for n in out if isinstance(n, str) and n]))
def _extract_networks_from_inspect(cid: str) -> tuple[list[str], dict]:
"""
Returns: (networks, extra_info)
extra_info kan bv. networkMode/containerOwner bevatten.
Returns: (net_names, extra, net_details)
- net_names: lijst van netwerknamen
- extra: {networkMode, networkOwnerId, networkOwnerName} voor container: mode
- net_details: {net_name: {ip, aliases}} voor bridge-netwerken
"""
if not cid:
return ([], {})
return [], {}, {}
insp = _podman_get_json_checked(f"{podman_api_base}/libpod/containers/{cid}/json")
insp = _podman_get_json_checked(
f"{podman_api_base}/libpod/containers/{cid}/json"
)
extra: dict = {}
# 1) normale inspect: probeer meerdere paden
nets0 = _extract_networks_from_inspect_obj(insp)
if nets0:
return (nets0, extra)
# 1) NetworkSettings.Networks is de ground truth voor bridge-containers
nets_dict = _ns_networks(insp)
if nets_dict:
net_details = {}
for net_name, net_info in nets_dict.items():
if isinstance(net_info, dict):
ip = net_info.get("IPAddress") or ""
aliases = [
a for a in (net_info.get("Aliases") or [])
if isinstance(a, str)
]
net_details[net_name] = {"ip": ip, "aliases": aliases}
else:
net_details[net_name] = {"ip": "", "aliases": []}
return sorted(nets_dict.keys()), extra, net_details
# 2) container network namespace mode: HostConfig.NetworkMode = "container:<id>"
# 2) Shared network namespace: NetworkMode = "container:<id>"
hc = insp.get("HostConfig") if isinstance(insp, dict) else None
if isinstance(hc, dict):
nm = hc.get("NetworkMode")
nm = hc.get("NetworkMode") if isinstance(hc, dict) else None
if isinstance(nm, str) and nm.startswith("container:"):
owner_id = nm.split("container:", 1)[1]
extra["networkMode"] = nm
extra["networkOwnerId"] = owner_id
# Inspect owner container en pak diens netwerken
owner = _podman_get_json_checked(f"{podman_api_base}/libpod/containers/{owner_id}/json")
# 1) netwerken van owner vinden (meerdere varianten)
owner_nets_list = _extract_networks_from_inspect_obj(owner)
owner = _podman_get_json_checked(
f"{podman_api_base}/libpod/containers/{owner_id}/json"
)
owner_name = str(owner.get("Name") or owner_id[:12]).lstrip("/")
extra["networkOwnerName"] = owner_name
# 2) owner naam vinden (meerdere varianten)
owner_name = None
owner_nets = _ns_networks(owner)
if owner_nets:
return sorted(owner_nets.keys()), extra, {}
# meest voorkomend bij inspect
if isinstance(owner.get("Name"), str) and owner.get("Name"):
owner_name = owner.get("Name")
# Owner gebruikt pasta/host/none
owner_nm = (owner.get("HostConfig") or {}).get("NetworkMode") or ""
if owner_nm in ("pasta", "host", "none"):
return [owner_nm], extra, {}
# fallback: soms staat het in Config.Name
if not owner_name:
cfg = owner.get("Config") or {}
if isinstance(cfg.get("Name"), str) and cfg.get("Name"):
owner_name = cfg.get("Name")
return [], extra, {}
# fallback: soms in ContainerConfig
if not owner_name:
ccfg = owner.get("ContainerConfig") or {}
if isinstance(ccfg.get("Name"), str) and ccfg.get("Name"):
owner_name = ccfg.get("Name")
# 3) Pseudo-netwerken: pasta / host / none
if isinstance(nm, str) and nm in ("pasta", "host", "none"):
extra["networkMode"] = nm
return [nm], extra, {}
# fallback: als niets werkt, toon korte id
if not owner_name:
owner_name = owner_id[:12]
return [], {}, {}
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)
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
# 2) Loop over alle containers
for c in containers:
if not isinstance(c, dict):
continue
cname_pre = _norm_name(c)
if c.get("IsInfra") or _INFRA_NAME_RE.match(cname_pre):
continue # pod infra containers overslaan
cid = _norm_id(c)
cname = _norm_name(c)
cname = cname_pre
pod = _pod_name(c)
nets = _extract_networks_from_summary(c)
extra = {}
extra: dict = {}
net_details: dict = {}
if not nets:
nets, extra = _extract_networks_from_inspect(cid)
nets, extra, net_details = _extract_from_inspect(cid)
elif any(n not in _PSEUDO_NETS for n in nets):
# Bridge-container: inspect voor IP/aliases
_, extra, net_details = _extract_from_inspect(cid)
by_container_meta[cname] = extra
nets = [n for n in (nets or []) if isinstance(n, str) and n]
# byContainer blijft lijst (contract simpel houden)
by_container[cname] = sorted(set(nets))
for n in nets:
slot = by_network.setdefault(n, {"containers": [], "pods": []})
nd = net_details.get(n, {})
slot["containers"].append({
"id": cid,
"name": cname,
"pod": pod,
**extra, # voegt networkMode/owner info toe indien van toepassing
"ip": nd.get("ip", ""),
"aliases": nd.get("aliases", []),
**extra,
})
# 3) Pods afleiden (lightweight) via containers
# 3) Pods afleiden via containers
for n, slot in by_network.items():
pods = sorted({c.get("pod") for c in slot["containers"] if isinstance(c.get("pod"), str) and c.get("pod")})
pods = sorted({
c.get("pod") for c in slot["containers"]
if isinstance(c.get("pod"), str) and c.get("pod")
})
slot["pods"] = [{"name": p} for p in pods]
# --- FALLBACK: derive owner networks via network-inspect (works for pod infra/shared netns) ---
# We look at shared netns containers (networkMode=container:...) and map their owner-id to networks
owner_ids: set[str] = set()
owner_names: dict[str, str] = {} # ownerId -> ownerName
for cname, meta in by_container_meta.items():
try:
mode = str((meta or {}).get("networkMode") or "")
except Exception:
mode = ""
if not mode.startswith("container:"):
continue
owner_id = (meta or {}).get("networkOwnerId")
owner_name = (meta or {}).get("networkOwnerName")
if isinstance(owner_id, str) and owner_id:
owner_ids.add(owner_id)
if isinstance(owner_name, str) and owner_name:
owner_names[owner_id] = owner_name
def _collect_container_ids_from_network_inspect(net_inspect) -> set[str]:
"""
Key-agnostic: scan alle strings in network inspect en verzamel hex IDs (12..64 chars).
Dit is robuust tegen schema-verschillen tussen netavark/cni/podman versies.
"""
ids: set[str] = set()
def looks_like_hex_id(s: str) -> bool:
if not isinstance(s, str):
return False
s = s.strip()
if len(s) < 12 or len(s) > 64:
return False
# alleen hex chars
for ch in s:
if ch not in "0123456789abcdef":
return False
return True
def walk(obj):
if obj is None:
return
if isinstance(obj, str):
# soms staat id als "container:<id>"
if obj.startswith("container:"):
cand = obj.split("container:", 1)[1]
if looks_like_hex_id(cand):
ids.add(cand)
elif looks_like_hex_id(obj):
ids.add(obj)
return
if isinstance(obj, dict):
for k, v in obj.items():
# keys kunnen ook ids zijn
if isinstance(k, str) and looks_like_hex_id(k):
ids.add(k)
walk(v)
return
if isinstance(obj, list):
for it in obj:
walk(it)
return
walk(net_inspect)
return ids
owner_networks_by_id: dict[str, set[str]] = {oid: set() for oid in owner_ids}
# List networks
try:
nets_list = _podman_get_json_checked(f"{podman_api_base}/libpod/networks/json")
except Exception:
nets_list = []
net_names: list[str] = []
if isinstance(nets_list, list):
for n in nets_list:
if isinstance(n, dict):
nm = n.get("name") or n.get("Name")
if isinstance(nm, str) and nm:
net_names.append(nm)
# Inspect each network and see if it contains any owner_id
for net_name in sorted(set(net_names)):
try:
net_inspect = _podman_get_json_checked(f"{podman_api_base}/libpod/networks/{net_name}/json")
except Exception:
continue
attached_ids = _collect_container_ids_from_network_inspect(net_inspect)
if not attached_ids:
continue
for oid in owner_ids:
short = oid[:12]
for aid in attached_ids:
if not isinstance(aid, str) or not aid:
continue
# match exact / short / prefix
if aid == oid or aid == short or oid.startswith(aid) or aid.startswith(short):
owner_networks_by_id.setdefault(oid, set()).add(net_name)
break
# Apply: if shared container or owner container has empty by_container[], fill it with owner's networks
for cname, meta in by_container_meta.items():
try:
mode = str((meta or {}).get("networkMode") or "")
except Exception:
mode = ""
if not mode.startswith("container:"):
continue
owner_id = (meta or {}).get("networkOwnerId")
if not (isinstance(owner_id, str) and owner_id):
continue
owner_nets = sorted(owner_networks_by_id.get(owner_id, set()))
if not owner_nets:
continue
# 1) fill owner-name entry (if known)
owner_name = (meta or {}).get("networkOwnerName") or owner_names.get(owner_id)
if isinstance(owner_name, str) and owner_name and not by_container.get(owner_name):
by_container[owner_name] = owner_nets
# 2) fill shared container entry
if not by_container.get(cname):
by_container[cname] = owner_nets
# --- FINALIZE: derive by_container from by_network (robust for pods/shared netns) ---
by_container_derived: dict[str, list[str]] = {}
for net_name, info in (by_network or {}).items():
containers2 = (info or {}).get("containers") or []
for c2 in containers2:
if not isinstance(c2, dict):
continue
cname2 = c2.get("name") or c2.get("Name")
if not cname2:
continue
by_container_derived.setdefault(cname2, []).append(net_name)
# dedupe + stable sort
for k, v in by_container_derived.items():
by_container_derived[k] = sorted(set(v))
# merge: vul lege items in by_container, maar breek niks
for k, v in by_container_derived.items():
if not by_container.get(k):
by_container[k] = v
# --- shared netns: shared containers erven owner-netwerken (als owner bekend is) ---
for cname, meta in by_container_meta.items():
try:
mode = str((meta or {}).get("networkMode") or "")
except Exception:
mode = ""
if not mode.startswith("container:"):
continue
owner = (meta or {}).get("networkOwnerName") or (meta or {}).get("networkOwnerId")
if owner and by_container.get(owner) and not by_container.get(cname):
by_container[cname] = by_container[owner]
return {"byNetwork": by_network, "byContainer": by_container, "byContainerMeta": by_container_meta}
@router.get("/networks/{name}")
+15 -13
View File
@@ -1,8 +1,9 @@
import os
import subprocess
import socket
from fastapi import APIRouter, HTTPException
from common import (
HELPER_SOCKET,
_helper_call,
_podman_get_json as _common_podman_get_json,
_systemctl as _common_systemctl,
@@ -27,24 +28,25 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
except Exception:
podman_ok = False
systemd_reachable = False
helper_ok = False
try:
res = subprocess.run(
["systemctl", "--user", "list-units", "--no-pager", "--no-legend"],
capture_output=True,
text=True,
check=False,
timeout=2,
)
systemd_reachable = (res.returncode == 0)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(2)
s.connect(HELPER_SOCKET)
helper_ok = True
except Exception:
systemd_reachable = False
helper_ok = False
ok = podman_ok and systemd_reachable
# Helper draait op de host als de kodi-user en voert systemctl --user uit.
# Als de helper bereikbaar is, is systemd ook bereikbaar.
systemd_reachable = helper_ok
ok = podman_ok and helper_ok
return {
"ok": ok,
"podman": {"ok": podman_ok},
"systemd_user": {"reachable": systemd_reachable},
"helper": {"ok": helper_ok},
}
@router.get("/test-hybrid")
@@ -80,7 +82,7 @@ def init_system_router(session, podman_api_base: str, workloads_dir: str) -> API
@router.post("/daemon-reload")
def api_daemon_reload():
try:
code, out = _systemctl(["systemctl", "--user", "daemon-reload"])
code, out = _helper_call("daemon-reload", "")
return {
"cmd": "systemctl --user daemon-reload",
"exit": code,
+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
+1 -1
View File
@@ -4,7 +4,7 @@ import subprocess
from fastapi import HTTPException
HELPER_SOCKET = "/run/podman-helper.sock"
HELPER_SOCKET = "/run/podman-mvp/podman-helper.sock"
def _helper_call(action: str, unit: str) -> tuple[int, str]:
+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.
+12 -7
View File
@@ -6,13 +6,14 @@ Unix socket helper die op de HOST draait als de gewone gebruiker.
Ontvangt JSON verzoeken van de API container en voert systemctl --user uit.
Beveiligingsmodel:
- Alleen start / stop / restart toegestaan
- Alleen .service units
- Unit naam mag alleen letters, cijfers, punt, koppelteken en underscore bevatten
- Alleen start / stop / restart / daemon-reload toegestaan
- start/stop/restart: alleen .service units met veilige tekens
- daemon-reload: geen unit naam, wordt genegeerd
- Meerdere gelijktijdige verbindingen worden afgehandeld via asyncio
Protocol:
Inkomend: {"action": "start"|"stop"|"restart", "unit": "naam.service"}
{"action": "daemon-reload", "unit": ""}
Uitkomend: {"ok": true, "output": "..."} of {"ok": false, "error": "..."}
"""
@@ -27,7 +28,7 @@ import sys
# ── Configuratie ─────────────────────────────────────────────────────────────
SOCKET_PATH = os.getenv(
"HELPER_SOCKET",
os.path.join(os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"), "podman-helper.sock")
os.path.join(os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"), "podman-mvp", "podman-helper.sock")
)
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
TIMEOUT = 30 # seconden maximaal per systemctl aanroep
@@ -42,21 +43,25 @@ logging.basicConfig(
log = logging.getLogger("podman-helper")
# ── Whitelist ─────────────────────────────────────────────────────────────────
ALLOWED_ACTIONS = {"start", "stop", "restart"}
ALLOWED_ACTIONS = {"start", "stop", "restart", "daemon-reload"}
UNIT_PATTERN = re.compile(r'^[a-zA-Z0-9._\-]+\.service$')
NO_UNIT_ACTIONS = {"daemon-reload"}
def validate(action: str, unit: str) -> str | None:
"""Geeft een foutmelding terug als het verzoek niet toegestaan is, anders None."""
if action not in ALLOWED_ACTIONS:
return f"Actie '{action}' niet toegestaan. Gebruik: {', '.join(sorted(ALLOWED_ACTIONS))}"
if not UNIT_PATTERN.match(unit):
if action not in NO_UNIT_ACTIONS and not UNIT_PATTERN.match(unit):
return f"Ongeldige unit naam '{unit}'. Alleen .service units met veilige tekens."
return None
async def run_systemctl(action: str, unit: str) -> dict:
"""Voert systemctl --user <action> <unit> uit en geeft het resultaat terug."""
"""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))
-1
View File
@@ -12,7 +12,6 @@ Environment=XDG_RUNTIME_DIR=/run/user/%U
Environment=LOG_LEVEL=INFO
ExecStart=/usr/bin/python3 %h/.config/podman-mvp/podman-helper/podman-helper.py
ExecStopPost=-/bin/rm -f ${XDG_RUNTIME_DIR}/podman-helper.sock
StandardOutput=journal
StandardError=journal
+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:active{transform: translateY(1px)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.tiny{padding:1px 5px; border-radius: 4px; font-size:11px; line-height:16px;}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
@@ -643,29 +644,30 @@ pre{
.sidebar .navLabel { display: none; }
.sidebar .tab { justify-content: center; }
}
/* Files tree (Portainer-ish) */
/* Files tree (IDE sidebar stijl) */
.file-folder-row{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
gap:6px;
cursor:pointer;
user-select:none;
padding: 10px 12px;
border: 1px solid var(--card-border);
border-radius: 12px;
background: var(--folder-bg);
transition: background .12s ease, border-color .12s ease, transform .06s ease;
padding: 2px 6px;
border: none;
border-radius: 4px;
background: transparent;
transition: background .1s ease;
line-height: 22px;
font-weight: 600;
}
.file-folder-row:hover{
background: var(--folder-hover);
border-color: rgba(96,165,250,.35);
}
.file-folder-row:active{
transform: translateY(1px);
.file-entry:hover{
background: var(--folder-hover);
}
.file-folder-left{
@@ -697,19 +699,19 @@ pre{
.file-folder-files{
margin-left: 0;
margin-top: 6px;
padding-left: 12px;
border-left: 1px dashed var(--soft-line);
margin-top: 0;
padding-left: 0;
border-left: none;
}
.file-entry{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding:4px 0;
border-bottom:1px dashed var(--soft-line);
border-radius: 8px;
gap:6px;
padding: 2px 6px;
border-radius: 4px;
line-height: 22px;
}
.file-entry-name{
cursor:pointer;
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;
containersDashboardStatsInFlight = true;
try {
const containers = await api('/containers-dashboard', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
const stats = await api('/stats', 'GET');
// totals per pod voor deze poll tick
const podCpu = new Map(); // podName -> cpuPct sum
const podMem = new Map(); // podName -> memBytes sum
const podMemPct = new Map(); // podName -> memPct sum
for (const c of (list || [])) {
const cname = normalizeContainerName((c?.Names && c.Names[0]) ? c.Names[0] : (c?.Names || c?.Name || c?.name || ''));
if (!cname) continue;
for (const [cname, s] of Object.entries(stats || {})) {
const key = cssSafeId(cname);
const cpuRaw = c?._dashboard_cpu;
const memBytesRaw = c?._dashboard_mem_usage;
const memPctRaw = c?._dashboard_mem_perc;
const cpuPct = Number(cpuRaw);
const memBytes = Number(memBytesRaw);
const memPct = Number(memPctRaw);
const cpuPct = Number(s?.cpu);
const memBytes = Number(s?.mem_usage);
const memPct = Number(s?.mem_perc);
const pod = containersC2P.get(cname);
if (pod) {
+22 -39
View File
@@ -21,9 +21,10 @@ function filesSetEditorTheme(themeName) {
window.filesSetEditorTheme = filesSetEditorTheme;
function _isFolderCollapsed(folderKey) {
return localStorage.getItem('files_folder_collapsed:' + folderKey) !== '0';
// default collapsed = true
function _isFolderCollapsed(folderKey, level) {
const stored = localStorage.getItem('files_folder_collapsed:' + folderKey);
if (stored !== null) return stored !== '0';
return true; // standaard alles ingeklapt
}
function _setFolderCollapsed(folderKey, v) {
@@ -246,37 +247,26 @@ async function filesRefresh() {
function renderNode(node, level) {
const folderKey = node.apiPath;
const collapsed = _isFolderCollapsed(folderKey);
const collapsed = _isFolderCollapsed(folderKey, level);
const label = node.uiPath || 'root';
const indent = Math.max(0, level) * 14;
const folder = folderByPath.get(folderKey);
const files = (folder && folder.files) ? folder.files : [];
// subfolders (NU AL beschikbaar voor de badges)
const childNames = Array.from(node.children.keys()).sort((a,b) => a.localeCompare(b));
// files (NU AL beschikbaar voor de badges)
const sortedFiles = (files || []).slice().sort((a,b) => a.localeCompare(b));
const out = [];
out.push(`
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:${level === 0 ? '8px' : '6px'} 0 6px 0; padding-left:${indent}px; font-weight:600;">
out.push(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="padding-left:${indent}px;">
<span class="file-folder-left">
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
<span>📂 ${esc(label)}</span>
</span>
<span class="file-folder-meta" onclick="event.stopPropagation();">
<span class="file-badge" title="Subfolders in deze map">📁 ${childNames.length}</span>
<span class="file-badge" title="Bestanden in deze map">📄 ${sortedFiles.length}</span>
<span class="flex file-folder-actions">
<button class="btn small ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
<button class="btn small bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">🗑️</button>
<span class="file-folder-actions" onclick="event.stopPropagation();">
<button class="btn tiny ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
<button class="btn tiny bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">✕</button>
</span>
</span>
</div>
`);
</div>`);
out.push(`<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">`);
@@ -287,16 +277,14 @@ async function filesRefresh() {
for (const f of sortedFiles) {
const fullUi = node.uiPath ? `${node.uiPath}/${f}` : f;
const fileKey = encodeURIComponent(fullUi);
out.push(`
<div class="file-entry" data-file="${fileKey}" style="padding-left:${indent + 18}px;">
out.push(`<div class="file-entry" data-file="${fileKey}" style="padding-left:${indent + 16}px;">
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
<span class="file-entry-state"></span>
</div>
`);
</div>`);
}
if (!childNames.length && !sortedFiles.length) {
out.push(`<div class="muted" style="padding-left:${indent + 18}px;">(leeg)</div>`);
out.push(`<div class="muted" style="padding-left:${indent + 16}px; font-size:0.85em;">(leeg)</div>`);
}
out.push(`</div>`);
@@ -311,30 +299,25 @@ async function filesRefresh() {
const rootFolder = folderByPath.get(FILES_ROOT);
if (rootFolder && (rootFolder.files || []).length) {
const folderKey = FILES_ROOT;
const collapsed = _isFolderCollapsed(folderKey);
parts.unshift(`
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:8px 0 6px 0; font-weight:600;">
const collapsed = _isFolderCollapsed(folderKey, 0);
parts.unshift(`<div class="mono file-folder-row" data-folder="${esc(folderKey)}">
<span class="file-folder-left">
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
<span>📂 root</span>
</span>
<span class="flex" onclick="event.stopPropagation();">
<button class="btn small ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
<span class="file-folder-actions" onclick="event.stopPropagation();">
<button class="btn tiny ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
</span>
</div>
<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">
${(rootFolder.files || []).slice().sort((a,b)=>a.localeCompare(b)).map(f => {
const fullUi = f;
const fileKey = encodeURIComponent(fullUi);
return `
<div class="file-entry" data-file="${fileKey}" style="padding-left:18px;">
<span class="mono file-entry-name" style="cursor:pointer" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
const fileKey = encodeURIComponent(f);
return `<div class="file-entry" data-file="${fileKey}" style="padding-left:16px;">
<span class="mono file-entry-name" onclick="filesOpen(decodeURIComponent('${fileKey}'))">📄 ${esc(f)}</span>
<span class="file-entry-state"></span>
</div>
`;
</div>`;
}).join('')}
</div>
`);
</div>`);
}
treeEl.innerHTML = parts.join('');
+144 -56
View File
@@ -620,34 +620,27 @@
}
let graphCtx = null;
let modalGraphCtx = null;
let _escBound = false;
function renderGraph(model, opts = {}) {
const host = document.getElementById('networksMapHost');
if (!host) return;
// leeg host (placeholder weg)
host.innerHTML = '';
function _renderGraphInHost(hostEl, ctx, model, opts = {}) {
hostEl.innerHTML = '';
const tooltip = document.createElement('div');
tooltip.className = 'mapTooltip';
tooltip.style.display = 'none';
host.appendChild(tooltip);
hostEl.appendChild(tooltip);
const w = Math.max(600, host.clientWidth || 600);
const h = Math.max(420, host.clientHeight || 420);
const svg = d3.select(host).append('svg')
.attr('viewBox', `0 0 ${w} ${h}`);
const w = Math.max(600, hostEl.clientWidth || 600);
const h = Math.max(420, hostEl.clientHeight || 420);
const svg = d3.select(hostEl).append('svg').attr('viewBox', `0 0 ${w} ${h}`);
const g = svg.append('g');
// zoom/pan
const zoom = d3.zoom()
.scaleExtent([0.2, 2.5])
.on('zoom', (ev) => g.attr('transform', ev.transform));
svg.call(zoom);
// links
const link = g.append('g')
.selectAll('line')
.data(model.links)
@@ -655,7 +648,6 @@
.append('line')
.attr('class', d => d.type === 'shared' ? 'graphLink shared' : 'graphLink');
// nodes
const node = g.append('g')
.selectAll('g')
.data(model.nodes)
@@ -663,49 +655,38 @@
.append('g')
.attr('class', d => `graphNode ${d.type}`);
node.append('circle')
.attr('r', d => d.type === 'network' ? 14 : 9);
node.append('circle').attr('r', d => d.type === 'network' ? 14 : 9);
node.append('text')
.attr('class', 'graphLabel')
.attr('x', d => d.type === 'network' ? 18 : 12)
.attr('y', 4)
.text(d => d.label || d.key);
// drag
const drag = d3.drag()
.on('start', (ev, d) => {
if (!ev.active) graphCtx.sim.alphaTarget(0.2).restart();
if (!ev.active) ctx.sim.alphaTarget(0.2).restart();
d.fx = d.x; d.fy = d.y;
})
.on('drag', (ev, d) => {
d.fx = ev.x; d.fy = ev.y;
})
.on('drag', (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
.on('end', (ev, d) => {
if (!ev.active) graphCtx.sim.alphaTarget(0);
if (!ev.active) ctx.sim.alphaTarget(0);
d.fx = null; d.fy = null;
});
node.call(drag);
// hover highlight (connected)
node.on('mouseenter', (ev, d) => {
node.classed('graphDim', true);
link.classed('graphDim', true);
d3.select(ev.currentTarget).classed('graphDim', false);
link.each(function(l) {
const sid = l.source?.id || l.source;
const tid = l.target?.id || l.target;
const hit = (sid === d.id || tid === d.id);
if (hit) {
if (sid === d.id || tid === d.id) {
d3.select(this).classed('graphDim', false).classed('graphActive', true);
} else {
d3.select(this).classed('graphActive', false);
}
});
const typeLabel = d.type === 'network' ? 'Netwerk' : 'Container';
const extra = d.type === 'network'
? `Driver: ${d?.meta?.driver || 'onbekend'}`
@@ -713,15 +694,11 @@
tooltip.innerHTML = `<strong>${typeLabel}</strong><br>${d.label || d.key}<br>${extra}`;
tooltip.style.display = 'block';
});
node.on('mousemove', (ev) => {
const rect = host.getBoundingClientRect();
const x = (ev.clientX - rect.left) + 14;
const y = (ev.clientY - rect.top) + 14;
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
const rect = hostEl.getBoundingClientRect();
tooltip.style.left = `${(ev.clientX - rect.left) + 14}px`;
tooltip.style.top = `${(ev.clientY - rect.top) + 14}px`;
});
node.on('mouseleave', () => {
node.classed('graphDim', false);
link.classed('graphDim', false).classed('graphActive', false);
@@ -730,15 +707,14 @@
node.on('click', (ev, d) => {
if (d.type === 'network') {
openNetworkDetail(d.key);
if (opts.onNetworkClick) opts.onNetworkClick(d.key);
else openNetworkDetail(d.key);
return;
}
const s = document.getElementById('networksMapStatus');
if (s) s.textContent = `Geselecteerd: ${d.type} ${d.key}`;
const statusEl = opts.statusEl || document.getElementById('networksMapStatus');
if (statusEl) statusEl.textContent = `Geselecteerd: ${d.type} ${d.key}`;
});
// simulation
const sim = d3.forceSimulation(model.nodes)
.force('link', d3.forceLink(model.links).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-30))
@@ -746,23 +722,24 @@
.force('collide', d3.forceCollide().radius(d => d.type === 'network' ? 18 : 16))
.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
// detail-mode: pin netwerk in het midden
if (opts.pinNetwork) {
const pinId = opts.pinNetwork;
const pinned = model.nodes.find(n => n.id === pinId);
if (pinned) {
pinned.fx = w / 2;
pinned.fy = h / 2;
const pinned = model.nodes.find(n => n.id === opts.pinNetwork);
if (pinned) { pinned.fx = w / 2; pinned.fy = h / 2; }
}
ctx.svg = svg; ctx.g = g; ctx.sim = sim; ctx.model = model; ctx.zoom = zoom;
}
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) {
@@ -839,6 +816,79 @@
if (s) s.textContent = buildMapStatus('Global', model.nodes.length, model.links.length);
}
// ---- Vergroot modal ----
function openModalNetworkDetail(networkName) {
const host = document.getElementById('networksMapModalHost');
if (!host) return;
if (!modalGraphCtx) modalGraphCtx = {};
const model = buildNetworkDetailGraphModel(networkName);
_renderGraphInHost(host, modalGraphCtx, model, {
pinNetwork: `net:${networkName}`,
onNetworkClick: openModalNetworkDetail,
statusEl: document.getElementById('networksMapModalStatus'),
});
const s = document.getElementById('networksMapModalStatus');
if (s) s.textContent = buildMapStatus(`Detail: ${networkName}`, model.nodes.length, model.links.length);
const list = Array.isArray(state.list?.networks) ? state.list.networks : [];
const meta = list.find(n => (n?.name || n?.Name) === networkName) || { name: networkName };
const usage = state.usage?.byNetwork?.[networkName];
const containers = Array.isArray(usage?.containers) ? usage.containers : [];
const driver = String(meta?.driver || meta?.Driver || '');
const subnets = fmtSubnets(meta).join(', ');
const listHtml = containers.length
? `<ul class="mapDetailList">${containers.map(c => `<li>${esc(c.name || c.id.slice(0, 12))}</li>`).join('')}</ul>`
: `<div class="muted">Geen containers op dit netwerk.</div>`;
const title = document.getElementById('networksMapModalDetailTitle');
const body = document.getElementById('networksMapModalDetailBody');
const side = document.getElementById('networksMapModalSide');
if (title) title.textContent = networkName;
if (body) body.innerHTML = `
<div class="mapDetailGrid">
<div class="mapDetailKey">Driver</div><div>${esc(driver || '—')}</div>
<div class="mapDetailKey">Subnets</div><div>${esc(subnets || '—')}</div>
<div class="mapDetailKey">Containers</div><div>${containers.length}</div>
</div>${listHtml}`;
if (side) side.style.display = '';
}
function _openModalGlobalMap() {
const host = document.getElementById('networksMapModalHost');
if (!host) return;
modalGraphCtx = {};
const model = buildGlobalGraphModel();
_renderGraphInHost(host, modalGraphCtx, model, {
onNetworkClick: openModalNetworkDetail,
statusEl: document.getElementById('networksMapModalStatus'),
});
const s = document.getElementById('networksMapModalStatus');
if (s) s.textContent = buildMapStatus('Global', model.nodes.length, model.links.length);
const side = document.getElementById('networksMapModalSide');
if (side) side.style.display = 'none';
}
function openMapModal() {
const modal = document.getElementById('networksMapModal');
if (!modal) return;
modal.style.display = 'flex';
requestAnimationFrame(() => { _openModalGlobalMap(); });
}
function closeMapModal() {
const modal = document.getElementById('networksMapModal');
if (modal) modal.style.display = 'none';
if (modalGraphCtx && modalGraphCtx.sim) modalGraphCtx.sim.stop();
modalGraphCtx = {};
const host = document.getElementById('networksMapModalHost');
if (host) host.innerHTML = '';
const side = document.getElementById('networksMapModalSide');
if (side) side.style.display = 'none';
}
function renderNetworks() {
const tbody = document.getElementById('networksTbody');
const rel = document.getElementById('networksRelations');
@@ -1019,6 +1069,44 @@
});
}
// Modal: expand knop
const expandBtn = document.getElementById('networksMapExpandBtn');
if (expandBtn && !expandBtn.dataset.bound) {
expandBtn.dataset.bound = '1';
expandBtn.addEventListener('click', openMapModal);
}
// Modal: sluitknop
const modalCloseBtn = document.getElementById('networksMapModalClose');
if (modalCloseBtn && !modalCloseBtn.dataset.bound) {
modalCloseBtn.dataset.bound = '1';
modalCloseBtn.addEventListener('click', closeMapModal);
}
// Modal: overlay klik
const mapModal = document.getElementById('networksMapModal');
if (mapModal && !mapModal.dataset.bound) {
mapModal.dataset.bound = '1';
mapModal.addEventListener('click', (ev) => { if (ev.target === mapModal) closeMapModal(); });
}
// Modal: terug naar global
const modalBackBtn = document.getElementById('networksMapModalBackBtn');
if (modalBackBtn && !modalBackBtn.dataset.bound) {
modalBackBtn.dataset.bound = '1';
modalBackBtn.addEventListener('click', _openModalGlobalMap);
}
// ESC sluit modal (éénmalig binden)
if (!_escBound) {
_escBound = true;
document.addEventListener('keydown', (ev) => {
if (ev.key !== 'Escape') return;
const m = document.getElementById('networksMapModal');
if (m && m.style.display !== 'none') closeMapModal();
});
}
const detailBody = document.getElementById('networksDetailBody');
if (detailBody && !detailBody.dataset.bound) {
detailBody.dataset.bound = '1';
+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 class="flex">
<button class="btn ghost" onclick="pingApi()">◉ Ping</button>
<button class="btn" onclick="refreshActive()">↻ Ververs</button>
<button class="btn ghost" id="themeToggleBtn" title="Schakel light/dark mode">◐ Theme</button>
<button class="btn ghost" id="themeToggleBtn" title="Schakel thema">🌙</button>
<span class="statusline headerMeta" id="lastRefreshHeader">Laatste refresh: -</span>
</div>
</div>
@@ -57,6 +55,9 @@
<div class="tab" id="tab-images" onclick="setTab('images')" title="Images">
<span class="navIcon">📦</span><span class="navLabel">Images</span><span class="navCount" id="countNavImages">-</span>
</div>
<div class="tab" id="tab-volumes" onclick="setTab('volumes')" title="Volumes">
<span class="navIcon">🗄️</span><span class="navLabel">Volumes</span><span class="navCount" id="countNavVolumes">-</span>
</div>
<div class="tab" id="tab-files" onclick="setTab('files')" title="Files">
<span class="navIcon">📁</span><span class="navLabel">Files</span><span class="navCount" id="countNavFiles">-</span>
</div>
@@ -110,6 +111,7 @@
<button class="btn" onclick="setTab('networks')">Ga naar netwerken</button>
<button class="btn" onclick="setTab('images')">Ga naar images</button>
<button class="btn" onclick="setTab('files')">Ga naar files</button>
<a class="btn ghost" href="/docs/" target="_blank">API docs ↗</a>
</div>
<div class="hint">Gebruik de zijbalk voor detailbeheer; deze acties geven snelle toegang tot de hoofdsecties.</div>
</div>
@@ -202,6 +204,7 @@
<span class="muted">Alleen verbonden</span>
</label>
<span class="muted" id="networksMapStatus" style="margin-left:auto;">Kaartweergave (placeholder)</span>
<button class="btn small ghost" type="button" id="networksMapExpandBtn" title="Vergroot naar volledig scherm"></button>
</div>
</div>
@@ -304,6 +307,35 @@
</div>
</div>
<div id="view-volumes" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Volumes</div>
<div class="flex">
<button class="btn" onclick="loadVolumes()">Ververs</button>
<button class="btn ok" onclick="openCreateVolumeModal()">+ Volume</button>
<button class="btn bad" onclick="pruneVolumes()">Prune</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Driver</th>
<th>Mountpoint</th>
<th>Aangemaakt</th>
<th>Labels</th>
<th>Containers</th>
<th style="width:90px;">Acties</th>
</tr>
</thead>
<tbody id="volumes-tbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-files" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
@@ -394,6 +426,30 @@
</div>
</div>
<!-- Create Volume Modal -->
<div class="modalBack" id="createVolumeModalBack" style="display:none;" onclick="closeCreateVolumeModal(event)">
<div class="modal" onclick="event.stopPropagation()" style="width:460px;">
<div class="modalHeader">
<div class="modalTitle">Volume aanmaken</div>
<button class="btn small ghost" onclick="hideCreateVolumeModal()">Sluiten</button>
</div>
<div class="modalBody">
<div style="margin-bottom:12px;">
<label class="label">Naam <span style="color:var(--bad)">*</span></label>
<input id="createVolumeName" class="input" type="text" placeholder="mijn-volume" style="width:100%;" />
</div>
<div style="margin-bottom:16px;">
<label class="label">Labels <span class="muted">(optioneel, één key=value per regel)</span></label>
<textarea id="createVolumeLabels" class="textarea mono" rows="3" placeholder="app=myapp&#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 -->
<div class="modalBack" id="buildModalBack" style="display:none;" onclick="closeBuildModal(event)">
<div class="modal" onclick="event.stopPropagation()" style="width:700px;">
@@ -589,6 +645,9 @@
if (tab === "images") {
loadImages();
}
if (tab === "volumes") {
loadVolumes();
}
// Start/stop live stats alleen in Containers tab (polling via /containers-dashboard)
if (tab === 'containers') startContainersDashboardStatsPoll();
else stopContainersDashboardStatsPoll();
@@ -623,21 +682,30 @@
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods-dashboard', 'GET');
setApiState(true, 'API: OK');
const h = await api('/health', 'GET');
const helperOk = h?.helper?.ok === true;
if (!h?.ok) {
const detail = !h?.podman?.ok ? 'podman' : !h?.systemd_user?.reachable ? 'systemd' : 'onbekend';
setApiState('error', `API: fout (${detail})`);
} else if (!helperOk) {
setApiState('warn', 'API: OK | ⚠️ helper');
} else {
setApiState('ok', 'API: OK');
}
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
setApiState('error', 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
function setApiState(state, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
const ok = state === 'ok';
const warn = state === 'warn';
dot.style.background = ok ? 'var(--ok)' : warn ? 'var(--warn, #f59e0b)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : warn ? '0 0 0 6px rgba(245,158,11,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
const apiStat = document.getElementById('dashboardApiState');
if (apiStat) apiStat.textContent = ok ? 'OK' : 'Fout';
if (apiStat) apiStat.textContent = ok ? 'OK' : warn ? 'Waarschuwing' : 'Fout';
}
function currentClockText() {
@@ -671,10 +739,10 @@
const nCount = Array.isArray(networks?.networks) ? networks.networks.length : 0;
updateNavCount('countNavNetworks', nCount);
}
setApiState(true, 'API: OK');
setLastRefreshNow();
pingApi();
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
setApiState('error', 'API: fout (' + e.message + ')');
}
}
@@ -708,9 +776,9 @@
function updateThemeToggleUi(theme) {
const btn = document.getElementById('themeToggleBtn');
if (!btn) return;
const next = theme === 'dark' ? 'light' : 'dark';
btn.textContent = `Theme: ${theme === 'dark' ? 'Dark' : 'Light'}`;
btn.title = `Schakel naar ${next === 'dark' ? 'dark' : 'light'} mode`;
const goingTo = theme === 'dark' ? 'light' : 'dark';
btn.textContent = goingTo === 'light' ? '☀️' : '🌙';
btn.title = goingTo === 'light' ? 'Schakel naar licht thema' : 'Schakel naar donker thema';
}
function applyTheme(theme, persist = false) {
@@ -759,8 +827,29 @@
setInterval(() => { pingApi(); }, 20000);
})();
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script src="assets/js/d3.min.js"></script>
<script src="assets/js/tabs/networks.js"></script>
<script src="assets/js/tabs/images.js"></script>
<script src="assets/js/tabs/volumes.js"></script>
<!-- Netwerktopologie vergroot modal -->
<div id="networksMapModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:1000; align-items:center; justify-content:center;">
<div style="position:relative; width:90vw; height:90vh; background:var(--card-bg); border:1px solid var(--card-border); border-radius:16px; display:flex; flex-direction:column; overflow:hidden;">
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 16px; border-bottom:1px solid var(--card-border); flex:0 0 auto;">
<span class="muted mono" id="networksMapModalStatus">Netwerktopologie</span>
<button class="btn ghost" id="networksMapModalClose" title="Sluiten (Esc)"></button>
</div>
<div style="display:flex; flex:1; min-height:0;">
<div id="networksMapModalHost" style="flex:1; min-width:0; min-height:0; position:relative; overflow:hidden; background:var(--map-bg);"></div>
<div id="networksMapModalSide" style="display:none; width:280px; flex:0 0 280px; border-left:1px solid var(--card-border); overflow-y:auto; padding:12px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
<strong id="networksMapModalDetailTitle">Netwerk</strong>
<button class="btn small ghost" id="networksMapModalBackBtn">← Terug</button>
</div>
<div id="networksMapModalDetailBody"></div>
</div>
</div>
</div>
</div>
</body>
</html>