From 7f0d34cb1a4b99d38754aff3214a7058a58a0242 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 27 Jul 2026 07:57:35 +0200 Subject: [PATCH] v0.5.50-beta - containers tonen die niet bij een beheerde stack horen Het dashboard meldde 13 actieve containers en 17 images terwijl die nergens terug te vinden waren. De tellers komen van `docker info` (de hele daemon), de stacklijst toont alleen mappen in LIBRARY_DIR met een compose-bestand. Alles wat buiten Server Up om gestart is telde dus wel mee maar was onzichtbaar. - Nieuw /api/containers: alle containers op de host, met per container of hij bij een beheerde stack hoort (compose-project of werkmap onder LIBRARY_DIR). - Blok "Overige containers" onder de stacklijst, met start/stop/herstart/logs. - Dashboardteller "actief" vermeldt nu hoeveel daarvan via Server Up loopt. - De eigen container is gemarkeerd en kan niet via dit blok gestopt worden; daarvoor blijft de herstartknop in de instellingen. - Rollen: een viewer ziet de lijst maar mag niets bedienen. Verholpen tijdens het bouwen: containers zonder poorten en zonder compose-labels vielen uit de lijst als ze als laatste regel stonden, doordat strip() de lege velden aan het eind wegnam en de regel daarna te weinig kolommen leek te hebben. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- CHANGELOG.md | 36 ++++++++++ VERSION | 2 +- server-up/app.py | 69 +++++++++++++++++++ server-up/core/docker.py | 38 +++++++++++ server-up/templates/index.html | 80 ++++++++++++++++++++-- server-up/translations/en.json | 6 +- server-up/translations/nl.json | 6 +- tests/test_containers.py | 118 +++++++++++++++++++++++++++++++++ 8 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 tests/test_containers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 93cca2d..fba81fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +# v0.5.50-beta — Containers die nergens te vinden waren + +Het dashboard meldde 13 actieve containers en 17 images, terwijl je die nergens +terugzag. Dat kwam doordat de cijfers en de lijst uit twee verschillende bronnen +komen: + +* de **tellers** komen van `docker info` en gaan over de hele Docker-daemon +* de **stacklijst** toont alleen mappen in `LIBRARY_DIR` met een compose-bestand + +Alles wat je buiten Server Up om had gestart — je Forgejo, de act_runner, +containers van vóór je Server Up ging gebruiken — telde dus wel mee maar was +onzichtbaar en onbedienbaar. Een beheertool die dingen telt die hij niet toont, +is verwarrend. + +## Wat er nu is + +- **Overzicht van alle containers op de host.** Onder de stacklijst staat een + blok "Overige containers" met alles wat niet bij een beheerde stack hoort, + inclusief image, poorten en compose-project. Starten, stoppen, herstarten en + logs bekijken kan direct. +- **De dashboardteller vertelt nu het hele verhaal**: onder "actief" staat + hoeveel daarvan via Server Up loopt. +- De eigen container is gemarkeerd als "deze app" en kan niet via dit blok + gestopt worden — daarvoor is de herstartknop in de instellingen, die dat + netjes afhandelt in plaats van zichzelf halverwege een verzoek te stoppen. +- Een viewer ziet de lijst wel maar kan niets bedienen. + +## Verholpen tijdens het bouwen + +Containers zonder poorten én zonder compose-labels vielen weg uit de lijst als +ze toevallig als laatste stonden: `strip()` haalde de lege velden aan het eind +van de uitvoer weg, waarna die regel te weinig kolommen leek te hebben. Gevonden +door de test die precies zo'n container als laatste zet. + +--- + # v0.5.43-beta — Van-tot omrekenen naar een CIDR-blok "Ik wil van 10.0.20.200 tot .254" is een volstrekt redelijke wens, maar bestaat diff --git a/VERSION b/VERSION index 6bbadc1..287a4da 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.46-beta +0.5.50-beta diff --git a/server-up/app.py b/server-up/app.py index e03b2fe..2b043cc 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -459,6 +459,75 @@ def api_update_rollback(): return jsonify(ok=True, job_id=jid, tag=st["previous_tag"]) +@app.route("/api/containers") +def api_containers(): + """Alle containers op deze host, met of ze bij een beheerde stack horen. + + Het dashboard telde alles wat de Docker-daemon kent, terwijl de stacklijst + alleen toont wat in LIBRARY_DIR staat. Containers die je buiten Server Up om + hebt gestart — of die er al stonden — waren daardoor wel zichtbaar in de + tellers maar nergens terug te vinden. + """ + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + beheerd = set() + if lib.is_dir(): + beheerd = {d.name for d in lib.iterdir() + if d.is_dir() and not d.name.startswith(".") and docker.has_compose(d)} + + containers = docker.list_containers() + for c in containers: + # Een container hoort bij ons als zijn compose-project overeenkomt met + # een map in de library, of als zijn projectmap daar onder valt. + stack = c.get("project") or "" + in_lib = False + if c.get("working_dir"): + try: + in_lib = Path(c["working_dir"]).resolve().parent == lib + except Exception: + in_lib = False + c["managed"] = bool(stack and stack in beheerd) or in_lib + c["stack"] = stack if c["managed"] else "" + c["self"] = c["name"] == CONTAINER_NAME + + return jsonify(containers=containers, + managed=sum(1 for c in containers if c["managed"]), + total=len(containers), + running=sum(1 for c in containers if c["running"])) + + +@app.route("/api/containers//", methods=["POST"]) +def api_any_container_action(name, action): + """Start/stop/herstart een willekeurige container op deze host. + + Bedoeld voor containers die niet bij een beheerde stack horen. De eigen + container is uitgezonderd: daarvoor is /api/docker/restart, dat de + herstart netjes afhandelt in plaats van zichzelf midden in een verzoek te + stoppen. + """ + if action not in ("start", "stop", "restart"): + return jsonify(ok=False, msg="onbekende actie"), 400 + if name == CONTAINER_NAME: + return jsonify(ok=False, + msg="Gebruik 'Container herstarten' in de instellingen " + "om Server Up zelf te herstarten."), 400 + bestaand = {c["name"] for c in docker.list_containers()} + if name not in bestaand: + return jsonify(ok=False, msg="container niet gevonden"), 404 + ok, msg = docker.container_action(name, action) + audit.log("containers", action, "ok" if ok else "error", ref=name, + ip=request.remote_addr) + return jsonify(ok=ok, msg=msg or ("gelukt" if ok else "mislukt")) + + +@app.route("/api/containers//logs") +def api_any_container_logs(name): + bestaand = {c["name"] for c in docker.list_containers()} + if name not in bestaand: + return jsonify(ok=False, msg="container niet gevonden"), 404 + tail = min(max(int(request.args.get("tail", 200)), 1), 2000) + return jsonify(ok=True, logs=docker.container_logs(name, tail)) + + @app.route("/api/docker/images") def api_images(): return jsonify(images=docker.images()) diff --git a/server-up/core/docker.py b/server-up/core/docker.py index 4c87416..5493074 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -137,6 +137,44 @@ def restart_container(name: str) -> tuple[bool, str]: return r.returncode == 0, (r.stderr or r.stdout).strip() +def list_containers(alle: bool = True) -> list[dict]: + """Alle containers op deze host, met hun compose-herkomst. + + De dashboardtellers komen van `docker info` en gaan over de hele daemon, + terwijl de stacklijst alleen toont wat in LIBRARY_DIR staat. Met deze lijst + kunnen we die twee met elkaar rijmen en ook containers tonen die Server Up + niet zelf heeft aangemaakt. + """ + fmt = ("{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}" + "\t{{.Label \"com.docker.compose.project\"}}" + "\t{{.Label \"com.docker.compose.project.working_dir\"}}") + cmd = [_bin(), "ps", "--format", fmt] + if alle: + cmd.insert(2, "-a") + r = _run(cmd, timeout=20) + if r.returncode != 0: + return [] + uit = [] + # Bewust geen .strip() op de hele uitvoer: een container zonder poorten en + # zonder compose-labels eindigt op lege velden, en die tabs zou strip() + # weghalen — waarna de laatste regel te weinig kolommen leek te hebben en + # stilzwijgend werd overgeslagen. + for regel in r.stdout.splitlines(): + if not regel.strip(): + continue + d = regel.split("\t") + if len(d) < 4: + continue + d += [""] * (8 - len(d)) + uit.append({ + "id": d[0], "name": d[1], "image": d[2], + "state": d[3], "status": d[4], "ports": d[5], + "project": d[6], "working_dir": d[7], + "running": d[3].lower() in ("running", "up"), + }) + return sorted(uit, key=lambda c: c["name"]) + + def container_action(name: str, action: str) -> tuple[bool, str]: """Start, stop of herstart één container. diff --git a/server-up/templates/index.html b/server-up/templates/index.html index a516a33..18e8e6f 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -199,10 +199,18 @@ tailwind.config = {
-
+
+
+
+
Running
+ +
+
+
Stopped
@@ -326,6 +334,46 @@ tailwind.config = {

Geen stacks gevonden

+ + +
+
+ +

Overige containers

+ + +
+

+
+ +
+
@@ -1362,6 +1410,9 @@ function app() { // Uitklapbaar containerpaneel per stack: { : {open, loading, items} } cpanel: {}, + // Containers op deze host, ook die Server Up niet zelf beheert + hostContainers: { containers: [], managed: 0, total: 0, running: 0 }, + // Backups en updatestatus per stack bkStack: '', bkList: [], bkSettings: {}, stackUpd: {}, checkingUpd: false, @@ -1439,10 +1490,11 @@ function app() { await this.loadDocker(); await this.loadModules(); await this.loadDashboard(); + this.loadHostContainers(); this.loadUpdate(); setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000); this.$watch('page', async (p) => { - if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); } + if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); this.loadHostContainers(); } if (p === 'app_store') this.loadAppStore(); if (p === 'docker') this.loadImages(); if (p === 'audit') this.loadAudit(); @@ -1884,6 +1936,26 @@ function app() { .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }, + // ── Containers op deze host ────────────────────────────────────────────── + async loadHostContainers() { + const d = await this.rpc('/api/containers'); + this.hostContainers = d && d.containers ? d : { containers: [], managed: 0, total: 0, running: 0 }; + }, + get unmanaged() { + return (this.hostContainers.containers || []).filter(c => !c.managed); + }, + async hostContainerAction(naam, actie) { + const d = await this.rpc(`/api/containers/${encodeURIComponent(naam)}/${actie}`, {}); + this.toast(d.ok ? this.t(actie) + ' ✓' : (d.msg || this.t('error')), d.ok ? 'ok' : 'er'); + await this.loadHostContainers(); + this.loadDocker(); + }, + async openHostLogs(naam) { + const d = await this.rpc(`/api/containers/${encodeURIComponent(naam)}/logs`); + this.logs = { title: naam + ' · logs', content: d.logs || '(geen output)' }; + this.modal = 'logs'; + }, + // ── Containers binnen een stack ────────────────────────────────────────── async toggleContainers(stack) { const cur = this.cpanel[stack] || { open: false, loading: false, items: [] }; diff --git a/server-up/translations/en.json b/server-up/translations/en.json index 2101f7c..f6db6f5 100644 --- a/server-up/translations/en.json +++ b/server-up/translations/en.json @@ -242,5 +242,9 @@ "range_count": "{count} addresses", "already_added": "already added", "detect_empty": "No network with a default route was found on this server. Fill in the details below manually.", - "wizard_hint": "Run the wizard again to adjust paths and repositories. Your existing stacks are left untouched." + "wizard_hint": "Run the wizard again to adjust paths and repositories. Your existing stacks are left untouched.", + "of_which_managed": "of which {n} via Server Up", + "other_containers": "Other containers", + "other_containers_hint": "These containers run on this host but do not belong to a stack managed by Server Up — started manually, for example, or from before you began using Server Up. They do count towards the dashboard figures.", + "this_app": "this app" } diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json index 2076161..c27b43f 100644 --- a/server-up/translations/nl.json +++ b/server-up/translations/nl.json @@ -242,5 +242,9 @@ "range_count": "{count} adressen", "already_added": "al toegevoegd", "detect_empty": "Er is geen netwerk met een default route gevonden op deze server. Vul de gegevens hieronder handmatig in.", - "wizard_hint": "Doorloop de wizard opnieuw om paden en repositories aan te passen. Je bestaande stacks blijven staan." + "wizard_hint": "Doorloop de wizard opnieuw om paden en repositories aan te passen. Je bestaande stacks blijven staan.", + "of_which_managed": "waarvan {n} via Server Up", + "other_containers": "Overige containers", + "other_containers_hint": "Deze containers draaien op deze host maar horen niet bij een stack die Server Up beheert — bijvoorbeeld handmatig gestart of van vóór je Server Up ging gebruiken. Ze tellen wel mee in de cijfers op het dashboard.", + "this_app": "deze app" } diff --git a/tests/test_containers.py b/tests/test_containers.py new file mode 100644 index 0000000..a7ed0ec --- /dev/null +++ b/tests/test_containers.py @@ -0,0 +1,118 @@ +"""Containers op de host: wat is beheerd en wat niet. + +Het dashboard telt alles wat de Docker-daemon kent, terwijl de stacklijst alleen +toont wat in LIBRARY_DIR staat. Containers die daarbuiten vallen waren zichtbaar +in de cijfers maar nergens terug te vinden. +""" +import pytest + +from conftest import login + +PS_UITVOER = "\n".join([ + "abc123\tvaultwarden\tvaultwarden/server:latest\trunning\tUp 2 hours\t0.0.0.0:8222->80/tcp\tvaultwarden\t/opt/serverup/stacks/vaultwarden", + "def456\tforgejo\tcodeberg.org/forgejo/forgejo:1.21\trunning\tUp 3 days\t0.0.0.0:3000->3000/tcp\tforgejo\t/opt/docker/forgejo", + "ghi789\tact_runner\tgitea/act_runner:latest\trunning\tUp 3 days\t\t\t", + "jkl012\tserver-up\tserver-up:0.5.46-beta\trunning\tUp 5 minutes\t127.0.0.1:5000->5000/tcp\tserver-up\t/opt/docker/server-up", + "mno345\toud-testje\tnginx:latest\texited\tExited (0) 4 days ago\t\t\t", +]) + + +@pytest.fixture +def host(env, monkeypatch): + from core import docker + monkeypatch.setattr(docker, "_run", lambda *a, **k: type( + "R", (), {"returncode": 0, "stdout": PS_UITVOER, "stderr": ""})()) + return docker + + +def test_list_containers_leest_alle_velden(host): + c = host.list_containers() + assert len(c) == 5 + vw = next(x for x in c if x["name"] == "vaultwarden") + assert vw["image"] == "vaultwarden/server:latest" + assert vw["running"] is True + assert vw["project"] == "vaultwarden" + assert vw["working_dir"] == "/opt/serverup/stacks/vaultwarden" + + +def test_list_containers_zonder_labels(host): + """Een container zonder compose-labels heeft lege project-/werkmapvelden.""" + c = next(x for x in host.list_containers() if x["name"] == "act_runner") + assert c["project"] == "" and c["working_dir"] == "" + assert c["running"] is True + + +def test_gestopte_container_wordt_herkend(host): + c = next(x for x in host.list_containers() if x["name"] == "oud-testje") + assert c["running"] is False + + +def test_endpoint_scheidt_beheerd_van_de_rest(client, env, monkeypatch): + from core import docker + login(client) + # Eén stack in de library: vaultwarden. + stack = env["lib"] / "vaultwarden" + stack.mkdir(parents=True) + (stack / "docker-compose.yml").write_text("services:\n app:\n image: nginx\n") + monkeypatch.setattr(docker, "_run", lambda *a, **k: type( + "R", (), {"returncode": 0, + "stdout": PS_UITVOER.replace("/opt/serverup/stacks/vaultwarden", + str(stack)), + "stderr": ""})()) + + d = client.get("/api/containers").get_json() + beheerd = [c["name"] for c in d["containers"] if c["managed"]] + overig = [c["name"] for c in d["containers"] if not c["managed"]] + assert beheerd == ["vaultwarden"] + assert set(overig) == {"forgejo", "act_runner", "server-up", "oud-testje"} + assert d["managed"] == 1 + assert d["total"] == 5 + assert d["running"] == 4 + + +def test_eigen_container_wordt_gemarkeerd(client, env, monkeypatch): + from core import docker + import app as app_module + login(client) + monkeypatch.setattr(app_module, "CONTAINER_NAME", "server-up") + monkeypatch.setattr(docker, "_run", lambda *a, **k: type( + "R", (), {"returncode": 0, "stdout": PS_UITVOER, "stderr": ""})()) + d = client.get("/api/containers").get_json() + assert next(c for c in d["containers"] if c["name"] == "server-up")["self"] is True + + +def test_eigen_container_kan_niet_gestopt_worden(client, monkeypatch): + """Zichzelf stoppen midden in een verzoek levert een halve actie op; daar + is de aparte herstartknop voor.""" + import app as app_module + csrf = login(client) + monkeypatch.setattr(app_module, "CONTAINER_NAME", "server-up") + r = client.post("/api/containers/server-up/stop", headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + assert "herstarten" in r.get_json()["msg"] + + +def test_onbekende_container(client, host): + csrf = login(client) + r = client.post("/api/containers/bestaatniet/stop", headers={"X-CSRF-Token": csrf}) + assert r.status_code == 404 + + +def test_onbekende_actie(client, host): + csrf = login(client) + r = client.post("/api/containers/forgejo/verwijder", headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + + +def test_viewer_mag_geen_containers_bedienen(client, host): + csrf = login(client) + client.post("/api/auth/users", + json={"username": "kijker", "password": "eenlangwachtwoord", + "role": "viewer"}, + headers={"X-CSRF-Token": csrf}) + r = client.post("/api/auth/login", + json={"username": "kijker", "password": "eenlangwachtwoord"}) + kijker_csrf = r.get_json()["csrf_token"] + assert client.get("/api/containers").status_code == 200 + assert client.post("/api/containers/forgejo/stop", + headers={"X-CSRF-Token": kijker_csrf}).status_code == 403