fix: herstel app verwijderen en mobiele modals
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m46s

This commit is contained in:
Ramon 2026-08-04 18:50:16 +02:00
parent 1575db42c2
commit 6c84def759
9 changed files with 302 additions and 56 deletions

View file

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

View file

@ -1 +1 @@
0.8.14-beta
0.8.15-beta

View file

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

View file

@ -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. -->
<style>
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; height: 100vh; height: 100dvh; }
[x-cloak] { display: none !important; }
.mdi-spin { animation: mdis 1s linear infinite; display:inline-block; }
@keyframes mdis { to { transform: rotate(360deg); } }
@ -70,7 +70,7 @@ tailwind.config = {
<!-- Ligt bovenop alles (z-[90]) tot er een geldige sessie is. De API weigert
ondertussen sowieso elk verzoek met 401, dit is de nette voorkant. -->
<div x-show="auth.ready && !auth.authenticated" x-cloak
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4">
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4 overflow-y-auto">
<div class="w-full max-w-[380px] anim">
<div class="flex items-center gap-3 mb-6 justify-center">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-ac to-ac2 grid place-items-center shadow-soft text-white text-xl">🚀</div>
@ -194,7 +194,7 @@ tailwind.config = {
<!-- Scroll area -->
<main class="flex-1 overflow-y-auto">
<div class="p-4 sm:p-7 max-w-6xl mx-auto pb-48">
<div class="p-4 sm:p-7 max-w-6xl mx-auto pb-48 min-w-0">
<!-- ══ Dashboard ══ -->
<section x-show="page === 'dashboard'" x-cloak class="anim">
@ -816,7 +816,7 @@ tailwind.config = {
</section>
<!-- ══ Settings ══ -->
<section x-show="page === 'settings'" x-cloak class="anim space-y-5">
<section x-show="page === 'settings'" x-cloak class="anim space-y-5 min-w-0">
<div class="flex items-center gap-3">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('settings')">Instellingen</p>
<span x-show="saved" class="text-xs text-ok flex items-center gap-1.5 font-semibold"><span class="mdi mdi-check-circle"></span> <span x-text="t('auto_saved')">Opgeslagen</span></span>
@ -825,7 +825,7 @@ tailwind.config = {
<div class="card card-pad grid sm:grid-cols-2 gap-6">
<div>
<div class="form-label" x-text="t('theme')">Thema</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<div class="inline-flex max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button @click="setThemePref('system')" class="seg" :class="themePref==='system' ? 'seg-on' : ''"><span class="mdi mdi-monitor"></span> Auto</button>
<button @click="setThemePref('light')" class="seg" :class="themePref==='light' ? 'seg-on' : ''"><span class="mdi mdi-weather-sunny"></span> Light</button>
<button @click="setThemePref('dark')" class="seg" :class="themePref==='dark' ? 'seg-on' : ''"><span class="mdi mdi-weather-night"></span> Dark</button>
@ -841,7 +841,7 @@ tailwind.config = {
<!-- Setup wizard opnieuw doorlopen -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-wizard-hat text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('wizard')">Setup Wizard</span>
</div>
@ -858,10 +858,10 @@ tailwind.config = {
<!-- Beveiliging: accounts + wachtwoord -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('security')">Beveiliging</span>
<span class="badge badge-dim ml-auto" x-text="t('logged_in_as', {user: auth.user})"></span>
<span class="badge badge-dim sm:ml-auto max-w-full truncate" x-text="t('logged_in_as', {user: auth.user})"></span>
</div>
<div class="p-5 grid md:grid-cols-2 gap-6">
<div>
@ -907,7 +907,7 @@ tailwind.config = {
adressen hieronder; zonder die lijst weigert de server de modus. -->
<div class="px-5 pb-5 pt-4 border-t border-bd dark:border-bd-dark">
<div class="form-label" x-text="t('auth_mode')">Hoe wordt er ingelogd?</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
<div class="inline-flex max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
<button class="seg" :class="authCfg.mode==='local' ? 'seg-on' : ''"
@click="authCfg.mode='local'"><span class="mdi mdi-account-key-outline"></span> <span x-text="t('auth_mode_local')">Lokaal</span></button>
<button class="seg" :class="authCfg.mode==='proxy' ? 'seg-on' : ''"
@ -944,7 +944,7 @@ tailwind.config = {
<!-- Backups: bewaarbeleid en planning -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-archive-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('backups')">Backups</span>
</div>
@ -1018,7 +1018,7 @@ tailwind.config = {
<!-- Meldingen: je hoort het als er 's nachts iets misgaat -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-bell-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('notifications')">Meldingen</span>
</div>
@ -1113,7 +1113,7 @@ tailwind.config = {
<!-- Pangolin: publieke URL's zonder poorten open te zetten -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-earth text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Pangolin</span>
</div>
@ -1162,7 +1162,7 @@ tailwind.config = {
<!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('networks')">Netwerken</span>
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> <span x-text="t('network')">Netwerk</span></button>
@ -1187,7 +1187,7 @@ tailwind.config = {
<!-- Updates -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-update text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('updates')">Updates</span>
<template x-if="update.update_available"><span class="badge badge-ok" x-text="t('update_available')">update beschikbaar</span></template>
@ -1231,7 +1231,7 @@ tailwind.config = {
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
<div>
<label class="form-label" x-text="t('channel')">Kanaal</label>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<div class="inline-flex max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
@ -1278,7 +1278,7 @@ tailwind.config = {
<!-- Taalmodule (add-on) -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-translate text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('language_addon')">Taalmodule</span>
<span class="badge badge-info" x-text="languages.length"></span>
@ -1453,7 +1453,7 @@ tailwind.config = {
</div>
<!-- ░░ Modals ░░ -->
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-0 sm:p-4">
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-0 sm:p-4 overflow-hidden">
<!-- Add repo -->
<div x-show="modal==='add-repo'" class="modal max-w-md">
@ -2207,15 +2207,38 @@ tailwind.config = {
<!-- Remove confirm -->
<div x-show="modal==='remove'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-delete-alert-outline text-er"></span> <span x-text="t('remove_stack')">Verwijderen</span></div>
<div class="modal-head">
<span class="mdi mdi-delete-alert-outline text-er"></span>
<span x-text="t('remove_stack')">App verwijderen</span>
</div>
<div class="modal-body">
<p class="text-sm mb-4 text-t2 dark:text-t2-dark">App: <strong class="text-wh dark:text-wh-dark" x-text="install.stack"></strong></p>
<div class="flex flex-col gap-2">
<button class="btn" @click="doRemove('stack')"><span class="mdi mdi-stop-circle-outline"></span> <span x-text="t('remove_stack_only')">Stop + volumes</span></button>
<button class="btn btn-danger" @click="doRemove('all')"><span class="mdi mdi-delete-outline"></span> <span x-text="t('remove_all')">Verwijder alles</span></button>
<p class="text-sm mb-4 text-t2 dark:text-t2-dark">App:
<strong class="text-wh dark:text-wh-dark break-all" x-text="removeTarget"></strong>
</p>
<div class="flex flex-col gap-3">
<button class="btn !h-auto !py-3 !justify-start text-left"
:disabled="removeBusy" @click="doRemove('full')">
<span class="mdi mdi-delete-outline text-lg"></span>
<span class="min-w-0">
<span class="block" x-text="t('remove_stack_only')">App verwijderen · data bewaren</span>
<span class="block mt-0.5 text-[11px] font-normal text-dm dark:text-dm-dark"
x-text="t('remove_keep_data_hint')"></span>
</span>
</button>
<button class="btn btn-danger !h-auto !py-3 !justify-start text-left"
:disabled="removeBusy" @click="doRemove('all')">
<span class="mdi mdi-delete-forever-outline text-lg"></span>
<span class="min-w-0">
<span class="block" x-text="t('remove_all')">App en alle data verwijderen</span>
<span class="block mt-0.5 text-[11px] font-normal opacity-80"
x-text="t('remove_all_hint')"></span>
</span>
</button>
</div>
</div>
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button></div>
<div class="modal-foot">
<button class="btn" :disabled="removeBusy" @click="modal=null" x-text="t('cancel')">Annuleren</button>
</div>
</div>
<!-- Editor -->
@ -2225,7 +2248,7 @@ tailwind.config = {
<span x-text="editor.stack"></span>
<!-- Tabbladen: tot nu toe moest je de modal sluiten en opnieuw openen om
van compose naar .env te gaan. -->
<div class="ml-auto flex items-center gap-1">
<div class="ml-auto max-w-full flex items-center gap-1 overflow-x-auto">
<button class="btn-sm" :class="editor.type==='compose' ? '!bg-ac !text-white' : ''"
@click="switchEditor('compose')">docker-compose.yml</button>
<button class="btn-sm" :class="editor.type==='env' ? '!bg-ac !text-white' : ''"
@ -2344,10 +2367,10 @@ tailwind.config = {
</div>
<!-- ░░ First-run wizard ░░ -->
<div x-show="wizard.open" x-cloak class="fixed inset-0 bg-slate-900/80 backdrop-blur z-[80] grid place-items-center p-4">
<div class="bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full max-w-xl shadow-pop overflow-hidden">
<div x-show="wizard.open" x-cloak class="fixed inset-0 bg-slate-900/80 backdrop-blur z-[80] grid place-items-center p-0 sm:p-4 overflow-hidden">
<div class="modal max-w-xl">
<div class="h-1.5 bg-s2 dark:bg-s2-dark"><div class="h-full bg-gradient-to-r from-ac to-ac2 transition-all" :style="`width:${(wizard.step+1)/(wizard.steps.length||1)*100}%`"></div></div>
<div class="p-7 min-h-[270px]">
<div class="modal-body !p-5 sm:!p-7 min-h-[270px]">
<h2 class="text-2xl font-extrabold text-wh dark:text-wh-dark mb-1 tracking-tight" x-text="wizard.steps[wizard.step]?.title || ''"></h2>
<p class="text-sm text-t2 dark:text-t2-dark mb-6" x-text="wizard.steps[wizard.step]?.subtitle || ''"></p>
<div x-show="wizardStapId() === 'welkom'">
@ -2453,7 +2476,7 @@ tailwind.config = {
<div x-show="wizardStapId() === 'klaar'"><p class="text-sm">We synchroniseren nu de repos en zijn klaar voor gebruik.</p></div>
</div>
<div class="px-7 py-4 border-t border-bd dark:border-bd-dark flex justify-end gap-2">
<div class="modal-foot !px-5 sm:!px-7">
<button class="btn" @click="wizard.open = false" x-text="t('close')">Sluiten</button>
<button class="btn" @click="wizard.step = Math.max(0, wizard.step-1)" :disabled="wizard.step===0" x-text="t('prev')">Terug</button>
<button class="btn btn-primary" @click="wizardNext()"><span x-text="wizard.step < wizard.steps.length-1 ? 'Volgende' : 'Klaar'"></span></button>
@ -2472,7 +2495,7 @@ tailwind.config = {
.btn-sm { @apply inline-flex items-center justify-center gap-1.5 px-3 h-9 rounded-lg border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-xs font-semibold hover:bg-s2 dark:hover:bg-s2-dark hover:border-bd2 dark:hover:border-bd2-dark active:scale-95 cursor-pointer transition shrink-0; }
.seg { @apply inline-flex items-center gap-1.5 px-3 h-9 rounded-lg text-xs font-semibold text-t2 dark:text-t2-dark cursor-pointer transition; }
.seg-on { @apply bg-s1 dark:bg-s1-dark text-ac dark:text-ac-dark shadow-soft; }
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft; }
.card { @apply min-w-0 bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft; }
.card-hover { @apply hover:shadow-soft2 hover:-translate-y-0.5 transition duration-200; }
.card-pad { @apply p-5; }
.nav-link { @apply flex items-center gap-3 px-3.5 h-11 rounded-xl text-sm font-semibold text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark cursor-pointer transition mb-0.5; }
@ -2482,7 +2505,7 @@ tailwind.config = {
.stat-value { @apply text-2xl sm:text-3xl font-extrabold text-wh dark:text-wh-dark leading-none tracking-tight; }
.stat-label { @apply text-[11px] text-t2 dark:text-t2-dark mt-1.5 uppercase tracking-wider font-semibold truncate; }
.form-label { @apply block text-[11px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5; }
.form-input { @apply w-full bg-s1 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-xl px-3.5 h-11 text-sm text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-4 focus:ring-ac/10 transition placeholder:text-dm dark:placeholder:text-dm-dark; }
.form-input { @apply min-w-0 w-full bg-s1 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-xl px-3.5 h-11 text-sm text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-4 focus:ring-ac/10 transition placeholder:text-dm dark:placeholder:text-dm-dark; }
textarea.form-input { @apply h-auto py-2.5; }
.badge { @apply inline-flex items-center text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-md; }
.badge-ok { @apply bg-ok/10 text-ok; }
@ -2493,10 +2516,31 @@ tailwind.config = {
.empty { @apply text-center py-16 text-dm dark:text-dm-dark; }
.empty span { @apply text-5xl opacity-40 block mb-2; }
.empty p { @apply text-sm; }
.modal { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full shadow-pop; }
.modal-head { @apply px-4 sm:px-5 py-4 text-base font-extrabold text-wh dark:text-wh-dark flex items-center gap-2.5 border-b border-bd dark:border-bd-dark; }
.modal-body { @apply px-4 sm:px-5 py-4; }
.modal-foot { @apply px-4 sm:px-5 py-3 sm:py-4 flex flex-wrap justify-end gap-2 border-t border-bd dark:border-bd-dark; }
.modal { @apply min-w-0 bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full shadow-pop flex flex-col overflow-hidden;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem); }
.modal-head { @apply min-w-0 shrink-0 px-4 sm:px-5 py-4 text-base font-extrabold text-wh dark:text-wh-dark flex items-center gap-2.5 border-b border-bd dark:border-bd-dark; }
.modal-body { @apply min-h-0 flex-1 px-4 sm:px-5 py-4 overflow-y-auto overflow-x-auto; }
.modal-foot { @apply shrink-0 px-4 sm:px-5 py-3 sm:py-4 flex flex-wrap justify-end gap-2 border-t border-bd dark:border-bd-dark; }
}
.modal-body {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 639px) {
.modal {
width: 100vw !important;
max-width: 100vw !important;
height: 100vh;
height: 100dvh;
max-height: 100vh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
border-left-width: 0;
border-right-width: 0;
}
.modal-head { flex-wrap: wrap; }
}
</style>
@ -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`);

View file

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

View file

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

View file

@ -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 = '';

View file

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

View file

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