v0.5.50-beta - containers tonen die niet bij een beheerde stack horen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m5s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-27 07:57:35 +02:00
parent 34a48fb74a
commit 7f0d34cb1a
8 changed files with 348 additions and 7 deletions

View file

@ -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

View file

@ -1 +1 @@
0.5.46-beta
0.5.50-beta

View file

@ -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/<name>/<action>", 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/<name>/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())

View file

@ -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.

View file

@ -199,10 +199,18 @@ tailwind.config = {
<!-- ══ Dashboard ══ -->
<section x-show="page === 'dashboard'" x-cloak class="anim">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-7">
<div class="stat-card">
<button class="stat-card text-left hover:shadow-soft2 transition" @click="goto('stacks')">
<div class="stat-icon bg-ok/10 text-ok"><span class="mdi mdi-play-circle"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="docker.running ?? ''"></div><div class="stat-label" x-text="t('running')">Running</div></div>
</div>
<div class="min-w-0">
<div class="stat-value" x-text="docker.running ?? ''"></div>
<div class="stat-label" x-text="t('running')">Running</div>
<!-- De teller komt van de Docker-daemon en telt dus ook containers
die niet door Server Up zijn aangemaakt. -->
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5"
x-show="hostContainers.total"
x-text="t('of_which_managed', {n: hostContainers.managed})"></div>
</div>
</button>
<div class="stat-card">
<div class="stat-icon bg-dm/15 text-dm dark:text-dm-dark"><span class="mdi mdi-stop-circle-outline"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="(docker.containers ?? 0) - (docker.running ?? 0)"></div><div class="stat-label" x-text="t('stopped')">Stopped</div></div>
@ -326,6 +334,46 @@ tailwind.config = {
</template>
</div>
<div x-show="filteredStacks.length === 0" class="empty"><span class="mdi mdi-server-network"></span><p x-text="t('no_stacks')">Geen stacks gevonden</p></div>
<!-- Containers die niet bij een door Server Up beheerde stack horen.
Zonder dit blok tellen ze wel mee op het dashboard maar zijn ze
nergens terug te vinden. -->
<div x-show="unmanaged.length" class="mt-8">
<div class="flex items-center gap-2 mb-2">
<span class="mdi mdi-cube-outline text-t2 dark:text-t2-dark"></span>
<h3 class="text-sm font-bold text-wh dark:text-wh-dark" x-text="t('other_containers')">Overige containers</h3>
<span class="badge badge-dim" x-text="unmanaged.length"></span>
<button class="btn-icon ml-auto w-9 h-9" @click="loadHostContainers()"><span class="mdi mdi-refresh"></span></button>
</div>
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('other_containers_hint')"></p>
<div class="card divide-y divide-bd dark:divide-bd-dark">
<template x-for="c in unmanaged" :key="c.id">
<div class="px-4 py-3 flex flex-wrap items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="c.running ? 'bg-ok' : 'bg-dm dark:bg-dm-dark'"></span>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-wh dark:text-wh-dark truncate" x-text="c.name"></div>
<div class="text-[10px] font-mono text-dm dark:text-dm-dark truncate">
<span x-text="c.image"></span><span x-show="c.ports"> · </span><span x-text="c.ports"></span>
</div>
</div>
<span class="badge badge-dim" x-show="c.project" x-text="c.project"></span>
<span class="badge badge-info" x-show="c.self" x-text="t('this_app')">deze app</span>
<template x-if="!c.self">
<div class="flex gap-1.5">
<button class="btn-sm !h-8 !px-2" :title="c.running ? t('stop') : t('start')"
@click="hostContainerAction(c.name, c.running ? 'stop' : 'start')">
<span class="mdi" :class="c.running ? 'mdi-stop' : 'mdi-play'"></span>
</button>
<button class="btn-sm !h-8 !px-2" :title="t('restart')"
@click="hostContainerAction(c.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm !h-8 !px-2" :title="t('logs')"
@click="openHostLogs(c.name)"><span class="mdi mdi-text-box-outline"></span></button>
</div>
</template>
</div>
</template>
</div>
</div>
</section>
<!-- ══ App Store ══ -->
@ -1362,6 +1410,9 @@ function app() {
// Uitklapbaar containerpaneel per stack: { <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: [] };

View file

@ -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"
}

View file

@ -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"
}

118
tests/test_containers.py Normal file
View file

@ -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