v0.7.70-beta - invulmenu bij installeren, geheimen naar .env
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m34s
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m34s
Invulmenu:
- Vaste stappen (Basis, Verbinden, Instellingen, Toegang, Controleren) in
plaats van één lange lijst. Eén stap per groep werkt niet: de mediane app
heeft één groep, arr-stack eenentwintig. Lege stappen worden overgeslagen.
- Volgende controleert de verplichte velden van die stap; die melding kwam
eerder pas bij het installeren.
- Geavanceerde opties achter één schakelaar die op elke stap zichtbaar is,
plus 'Alles op één pagina' voor het oude gedrag. Beide onthouden.
Geheimen naar .env:
- compose_transform.geheimen_naar_env() vervangt geheime waarden door
${NAAM} en schrijft ze naar .env met rechten 0600. Werkt ook midden in
een database-URL. Poorten en paden blijven leesbaar in compose.
- .serverup.json bevat geen geheimen meer en krijgt ook 0600; daar stonden
ze wereldleesbaar in. Het herconfiguratieformulier leest ze terug uit
.env via een bewaarde veld-naar-sleutel-koppeling.
- docs/beveiliging.md legt uit wat dit niet oplost: docker inspect toont de
waarde nog steeds.
Bewerken:
- Laatste stap toont het gerenderde resultaat met jouw waarden en is
bewerkbaar; compose_override/env_override gaan mee bij het installeren.
Ongeldige YAML wordt geweigerd voordat er een map bestaat.
- De bestaande editor heeft tabbladen (compose/.env) en biedt herstarten na
opslaan. De .env-PUT weigert een regel zonder '=' en logt in audit.
Inloggegevens:
- /api/stacks/<n>/credentials plus paneel op de stackkaart: adres,
gebruikersnaam, wachtwoord achter een oog-knop, kopieerknop. Alleen voor
beheerders, elk bekijken komt in het auditlog.
- Nieuw 'credential'-kenmerk in template.json met naamherkenning als
terugval, zodat de bestaande 96 sjablonen meteen werken.
Herstel van een regressie uit v0.7.60: acht inlogwachtwoorden hadden hun
genereerknop verloren toen die aan 'generate' werd gehangen. Terug, behalve
waar een verzonnen waarde fout is (WireGuard-sleutel, externe tokens).
De menulogica wordt getest door de echte component uit index.html in Node uit
te voeren; slaat zichzelf over waar node ontbreekt. 2295 tests groen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
parent
952e7ddb7a
commit
bc55252267
24 changed files with 1806 additions and 203 deletions
63
CHANGELOG.md
63
CHANGELOG.md
|
|
@ -1,3 +1,66 @@
|
|||
# v0.7.70-beta — Installeren via een invulmenu, geheimen naar .env
|
||||
|
||||
**Het installatieformulier is een invulmenu geworden.** Eén lange lijst werkte
|
||||
slecht aan beide uiteinden: de mediane app heeft vijf velden in één groep, en de
|
||||
ARR-stack tweeënvijftig in eenentwintig groepen. Nu zijn er vaste stappen —
|
||||
Basis, Verbinden, Instellingen, Toegang, Controleren — waarbij een lege stap
|
||||
wordt overgeslagen. Een eenvoudige app krijgt er dus drie, de ARR-stack vijf.
|
||||
|
||||
**Volgende** controleert de verplichte velden van díé stap en noemt wat er nog
|
||||
leeg is. Tot nu toe kwam die melding pas als je op Installeren drukte. De
|
||||
stapindicator is klikbaar om terug te springen.
|
||||
|
||||
**Geavanceerde opties zitten achter één schakelaar** die op elke stap zichtbaar
|
||||
is, in plaats van een uitklapblok per groep dat je steeds opnieuw moest zoeken.
|
||||
Een badge toont hoeveel velden verborgen zijn. Er is ook een schakelaar **Alles
|
||||
op één pagina** voor wie het oude gedrag wil; beide keuzes worden onthouden.
|
||||
|
||||
**Geheimen gaan naar `.env`.** Wachtwoorden en sleutels staan niet meer in het
|
||||
compose-bestand maar als `${NAAM}` met de waarde in `.env`, geschreven met
|
||||
rechten `0600`. Poorten, paden en tijdzone blijven leesbaar in compose, zodat je
|
||||
nog steeds in één bestand kunt zien wat er draait. Ook `.serverup.json` bevat
|
||||
geen geheimen meer en krijgt `0600` — daar stonden ze tot nu toe wereldleesbaar
|
||||
in.
|
||||
|
||||
Eerlijk over de grens hiervan: compose vult `${...}` in bij het inlezen, dus
|
||||
`docker inspect` toont de waarde nog steeds. Wat je wint is dat het
|
||||
compose-bestand deelbaar wordt en dat alles op één afgeschermde plek staat.
|
||||
`docs/beveiliging.md` legt uit wanneer je verder wilt gaan met `secrets:` van
|
||||
Compose, en waarom een externe kluis hier een slecht idee is.
|
||||
|
||||
**Compose en `.env` zijn nu ook vóór het installeren te bewerken.** De laatste
|
||||
stap toont het gerenderde resultaat met de waarden die je hebt ingevuld; met
|
||||
Bewerken pas je het aan en gaat jouw versie mee in plaats van het sjabloon.
|
||||
Ongeldige YAML wordt geweigerd voordat er een map is aangemaakt. De editor voor
|
||||
bestaande stacks heeft tabbladen gekregen (je moest hem sluiten en opnieuw
|
||||
openen om van compose naar `.env` te gaan) en biedt aan te herstarten na
|
||||
opslaan. De `.env`-editor weigert nu een regel zonder `=` — compose negeert die
|
||||
stilzwijgend, waarna er ineens een wachtwoord mist — en schrijft een auditregel.
|
||||
|
||||
**Nieuw: Inloggegevens per app.** Een knop op de stackkaart toont het adres, de
|
||||
gebruikersnaam en het wachtwoord, met een oog-knop en een kopieerknop. Bij een
|
||||
gegenereerd wachtwoord kon je dat nergens meer terugvinden. Apps die je bij het
|
||||
eerste bezoek zelf een account laten aanmaken (Vaultwarden, Immich) melden dat.
|
||||
Alleen beheerders kunnen erbij en elk bekijken komt in het auditlog.
|
||||
|
||||
**Herstel van een regressie uit v0.7.60.** Ik had de genereerknop toen aan
|
||||
`generate` gehangen in plaats van aan `secret`, waardoor vijftien velden hun knop
|
||||
kwijtraakten. Acht daarvan zijn inlogwachtwoorden (InfluxDB, Kimai, Linkding,
|
||||
Miniflux, PhotoPrism, Pi-hole, Technitium, ChangeDetection) en die hebben hem
|
||||
terug. De zeven waar een verzonnen waarde juist fout is — een WireGuard-
|
||||
privésleutel, een Beszel-agentsleutel, tokens uit een andere app — houden geen
|
||||
knop.
|
||||
|
||||
Bij het uitzoeken bleek ook dat ik in v0.7.60 twee dingen verkeerd heb gemeld.
|
||||
De genereerknop wérkte destijds wel (de vlag werd gezet in `app.py`, niet in
|
||||
`boilerplates.py` waar ik keek), en er wás al een compose- en `.env`-editor.
|
||||
|
||||
De logica van het invulmenu wordt nu getest door de echte component uit
|
||||
`index.html` in Node uit te voeren; die test slaat zichzelf over waar Node
|
||||
ontbreekt, zoals in het bouwimage. 2295 tests groen.
|
||||
|
||||
---
|
||||
|
||||
# v0.7.60-beta — App-instellingen die niet meer stilzwijgend misgaan
|
||||
|
||||
Aanleiding: Homepage weigerde na installatie met **"Host validation failed"**.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.7.60-beta
|
||||
0.7.70-beta
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@
|
|||
"description": "Optioneel wachtwoord om de webinterface te beveiligen. Laat leeg voor geen beveiliging.",
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@
|
|||
"description": "Minimaal 8 tekens",
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,9 @@
|
|||
"description": "Minimaal 8 tekens",
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@
|
|||
"required": true,
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@
|
|||
"required": true,
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@
|
|||
"description": "Minimaal 8 tekens",
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "db_password",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,9 @@
|
|||
"description": "Wachtwoord voor het Pi-hole webpaneel",
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@
|
|||
"description": "Wachtwoord voor de webinterface",
|
||||
"config": {
|
||||
"placeholder": "sterk-wachtwoord"
|
||||
}
|
||||
},
|
||||
"generate": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,42 @@ keuze; die moet gewoon willekeurig zijn. Zet daarvoor `generate`:
|
|||
> installeert hetzelfde, en de meesten klikken erdoorheen. Een test weigert
|
||||
> sjablonen met een vaste waarde op een geheim veld.
|
||||
|
||||
### In welke stap komt een veld?
|
||||
|
||||
Het installatieformulier is een invulmenu met vaste stappen. Server Up leidt af
|
||||
waar een veld hoort, en meestal klopt dat:
|
||||
|
||||
| Stap | Wat er terechtkomt |
|
||||
|---|---|
|
||||
| **Basis** | `service_name`, poortvelden (type `int` met `port` in de naam), `data_dir`, `timezone`, `puid`/`pgid` |
|
||||
| **Verbinden** | velden met `connect` |
|
||||
| **Toegang** | alles wat als geheim geldt, plus gebruikersnamen en e-mailadressen |
|
||||
| **Instellingen** | de rest |
|
||||
|
||||
Klopt dat voor jouw veld niet, zet het dan zelf:
|
||||
|
||||
```json
|
||||
{"name": "retention", "type": "str", "title": "Bewaartermijn", "step": "basis"}
|
||||
```
|
||||
|
||||
Geldige waarden: `basis`, `verbinden`, `instellingen`, `geheimen`. Een onbekende
|
||||
waarde valt terug op `instellingen`.
|
||||
|
||||
### Inloggegevens
|
||||
|
||||
Velden met `credential` komen terug in het paneel **Inloggegevens** op de
|
||||
stackkaart, zodat iemand na de installatie kan opzoeken waarmee hij moet
|
||||
inloggen — handig bij een gegenereerd wachtwoord.
|
||||
|
||||
```json
|
||||
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
|
||||
"default": "admin", "credential": "username"}
|
||||
```
|
||||
|
||||
Meestal hoef je dit niet te zetten: een veld met `user`, `login` of `email` in de
|
||||
naam geldt als `username`, en een geheim veld met `password` erin als `password`.
|
||||
Tokens en sleutels tellen bewust níét mee — daar log je niet mee in.
|
||||
|
||||
### Verplichte velden
|
||||
|
||||
`"required": true` wordt afgedwongen: een lege waarde levert een foutmelding op
|
||||
|
|
|
|||
|
|
@ -106,13 +106,49 @@ je vertrouwt. Twee knoppen om aan te draaien:
|
|||
{"id": "mijn-apps", "url": "https://…", "branch": "main", "commit": "a1b2c3d"}
|
||||
```
|
||||
|
||||
## Waar de wachtwoorden van je apps staan
|
||||
|
||||
Vul je bij het installeren een wachtwoord of sleutel in, dan komt die waarde
|
||||
**niet** in het compose-bestand terecht maar in `.env` naast de stack:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml — te delen, te committen
|
||||
environment:
|
||||
- TZ=Europe/Amsterdam
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
```
|
||||
|
||||
```bash
|
||||
# .env — rechten 0600, alleen leesbaar voor de eigenaar
|
||||
POSTGRES_PASSWORD=aB3dE6gH9jK2mN5p
|
||||
```
|
||||
|
||||
Ook `.serverup.json`, waarin de gemaakte keuzes staan zodat je ze later kunt
|
||||
wijzigen, bevat geen geheimen meer en krijgt `0600`. Tot v0.7.60 stonden
|
||||
wachtwoorden op allebei die plekken wereldleesbaar.
|
||||
|
||||
**Wat dit wél oplost:** het compose-bestand is deelbaar en te committen, alle
|
||||
geheimen staan op één plek, en die plek is afgeschermd.
|
||||
|
||||
**Wat dit níét oplost:** compose vult `${...}` in bij het inlezen, dus de
|
||||
container krijgt gewoon de letterlijke waarde en `docker inspect` toont hem nog
|
||||
steeds. Wie de docker-socket kan bereiken, kan de wachtwoorden van je apps lezen
|
||||
— maar die kan sowieso al elke container overnemen.
|
||||
|
||||
Wil je een geheim écht buiten `docker inspect` houden, dan is `secrets:` van
|
||||
Compose de weg: de waarde staat dan in een bestand dat de container zelf inleest.
|
||||
Dat vereist wel dat het image een `*_FILE`-variant kent. Postgres, MariaDB en
|
||||
MySQL kunnen het; de meeste apps niet.
|
||||
|
||||
Een externe wachtwoordkluis (Vault, Infisical) raden we hier af: Server Up zou
|
||||
dan afhangen van een app die het zelf beheert, en als die niet draait start er
|
||||
niets meer.
|
||||
|
||||
## Wat níét is afgedekt
|
||||
|
||||
- **De docker-socket zelf.** Server Up heeft volledige toegang tot de daemon; dat
|
||||
is inherent aan wat het doet. Een socket-proxy die alleen bepaalde endpoints
|
||||
toelaat werkt niet, omdat compose vrijwel alles nodig heeft.
|
||||
- **Rechten per gebruiker.** Elk account heeft dezelfde volledige toegang; er
|
||||
zijn geen rollen.
|
||||
- **Rate limiting op de API** buiten de login-lockout om. Zet er zo nodig een
|
||||
proxy met rate limiting voor.
|
||||
|
||||
|
|
|
|||
225
server-up/app.py
225
server-up/app.py
|
|
@ -741,10 +741,73 @@ def api_stack_env_put(name):
|
|||
lib, d = _stack_dir(name)
|
||||
if not d:
|
||||
return jsonify(ok=False, msg="ongeldige naam"), 400
|
||||
docker.write_env(d, (request.json or {}).get("content", ""))
|
||||
inhoud = (request.json or {}).get("content", "")
|
||||
# Compose leest dit bestand regel voor regel; een regel zonder '=' wordt
|
||||
# stilzwijgend genegeerd en dan mist er ineens een wachtwoord.
|
||||
for nr, regel in enumerate(inhoud.splitlines(), 1):
|
||||
kaal = regel.strip()
|
||||
if not kaal or kaal.startswith("#"):
|
||||
continue
|
||||
if "=" not in kaal:
|
||||
return jsonify(ok=False,
|
||||
msg=f"Regel {nr} heeft geen '=': {kaal[:60]}"), 400
|
||||
docker.write_env(d, inhoud)
|
||||
audit.log("stacks", "env_edit", "ok", ref=name, ip=request.remote_addr)
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.route("/api/stacks/<name>/credentials")
|
||||
def api_stack_credentials(name):
|
||||
"""Waarmee log je bij deze app in?
|
||||
|
||||
De wachtwoorden zijn vaak door Server Up gegenereerd; zonder deze route kun
|
||||
je ze nergens meer terugvinden zonder het compose-bestand of `.env` open te
|
||||
slaan.
|
||||
"""
|
||||
lib, d = _stack_dir(name)
|
||||
if not d or not d.exists():
|
||||
return jsonify(ok=False, msg="niet gevonden"), 404
|
||||
if auth.role_of(auth.current_user() or "") != "admin":
|
||||
return jsonify(ok=False, msg="Alleen een beheerder mag inloggegevens zien"), 403
|
||||
|
||||
meta = _stack_meta_lezen(d)
|
||||
bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
|
||||
velden = boilerplates.fields(bron) if bron else []
|
||||
waarden = {**(meta.get("values") or {}), **_geheimen_uit_env(d, meta)}
|
||||
|
||||
items = []
|
||||
for f in velden:
|
||||
soort = f.get("credential")
|
||||
if not soort:
|
||||
continue
|
||||
waarde = str(waarden.get(f["name"], "") or "")
|
||||
if not waarde:
|
||||
continue
|
||||
items.append({"kind": soort, "title": f.get("title") or f["name"],
|
||||
"value": waarde, "secret": bool(f.get("secret"))})
|
||||
|
||||
# Het adres waarop de app draait: eigen IP, anders de gepubliceerde poort.
|
||||
adres = ""
|
||||
try:
|
||||
poorten = compose_transform.published_ports(docker.read_compose(d))
|
||||
except Exception:
|
||||
poorten = []
|
||||
if meta.get("ip"):
|
||||
adres = f"http://{meta['ip']}"
|
||||
elif poorten:
|
||||
eerste = str(poorten[0]).strip('"').split(":")
|
||||
if len(eerste) >= 2 and eerste[-2].isdigit():
|
||||
adres = f"http://{request.host.split(':')[0]}:{eerste[-2]}"
|
||||
|
||||
if items:
|
||||
audit.log("stacks", "credentials_view", "ok", ref=name,
|
||||
ip=request.remote_addr)
|
||||
return jsonify(ok=True, address=adres, items=items,
|
||||
# Apps als Vaultwarden en Immich maken het eerste account aan
|
||||
# bij het eerste bezoek; dan valt er hier niets te tonen.
|
||||
self_signup=not items)
|
||||
|
||||
|
||||
@app.route("/api/stacks/<name>/compose")
|
||||
def api_stack_compose(name):
|
||||
lib, d = _stack_dir(name)
|
||||
|
|
@ -782,8 +845,49 @@ def _stack_meta_lezen(d: Path) -> dict:
|
|||
def _stack_meta_schrijven(d: Path, updates: dict):
|
||||
meta = _stack_meta_lezen(d)
|
||||
meta.update(updates)
|
||||
(d / ".serverup.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
mf = d / ".serverup.json"
|
||||
mf.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# Hier stond tot v0.7.60 elk wachtwoord in, wereldleesbaar. De geheimen zijn
|
||||
# nu naar .env verhuisd, maar de rechten horen hoe dan ook beperkt.
|
||||
try:
|
||||
mf.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _splits_geheimen(velden: list[dict], waarden: dict) -> tuple[dict, list[str]]:
|
||||
"""Scheid de geheime waarden van de rest.
|
||||
|
||||
Wat overblijft gaat naar `.serverup.json` voor het herconfiguratieformulier;
|
||||
de geheimen staan in `.env` en worden daar weer opgehaald.
|
||||
"""
|
||||
geheim = {f["name"] for f in velden if f.get("secret")}
|
||||
zonder = {k: v for k, v in (waarden or {}).items() if k not in geheim}
|
||||
return zonder, sorted(n for n in geheim if str((waarden or {}).get(n, "")).strip())
|
||||
|
||||
|
||||
def _env_lezen(d: Path) -> dict:
|
||||
"""`.env` als dictionary."""
|
||||
env = {}
|
||||
for regel in docker.read_env(d).splitlines():
|
||||
if "=" in regel and not regel.lstrip().startswith("#"):
|
||||
sleutel, _, waarde = regel.partition("=")
|
||||
env[sleutel.strip()] = waarde
|
||||
return env
|
||||
|
||||
|
||||
def _geheimen_uit_env(d: Path, meta: dict) -> dict:
|
||||
"""De geheime waarden terug uit `.env`, via de bewaarde koppeling.
|
||||
|
||||
`secret_keys` staat in `.serverup.json` en zegt precies welk veld bij welke
|
||||
`.env`-sleutel hoort, zodat we niet hoeven te raden.
|
||||
"""
|
||||
koppeling = meta.get("secret_keys") or {}
|
||||
if not koppeling:
|
||||
return {}
|
||||
env = _env_lezen(d)
|
||||
return {veld: env[sleutel] for veld, sleutel in koppeling.items()
|
||||
if sleutel in env}
|
||||
|
||||
|
||||
@app.route("/api/stacks/<name>/config")
|
||||
|
|
@ -808,12 +912,16 @@ def api_stack_config(name):
|
|||
msg=("Deze stack is ge\u00efnstalleerd voordat Server Up de gemaakte "
|
||||
"keuzes bewaarde, of het bronsjabloon is niet meer beschikbaar. "
|
||||
"Wijzigen kan via 'compose bewerken'."))
|
||||
# De geheimen staan niet in .serverup.json maar in .env; zonder dit zou het
|
||||
# formulier ze leeg tonen en zou opslaan ze wissen.
|
||||
waarden = {**waarden, **_geheimen_uit_env(d, meta)}
|
||||
return jsonify(ok=True, editable=True,
|
||||
fields=_annotate_fields(boilerplates.fields(bron)),
|
||||
values=waarden,
|
||||
network=meta.get("network", ""), ip=meta.get("ip", ""),
|
||||
connected=bool(meta.get("connected")),
|
||||
source=meta.get("source", ""), name=meta.get("name", name))
|
||||
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
|
||||
name=meta.get("name", name))
|
||||
|
||||
|
||||
@app.route("/api/stacks/<name>/reconfigure", methods=["POST"])
|
||||
|
|
@ -867,6 +975,22 @@ def api_stack_reconfigure(name):
|
|||
jobs.log(qq, "ok", "Opnieuw gerenderd")
|
||||
|
||||
cf = docker.find_compose(d)
|
||||
# Ook hier de geheimen uit het compose-bestand halen, anders staan
|
||||
# ze na een wijziging alsnog leesbaar in de stackmap.
|
||||
velden_bron = boilerplates.fields(bron)
|
||||
secret_keys = {}
|
||||
if cf:
|
||||
try:
|
||||
nieuw_txt, env_geheim, secret_keys = compose_transform.geheimen_naar_env(
|
||||
cf.read_text(encoding="utf-8"), velden_bron, nieuwe_waarden)
|
||||
if env_geheim:
|
||||
cf.write_text(nieuw_txt, encoding="utf-8")
|
||||
docker.write_env(d, compose_transform.env_samenvoegen(
|
||||
docker.read_env(d), env_geheim))
|
||||
jobs.log(qq, "ok",
|
||||
f"{len(env_geheim)} geheim(en) in .env bijgewerkt")
|
||||
except compose_transform.TransformError as e:
|
||||
jobs.log(qq, "warn", f"Geheimen niet verplaatst: {e}")
|
||||
if net_name:
|
||||
net = _network_by_name(net_name)
|
||||
if net and net_ip:
|
||||
|
|
@ -888,8 +1012,11 @@ def api_stack_reconfigure(name):
|
|||
backups.restore(name, backup_naam, log_fn=lf)
|
||||
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||
|
||||
zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden)
|
||||
_stack_meta_schrijven(d, {
|
||||
"values": nieuwe_waarden, "network": net_name, "ip": net_ip,
|
||||
"values": zichtbaar, "secret_fields": geheime_namen,
|
||||
"secret_keys": secret_keys,
|
||||
"network": net_name, "ip": net_ip,
|
||||
"connected": bool(verbinden and gedeeld_net)})
|
||||
|
||||
docker.compose_down(d, log_fn=lf, name=name)
|
||||
|
|
@ -1204,15 +1331,29 @@ def api_store_preview():
|
|||
if boilerplates.is_boilerplate(src):
|
||||
meta = boilerplates.metadata(src)
|
||||
flds = _annotate_fields(boilerplates.fields(src))
|
||||
# Render een preview met defaults, zodat de gebruiker ziet wat er komt
|
||||
# Render met de waarden die de gebruiker tot nu toe heeft gekozen, zodat
|
||||
# de laatste stap van het invulmenu laat zien wat er écht komt te staan.
|
||||
# Zonder waarden (bij het openen) vallen we terug op de standaarden.
|
||||
waarden = dict(d.get("values") or {})
|
||||
env_preview = ""
|
||||
try:
|
||||
ctx = boilerplates.build_context(src, {})
|
||||
ctx = boilerplates.build_context(src, waarden)
|
||||
files_dir = src / "files"
|
||||
preview_files = {}
|
||||
for p in sorted(files_dir.rglob("*")):
|
||||
if p.is_file() and p.suffix.lower() in (".yml", ".yaml") and p.stat().st_size < 32_000:
|
||||
preview_files[p.relative_to(files_dir).as_posix()] = \
|
||||
boilerplates.render_text(p.read_text(encoding="utf-8"), ctx)[:4000]
|
||||
boilerplates.render_text(p.read_text(encoding="utf-8"), ctx)[:8000]
|
||||
# Dezelfde bewerking als bij het installeren, anders toont de
|
||||
# voorvertoning iets anders dan er straks op schijf komt.
|
||||
sleutel = next((k for k in ("compose.yaml", "docker-compose.yml",
|
||||
"compose.yml") if k in preview_files), "")
|
||||
if sleutel and waarden:
|
||||
nieuw, env_geheim, _ = compose_transform.geheimen_naar_env(
|
||||
preview_files[sleutel], flds, waarden)
|
||||
if env_geheim:
|
||||
preview_files[sleutel] = nieuw
|
||||
env_preview = "".join(f"{k}={v}\n" for k, v in env_geheim.items())
|
||||
except Exception as e:
|
||||
preview_files = {"_error": f"render: {e}"}
|
||||
return jsonify(
|
||||
|
|
@ -1227,7 +1368,7 @@ def api_store_preview():
|
|||
env_vars=[], images=[], ports=[],
|
||||
compose_preview=preview_files.get("compose.yaml", "") or
|
||||
preview_files.get("docker-compose.yml", ""),
|
||||
env_preview="",
|
||||
env_preview=env_preview,
|
||||
)
|
||||
|
||||
# ── Legacy Server Up formaat (compose.yml + .env) ─────────────────────────
|
||||
|
|
@ -1318,6 +1459,9 @@ def api_store_install():
|
|||
env_overrides = d.get("env_overrides", {}) # {"PORT": "8080", "TAG": "v2"}
|
||||
image_tags = d.get("image_tags", {}) # {"service_name": "v2.1"}
|
||||
bp_values = d.get("values", {}) # boilerplate variable values
|
||||
# Handmatig bewerkt in de laatste stap van het invulmenu.
|
||||
compose_override = str(d.get("compose_override") or "")
|
||||
env_override = str(d.get("env_override") or "")
|
||||
net_name = (d.get("network") or "").strip() # leeg = poorten op de host
|
||||
net_ip = (d.get("ip") or "").strip()
|
||||
# Aan het gedeelde netwerk hangen, zodat deze stack andere apps op naam
|
||||
|
|
@ -1366,6 +1510,14 @@ def api_store_install():
|
|||
return jsonify(ok=False, msg="Nog niet ingevuld: " + ", ".join(ontbreekt),
|
||||
missing=ontbreekt), 400
|
||||
|
||||
# Een zelf bewerkt compose-bestand eerst controleren: een typefout hoort hier
|
||||
# te stranden, niet halverwege een installatie die al een map heeft gemaakt.
|
||||
if compose_override.strip():
|
||||
try:
|
||||
yaml.safe_load(compose_override)
|
||||
except yaml.YAMLError as e:
|
||||
return jsonify(ok=False, msg=f"Het compose-bestand is geen geldige YAML:\n{e}"), 400
|
||||
|
||||
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
|
||||
if lib == Path("/app").resolve():
|
||||
return jsonify(ok=False, msg="LIBRARY_DIR mag niet /app zijn.")
|
||||
|
|
@ -1383,6 +1535,12 @@ def api_store_install():
|
|||
|
||||
def _run(jid):
|
||||
qq = jobs.get_queue(jid)
|
||||
secret_keys: dict = {}
|
||||
velden_bron: list = []
|
||||
# De waarden zoals er echt mee gerenderd is, inclusief de defaults die
|
||||
# de client niet meestuurde. Alleen die zijn bruikbaar om het formulier
|
||||
# later opnieuw te openen.
|
||||
gebruikte_waarden: dict = {}
|
||||
try:
|
||||
jobs.log(qq, "section", f"Installeren: {inst}")
|
||||
try:
|
||||
|
|
@ -1396,10 +1554,17 @@ def api_store_install():
|
|||
values = dict(bp_values or {})
|
||||
# Defaults voor service_name als de gebruiker niets opgaf
|
||||
values.setdefault("service_name", inst)
|
||||
# Velden die de client niet meestuurde krijgen alsnog hun
|
||||
# standaardwaarde, zodat het formulier later compleet is.
|
||||
for _f in boilerplates.fields(src):
|
||||
if _f["name"] not in values and _f.get("default") is not None:
|
||||
values[_f["name"]] = _f["default"]
|
||||
gebruikte_waarden = values
|
||||
jobs.log(qq, "info", f"Boilerplate render ({len(values)} waarden)")
|
||||
# Wachtwoorden en sleutels niet uitschrijven: dit log staat in
|
||||
# de interface en wordt vaak gekopieerd in een bugmelding.
|
||||
geheim = {f["name"] for f in boilerplates.fields(src) if f.get("secret")}
|
||||
velden_bron = boilerplates.fields(src)
|
||||
geheim = {f["name"] for f in velden_bron if f.get("secret")}
|
||||
for k, v in sorted(values.items()):
|
||||
jobs.log(qq, "dim", f" {k}=" + ("••••••" if k in geheim and v else str(v)))
|
||||
try:
|
||||
|
|
@ -1410,6 +1575,33 @@ def api_store_install():
|
|||
except boilerplates.BoilerplateError as e:
|
||||
jobs.log(qq, "error", str(e))
|
||||
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||
|
||||
# Wachtwoorden en sleutels naar .env, zodat het compose-bestand
|
||||
# te delen is en de geheimen op één plek met 0600 staan.
|
||||
cf_render = docker.find_compose(dest)
|
||||
if cf_render:
|
||||
try:
|
||||
nieuw, env_geheim, koppeling = compose_transform.geheimen_naar_env(
|
||||
cf_render.read_text(encoding="utf-8"), velden_bron, values)
|
||||
if env_geheim:
|
||||
cf_render.write_text(nieuw, encoding="utf-8")
|
||||
docker.write_env(dest, compose_transform.env_samenvoegen(
|
||||
docker.read_env(dest), env_geheim))
|
||||
secret_keys = koppeling
|
||||
jobs.log(qq, "ok",
|
||||
f"{len(env_geheim)} geheim(en) naar .env verplaatst")
|
||||
except compose_transform.TransformError as e:
|
||||
jobs.log(qq, "warn", f"Geheimen niet verplaatst: {e}")
|
||||
|
||||
# Heb je het resultaat in de laatste stap zelf aangepast, dan
|
||||
# telt jouw versie — het renderen hierboven levert dan alleen de
|
||||
# overige bestanden.
|
||||
if compose_override.strip() and cf_render:
|
||||
cf_render.write_text(compose_override, encoding="utf-8")
|
||||
jobs.log(qq, "ok", "Eigen compose-bestand geschreven")
|
||||
if env_override.strip():
|
||||
docker.write_env(dest, env_override)
|
||||
jobs.log(qq, "ok", "Eigen .env geschreven")
|
||||
else:
|
||||
# ── Legacy: copy verbatim ─────────────────────────────────────
|
||||
shutil.copytree(str(src), str(dest), dirs_exist_ok=True)
|
||||
|
|
@ -1520,7 +1712,12 @@ def api_store_install():
|
|||
logo = meta.get("logo_url", "")
|
||||
if not logo and not is_bp:
|
||||
logo = git.guess_logo_url(meta.get("name") or stack)
|
||||
(dest / ".serverup.json").write_text(json.dumps({
|
||||
# Geheimen gaan niet mee: die staan in .env. Hier blijft alleen
|
||||
# staan wélke velden geheim waren en onder welke .env-sleutel de
|
||||
# waarde te vinden is.
|
||||
zichtbaar, geheime_namen = _splits_geheimen(
|
||||
velden_bron if is_bp else [], gebruikte_waarden if is_bp else {})
|
||||
_stack_meta_schrijven(dest, {
|
||||
"source": stack,
|
||||
"repo_id": rid,
|
||||
"name": meta.get("name", stack),
|
||||
|
|
@ -1536,9 +1733,11 @@ def api_store_install():
|
|||
# Zonder de gebruikte waarden valt het installatieformulier
|
||||
# later niet opnieuw te openen om iets te wijzigen.
|
||||
"format": "boilerplate" if is_bp else "compose",
|
||||
"values": bp_values if is_bp else {},
|
||||
"values": zichtbaar if is_bp else {},
|
||||
"secret_fields": geheime_namen,
|
||||
"secret_keys": secret_keys,
|
||||
"env_overrides": {} if is_bp else env_overrides,
|
||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ def fields(d: Path) -> list[dict]:
|
|||
"required": False, "options": [], "needs": [],
|
||||
"placeholder": "", "description": grp.get("description", ""),
|
||||
"is_group_toggle": True,
|
||||
# De schakelaar hoort bij de stap waar zijn eigen velden staan;
|
||||
# die bepalen we pas hieronder, dus voorlopig 'instellingen'.
|
||||
"step": "instellingen", "credential": "",
|
||||
"secret": False, "generate": "", "advanced": bool(grp.get("advanced")),
|
||||
})
|
||||
groep_geavanceerd = bool(grp.get("advanced"))
|
||||
for item in grp.get("items", []) or []:
|
||||
|
|
@ -131,12 +135,73 @@ def fields(d: Path) -> list[dict]:
|
|||
"secret": bool(item.get("secret")) or _lijkt_geheim(item),
|
||||
# Waarden die de gebruiker toch niet zelf kan bedenken (JWT-
|
||||
# sleutels, database-wachtwoorden) worden voorgevuld met iets
|
||||
# willekeurigs. "hex32" = 32 hexadecimale tekens.
|
||||
# willekeurigs. "auto" = ook meteen invullen, True = alleen een
|
||||
# knop (voor wachtwoorden waarmee je zelf inlogt).
|
||||
"generate": item.get("generate") or "",
|
||||
# In welke stap van het invulmenu dit veld thuishoort.
|
||||
"step": _stap_van(item, toggle),
|
||||
# "username" of "password": wordt getoond in het paneel
|
||||
# Inloggegevens, zodat je na de installatie kunt opzoeken
|
||||
# waarmee je moet inloggen.
|
||||
"credential": item.get("credential") or _credential_van(item),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Indeling in het invulmenu ────────────────────────────────────────────────
|
||||
# Vijf vaste stappen voor elke app. Eén stap per groep werkt niet: de mediane
|
||||
# app heeft één groep en arr-stack heeft er eenentwintig.
|
||||
STAPPEN = ("basis", "verbinden", "instellingen", "geheimen")
|
||||
|
||||
_BASIS_NAMEN = ("service_name", "data_dir", "timezone", "tz", "puid", "pgid",
|
||||
"downloads_dir", "media_dir", "config_dir")
|
||||
|
||||
|
||||
def _stap_van(item: dict, groep_toggle: str = "") -> str:
|
||||
"""In welke stap hoort dit veld?
|
||||
|
||||
Expliciet in template.json gaat voor; anders afgeleid uit naam en type.
|
||||
"""
|
||||
expliciet = (item.get("step") or "").strip().lower()
|
||||
if expliciet in STAPPEN:
|
||||
return expliciet
|
||||
naam = (item.get("name") or "").lower()
|
||||
if item.get("connect"):
|
||||
return "verbinden"
|
||||
if item.get("secret") or _lijkt_geheim(item):
|
||||
return "geheimen"
|
||||
# Een gebruikersnaam hoort bij het wachtwoord waarmee je inlogt, niet ergens
|
||||
# tussen de overige instellingen.
|
||||
if _credential_van(item) == "username":
|
||||
return "geheimen"
|
||||
# Poorten horen bij de basis, maar alleen als ze ook echt een poort zijn.
|
||||
if item.get("type") == "int" and "port" in naam:
|
||||
return "basis"
|
||||
if naam in _BASIS_NAMEN:
|
||||
return "basis"
|
||||
return "instellingen"
|
||||
|
||||
|
||||
_GEBRUIKERSNAAM_DELEN = ("user", "username", "email", "login", "account")
|
||||
|
||||
|
||||
def _credential_van(item: dict) -> str:
|
||||
"""Herken inloggegevens, zodat de bestaande 96 sjablonen meteen werken."""
|
||||
naam = (item.get("name") or "").lower()
|
||||
if _lijkt_geheim(item) and any(w in naam for w in ("password", "passwd",
|
||||
"wachtwoord", "adminpass")):
|
||||
return "password"
|
||||
if _lijkt_geheim(item):
|
||||
return "" # tokens en sleutels zijn geen inloggegevens
|
||||
delen = naam.split("_")
|
||||
if any(d in _GEBRUIKERSNAAM_DELEN for d in delen) or "email" in naam:
|
||||
return "username"
|
||||
# Ook samenstellingen: superuser_name, adminuser, login_name.
|
||||
if any(w in naam for w in ("user", "login", "email")) and "password" not in naam:
|
||||
return "username"
|
||||
return ""
|
||||
|
||||
|
||||
# Namen die vrijwel altijd een geheim aanduiden. Bedoeld om te voorkomen dat een
|
||||
# nieuw template het per ongeluk als gewoon tekstveld toont.
|
||||
_GEHEIM_WOORDEN = ("password", "passwd", "secret", "token", "apikey",
|
||||
|
|
|
|||
|
|
@ -127,6 +127,121 @@ def add_shared_network(compose_text: str, network: str) -> str:
|
|||
allow_unicode=True, width=120)
|
||||
|
||||
|
||||
def geheimen_naar_env(compose_text: str, velden: list[dict],
|
||||
waarden: dict) -> tuple[str, dict[str, str], dict[str, str]]:
|
||||
"""Haal wachtwoorden en sleutels uit het compose-bestand naar `.env`.
|
||||
|
||||
Compose vult `${NAAM}` in bij het inlezen, dus functioneel verandert er
|
||||
niets — maar het compose-bestand is daarna te delen en te committen, en alle
|
||||
geheimen staan op één plek die we met 0600 kunnen wegschrijven.
|
||||
|
||||
Retourneert het aangepaste compose-bestand, de `.env`-regels, en welk veld
|
||||
bij welke `.env`-sleutel hoort — die koppeling bewaren we, zodat we de
|
||||
waarde later exact kunnen terugvinden in plaats van te moeten raden.
|
||||
|
||||
De sleutelnaam komt uit de omgevingsvariabele zelf (`POSTGRES_PASSWORD`),
|
||||
zodat `.env` leesbaar blijft. Staat het geheim midden in een langere waarde,
|
||||
zoals een database-URL, dan wordt alleen dat stuk vervangen.
|
||||
"""
|
||||
# Alleen niet-lege geheimen, langste eerst: staat dezelfde waarde ook als
|
||||
# deel van een andere, dan vervangen we eerst de langste en houden we de
|
||||
# korte niet per ongeluk halverwege over.
|
||||
geheim = sorted(
|
||||
((str(waarden.get(f["name"], "")), f["name"]) for f in velden
|
||||
if f.get("secret") and str(waarden.get(f["name"], "")).strip()),
|
||||
key=lambda p: len(p[0]), reverse=True)
|
||||
if not geheim:
|
||||
return compose_text, {}, {}
|
||||
|
||||
try:
|
||||
doc = yaml.safe_load(compose_text)
|
||||
except yaml.YAMLError as e:
|
||||
raise TransformError(f"compose is geen geldige YAML: {e}") from e
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get("services"), dict):
|
||||
return compose_text, {}, {}
|
||||
|
||||
env: dict[str, str] = {}
|
||||
gebruikt: dict[str, str] = {} # waarde → gekozen sleutelnaam
|
||||
per_veld: dict[str, str] = {} # veldnaam → .env-sleutel
|
||||
|
||||
def _sleutel(voorkeur: str, veldnaam: str, waarde: str) -> str:
|
||||
"""Kies een .env-naam; dezelfde waarde krijgt altijd dezelfde naam."""
|
||||
if waarde in gebruikt:
|
||||
# Twee velden met dezelfde waarde delen één .env-sleutel; allebei
|
||||
# moeten ze wel in de koppeling staan.
|
||||
per_veld.setdefault(veldnaam, gebruikt[waarde])
|
||||
return gebruikt[waarde]
|
||||
naam = "".join(c if c.isalnum() or c == "_" else "_"
|
||||
for c in (voorkeur or veldnaam)).upper().strip("_")
|
||||
if not naam or naam[0].isdigit():
|
||||
naam = f"SU_{naam}"
|
||||
basis, n = naam, 2
|
||||
while naam in env and env[naam] != waarde:
|
||||
naam, n = f"{basis}_{n}", n + 1
|
||||
env[naam] = waarde
|
||||
gebruikt[waarde] = naam
|
||||
per_veld[veldnaam] = naam
|
||||
return naam
|
||||
|
||||
def _vervang(tekst: str, env_sleutel: str = "") -> str:
|
||||
for waarde, veldnaam in geheim:
|
||||
if waarde and waarde in tekst:
|
||||
naam = _sleutel(env_sleutel if tekst == waarde else "",
|
||||
veldnaam, waarde)
|
||||
tekst = tekst.replace(waarde, "${" + naam + "}")
|
||||
return tekst
|
||||
|
||||
for svc in doc["services"].values():
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
omgeving = svc.get("environment")
|
||||
if isinstance(omgeving, dict):
|
||||
for sleutel, waarde in list(omgeving.items()):
|
||||
if isinstance(waarde, str):
|
||||
omgeving[sleutel] = _vervang(waarde, str(sleutel))
|
||||
elif isinstance(omgeving, list):
|
||||
for i, regel in enumerate(omgeving):
|
||||
if not isinstance(regel, str) or "=" not in regel:
|
||||
continue
|
||||
sleutel, _, waarde = regel.partition("=")
|
||||
omgeving[i] = f"{sleutel}={_vervang(waarde, sleutel)}"
|
||||
# Ook command en labels kunnen een geheim bevatten (bv. een
|
||||
# database-URL die als argument wordt meegegeven).
|
||||
for blok in ("command", "entrypoint", "labels"):
|
||||
inhoud = svc.get(blok)
|
||||
if isinstance(inhoud, str):
|
||||
svc[blok] = _vervang(inhoud)
|
||||
elif isinstance(inhoud, list):
|
||||
svc[blok] = [_vervang(x) if isinstance(x, str) else x
|
||||
for x in inhoud]
|
||||
elif isinstance(inhoud, dict):
|
||||
svc[blok] = {k: _vervang(v) if isinstance(v, str) else v
|
||||
for k, v in inhoud.items()}
|
||||
|
||||
if not env:
|
||||
return compose_text, {}, {}
|
||||
return (yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
|
||||
allow_unicode=True, width=120), env, per_veld)
|
||||
|
||||
|
||||
def env_samenvoegen(bestaand: str, nieuw: dict[str, str]) -> str:
|
||||
"""Werk `.env`-regels bij zonder de rest van het bestand te verliezen."""
|
||||
regels = (bestaand or "").splitlines()
|
||||
gezien = set()
|
||||
uit = []
|
||||
for regel in regels:
|
||||
sleutel = regel.split("=", 1)[0].strip()
|
||||
if sleutel in nieuw:
|
||||
uit.append(f"{sleutel}={nieuw[sleutel]}")
|
||||
gezien.add(sleutel)
|
||||
else:
|
||||
uit.append(regel)
|
||||
for sleutel, waarde in nieuw.items():
|
||||
if sleutel not in gezien:
|
||||
uit.append(f"{sleutel}={waarde}")
|
||||
return "\n".join(uit).strip("\n") + "\n"
|
||||
|
||||
|
||||
def published_ports(compose_text: str) -> list[str]:
|
||||
"""Alle host-poorten die dit compose-bestand publiceert (voor de UI)."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -302,8 +302,17 @@ def read_env(d: Path) -> str:
|
|||
|
||||
|
||||
def write_env(d: Path, content: str):
|
||||
"""Schrijf `.env` en beperk de rechten tot de eigenaar.
|
||||
|
||||
Hier staan de wachtwoorden en sleutels van de stack in; standaard maakt
|
||||
Python het bestand wereldleesbaar.
|
||||
"""
|
||||
f = d / ".env"
|
||||
f.write_text(content)
|
||||
try:
|
||||
f.chmod(0o600)
|
||||
except OSError:
|
||||
pass # bv. op een filesystem dat geen rechten kent
|
||||
|
||||
|
||||
def read_compose(d: Path) -> str:
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ tailwind.config = {
|
|||
<button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
|
||||
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
|
||||
<button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
|
||||
<button class="btn-sm" :title="t('credentials')" @click="openCredentials(s.name)"><span class="mdi mdi-account-key-outline"></span></button>
|
||||
<button class="btn-sm" :title="t('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button>
|
||||
<button class="btn-sm" :title="t('stack_settings')" @click="openReconfigure(s.name)"
|
||||
:disabled="!s.editable"><span class="mdi mdi-tune"></span></button>
|
||||
|
|
@ -1358,12 +1359,6 @@ tailwind.config = {
|
|||
<span x-show="install.format === 'boilerplate'" class="badge badge-info">Boilerplate</span>
|
||||
</div>
|
||||
<div class="modal-body overflow-y-auto space-y-3">
|
||||
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
|
||||
<div x-show="install.mode !== 'edit'">
|
||||
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
|
||||
<input class="form-input" x-model="install.instance">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
|
||||
</div>
|
||||
<!-- Bij wijzigen wordt de stack opnieuw gerenderd; dat overschrijft
|
||||
handmatige aanpassingen in het compose-bestand. -->
|
||||
<p x-show="install.mode === 'edit'" class="text-[11px] text-wr flex items-start gap-1.5">
|
||||
|
|
@ -1390,183 +1385,242 @@ tailwind.config = {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Koppelen aan andere apps -->
|
||||
<label class="flex items-start gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" :checked="install.connect_apps !== false"
|
||||
@change="install.connect_apps = $event.target.checked"
|
||||
class="w-5 h-5 mt-0.5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
|
||||
<span class="min-w-0">
|
||||
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('connect_apps')">Verbinden met andere apps</span>
|
||||
<span class="block text-[11px] text-dm dark:text-dm-dark"
|
||||
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
|
||||
<div>
|
||||
<label class="form-label" x-text="t('network')">Netwerk</label>
|
||||
<select class="form-input" x-model="install.network" @change="pickIp()">
|
||||
<option value="" x-text="t('ports_on_host')">Poorten op de host</option>
|
||||
<template x-for="n in netList" :key="n.name">
|
||||
<option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<template x-if="install.network">
|
||||
<div class="mt-2">
|
||||
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
|
||||
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!install.network && netList.length === 0"
|
||||
class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
|
||||
</div>
|
||||
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
|
||||
|
||||
<!-- Boilerplate fields -->
|
||||
<!-- Invulmenu: vaste stappen, ongeacht hoeveel groepen de app heeft -->
|
||||
<template x-if="install.format === 'boilerplate' && !install.loading">
|
||||
<div class="space-y-3">
|
||||
<template x-for="grp in groupedFields" :key="grp.name">
|
||||
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden" :open="!grp.toggle || !!install.values[grp.toggle]">
|
||||
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60">
|
||||
<span x-text="grp.name"></span>
|
||||
<template x-if="grp.toggle">
|
||||
<label class="ml-auto inline-flex items-center cursor-pointer" @click.stop>
|
||||
<input type="checkbox" :checked="!!install.values[grp.toggle]" @change="install.values[grp.toggle] = $event.target.checked" class="sr-only peer">
|
||||
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
|
||||
|
||||
<!-- Stapindicator; klikbaar om terug te springen -->
|
||||
<div class="flex items-center gap-1 overflow-x-auto pb-1" x-show="!onePage">
|
||||
<template x-for="(s, i) in wizardSteps()" :key="s.id">
|
||||
<button type="button" @click="gotoStep(i)"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] whitespace-nowrap transition"
|
||||
:class="i === install.step
|
||||
? 'bg-ac text-white font-bold'
|
||||
: (i < install.step ? 'text-ok' : 'text-dm dark:text-dm-dark hover:text-tx')">
|
||||
<span class="mdi" :class="i < install.step ? 'mdi-check-circle' : s.icon"></span>
|
||||
<span x-text="s.title"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Altijd bereikbaar: geavanceerde opties en alles-op-één-pagina -->
|
||||
<div class="flex items-center gap-3 flex-wrap text-[11px]">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" :checked="showAdvanced"
|
||||
@change="showAdvanced = $event.target.checked; saveUiKeuze()" class="sr-only peer">
|
||||
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
|
||||
<span class="mdi mdi-tune text-t2 dark:text-t2-dark"></span>
|
||||
<span class="text-t2 dark:text-t2-dark" x-text="t('advanced_options')">Geavanceerde opties</span>
|
||||
<span x-show="advancedCount() > 0" class="badge badge-dim" x-text="advancedCount()"></span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer ml-auto">
|
||||
<input type="checkbox" :checked="onePage"
|
||||
@change="onePage = $event.target.checked; saveUiKeuze()" class="sr-only peer">
|
||||
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
|
||||
<span class="text-t2 dark:text-t2-dark" x-text="t('one_page')">Alles op één pagina</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- De stappen zelf -->
|
||||
<template x-for="(s, i) in wizardSteps()" :key="s.id">
|
||||
<div x-show="onePage || i === install.step" class="space-y-3">
|
||||
<div x-show="onePage" class="text-[10px] font-bold tracking-wider uppercase text-ac dark:text-ac-dark pt-2"
|
||||
x-text="s.title"></div>
|
||||
|
||||
<!-- Verbinden: netwerk en koppelingen -->
|
||||
<template x-if="s.id === 'verbinden'">
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" :checked="install.connect_apps !== false"
|
||||
@change="install.connect_apps = $event.target.checked"
|
||||
class="w-5 h-5 mt-0.5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
|
||||
<span class="min-w-0">
|
||||
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('connect_apps')">Verbinden met andere apps</span>
|
||||
<span class="block text-[11px] text-dm dark:text-dm-dark"
|
||||
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
</summary>
|
||||
<div class="px-3.5 py-3" x-show="!grp.toggle || install.values[grp.toggle]">
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<template x-for="f in grp.items.filter(x => !x.advanced)" :key="f.name">
|
||||
<!-- De groepsschakelaar staat al in de kop; niet nog eens tonen -->
|
||||
<div x-show="!f.is_group_toggle && bpFieldVisible(f)"
|
||||
:class="f.type==='bool' ? 'sm:col-span-2' : ''">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<span x-text="f.title"></span>
|
||||
<span x-show="f.required" class="text-er">*</span>
|
||||
<!-- Info-venster met de volledige uitleg bij dit veld -->
|
||||
<button type="button" x-show="f.help || f.description"
|
||||
@click="fieldInfo = (fieldInfo && fieldInfo.name === f.name) ? null : f"
|
||||
class="text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark"
|
||||
:title="t('what_does_this_do')">
|
||||
<span class="mdi mdi-information-outline text-sm"></span>
|
||||
</button>
|
||||
</label>
|
||||
<!-- Koppeling met een al geïnstalleerde app -->
|
||||
<template x-if="f.connect">
|
||||
<div class="space-y-1.5">
|
||||
<select class="form-input" x-model="install.values[f.name]">
|
||||
<template x-for="doel in connectTargets(f)" :key="doel.value">
|
||||
<option :value="doel.value" x-text="doel.label"></option>
|
||||
</template>
|
||||
<option value="__handmatig__" x-text="t('enter_manually')">Handmatig invullen</option>
|
||||
</select>
|
||||
<input x-show="install.values[f.name] === '__handmatig__'"
|
||||
class="form-input font-mono !text-xs" :placeholder="f.placeholder"
|
||||
@input="install.values[f.name] = $event.target.value">
|
||||
<p x-show="!connectTargets(f).length" class="text-[11px] text-wr"
|
||||
x-text="t('no_connectable_app', {app: (f.connect || {}).app || ''})"></p>
|
||||
<div>
|
||||
<label class="form-label" x-text="t('network')">Netwerk</label>
|
||||
<select class="form-input" x-model="install.network" @change="pickIp()">
|
||||
<option value="" x-text="t('ports_on_host')">Poorten op de host</option>
|
||||
<template x-for="n in netList" :key="n.name">
|
||||
<option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<template x-if="install.network">
|
||||
<div class="mt-2">
|
||||
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
|
||||
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'enum'">
|
||||
<select class="form-input" x-model="install.values[f.name]"><template x-for="opt in f.options" :key="opt"><option :value="opt" x-text="opt"></option></template></select>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'bool'">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||
<template x-if="!f.connect && f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||
<!-- Geheim veld: genereerknop, zodat niemand 'admin123' invult -->
|
||||
<template x-if="!f.connect && f.type === 'str' && f.secret">
|
||||
<div class="flex gap-2">
|
||||
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
|
||||
<button type="button" class="btn-sm shrink-0" x-show="f.generate" :title="t('generate_value')"
|
||||
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
|
||||
<p x-show="!install.network && netList.length === 0"
|
||||
class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Controleren: het gerenderde resultaat, bewerkbaar -->
|
||||
<template x-if="s.id === 'controleren'">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="btn-sm" :class="install.tab==='compose' ? '!bg-ac !text-white' : ''"
|
||||
@click="install.tab='compose'">docker-compose.yml</button>
|
||||
<button type="button" class="btn-sm" :class="install.tab==='env' ? '!bg-ac !text-white' : ''"
|
||||
@click="install.tab='env'">.env</button>
|
||||
<button type="button" class="btn-sm ml-auto" @click="toggleEdit()">
|
||||
<span class="mdi" :class="install.editing ? 'mdi-eye-outline' : 'mdi-pencil-outline'"></span>
|
||||
<span x-text="install.editing ? t('preview') : t('edit')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="install.editing" class="text-[11px] text-wr flex items-start gap-1.5">
|
||||
<span class="mdi mdi-alert-outline shrink-0"></span>
|
||||
<span x-text="t('manual_edit_warning')"></span>
|
||||
</p>
|
||||
<textarea x-show="install.editing" rows="16"
|
||||
class="form-input font-mono !text-[11px] leading-snug"
|
||||
x-model="install.tab==='compose' ? install.composeText : install.envText"
|
||||
@input="install.tab==='compose'
|
||||
? install.composeText = $event.target.value
|
||||
: install.envText = $event.target.value"></textarea>
|
||||
<pre x-show="!install.editing"
|
||||
class="bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto max-h-80"
|
||||
x-text="(install.tab==='compose' ? install.composeText : install.envText) || t('nothing_here')"></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Basis, instellingen en geheimen: de velden van die stap -->
|
||||
<template x-if="!['verbinden','controleren'].includes(s.id)">
|
||||
<div class="space-y-3">
|
||||
<div x-show="s.id === 'basis' && install.mode !== 'edit'">
|
||||
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
|
||||
<input class="form-input" x-model="install.instance">
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
|
||||
</div>
|
||||
<template x-for="grp in stepGroups(s.id)" :key="s.id + '/' + grp.name">
|
||||
<div class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
|
||||
<div x-show="grp.showHeader"
|
||||
class="px-3.5 py-2.5 font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60">
|
||||
<span x-text="grp.name"></span>
|
||||
<template x-if="grp.toggle">
|
||||
<label class="ml-auto inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" :checked="!!install.values[grp.toggle]" @change="install.values[grp.toggle] = $event.target.checked" class="sr-only peer">
|
||||
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div class="px-3.5 py-3 grid sm:grid-cols-2 gap-3"
|
||||
x-show="!grp.toggle || install.values[grp.toggle]">
|
||||
<template x-for="f in grp.items" :key="f.name">
|
||||
<div x-show="!f.is_group_toggle && bpFieldVisible(f)"
|
||||
:class="f.type==='bool' ? 'sm:col-span-2' : ''">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<span x-text="f.title"></span>
|
||||
<span x-show="f.required" class="text-er">*</span>
|
||||
<span x-show="f.advanced" class="mdi mdi-tune text-[11px] text-dm dark:text-dm-dark" :title="t('advanced_options')"></span>
|
||||
<button type="button" x-show="f.help || f.description"
|
||||
@click="fieldInfo = (fieldInfo && fieldInfo.name === f.name) ? null : f"
|
||||
class="text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark"
|
||||
:title="t('what_does_this_do')">
|
||||
<span class="mdi mdi-information-outline text-sm"></span>
|
||||
</button>
|
||||
</label>
|
||||
<template x-if="f.connect">
|
||||
<div class="space-y-1.5">
|
||||
<select class="form-input" x-model="install.values[f.name]">
|
||||
<template x-for="doel in connectTargets(f)" :key="doel.value">
|
||||
<option :value="doel.value" x-text="doel.label"></option>
|
||||
</template>
|
||||
<option value="__handmatig__" x-text="t('enter_manually')">Handmatig invullen</option>
|
||||
</select>
|
||||
<input x-show="install.values[f.name] === '__handmatig__'"
|
||||
class="form-input font-mono !text-xs" :placeholder="f.placeholder"
|
||||
@input="install.values[f.name] = $event.target.value">
|
||||
<p x-show="!connectTargets(f).length" class="text-[11px] text-wr"
|
||||
x-text="t('no_connectable_app', {app: (f.connect || {}).app || ''})"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'enum'">
|
||||
<select class="form-input" x-model="install.values[f.name]"><template x-for="opt in f.options" :key="opt"><option :value="opt" x-text="opt"></option></template></select>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'bool'">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||
<template x-if="!f.connect && f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||
<!-- Geheim veld: verborgen tekst, oog om te tonen, dobbelsteen waar een
|
||||
willekeurige waarde klopt (niet bij een sleutel die ergens bij moet passen) -->
|
||||
<template x-if="!f.connect && f.type === 'str' && f.secret">
|
||||
<div class="flex gap-2">
|
||||
<input :type="revealed[f.name] ? 'text' : 'password'"
|
||||
class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
|
||||
<button type="button" class="btn-sm shrink-0" :title="t('show_value')"
|
||||
@click="revealed[f.name] = !revealed[f.name]">
|
||||
<span class="mdi" :class="revealed[f.name] ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"></span>
|
||||
</button>
|
||||
<button type="button" class="btn-sm shrink-0" x-show="f.generate" :title="t('generate_value')"
|
||||
@click="install.values[f.name] = randomSecret(); revealed[f.name] = true"><span class="mdi mdi-dice-5-outline"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
|
||||
x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></p>
|
||||
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
|
||||
x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></p>
|
||||
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!stepGroups(s.id).length" class="text-[11px] text-dm dark:text-dm-dark"
|
||||
x-text="t('nothing_to_choose')"></p>
|
||||
</div>
|
||||
|
||||
<!-- Verborgen menu: alles wat je meestal met rust laat -->
|
||||
<details x-show="grp.items.some(x => x.advanced)"
|
||||
class="mt-3 rounded-lg border border-bd dark:border-bd-dark overflow-hidden">
|
||||
<summary class="px-3 py-2 cursor-pointer text-[11px] font-bold text-t2 dark:text-t2-dark bg-s2/40 dark:bg-s2-dark/40 flex items-center gap-1.5">
|
||||
<span class="mdi mdi-tune"></span>
|
||||
<span x-text="t('advanced_options')">Geavanceerde opties</span>
|
||||
<span class="badge badge-dim ml-auto" x-text="grp.items.filter(x => x.advanced).length"></span>
|
||||
</summary>
|
||||
<div class="px-3 py-3 grid sm:grid-cols-2 gap-3">
|
||||
<template x-for="f in grp.items.filter(x => x.advanced)" :key="f.name">
|
||||
<div x-show="bpFieldVisible(f)" :class="f.type==='bool' ? 'sm:col-span-2' : ''">
|
||||
<label class="form-label flex items-center gap-1">
|
||||
<span x-text="f.title"></span>
|
||||
<span x-show="f.required" class="text-er">*</span>
|
||||
<!-- Info-venster met de volledige uitleg bij dit veld -->
|
||||
<button type="button" x-show="f.help || f.description"
|
||||
@click="fieldInfo = (fieldInfo && fieldInfo.name === f.name) ? null : f"
|
||||
class="text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark"
|
||||
:title="t('what_does_this_do')">
|
||||
<span class="mdi mdi-information-outline text-sm"></span>
|
||||
</button>
|
||||
</label>
|
||||
<!-- Koppeling met een al geïnstalleerde app -->
|
||||
<template x-if="f.connect">
|
||||
<div class="space-y-1.5">
|
||||
<select class="form-input" x-model="install.values[f.name]">
|
||||
<template x-for="doel in connectTargets(f)" :key="doel.value">
|
||||
<option :value="doel.value" x-text="doel.label"></option>
|
||||
</template>
|
||||
<option value="__handmatig__" x-text="t('enter_manually')">Handmatig invullen</option>
|
||||
</select>
|
||||
<input x-show="install.values[f.name] === '__handmatig__'"
|
||||
class="form-input font-mono !text-xs" :placeholder="f.placeholder"
|
||||
@input="install.values[f.name] = $event.target.value">
|
||||
<p x-show="!connectTargets(f).length" class="text-[11px] text-wr"
|
||||
x-text="t('no_connectable_app', {app: (f.connect || {}).app || ''})"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'enum'">
|
||||
<select class="form-input" x-model="install.values[f.name]"><template x-for="opt in f.options" :key="opt"><option :value="opt" x-text="opt"></option></template></select>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'bool'">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
|
||||
</template>
|
||||
<template x-if="!f.connect && f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||
<template x-if="!f.connect && f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||||
<!-- Geheim veld: genereerknop, zodat niemand 'admin123' invult -->
|
||||
<template x-if="!f.connect && f.type === 'str' && f.secret">
|
||||
<div class="flex gap-2">
|
||||
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
|
||||
<button type="button" class="btn-sm shrink-0" x-show="f.generate" :title="t('generate_value')"
|
||||
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
|
||||
x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></p>
|
||||
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
|
||||
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60"><span class="mdi mdi-eye-outline"></span> Preview</summary>
|
||||
<div class="p-3"><pre class="bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto max-h-64" x-text="bpPreview()"></pre></div>
|
||||
</details>
|
||||
|
||||
<!-- Wat er nog leeg is; blokkeert Volgende -->
|
||||
<p x-show="stepMissing().length" class="text-[11px] text-wr flex items-start gap-1.5">
|
||||
<span class="mdi mdi-alert-circle-outline shrink-0"></span>
|
||||
<span x-text="t('still_empty', {fields: stepMissing().join(', ')})"></span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Legacy fields -->
|
||||
<template x-if="install.format === 'compose' && !install.loading">
|
||||
<div class="space-y-4">
|
||||
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
|
||||
<div x-show="install.mode !== 'edit'">
|
||||
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
|
||||
<input class="form-input" x-model="install.instance">
|
||||
</div>
|
||||
<label class="flex items-start gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" :checked="install.connect_apps !== false"
|
||||
@change="install.connect_apps = $event.target.checked"
|
||||
class="w-5 h-5 mt-0.5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
|
||||
<span class="min-w-0">
|
||||
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('connect_apps')">Verbinden met andere apps</span>
|
||||
<span class="block text-[11px] text-dm dark:text-dm-dark"
|
||||
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<label class="form-label" x-text="t('network')">Netwerk</label>
|
||||
<select class="form-input" x-model="install.network" @change="pickIp()">
|
||||
<option value="" x-text="t('ports_on_host')">Poorten op de host</option>
|
||||
<template x-for="n in netList" :key="n.name">
|
||||
<option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<template x-if="install.network">
|
||||
<div class="mt-2">
|
||||
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
|
||||
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="install.images.length">
|
||||
<div>
|
||||
<div class="text-[10px] font-bold tracking-wider uppercase text-pu mb-2"><span class="mdi mdi-docker"></span> Image Tags</div>
|
||||
|
|
@ -1594,7 +1648,15 @@ tailwind.config = {
|
|||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||||
<button class="btn btn-primary" @click="install.mode === 'edit' ? doReconfigure() : doInstall()">
|
||||
<button class="btn" x-show="wizardActief() && install.step > 0" @click="prevStep()">
|
||||
<span class="mdi mdi-chevron-left"></span> <span x-text="t('previous')">Vorige</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" x-show="wizardActief() && !opLaatsteStap()"
|
||||
@click="nextStep()" :disabled="stepMissing().length">
|
||||
<span x-text="t('next')">Volgende</span> <span class="mdi mdi-chevron-right"></span>
|
||||
</button>
|
||||
<button class="btn btn-primary" x-show="!wizardActief() || opLaatsteStap()"
|
||||
@click="install.mode === 'edit' ? doReconfigure() : doInstall()">
|
||||
<span class="mdi" :class="install.mode === 'edit' ? 'mdi-content-save-outline' : 'mdi-rocket-launch'"></span>
|
||||
<span x-text="install.mode === 'edit' ? t('save') : t('install')">Installeren</span>
|
||||
</button>
|
||||
|
|
@ -1616,14 +1678,75 @@ tailwind.config = {
|
|||
|
||||
<!-- Editor -->
|
||||
<div x-show="modal==='editor'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
|
||||
<div class="modal-head"><span class="mdi" :class="editor.type==='env' ? 'mdi-file-cog-outline' : 'mdi-file-code-outline'"></span> <span x-text="editor.title"></span></div>
|
||||
<div class="modal-body flex-1 flex"><textarea x-model="editor.content" class="flex-1 form-input !bg-[#0b1018] !text-slate-200 !border-black/30 font-mono text-xs min-h-[300px] leading-relaxed"></textarea></div>
|
||||
<div class="modal-head">
|
||||
<span class="mdi mdi-file-document-edit-outline text-ac dark:text-ac-dark"></span>
|
||||
<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">
|
||||
<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' : ''"
|
||||
@click="switchEditor('env')">.env</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body flex-1 flex flex-col gap-2">
|
||||
<p x-show="editor.type==='env'" class="text-[11px] text-dm dark:text-dm-dark flex items-start gap-1.5">
|
||||
<span class="mdi mdi-key-variant shrink-0"></span>
|
||||
<span x-text="t('env_holds_secrets')"></span>
|
||||
</p>
|
||||
<textarea x-model="editor.content" class="flex-1 form-input !bg-[#0b1018] !text-slate-200 !border-black/30 font-mono text-xs min-h-[300px] leading-relaxed"></textarea>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer text-[11px] mr-auto">
|
||||
<input type="checkbox" :checked="editor.restart" @change="editor.restart = $event.target.checked"
|
||||
class="w-4 h-4 rounded border-bd dark:border-bd-dark text-ac">
|
||||
<span class="text-t2 dark:text-t2-dark" x-text="t('restart_after_save')">Herstarten na opslaan</span>
|
||||
</label>
|
||||
<button class="btn btn-primary" @click="saveEditor()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inloggegevens: waarmee log je bij deze app in? -->
|
||||
<div x-show="modal==='credentials'" class="modal max-w-lg flex flex-col max-h-[85vh]">
|
||||
<div class="modal-head">
|
||||
<span class="mdi mdi-account-key-outline text-ac dark:text-ac-dark"></span>
|
||||
<span x-text="t('credentials')">Inloggegevens</span>
|
||||
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="creds.stack"></span>
|
||||
</div>
|
||||
<div class="modal-body overflow-y-auto space-y-3">
|
||||
<template x-if="creds.address">
|
||||
<a :href="creds.address" target="_blank" rel="noopener"
|
||||
class="flex items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40 hover:border-ac">
|
||||
<span class="mdi mdi-open-in-new text-ac dark:text-ac-dark"></span>
|
||||
<span class="font-mono text-xs text-tx dark:text-tx-dark" x-text="creds.address"></span>
|
||||
</a>
|
||||
</template>
|
||||
<template x-for="c in creds.items" :key="c.title">
|
||||
<div class="px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
|
||||
<div class="text-[10px] uppercase tracking-wider text-dm dark:text-dm-dark" x-text="c.title"></div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="font-mono text-xs text-tx dark:text-tx-dark flex-1 break-all"
|
||||
x-text="(c.secret && !creds.reveal[c.title]) ? '••••••••••••' : c.value"></span>
|
||||
<button class="btn-sm !h-7 !px-2" x-show="c.secret" :title="t('show_value')"
|
||||
@click="creds.reveal[c.title] = !creds.reveal[c.title]">
|
||||
<span class="mdi" :class="creds.reveal[c.title] ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"></span>
|
||||
</button>
|
||||
<button class="btn-sm !h-7 !px-2" :title="t('copy')" @click="kopieer(c.value)">
|
||||
<span class="mdi mdi-content-copy"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="creds.self_signup" class="text-[11px] text-t2 dark:text-t2-dark flex items-start gap-1.5">
|
||||
<span class="mdi mdi-information-outline shrink-0"></span>
|
||||
<span x-text="t('no_credentials_hint')"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button></div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div x-show="modal==='logs'" class="modal max-w-3xl flex flex-col max-h-[88vh]">
|
||||
<div class="modal-head"><span class="mdi mdi-text-box-outline"></span> <span x-text="logs.title"></span></div>
|
||||
|
|
@ -1797,6 +1920,9 @@ function app() {
|
|||
// Backups en updatestatus per stack
|
||||
bkStack: '', bkList: [], bkSettings: {}, bkDisk: {volumes: []},
|
||||
notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []},
|
||||
// Invulmenu: keuzes die over installaties heen blijven gelden.
|
||||
showAdvanced: false, onePage: false, revealed: {},
|
||||
creds: {stack: '', address: '', items: [], self_signup: false, reveal: {}},
|
||||
notifyTesting: false,
|
||||
stackUpd: {}, checkingUpd: false,
|
||||
|
||||
|
|
@ -1832,7 +1958,8 @@ function app() {
|
|||
stack: '', repo_id: '', instance: '',
|
||||
format: 'compose', loading: false,
|
||||
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
||||
fields: [], values: {}, preview_files: {}
|
||||
fields: [], values: {}, preview_files: {},
|
||||
step: 0, tab: 'compose', editing: false, composeText: '', envText: ''
|
||||
},
|
||||
editor: { stack:'', type:'env', title:'', content:'' },
|
||||
logs: { title:'', content:'' },
|
||||
|
|
@ -1850,6 +1977,7 @@ function app() {
|
|||
],
|
||||
|
||||
async init() {
|
||||
this.laadUiKeuzes();
|
||||
this.applyThemePref();
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
|
|
@ -2202,16 +2330,47 @@ function app() {
|
|||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode });
|
||||
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
|
||||
},
|
||||
async openCredentials(stack) {
|
||||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/credentials`);
|
||||
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||
this.creds = { stack, address: d.address || '', items: d.items || [],
|
||||
self_signup: !!d.self_signup, reveal: {} };
|
||||
this.modal = 'credentials';
|
||||
},
|
||||
async kopieer(tekst) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tekst);
|
||||
this.toast(this.t('copied'), 'ok');
|
||||
} catch (e) {
|
||||
// Zonder https staat de clipboard-API niet toe; dan maar selecteren.
|
||||
this.toast(this.t('copy_failed'), 'er');
|
||||
}
|
||||
},
|
||||
async openEditor(name, type) {
|
||||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${type}`);
|
||||
this.editor = { stack: name, type, title: name+' · '+type, content: d.content || '' };
|
||||
this.editor = { stack: name, type, title: name+' · '+type,
|
||||
content: d.content || '', restart: false };
|
||||
this.modal = 'editor';
|
||||
},
|
||||
// Wisselen van tabblad: eerst opslaan wat je hebt getypt zou verrassend
|
||||
// zijn, dus we waarschuwen alleen als er iets gewijzigd is.
|
||||
async switchEditor(type) {
|
||||
if (type === this.editor.type) return;
|
||||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${type}`);
|
||||
this.editor.type = type;
|
||||
this.editor.title = this.editor.stack + ' · ' + type;
|
||||
this.editor.content = d.content || '';
|
||||
},
|
||||
async saveEditor() {
|
||||
const d = await this.req(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${this.editor.type}`,
|
||||
{ method:'PUT', json: { content: this.editor.content } });
|
||||
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
|
||||
else this.toast(d.msg || 'Fout bij opslaan', 'er');
|
||||
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||
this.toast(this.t('saved'), 'ok');
|
||||
if (this.editor.restart) {
|
||||
// Een gewijzigde compose of .env doet pas iets na een herstart.
|
||||
await this.stackAction(this.editor.stack, 'restart');
|
||||
}
|
||||
this.modal = null;
|
||||
},
|
||||
// ── Backups ──────────────────────────────────────────────────────────────
|
||||
async openBackups(stack) {
|
||||
|
|
@ -2515,6 +2674,7 @@ function app() {
|
|||
loading: true,
|
||||
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
||||
fields: [], values: {}, preview_files: {},
|
||||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
||||
network: '', ip: ''
|
||||
};
|
||||
this.modal = 'install';
|
||||
|
|
@ -2551,6 +2711,8 @@ function app() {
|
|||
}
|
||||
this.install.values = init;
|
||||
this.install.preview_files = d.preview_files || {};
|
||||
this.install.step = 0;
|
||||
this.revealed = {};
|
||||
} else {
|
||||
this.install.envVars = d.env_vars || [];
|
||||
this.install.images = d.images || [];
|
||||
|
|
@ -2565,6 +2727,104 @@ function app() {
|
|||
}
|
||||
return Array.from(map.values());
|
||||
},
|
||||
|
||||
// ── Invulmenu ────────────────────────────────────────────────────────────
|
||||
// Vijf vaste stappen voor elke app. Eén stap per groep werkt niet: de
|
||||
// mediane app heeft één groep, arr-stack heeft er eenentwintig.
|
||||
STAP_INFO: {
|
||||
basis: { icon: 'mdi-tune-variant', key: 'step_basics' },
|
||||
verbinden: { icon: 'mdi-lan-connect', key: 'step_network' },
|
||||
instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' },
|
||||
geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' },
|
||||
controleren: { icon: 'mdi-file-check-outline', key: 'step_review' },
|
||||
},
|
||||
wizardActief() {
|
||||
return this.install.format === 'boilerplate' && !this.install.loading && !this.onePage;
|
||||
},
|
||||
// Velden van één stap, met de geavanceerde erbij of niet.
|
||||
stepFields(stap) {
|
||||
return (this.install.fields || []).filter(f =>
|
||||
(f.step || 'instellingen') === stap && (this.showAdvanced || !f.advanced));
|
||||
},
|
||||
advancedCount() {
|
||||
return (this.install.fields || []).filter(f => f.advanced).length;
|
||||
},
|
||||
// Een lege stap slaan we over; anders klik je door schermen zonder inhoud.
|
||||
wizardSteps() {
|
||||
const uit = [];
|
||||
for (const id of ['basis', 'verbinden', 'instellingen', 'geheimen']) {
|
||||
if (id === 'verbinden') { uit.push(this._stap(id)); continue; }
|
||||
// 'basis' toont ook de instantienaam, dus die stap is nooit leeg.
|
||||
if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id));
|
||||
}
|
||||
uit.push(this._stap('controleren'));
|
||||
return uit;
|
||||
},
|
||||
_stap(id) {
|
||||
const info = this.STAP_INFO[id];
|
||||
return { id, icon: info.icon, title: this.t(info.key) };
|
||||
},
|
||||
stepGroups(stap) {
|
||||
const map = new Map();
|
||||
for (const f of this.stepFields(stap)) {
|
||||
const g = f.group || 'Algemeen';
|
||||
if (!map.has(g)) map.set(g, { name: g, toggle: f.group_toggle || null, items: [] });
|
||||
map.get(g).items.push(f);
|
||||
}
|
||||
const groepen = Array.from(map.values());
|
||||
// Eén naamloze groep hoeft geen kop; dat is bij de meeste apps het geval.
|
||||
for (const g of groepen) g.showHeader = groepen.length > 1 || !!g.toggle;
|
||||
return groepen;
|
||||
},
|
||||
// Verplichte velden van de huidige stap die nog leeg zijn.
|
||||
stepMissing() {
|
||||
if (!this.wizardActief()) return [];
|
||||
const s = this.wizardSteps()[this.install.step];
|
||||
if (!s || s.id === 'controleren' || s.id === 'verbinden') return [];
|
||||
return this.stepFields(s.id).filter(f => {
|
||||
if (!f.required || f.is_group_toggle) return false;
|
||||
if (f.group_toggle && !this.install.values[f.group_toggle]) return false;
|
||||
if (!this.bpFieldVisible(f)) return false;
|
||||
const v = this.install.values[f.name];
|
||||
return v === undefined || v === null || String(v).trim() === '';
|
||||
}).map(f => f.title);
|
||||
},
|
||||
opLaatsteStap() {
|
||||
return this.install.step >= this.wizardSteps().length - 1;
|
||||
},
|
||||
async gotoStep(i) {
|
||||
const stappen = this.wizardSteps();
|
||||
this.install.step = Math.max(0, Math.min(i, stappen.length - 1));
|
||||
this.fieldInfo = null;
|
||||
if (stappen[this.install.step].id === 'controleren') await this.refreshPreview();
|
||||
},
|
||||
async nextStep() {
|
||||
if (this.stepMissing().length) return;
|
||||
await this.gotoStep(this.install.step + 1);
|
||||
},
|
||||
prevStep() { this.gotoStep(this.install.step - 1); },
|
||||
// Het gerenderde resultaat ophalen met de waarden die nu ingevuld staan.
|
||||
async refreshPreview() {
|
||||
if (this.install.editing) return; // niet overschrijven wat je zelf typte
|
||||
const d = await this.rpc('/api/store/preview', {
|
||||
stack: this.install.stack, repo_id: this.install.repo_id,
|
||||
values: this.install.values,
|
||||
});
|
||||
if (!d || !d.ok) return;
|
||||
this.install.preview_files = d.preview_files || {};
|
||||
this.install.composeText = this.bpPreview();
|
||||
this.install.envText = d.env_preview || '';
|
||||
},
|
||||
toggleEdit() {
|
||||
this.install.editing = !this.install.editing;
|
||||
if (this.install.editing) this.toast(this.t('manual_edit_warning'), 'in');
|
||||
},
|
||||
saveUiKeuze() {
|
||||
try {
|
||||
localStorage.setItem('su_advanced', this.showAdvanced ? '1' : '0');
|
||||
localStorage.setItem('su_onepage', this.onePage ? '1' : '0');
|
||||
} catch (e) { /* privémodus: dan onthouden we het gewoon niet */ }
|
||||
},
|
||||
bpFieldVisible(f) {
|
||||
for (const cond of (f.needs || [])) {
|
||||
const m = cond.match(/^(\w+)\s*(==|!=|=)\s*(.+)$/);
|
||||
|
|
@ -2596,8 +2856,12 @@ function app() {
|
|||
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
||||
fields: d.fields || [], values: { ...(d.values || {}) }, preview_files: {},
|
||||
network: d.network || '', ip: d.ip || '', connect_apps: !!d.connected,
|
||||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
||||
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
|
||||
repo_id: d.repo_id || '',
|
||||
};
|
||||
this.fieldInfo = null;
|
||||
this.revealed = {};
|
||||
this.modal = 'install';
|
||||
},
|
||||
async doReconfigure() {
|
||||
|
|
@ -2633,6 +2897,12 @@ function app() {
|
|||
body.connect_apps = this.install.connect_apps !== false;
|
||||
if (this.install.format === 'boilerplate') body.values = this.install.values;
|
||||
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
|
||||
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
|
||||
// versie in plaats van wat er uit het sjabloon zou rollen.
|
||||
if (this.install.editing) {
|
||||
body.compose_override = this.install.composeText;
|
||||
body.env_override = this.install.envText;
|
||||
}
|
||||
const d = await this.rpc('/api/store/install', body);
|
||||
if (d.job_id) {
|
||||
this.modal = null;
|
||||
|
|
@ -2677,6 +2947,13 @@ function app() {
|
|||
this.loadAudit();
|
||||
},
|
||||
|
||||
// Voorkeuren die over installaties heen gelden.
|
||||
laadUiKeuzes() {
|
||||
try {
|
||||
this.showAdvanced = localStorage.getItem('su_advanced') === '1';
|
||||
this.onePage = localStorage.getItem('su_onepage') === '1';
|
||||
} catch (e) { /* privémodus */ }
|
||||
},
|
||||
async loadSettings() {
|
||||
const d = await this.rpc('/api/settings'); this.settings = d || {};
|
||||
await this.loadAppStore();
|
||||
|
|
|
|||
|
|
@ -297,5 +297,25 @@
|
|||
"notify_smtp_user": "Username",
|
||||
"notify_smtp_password": "Password",
|
||||
"notify_email_from": "From",
|
||||
"notify_email_to": "To"
|
||||
"notify_email_to": "To",
|
||||
"copied": "Copied",
|
||||
"copy": "Copy",
|
||||
"copy_failed": "Could not copy — select the text yourself",
|
||||
"credentials": "Credentials",
|
||||
"env_holds_secrets": "This holds the passwords and keys for this app. The file is readable by its owner only.",
|
||||
"manual_edit_warning": "You are editing the result yourself. If you change a field after this, your edit will be overwritten.",
|
||||
"no_credentials_hint": "This app has no preset credentials: you create an account yourself on first visit.",
|
||||
"nothing_here": "(empty)",
|
||||
"nothing_to_choose": "Nothing to configure here.",
|
||||
"one_page": "All on one page",
|
||||
"preview": "View",
|
||||
"previous": "Previous",
|
||||
"restart_after_save": "Restart after saving",
|
||||
"show_value": "Show value",
|
||||
"still_empty": "Not filled in yet: {fields}",
|
||||
"step_basics": "Basics",
|
||||
"step_network": "Connect",
|
||||
"step_settings": "Settings",
|
||||
"step_secrets": "Access",
|
||||
"step_review": "Review"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,25 @@
|
|||
"notify_smtp_user": "Gebruikersnaam",
|
||||
"notify_smtp_password": "Wachtwoord",
|
||||
"notify_email_from": "Afzender",
|
||||
"notify_email_to": "Ontvanger"
|
||||
"notify_email_to": "Ontvanger",
|
||||
"copied": "Gekopieerd",
|
||||
"copy": "Kopiëren",
|
||||
"copy_failed": "Kopiëren lukte niet — selecteer de tekst zelf",
|
||||
"credentials": "Inloggegevens",
|
||||
"env_holds_secrets": "Hier staan de wachtwoorden en sleutels van deze app. Het bestand is alleen leesbaar voor de eigenaar.",
|
||||
"manual_edit_warning": "Je bewerkt het resultaat zelf. Wijzig je hierna nog een veld, dan wordt je aanpassing overschreven.",
|
||||
"no_credentials_hint": "Deze app heeft geen vooraf ingestelde inloggegevens: je maakt bij het eerste bezoek zelf een account aan.",
|
||||
"nothing_here": "(leeg)",
|
||||
"nothing_to_choose": "Hier valt niets in te stellen.",
|
||||
"one_page": "Alles op één pagina",
|
||||
"preview": "Bekijken",
|
||||
"previous": "Vorige",
|
||||
"restart_after_save": "Herstarten na opslaan",
|
||||
"show_value": "Waarde tonen",
|
||||
"still_empty": "Nog niet ingevuld: {fields}",
|
||||
"step_basics": "Basis",
|
||||
"step_network": "Verbinden",
|
||||
"step_settings": "Instellingen",
|
||||
"step_secrets": "Toegang",
|
||||
"step_review": "Controleren"
|
||||
}
|
||||
|
|
|
|||
96
tests/js/menu.test.js
Normal file
96
tests/js/menu.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Draait de echte Alpine-component uit index.html en toetst de logica van
|
||||
// het invulmenu. Wordt aangeroepen door tests/test_ui_logica.py; los draaien
|
||||
// kan met: node tests/js/menu.test.js
|
||||
const fs = require('fs');
|
||||
const html = fs.readFileSync(__dirname + '/../../server-up/templates/index.html', 'utf8');
|
||||
// Alleen de component-functie uitsnijden en uitvoeren.
|
||||
const start = html.indexOf('function app() {');
|
||||
const eind = html.indexOf('</script>', start);
|
||||
const bron = html.slice(start, eind);
|
||||
global.localStorage = { getItem: () => null, setItem: () => {} };
|
||||
global.location = { hash: '', search: '' };
|
||||
global.window = { matchMedia: () => ({matches:false, addEventListener(){}}),
|
||||
addEventListener(){}, innerWidth: 1200 };
|
||||
global.document = { documentElement: {classList:{add(){},remove(){}}},
|
||||
addEventListener(){}, querySelector: () => null };
|
||||
global.navigator = { clipboard: { writeText: async () => {} }, language: 'nl' };
|
||||
global.crypto = require('crypto').webcrypto;
|
||||
eval(bron);
|
||||
const c = app();
|
||||
c.t = (k, v) => k; // vertaling niet nodig voor de logica
|
||||
c.bpFieldVisible = () => true;
|
||||
c.rpc = async () => ({ok: true, preview_files: {}, env_preview: ''});
|
||||
c.toast = () => {};
|
||||
|
||||
function veld(naam, stap, extra = {}) {
|
||||
return Object.assign({name: naam, title: naam, step: stap, type: 'str',
|
||||
group: 'Algemeen', required: false, advanced: false}, extra);
|
||||
}
|
||||
|
||||
let fouten = 0;
|
||||
const check = (naam, waar) => { if (!waar) { fouten++; console.log(' ✖ ' + naam); }
|
||||
else console.log(' ✓ ' + naam); };
|
||||
|
||||
// ── Eenvoudige app: alleen basisvelden ──────────────────────────────────────
|
||||
c.install = {format:'boilerplate', loading:false, fields:[
|
||||
veld('service_name','basis'), veld('port','basis'), veld('data_dir','basis'),
|
||||
], values:{}, step:0};
|
||||
c.onePage = false; c.showAdvanced = false;
|
||||
let s = c.wizardSteps().map(x => x.id);
|
||||
check('eenvoudige app: basis, verbinden, controleren',
|
||||
JSON.stringify(s) === '["basis","verbinden","controleren"]');
|
||||
|
||||
// ── App met geheimen ────────────────────────────────────────────────────────
|
||||
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
|
||||
s = c.wizardSteps().map(x => x.id);
|
||||
check('geheimenstap verschijnt', s.includes('geheimen'));
|
||||
|
||||
// ── Lege stap wordt overgeslagen ────────────────────────────────────────────
|
||||
check('geen lege instellingenstap', !s.includes('instellingen'));
|
||||
|
||||
// ── Geavanceerde velden ─────────────────────────────────────────────────────
|
||||
c.install.fields.push(veld('retention','instellingen',{advanced:true}));
|
||||
check('geavanceerd veld verborgen', c.stepFields('instellingen').length === 0);
|
||||
check('stap zonder zichtbare velden verdwijnt',
|
||||
!c.wizardSteps().map(x=>x.id).includes('instellingen'));
|
||||
c.showAdvanced = true;
|
||||
check('geavanceerd veld zichtbaar na aanzetten', c.stepFields('instellingen').length === 1);
|
||||
check('stap komt terug', c.wizardSteps().map(x=>x.id).includes('instellingen'));
|
||||
check('teller klopt', c.advancedCount() === 1);
|
||||
|
||||
// ── Verplicht veld blokkeert Volgende ───────────────────────────────────────
|
||||
c.install.step = c.wizardSteps().findIndex(x => x.id === 'geheimen');
|
||||
check('leeg verplicht veld wordt gemeld', c.stepMissing().length === 1);
|
||||
c.install.values.db_password = 'ietsGeheims';
|
||||
check('ingevuld: niets meer te melden', c.stepMissing().length === 0);
|
||||
|
||||
// ── Groepsschakelaar uit: veld telt niet mee ────────────────────────────────
|
||||
c.install.fields.push(veld('opt_pass','geheimen',
|
||||
{required:true, secret:true, group:'Extra', group_toggle:'gebruik_extra'}));
|
||||
c.install.values.gebruik_extra = false;
|
||||
check('uitgeschakelde groep blokkeert niet', c.stepMissing().length === 0);
|
||||
c.install.values.gebruik_extra = true;
|
||||
check('ingeschakelde groep blokkeert wel', c.stepMissing().length === 1);
|
||||
|
||||
// ── Navigatie ───────────────────────────────────────────────────────────────
|
||||
c.install.values.opt_pass = 'x';
|
||||
c.install.step = 0;
|
||||
check('niet op de laatste stap', !c.opLaatsteStap());
|
||||
c.install.step = c.wizardSteps().length - 1;
|
||||
check('wel op de laatste stap', c.opLaatsteStap());
|
||||
check('laatste stap is controleren',
|
||||
c.wizardSteps()[c.install.step].id === 'controleren');
|
||||
|
||||
// ── Alles op één pagina ─────────────────────────────────────────────────────
|
||||
c.onePage = true;
|
||||
check('wizard uit bij één pagina', !c.wizardActief());
|
||||
|
||||
// ── Groepskoppen ────────────────────────────────────────────────────────────
|
||||
c.onePage = false;
|
||||
c.install.fields = [veld('a','basis'), veld('b','basis')];
|
||||
check('één groep: geen kop', c.stepGroups('basis')[0].showHeader === false);
|
||||
c.install.fields.push(veld('c','basis',{group:'Tweede'}));
|
||||
check('twee groepen: wel koppen', c.stepGroups('basis').every(g => g.showHeader));
|
||||
|
||||
console.log(fouten === 0 ? '\nAlle menucontroles geslaagd' : `\n${fouten} FOUTEN`);
|
||||
process.exit(fouten ? 1 : 0);
|
||||
239
tests/test_geheimen_env.py
Normal file
239
tests/test_geheimen_env.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""Geheimen horen in `.env`, niet in het compose-bestand.
|
||||
|
||||
Compose vult `${NAAM}` in bij het inlezen, dus functioneel verandert er niets.
|
||||
Wat je wint: het compose-bestand is te delen en te committen, en alle geheimen
|
||||
staan op één plek die met 0600 weggeschreven kan worden — tot v0.7.60 stonden
|
||||
ze zowel in `docker-compose.yml` als wereldleesbaar in `.serverup.json`.
|
||||
"""
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||
|
||||
from conftest import login
|
||||
|
||||
VELDEN = [{"name": "secret_key", "secret": True},
|
||||
{"name": "db_password", "secret": True},
|
||||
{"name": "timezone", "secret": False},
|
||||
{"name": "port", "secret": False}]
|
||||
WAARDEN = {"secret_key": "Xq2mB7vK9pL4nR8t", "db_password": "aB3dE6gH9jK2mN5p",
|
||||
"timezone": "Europe/Amsterdam", "port": 8000}
|
||||
|
||||
COMPOSE = """services:
|
||||
app:
|
||||
image: voorbeeld:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- APP_SECRET_KEY=Xq2mB7vK9pL4nR8t
|
||||
- APP_DBPASS=aB3dE6gH9jK2mN5p
|
||||
- TZ=Europe/Amsterdam
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_PASSWORD: aB3dE6gH9jK2mN5p
|
||||
worker:
|
||||
image: worker:latest
|
||||
environment:
|
||||
- DATABASE_URL=postgres://app:aB3dE6gH9jK2mN5p@db:5432/app
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ct(env):
|
||||
from core import compose_transform
|
||||
return compose_transform
|
||||
|
||||
|
||||
# ── De transformatie ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_geheimen_verdwijnen_uit_compose(ct):
|
||||
nieuw, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
||||
assert "Xq2mB7vK9pL4nR8t" not in nieuw
|
||||
assert "aB3dE6gH9jK2mN5p" not in nieuw
|
||||
assert set(env_regels.values()) == {"Xq2mB7vK9pL4nR8t", "aB3dE6gH9jK2mN5p"}
|
||||
|
||||
|
||||
def test_niet_geheime_waarden_blijven_leesbaar(ct):
|
||||
"""Poorten, paden en tijdzone horen zichtbaar te blijven; anders moet je
|
||||
twee bestanden naast elkaar lezen om te snappen wat er draait."""
|
||||
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
||||
assert "Europe/Amsterdam" in nieuw
|
||||
assert "8000" in nieuw
|
||||
|
||||
|
||||
def test_sleutelnaam_komt_uit_de_omgevingsvariabele(ct):
|
||||
"""`.env` moet leesbaar zijn: APP_SECRET_KEY zegt meer dan SECRET_KEY_1."""
|
||||
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
||||
assert "APP_SECRET_KEY" in env_regels
|
||||
|
||||
|
||||
def test_dezelfde_waarde_krijgt_een_sleutel(ct):
|
||||
"""Het databasewachtwoord staat op drie plekken; dat wordt één regel."""
|
||||
_, env_regels, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
||||
assert list(env_regels.values()).count("aB3dE6gH9jK2mN5p") == 1
|
||||
|
||||
|
||||
def test_geheim_midden_in_een_url(ct):
|
||||
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
||||
doc = yaml.safe_load(nieuw)
|
||||
url = doc["services"]["worker"]["environment"][0]
|
||||
assert url.startswith("DATABASE_URL=postgres://app:${")
|
||||
assert url.endswith("@db:5432/app")
|
||||
|
||||
|
||||
def test_koppeling_wijst_veld_naar_sleutel(ct):
|
||||
"""Zonder die koppeling kunnen we de waarde later niet exact terugvinden."""
|
||||
_, env_regels, koppeling = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
||||
assert set(koppeling) == {"secret_key", "db_password"}
|
||||
for veld, sleutel in koppeling.items():
|
||||
assert env_regels[sleutel] == WAARDEN[veld]
|
||||
|
||||
|
||||
def test_zonder_geheimen_verandert_er_niets(ct):
|
||||
nieuw, env_regels, koppeling = ct.geheimen_naar_env(
|
||||
COMPOSE, [{"name": "port", "secret": False}], {"port": 8000})
|
||||
assert nieuw == COMPOSE
|
||||
assert env_regels == {} and koppeling == {}
|
||||
|
||||
|
||||
def test_leeg_geheim_wordt_overgeslagen(ct):
|
||||
"""Anders zou een lege waarde overal in het bestand 'gevonden' worden."""
|
||||
nieuw, env_regels, _ = ct.geheimen_naar_env(
|
||||
COMPOSE, [{"name": "leeg", "secret": True}], {"leeg": ""})
|
||||
assert nieuw == COMPOSE and env_regels == {}
|
||||
|
||||
|
||||
def test_ongeldige_yaml_geeft_een_nette_fout(ct):
|
||||
with pytest.raises(ct.TransformError):
|
||||
ct.geheimen_naar_env("dit: [is: geen: yaml", VELDEN, WAARDEN)
|
||||
|
||||
|
||||
def test_compose_blijft_geldig(ct):
|
||||
nieuw, _, _ = ct.geheimen_naar_env(COMPOSE, VELDEN, WAARDEN)
|
||||
doc = yaml.safe_load(nieuw)
|
||||
assert set(doc["services"]) == {"app", "db", "worker"}
|
||||
|
||||
|
||||
# ── env samenvoegen ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_env_samenvoegen_behoudt_bestaande_regels(ct):
|
||||
uit = ct.env_samenvoegen("BIND=0.0.0.0\nPORT=5000\n", {"DB_PASS": "x"})
|
||||
assert "BIND=0.0.0.0" in uit and "PORT=5000" in uit and "DB_PASS=x" in uit
|
||||
|
||||
|
||||
def test_env_samenvoegen_vervangt_zonder_te_verdubbelen(ct):
|
||||
uit = ct.env_samenvoegen("DB_PASS=oud\nPORT=5000\n", {"DB_PASS": "nieuw"})
|
||||
assert uit.count("DB_PASS=") == 1
|
||||
assert "DB_PASS=nieuw" in uit and "oud" not in uit
|
||||
|
||||
|
||||
def test_env_samenvoegen_houdt_commentaar(ct):
|
||||
uit = ct.env_samenvoegen("# van de installatie\nPORT=5000\n", {"X": "1"})
|
||||
assert "# van de installatie" in uit
|
||||
|
||||
|
||||
# ── Rechten ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_env_wordt_met_beperkte_rechten_geschreven(env, tmp_path):
|
||||
"""Hier staan de wachtwoorden in; standaard maakt Python het bestand
|
||||
wereldleesbaar."""
|
||||
from core import docker
|
||||
docker.write_env(tmp_path, "DB_PASS=geheim\n")
|
||||
modus = stat.S_IMODE((tmp_path / ".env").stat().st_mode)
|
||||
assert modus == 0o600, oct(modus)
|
||||
|
||||
|
||||
# ── Van begin tot eind ───────────────────────────────────────────────────────
|
||||
|
||||
def _bron(env, naam="geheimapp"):
|
||||
from core import git
|
||||
wortel = git.cache_dir("server-up") / "apps" / naam
|
||||
(wortel / "files").mkdir(parents=True, exist_ok=True)
|
||||
(wortel / "template.json").write_text(json.dumps({
|
||||
"kind": "compose", "metadata": {"name": naam},
|
||||
"variables": [{"title": "Algemeen", "items": [
|
||||
{"name": "service_name", "type": "str", "title": "Naam",
|
||||
"default": naam, "required": True},
|
||||
{"name": "timezone", "type": "str", "title": "Tijdzone",
|
||||
"default": "Europe/Amsterdam"},
|
||||
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
|
||||
"default": "", "required": True, "generate": "auto"},
|
||||
]}]}), encoding="utf-8")
|
||||
(wortel / "files" / "compose.yaml").write_text(
|
||||
"services:\n"
|
||||
" << service_name >>:\n"
|
||||
" image: nginx\n"
|
||||
" environment:\n"
|
||||
" - TZ=<< timezone >>\n"
|
||||
" - DB_PASSWORD=<< db_password >>\n", encoding="utf-8")
|
||||
return wortel
|
||||
|
||||
|
||||
def _wacht(client, jid, tijdslimiet=10):
|
||||
einde = time.time() + tijdslimiet
|
||||
while time.time() < einde:
|
||||
d = client.get(f"/api/jobs/{jid}").get_json() or {}
|
||||
if d.get("status") in ("done", "error"):
|
||||
return d
|
||||
time.sleep(0.05)
|
||||
return {}
|
||||
|
||||
|
||||
def test_installatie_zet_het_geheim_in_env(client, env):
|
||||
csrf = login(client)
|
||||
_bron(env)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "geheimapp", "repo_id": "server-up",
|
||||
"instance": "ga", "values": {"db_password": "zeergeheim123"}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
|
||||
d = env["lib"] / "ga"
|
||||
compose = (d / "docker-compose.yml").read_text()
|
||||
assert "zeergeheim123" not in compose, compose
|
||||
assert "${" in compose
|
||||
assert "zeergeheim123" in (d / ".env").read_text()
|
||||
# De tijdzone hoort gewoon leesbaar te blijven.
|
||||
assert "Europe/Amsterdam" in compose
|
||||
|
||||
|
||||
def test_installatie_bewaart_geen_geheim_in_de_metadata(client, env):
|
||||
"""`.serverup.json` stond wereldleesbaar vol wachtwoorden."""
|
||||
csrf = login(client)
|
||||
_bron(env)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "geheimapp", "repo_id": "server-up",
|
||||
"instance": "gb", "values": {"db_password": "zeergeheim123"}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
|
||||
mf = env["lib"] / "gb" / ".serverup.json"
|
||||
ruw = mf.read_text()
|
||||
assert "zeergeheim123" not in ruw, ruw
|
||||
meta = json.loads(ruw)
|
||||
assert meta["secret_fields"] == ["db_password"]
|
||||
assert meta["secret_keys"]["db_password"] == "DB_PASSWORD"
|
||||
assert stat.S_IMODE(mf.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_formulier_toont_het_geheim_weer_bij_wijzigen(client, env):
|
||||
"""Anders staat het veld leeg en wist opslaan het wachtwoord."""
|
||||
csrf = login(client)
|
||||
_bron(env)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "geheimapp", "repo_id": "server-up",
|
||||
"instance": "gc", "values": {"db_password": "zeergeheim123"}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
|
||||
d = client.get("/api/stacks/gc/config").get_json()
|
||||
assert d["ok"] is True
|
||||
assert d["values"]["db_password"] == "zeergeheim123"
|
||||
340
tests/test_invulmenu.py
Normal file
340
tests/test_invulmenu.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"""Het invulmenu: stapindeling, inloggegevens en zelf bewerken.
|
||||
|
||||
Het formulier was één lange lijst. Dat werkt slecht aan beide uiteinden: de
|
||||
mediane app heeft vijf velden in één groep, arr-stack heeft er tweeënvijftig in
|
||||
eenentwintig groepen. Vandaar vaste stappen in plaats van één stap per groep.
|
||||
"""
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||
|
||||
from conftest import login
|
||||
|
||||
APPS = Path(__file__).resolve().parent.parent / "apps"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bp(env):
|
||||
from core import boilerplates
|
||||
return boilerplates
|
||||
|
||||
|
||||
def _veld(bp, **kw):
|
||||
"""Eén los veld door fields() halen, om de indeling te toetsen."""
|
||||
from core import git
|
||||
d = git.cache_dir("server-up") / "apps" / "los"
|
||||
(d / "files").mkdir(parents=True, exist_ok=True)
|
||||
item = {"name": "x", "type": "str", "title": "X"}
|
||||
item.update(kw)
|
||||
(d / "template.json").write_text(json.dumps({
|
||||
"kind": "compose", "metadata": {"name": "los"},
|
||||
"variables": [{"title": "Algemeen", "items": [item]}]}), encoding="utf-8")
|
||||
(d / "files" / "compose.yaml").write_text("services:\n a:\n image: nginx\n",
|
||||
encoding="utf-8")
|
||||
return bp.fields(d)[0]
|
||||
|
||||
|
||||
# ── Indeling in stappen ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("kw,stap", [
|
||||
({"name": "service_name"}, "basis"),
|
||||
({"name": "port", "type": "int"}, "basis"),
|
||||
({"name": "web_port", "type": "int"}, "basis"),
|
||||
({"name": "data_dir"}, "basis"),
|
||||
({"name": "timezone"}, "basis"),
|
||||
({"name": "db_password"}, "geheimen"),
|
||||
({"name": "jwt_secret"}, "geheimen"),
|
||||
({"name": "admin_user"}, "geheimen"), # hoort bij het wachtwoord
|
||||
({"name": "admin_email"}, "geheimen"),
|
||||
({"name": "retention"}, "instellingen"),
|
||||
({"name": "allow_signup", "type": "bool"}, "instellingen"),
|
||||
({"name": "broker", "connect": {"app": "mosquitto"}}, "verbinden"),
|
||||
])
|
||||
def test_velden_komen_in_de_juiste_stap(bp, kw, stap):
|
||||
assert _veld(bp, **kw)["step"] == stap
|
||||
|
||||
|
||||
def test_sjabloon_mag_de_stap_overrulen(bp):
|
||||
"""Een app-maker moet een veld ergens anders kunnen neerzetten."""
|
||||
assert _veld(bp, name="retention", step="basis")["step"] == "basis"
|
||||
|
||||
|
||||
def test_onbekende_stap_valt_terug(bp):
|
||||
assert _veld(bp, name="retention", step="verzonnen")["step"] == "instellingen"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("app,verwacht", [
|
||||
("grafana", {"basis", "geheimen"}),
|
||||
("vaultwarden", {"basis", "instellingen", "geheimen"}),
|
||||
])
|
||||
def test_echte_apps_krijgen_zinnige_stappen(bp, app, verwacht):
|
||||
stappen = {f["step"] for f in bp.fields(APPS / app)}
|
||||
assert stappen == verwacht
|
||||
|
||||
|
||||
def test_de_vangnetstap_slokt_niet_alles_op(bp):
|
||||
"""Een app waarvan alle velden in 'instellingen' belanden, betekent dat de
|
||||
indeling niets heeft herkend. Alles in 'basis' is juist prima: de meeste
|
||||
apps vragen alleen om een naam, een poort en een map."""
|
||||
mislukt = []
|
||||
for d in sorted(APPS.iterdir()):
|
||||
if not (d / "template.json").is_file():
|
||||
continue
|
||||
velden = bp.fields(d)
|
||||
if len(velden) > 3 and {f["step"] for f in velden} == {"instellingen"}:
|
||||
mislukt.append(d.name)
|
||||
assert not mislukt, mislukt
|
||||
|
||||
|
||||
def test_eenvoudige_apps_krijgen_maar_een_paar_stappen(bp):
|
||||
"""Voor de mediane app hoort het invulmenu kort te zijn."""
|
||||
velden = bp.fields(APPS / "freshrss")
|
||||
assert {f["step"] for f in velden} == {"basis"}
|
||||
|
||||
|
||||
# ── Inloggegevens ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("naam,soort", [
|
||||
("admin_password", "password"),
|
||||
("web_password", "password"),
|
||||
("admin_user", "username"),
|
||||
("admin_email", "username"),
|
||||
("superuser_name", "username"),
|
||||
("jwt_secret", ""), # een sleutel is geen inloggegeven
|
||||
("api_key", ""),
|
||||
("port", ""),
|
||||
])
|
||||
def test_inloggegevens_worden_herkend(bp, naam, soort):
|
||||
assert _veld(bp, name=naam)["credential"] == soort
|
||||
|
||||
|
||||
def test_sjabloon_mag_credential_overrulen(bp):
|
||||
assert _veld(bp, name="iets", credential="username")["credential"] == "username"
|
||||
|
||||
|
||||
def _bron(env, naam="inlogapp"):
|
||||
from core import git
|
||||
d = git.cache_dir("server-up") / "apps" / naam
|
||||
(d / "files").mkdir(parents=True, exist_ok=True)
|
||||
(d / "template.json").write_text(json.dumps({
|
||||
"kind": "compose", "metadata": {"name": naam},
|
||||
"variables": [{"title": "Algemeen", "items": [
|
||||
{"name": "service_name", "type": "str", "title": "Naam",
|
||||
"default": naam, "required": True},
|
||||
{"name": "port", "type": "int", "title": "Poort", "default": 8123},
|
||||
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
|
||||
"default": "admin"},
|
||||
{"name": "admin_password", "type": "str", "title": "Wachtwoord",
|
||||
"default": "", "required": True},
|
||||
]}]}), encoding="utf-8")
|
||||
(d / "files" / "compose.yaml").write_text(
|
||||
"services:\n << service_name >>:\n image: nginx\n"
|
||||
" ports:\n - \"<< port >>:80\"\n"
|
||||
" environment:\n"
|
||||
" - ADMIN_USER=<< admin_user >>\n"
|
||||
" - ADMIN_PASSWORD=<< admin_password >>\n", encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _wacht(client, jid, tijdslimiet=10):
|
||||
einde = time.time() + tijdslimiet
|
||||
while time.time() < einde:
|
||||
d = client.get(f"/api/jobs/{jid}").get_json() or {}
|
||||
if d.get("status") in ("done", "error"):
|
||||
return d
|
||||
time.sleep(0.05)
|
||||
return {}
|
||||
|
||||
|
||||
def _installeer(client, env, csrf, inst="ia", **waarden):
|
||||
_bron(env)
|
||||
v = {"service_name": inst, "admin_user": "ramon",
|
||||
"admin_password": "MijnWachtwoord1"}
|
||||
v.update(waarden)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "inlogapp", "repo_id": "server-up",
|
||||
"instance": inst, "values": v},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
return r
|
||||
|
||||
|
||||
def test_inloggegevens_tonen_naam_en_wachtwoord(client, env):
|
||||
csrf = login(client)
|
||||
_installeer(client, env, csrf)
|
||||
d = client.get("/api/stacks/ia/credentials").get_json()
|
||||
assert d["ok"] is True
|
||||
soorten = {i["kind"]: i["value"] for i in d["items"]}
|
||||
assert soorten["username"] == "ramon"
|
||||
assert soorten["password"] == "MijnWachtwoord1"
|
||||
assert d["self_signup"] is False
|
||||
|
||||
|
||||
def test_wachtwoord_is_als_geheim_gemarkeerd(client, env):
|
||||
"""De interface verbergt het achter een oog-knop."""
|
||||
csrf = login(client)
|
||||
_installeer(client, env, csrf, inst="ib")
|
||||
d = client.get("/api/stacks/ib/credentials").get_json()
|
||||
wachtwoord = next(i for i in d["items"] if i["kind"] == "password")
|
||||
naam = next(i for i in d["items"] if i["kind"] == "username")
|
||||
assert wachtwoord["secret"] is True
|
||||
assert naam["secret"] is False
|
||||
|
||||
|
||||
def test_app_zonder_inlogvelden_zegt_dat(client, env):
|
||||
"""Vaultwarden en Immich laten je bij het eerste bezoek zelf een account
|
||||
aanmaken; dan valt hier niets te tonen."""
|
||||
from core import git
|
||||
csrf = login(client)
|
||||
d = git.cache_dir("server-up") / "apps" / "geeninlog"
|
||||
(d / "files").mkdir(parents=True, exist_ok=True)
|
||||
(d / "template.json").write_text(json.dumps({
|
||||
"kind": "compose", "metadata": {"name": "geeninlog"},
|
||||
"variables": [{"title": "Algemeen", "items": [
|
||||
{"name": "service_name", "type": "str", "title": "Naam",
|
||||
"default": "geeninlog", "required": True}]}]}), encoding="utf-8")
|
||||
(d / "files" / "compose.yaml").write_text(
|
||||
"services:\n << service_name >>:\n image: nginx\n", encoding="utf-8")
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "geeninlog", "repo_id": "server-up",
|
||||
"instance": "gi", "values": {}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
|
||||
res = client.get("/api/stacks/gi/credentials").get_json()
|
||||
assert res["items"] == []
|
||||
assert res["self_signup"] is True
|
||||
|
||||
|
||||
def test_inloggegevens_alleen_voor_beheerders(client, env):
|
||||
"""Ze staan gelijk aan toegang tot de app zelf."""
|
||||
csrf = login(client)
|
||||
_installeer(client, env, csrf, inst="ic")
|
||||
client.post("/api/auth/users",
|
||||
json={"username": "op", "password": "eenlangwachtwoord",
|
||||
"role": "operator"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
client.post("/api/auth/login",
|
||||
json={"username": "op", "password": "eenlangwachtwoord"})
|
||||
r = client.get("/api/stacks/ic/credentials")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_tonen_komt_in_het_auditlog(client, env):
|
||||
csrf = login(client)
|
||||
_installeer(client, env, csrf, inst="id")
|
||||
from core import audit
|
||||
audit.init()
|
||||
client.get("/api/stacks/id/credentials")
|
||||
assert any(r["action"] == "credentials_view" for r in audit.query())
|
||||
|
||||
|
||||
def test_onbekende_stack(client, env):
|
||||
login(client)
|
||||
assert client.get("/api/stacks/bestaatniet/credentials").status_code == 404
|
||||
|
||||
|
||||
# ── Zelf bewerken vóór installeren ───────────────────────────────────────────
|
||||
|
||||
def test_eigen_compose_wordt_gebruikt(client, env):
|
||||
csrf = login(client)
|
||||
_bron(env)
|
||||
eigen = ("services:\n handmatig:\n image: nginx:1.27\n"
|
||||
" container_name: zelf-verzonnen\n")
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "inlogapp", "repo_id": "server-up",
|
||||
"instance": "ie",
|
||||
"values": {"admin_password": "MijnWachtwoord1"},
|
||||
"compose_override": eigen},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
assert "zelf-verzonnen" in (env["lib"] / "ie" / "docker-compose.yml").read_text()
|
||||
|
||||
|
||||
def test_eigen_env_wordt_gebruikt(client, env):
|
||||
csrf = login(client)
|
||||
_bron(env)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "inlogapp", "repo_id": "server-up",
|
||||
"instance": "if",
|
||||
"values": {"admin_password": "MijnWachtwoord1"},
|
||||
"env_override": "HANDMATIG=ja\n"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
assert "HANDMATIG=ja" in (env["lib"] / "if" / ".env").read_text()
|
||||
|
||||
|
||||
def test_kapotte_yaml_wordt_geweigerd_voordat_er_iets_gemaakt_is(client, env):
|
||||
"""Een typefout hoort hier te stranden, niet halverwege een installatie."""
|
||||
csrf = login(client)
|
||||
_bron(env)
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "inlogapp", "repo_id": "server-up",
|
||||
"instance": "ig",
|
||||
"values": {"admin_password": "MijnWachtwoord1"},
|
||||
"compose_override": "services: [kapot: ja: nee"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
assert "YAML" in r.get_json()["msg"]
|
||||
assert not (env["lib"] / "ig").exists()
|
||||
|
||||
|
||||
# ── De .env-editor ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_env_editor_weigert_een_regel_zonder_isgelijkteken(client, env):
|
||||
"""Compose negeert zo'n regel stilzwijgend; dan mist er ineens een
|
||||
wachtwoord zonder dat je het merkt."""
|
||||
csrf = login(client)
|
||||
_installeer(client, env, csrf, inst="ih")
|
||||
r = client.put("/api/stacks/ih/env",
|
||||
json={"content": "GOED=1\ndit is onzin\n"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
assert "Regel 2" in r.get_json()["msg"]
|
||||
|
||||
|
||||
def test_env_editor_accepteert_commentaar_en_lege_regels(client, env):
|
||||
csrf = login(client)
|
||||
_installeer(client, env, csrf, inst="ii")
|
||||
r = client.put("/api/stacks/ii/env",
|
||||
json={"content": "# uitleg\n\nGOED=1\n"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
assert stat.S_IMODE((env["lib"] / "ii" / ".env").stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_env_bewerken_komt_in_het_auditlog(client, env):
|
||||
csrf = login(client)
|
||||
_installeer(client, env, csrf, inst="ij")
|
||||
from core import audit
|
||||
audit.init()
|
||||
client.put("/api/stacks/ij/env", json={"content": "X=1\n"},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert any(r["action"] == "env_edit" for r in audit.query())
|
||||
|
||||
|
||||
# ── Voorvertoning met eigen waarden ──────────────────────────────────────────
|
||||
|
||||
def test_voorvertoning_gebruikt_de_ingevulde_waarden(client, env):
|
||||
"""Anders toont de laatste stap iets anders dan er straks komt te staan."""
|
||||
csrf = login(client)
|
||||
_bron(env)
|
||||
r = client.post("/api/store/preview",
|
||||
json={"stack": "inlogapp", "repo_id": "server-up",
|
||||
"values": {"service_name": "eigennaam", "port": 9999,
|
||||
"admin_password": "MijnWachtwoord1"}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
d = r.get_json()
|
||||
assert "eigennaam" in d["compose_preview"]
|
||||
assert "9999" in d["compose_preview"]
|
||||
# Het geheim hoort al in de .env-voorvertoning te staan, niet in compose.
|
||||
assert "MijnWachtwoord1" not in d["compose_preview"]
|
||||
assert "MijnWachtwoord1" in d["env_preview"]
|
||||
|
|
@ -12,6 +12,17 @@ sys.path.insert(0, str(WORTEL / "server-up"))
|
|||
from conftest import login
|
||||
|
||||
|
||||
def _wacht_op_job(client, jid, tijdslimiet=10):
|
||||
"""Installeren gebeurt in een achtergrondthread; wachten tot hij klaar is."""
|
||||
einde = time.time() + tijdslimiet
|
||||
while time.time() < einde:
|
||||
d = client.get(f"/api/jobs/{jid}").get_json() or {}
|
||||
if d.get("status") in ("done", "error"):
|
||||
return d
|
||||
time.sleep(0.05)
|
||||
return {}
|
||||
|
||||
|
||||
def _stack(env, naam="web", meta=None, compose=None):
|
||||
d = env["lib"] / naam
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -65,10 +76,37 @@ def test_reconfigure_van_onbekende_stack(client):
|
|||
|
||||
|
||||
def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
|
||||
"""Zonder opgeslagen waarden valt het formulier later niet te heropenen."""
|
||||
import app as app_module
|
||||
assert '"values": bp_values if is_bp else {}' in \
|
||||
(WORTEL / "server-up" / "app.py").read_text(encoding="utf-8")
|
||||
"""Zonder opgeslagen waarden valt het formulier later niet te heropenen.
|
||||
|
||||
Was een controle op een letterlijke regel broncode; die brak zodra de regel
|
||||
verplaatste terwijl het gedrag klopte.
|
||||
"""
|
||||
import json as _json
|
||||
from core import git
|
||||
csrf = login(client)
|
||||
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
|
||||
(wortel / "files").mkdir(parents=True, exist_ok=True)
|
||||
(wortel / "template.json").write_text(_json.dumps({
|
||||
"kind": "compose", "metadata": {"name": "keuzeapp"},
|
||||
"variables": [{"title": "Algemeen", "items": [
|
||||
{"name": "service_name", "type": "str", "title": "Naam",
|
||||
"default": "keuzeapp", "required": True},
|
||||
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
|
||||
]}]}), encoding="utf-8")
|
||||
(wortel / "files" / "compose.yaml").write_text(
|
||||
'services:\n << service_name >>:\n image: nginx\n'
|
||||
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
|
||||
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "keuzeapp", "repo_id": "server-up",
|
||||
"instance": "kz", "values": {"port": 9123}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
_wacht_op_job(client, r.get_json()["job_id"])
|
||||
|
||||
meta = _json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
|
||||
assert meta["values"]["port"] == 9123
|
||||
assert meta["source"] == "keuzeapp"
|
||||
|
||||
|
||||
# ── Koppelen ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
34
tests/test_ui_logica.py
Normal file
34
tests/test_ui_logica.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""De logica van het invulmenu, uitgevoerd zoals de browser het doet.
|
||||
|
||||
De stapindeling, de geavanceerd-schakelaar en het blokkeren van 'Volgende'
|
||||
zitten in JavaScript. Een Python-test kan daar niets over zeggen, dus draaien we
|
||||
de echte component uit index.html in Node. Zonder Node slaat dit zichzelf over.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
WORTEL = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = WORTEL / "tests" / "js" / "menu.test.js"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
|
||||
def test_invulmenu_logica():
|
||||
r = subprocess.run(["node", str(SCRIPT)], capture_output=True, text=True,
|
||||
timeout=60, cwd=str(WORTEL))
|
||||
assert r.returncode == 0, r.stdout + r.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
|
||||
def test_javascript_van_de_pagina_parseert():
|
||||
"""Een typefout in de component maakt de hele interface blanco; dat wil je
|
||||
niet pas in de browser ontdekken."""
|
||||
import re
|
||||
html = (WORTEL / "server-up" / "templates" / "index.html").read_text(encoding="utf-8")
|
||||
blokken = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
|
||||
grootste = max(blokken, key=len)
|
||||
r = subprocess.run(["node", "--check", "-"], input=grootste,
|
||||
capture_output=True, text=True, timeout=30)
|
||||
assert r.returncode == 0, r.stderr
|
||||
Loading…
Reference in a new issue