From 6c84def7592128b5ddbcd2177011f58760541ecc Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 4 Aug 2026 18:50:16 +0200 Subject: [PATCH] fix: herstel app verwijderen en mobiele modals --- CHANGELOG.md | 22 ++++++ VERSION | 2 +- server-up/app.py | 41 +++++++--- server-up/templates/index.html | 137 ++++++++++++++++++++++++--------- server-up/translations/en.json | 10 ++- server-up/translations/nl.json | 10 ++- tests/js/menu.test.js | 36 +++++++++ tests/test_stack_remove.py | 91 ++++++++++++++++++++++ tests/test_ui_logica.py | 9 +++ 9 files changed, 302 insertions(+), 56 deletions(-) create mode 100644 tests/test_stack_remove.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d06c508..b3deb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +# v0.8.15-beta — Apps verwijderen en mobiele schermen werken weer + +**De verwijderknop stopte containers en verwijderde volumes, maar liet de +appconfiguratie staan.** Daardoor bleef de app in Server Up zichtbaar en leek de +knop niets te doen. Daarnaast konden modals en instellingen op een klein scherm +buiten de viewport vallen zonder dat de inhoud bereikbaar was. + +- **Verwijderen doet nu wat de keuze zegt.** De veilige keuze verwijdert de + appconfiguratie, maar bewaart appdata en named volumes. De expliciete keuze + “app en alle data verwijderen” wist ook appdata en niet-externe volumes. +- **Overgenomen apps zijn verwijderbaar.** Bij een app die als symlink is + gekoppeld wordt alleen de Server Up-verwijzing weggehaald; de externe + Compose-map van de gebruiker blijft bestaan. +- **Duidelijke voortgang en fouten.** De dialoog onthoudt de juiste app, + voorkomt dubbele opdrachten en toont zowel succes als backendfouten. +- **Modals passen op ieder mobiel scherm.** Dialogen en de setupwizard gebruiken + de dynamische viewporthoogte, houden kop en knoppen in beeld en maken de + inhoud zelfstandig scrollbaar. +- **Instellingen blijven binnen de schermbreedte.** Brede keuzerijen kunnen + horizontaal scrollen, kaartkoppen lopen om en invoervelden mogen binnen hun + grid krimpen. Ook het loginvenster blijft op een laag scherm bereikbaar. + # v0.8.14-beta — Raspberry Pi krijgt werkende en eerlijke apps **De UniFi-template startte een MongoDB-container, maar maakte de database- diff --git a/VERSION b/VERSION index f0dce9a..58aa45e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.14-beta +0.8.15-beta diff --git a/server-up/app.py b/server-up/app.py index 96bf7e7..b335ced 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -752,7 +752,15 @@ def _stack_dir(name: str) -> tuple[Path | None, Path | None]: if not cfg.safe_name(name): return None, None lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() - d = (lib / name).resolve() + kandidaat = lib / name + # Overgenomen compose-apps zijn bewust een directe symlink in LIBRARY_DIR. + # resolve() maakte daarvan een pad buiten de library en blokkeerde daarna + # alle acties, inclusief het loskoppelen waarvoor hieronder speciale code + # staat. De veilige naam garandeert dat alleen dit ene directe kind wordt + # gekozen; een gewone map blijft volledig geresolved en begrensd. + if kandidaat.is_symlink(): + return lib, kandidaat + d = kandidaat.resolve() if not d.is_relative_to(lib) or d == lib: return None, None return lib, d @@ -763,17 +771,20 @@ def api_stack_action(name, action): lib, d = _stack_dir(name) if not d: return jsonify(ok=False, msg="ongeldige naam"), 400 - if not d.exists(): + if not d.exists() and not (action == "remove" and d.is_symlink()): return jsonify(ok=False, msg="niet gevonden"), 404 if action not in ("start", "stop", "restart", "update", "remove", "backup"): return jsonify(ok=False, msg="onbekend"), 400 body = request.json or {} + remove_mode = body.get("mode", "full") + if action == "remove" and remove_mode not in ("stack", "full", "all"): + return jsonify(ok=False, msg="ongeldige verwijderkeuze"), 400 jid, q = jobs.create(f"{action}:{name}") def _run(jid): qq = jobs.get_queue(jid) - pdir = Path(cfg.load()["LIBRARY_DIR"]).resolve() / name + pdir = d lf = lambda m: jobs.log(qq, "dim", m) try: jobs.log(qq, "section", f"{action}: {name}") @@ -790,27 +801,35 @@ def api_stack_action(name, action): docker.compose_pull(pdir, log_fn=lf) rc = docker.compose_up(pdir, log_fn=lf, name=name) elif action == "remove": - mode = body.get("mode", "stack") + mode = remove_mode if mode in ("full", "all"): _auto_backup(qq, name, "BACKUP_BEFORE_REMOVE", "voor-remove") if docker.has_compose(pdir): - docker.compose_down(pdir, log_fn=lf, volumes=True, name=name) - if mode in ("full", "all") and pdir.is_dir(): + # De gewone verwijderactie bewaart data: dus ook named + # volumes. Alleen de expliciete alles-wissenkeuze gebruikt + # compose down -v. De oude API-modus `stack` behoudt zijn + # historische betekenis (alleen stoppen + volumes wissen). + docker.compose_down( + pdir, log_fn=lf, volumes=mode in ("stack", "all"), + name=name) + if mode in ("full", "all") and ( + pdir.is_dir() or pdir.is_symlink()): # Een overgenomen app is een symlink naar een map van de # gebruiker zelf. Die map is niet van ons; alleen de - # verwijzing halen we weg. `shutil.rmtree` zou hier - # bovendien weigeren ("Cannot call rmtree on a symbolic - # link") en de verwijdering halverwege laten stranden. + # verwijzing halen we weg. if pdir.is_symlink(): doelmap = os.path.realpath(pdir) pdir.unlink() jobs.log(qq, "ok", f"Losgekoppeld; {doelmap} blijft staan") else: shutil.rmtree(pdir) - jobs.log(qq, "ok", "Bestanden verwijderd") + jobs.log(qq, "ok", "Appbestanden verwijderd") if mode == "all": dd = Path(cfg.load()["DATA_DIR"]).resolve() / name - if dd.is_dir(): + if dd.is_symlink(): + dd.unlink() + jobs.log(qq, "ok", "Dataverwijzing verwijderd") + elif dd.is_dir(): shutil.rmtree(dd) jobs.log(qq, "ok", "Data verwijderd") elif action == "backup": diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 3af25b3..2fec650 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -50,7 +50,7 @@ tailwind.config = { en JetBrains Mono worden gebruikt als ze lokaal geïnstalleerd zijn, anders valt de stack terug op de systeemfonts uit tailwind.config. --> @@ -2554,6 +2598,7 @@ function app() { docker: { running: 0, containers: 0, images: 0 }, stacks: [], stackQuery: '', stackCat: '', + removeTarget: '', removeBusy: false, storeQuery: '', storeTag: '', storeCat: '', filterOpen: false, filterTagQuery: '', stackMenu: '', stackFilterOpen: false, hoofdmap: '', afgeleidePaden: {}, padCheck: {}, padenVoorWijziging: {}, @@ -3098,12 +3143,28 @@ function app() { if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks()); else if (d.error) this.toast('Fout: '+d.error, 'er'); }, - confirmRemove(name) { this.install.stack = name; this.modal = 'remove'; }, + confirmRemove(name) { + this.removeTarget = name; + this.removeBusy = false; + this.modal = 'remove'; + }, async doRemove(mode) { - const name = this.install.stack; - this.modal = null; + const name = this.removeTarget; + if (!name || this.removeBusy) return; + this.removeBusy = true; const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode }); - if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks()); + if (!d.job_id) { + this.removeBusy = false; + this.toast(d.msg || d.error || this.t('remove_failed'), 'er'); + return; + } + this.modal = null; + this.pollJob(d.job_id, async (status) => { + this.removeBusy = false; + await this.loadStacks(); + this.toast(this.t(status === 'done' ? 'remove_done' : 'remove_failed'), + status === 'done' ? 'ok' : 'er'); + }); }, async openCredentials(stack) { const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/credentials`); diff --git a/server-up/translations/en.json b/server-up/translations/en.json index 8014ef0..6e90a78 100644 --- a/server-up/translations/en.json +++ b/server-up/translations/en.json @@ -59,8 +59,8 @@ "instance_name": "Instance name", "instance_hint": "Change for multiple installations", "remove_stack": "Remove app", - "remove_stack_only": "Stop containers + volumes", - "remove_all": "Stop + remove all files", + "remove_stack_only": "Remove app · keep data", + "remove_all": "Remove app and all data", "prune_images": "Remove all unused images?", "confirm_remove_image": "Remove image?", "force_remove": "Force removal?", @@ -471,5 +471,9 @@ "adopt_confirm": "Adopt '{naam}' as a Server Up app?\n\nNothing is moved or copied — the folder stays where it is. Server Up will simply manage it too.", "remove_container_confirm": "Remove container '{naam}'?\n\nThis removes only the container, not its data or its image.", "remove_container_project": "Note: this container belongs to compose project '{project}'. It will come back as soon as that project is started elsewhere. Adopting is probably what you want.", - "audit_act_adopt": "Adopted" + "audit_act_adopt": "Adopted", + "remove_keep_data_hint": "Stops the containers and removes the app configuration. App data and named volumes are preserved.", + "remove_all_hint": "Also removes app data and non-external named volumes. This cannot be undone.", + "remove_done": "App removed", + "remove_failed": "Failed to remove app" } diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json index 630ae30..95c849c 100644 --- a/server-up/translations/nl.json +++ b/server-up/translations/nl.json @@ -59,8 +59,8 @@ "instance_name": "Instantie naam", "instance_hint": "Wijzig voor meerdere installaties", "remove_stack": "App verwijderen", - "remove_stack_only": "Stop containers + volumes", - "remove_all": "Stop + verwijder alle bestanden", + "remove_stack_only": "App verwijderen · data bewaren", + "remove_all": "App en alle data verwijderen", "prune_images": "Alle ongebruikte images verwijderen?", "confirm_remove_image": "Image verwijderen?", "force_remove": "Forceer verwijdering?", @@ -471,5 +471,9 @@ "adopt_confirm": "'{naam}' overnemen als app van Server Up?\n\nEr wordt niets verplaatst of gekopieerd — de map blijft staan waar hij staat. Server Up gaat hem alleen mee beheren.", "remove_container_confirm": "Container '{naam}' verwijderen?\n\nDit verwijdert alleen de container, niet zijn gegevens of zijn image.", "remove_container_project": "Let op: deze container hoort bij het compose-project '{project}'. Hij komt terug zodra dat project ergens anders wordt gestart. Overnemen is dan waarschijnlijk wat je zoekt.", - "audit_act_adopt": "Overgenomen" + "audit_act_adopt": "Overgenomen", + "remove_keep_data_hint": "Stopt de containers en verwijdert de appconfiguratie. Appdata en named volumes blijven bewaard.", + "remove_all_hint": "Verwijdert ook appdata en niet-externe named volumes. Dit kan niet ongedaan worden gemaakt.", + "remove_done": "App verwijderd", + "remove_failed": "App verwijderen mislukt" } diff --git a/tests/js/menu.test.js b/tests/js/menu.test.js index 301da3e..f411530 100644 --- a/tests/js/menu.test.js +++ b/tests/js/menu.test.js @@ -349,6 +349,42 @@ check('balk staat halverwege', c.job.progress.pct === 50); // De poortcontrole praat met de backend, dus die checks zijn asynchroon. // Top-level await kan niet in een CommonJS-script; vandaar deze afsluiting. (async () => { + // ── App verwijderen ─────────────────────────────────────────────────────── + // De veilige keuze hoort de appconfiguratie weg te halen en data te bewaren. + let verwijderVerzoek = null; + let verwijderKlaar = null; + let verwijderMelding = null; + let herladenNaVerwijderen = 0; + c.rpc = async (url, body) => { + verwijderVerzoek = {url, body}; + return {job_id: 'remove-1'}; + }; + c.pollJob = (id, callback) => { verwijderKlaar = callback; }; + c.loadStacks = async () => { herladenNaVerwijderen++; }; + c.toast = (msg, kind) => { verwijderMelding = {msg, kind}; }; + + c.confirmRemove('mijn app'); + check('verwijdervenster onthoudt de juiste app', + c.modal === 'remove' && c.removeTarget === 'mijn app'); + await c.doRemove('full'); + check('veilige verwijdering gebruikt full en een gecodeerde appnaam', + verwijderVerzoek.url === '/api/stacks/mijn%20app/remove' && + verwijderVerzoek.body.mode === 'full'); + check('verwijdervenster sluit zodra de taak start', c.modal === null); + await verwijderKlaar('done'); + check('na verwijderen wordt de applijst opnieuw geladen', + herladenNaVerwijderen === 1 && c.removeBusy === false); + check('succesvolle verwijdering wordt gemeld', + verwijderMelding.msg === 'remove_done' && verwijderMelding.kind === 'ok'); + + c.confirmRemove('kapotte-app'); + c.rpc = async () => ({ok: false, msg: 'kan niet verwijderen'}); + await c.doRemove('all'); + check('backendfout blijft zichtbaar en ontgrendelt de knop', + c.modal === 'remove' && c.removeBusy === false && + verwijderMelding.msg === 'kan niet verwijderen' && + verwijderMelding.kind === 'er'); + // Een expliciet ongeschikte architectuur opent zelfs het formulier niet. let previewGevraagd = false; let platformMelding = ''; diff --git a/tests/test_stack_remove.py b/tests/test_stack_remove.py new file mode 100644 index 0000000..698ca9e --- /dev/null +++ b/tests/test_stack_remove.py @@ -0,0 +1,91 @@ +"""Apps verwijderen: veilige dataretentie, volledige wisactie en symlinks.""" +import time + +from conftest import login + + +def _wacht(client, jid, timeout=5): + einde = time.time() + timeout + while time.time() < einde: + data = client.get(f"/api/jobs/{jid}").get_json() or {} + if data.get("status") in ("done", "error"): + return data + time.sleep(0.03) + return {} + + +def _stack(env, naam): + pad = env["lib"] / naam + pad.mkdir() + (pad / "compose.yaml").write_text( + "services:\n web:\n image: nginx\n", encoding="utf-8") + data = env["tmp"] / "appdata" / naam + data.mkdir(parents=True) + (data / "blijft.txt").write_text("data", encoding="utf-8") + return pad, data + + +def test_app_verwijderen_haalt_app_weg_en_bewaart_data( + client, env, monkeypatch): + from core import docker + csrf = login(client) + env["core"].patch({"BACKUP_BEFORE_REMOVE": False}) + stack, data = _stack(env, "veilig") + volumes = [] + monkeypatch.setattr( + docker, "compose_down", + lambda *a, **kw: volumes.append(kw.get("volumes")) or 0) + + r = client.post("/api/stacks/veilig/remove", json={"mode": "full"}, + headers={"X-CSRF-Token": csrf}) + job = _wacht(client, r.get_json()["job_id"]) + + assert job["status"] == "done" + assert not stack.exists() + assert data.is_dir() + assert volumes == [False] + + +def test_app_en_data_verwijderen_wist_ook_volumes_en_appdata( + client, env, monkeypatch): + from core import docker + csrf = login(client) + env["core"].patch({"BACKUP_BEFORE_REMOVE": False}) + stack, data = _stack(env, "alles") + volumes = [] + monkeypatch.setattr( + docker, "compose_down", + lambda *a, **kw: volumes.append(kw.get("volumes")) or 0) + + r = client.post("/api/stacks/alles/remove", json={"mode": "all"}, + headers={"X-CSRF-Token": csrf}) + job = _wacht(client, r.get_json()["job_id"]) + + assert job["status"] == "done" + assert not stack.exists() + assert not data.exists() + assert volumes == [True] + + +def test_overgenomen_app_verwijderen_haalt_alleen_symlink_weg( + client, env, monkeypatch): + from core import docker + csrf = login(client) + env["core"].patch({"BACKUP_BEFORE_REMOVE": False}) + bron = env["tmp"] / "eigen-compose" + bron.mkdir() + (bron / "compose.yaml").write_text( + "services:\n web:\n image: nginx\n", encoding="utf-8") + link = env["lib"] / "overgenomen" + link.symlink_to(bron) + monkeypatch.setattr(docker, "compose_down", lambda *a, **kw: 0) + + r = client.post("/api/stacks/overgenomen/remove", + json={"mode": "full"}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 200, r.get_json() + job = _wacht(client, r.get_json()["job_id"]) + + assert job["status"] == "done" + assert not link.exists() and not link.is_symlink() + assert (bron / "compose.yaml").is_file() diff --git a/tests/test_ui_logica.py b/tests/test_ui_logica.py index e4fb8af..05a4f12 100644 --- a/tests/test_ui_logica.py +++ b/tests/test_ui_logica.py @@ -93,3 +93,12 @@ def test_de_html_van_de_pagina_is_gebalanceerd(): .read_text(encoding="utf-8")) assert not p.fouten, p.fouten[:5] assert not p.stapel, [f"<{t}> op regel {r} gaat nooit dicht" for t, r in p.stapel[:5]] + + +def test_modals_blijven_binnen_het_mobiele_scherm_en_scrollbaar(): + html = (WORTEL / "server-up" / "templates" / "index.html").read_text( + encoding="utf-8") + assert "height: 100dvh" in html + assert "max-height: 100dvh" in html + assert ".modal-body" in html and "overflow-y-auto" in html + assert "-webkit-overflow-scrolling: touch" in html