Compare commits
2 commits
8ff5f4b1d3
...
ae99df2fc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae99df2fc7 | ||
|
|
fe7abce95b |
16 changed files with 1996 additions and 119 deletions
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -1,3 +1,60 @@
|
||||||
|
# v0.5.20-beta — Vertalingen, SSO-scherm, complete backups en app-updates
|
||||||
|
|
||||||
|
## 🌍 Vertalingen en SSO
|
||||||
|
|
||||||
|
- De schermen uit v0.5.x (login, Beveiliging, Netwerken, Updates,
|
||||||
|
containerpaneel) gebruikten harde Nederlandse teksten terwijl de rest van de
|
||||||
|
interface via `t()` loopt. Op Engels gaf dat een mengelmoes. Nu volledig
|
||||||
|
vertaald: 208 sleutels in `nl` en `en`.
|
||||||
|
- `t()` ondersteunt plaatshouders: `t('port_taken', {port: 8080})`. Zo blijven
|
||||||
|
zinnen heel in plaats van in losse stukjes geknipt.
|
||||||
|
- Nieuw instelscherm voor **reverse-proxy-SSO** onder Instellingen →
|
||||||
|
Beveiliging: modus lokaal/proxy/beide, identiteitsheader en de vertrouwde
|
||||||
|
proxy-adressen. De backend bestond al maar was alleen met `curl` te bereiken.
|
||||||
|
- Nieuwe test bewaakt dat `nl` en `en` dezelfde sleutels houden, dat de
|
||||||
|
plaatshouders in beide talen gelijk zijn, en dat de interface geen sleutels
|
||||||
|
gebruikt die nergens gedefinieerd staan.
|
||||||
|
|
||||||
|
## 💾 Backups compleet
|
||||||
|
|
||||||
|
Backup was één `tar`-commando waarvan de returncode genegeerd werd — een
|
||||||
|
mislukte backup gold als succes, en terugzetten kon alleen met de hand.
|
||||||
|
|
||||||
|
- **Terugzetten** met één klik: stack stoppen, huidige map opzij, uitpakken,
|
||||||
|
starten. Mislukt het uitpakken, dan komt de oude situatie terug.
|
||||||
|
- **Bewaarbeleid**: aantal per stack en/of maximale leeftijd. De nieuwste
|
||||||
|
backup van een stack blijft altijd staan.
|
||||||
|
- **Geplande backups**, dagelijks of wekelijks, via een eigen planner in de
|
||||||
|
applicatie (geen cron). Een gemiste ronde loopt bij de eerstvolgende
|
||||||
|
gelegenheid alsnog.
|
||||||
|
- **Automatisch een backup vóór het bijwerken of verwijderen** van een stack.
|
||||||
|
Mislukt die, dan gaat de actie door — anders kun je een kapotte stack niet
|
||||||
|
meer opruimen.
|
||||||
|
- Uitpakken gebeurt met `tarfile` en `filter="data"`: absolute paden en
|
||||||
|
`..`-ingangen worden geweigerd. Met een kaal `tar xzf` kon een geprepareerd
|
||||||
|
archief buiten de stackmap schrijven.
|
||||||
|
- Backups binnen dezelfde seconde overschreven elkaar; namen krijgen nu een
|
||||||
|
teller.
|
||||||
|
- `docs/backups.md` legt uit wat er wél en niet in zit — de compose- en
|
||||||
|
`.env`-bestanden, **niet** de Docker-volumes met de eigenlijke appdata.
|
||||||
|
|
||||||
|
## 📦 Updates per app
|
||||||
|
|
||||||
|
- Server Up controleert nu ook of er nieuwere images zijn voor de
|
||||||
|
geïnstalleerde stacks, met een badge op de stackkaart en een knop om te
|
||||||
|
controleren. Bijwerken doet de bestaande update-knop.
|
||||||
|
- De vergelijking loopt via de Registry API v2 (`Docker-Content-Digest`), niet
|
||||||
|
via `docker manifest inspect`: dat laatste geeft per platform een aparte
|
||||||
|
digest terug terwijl `RepoDigests` de digest van de manifest-list bevat, wat
|
||||||
|
bij elk multi-arch image permanent "update beschikbaar" zou opleveren.
|
||||||
|
- Drie statussen per service — `update`, `current` en `unknown`. Een lokaal
|
||||||
|
gebouwd image, een nog niet gepulld image of een onbereikbare registry levert
|
||||||
|
`unknown` op en dus géén badge, zodat er nooit ten onrechte een update wordt
|
||||||
|
gemeld.
|
||||||
|
- Dagelijkse achtergrondcheck via dezelfde planner; resultaten 6 uur gecached.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v0.5.10-beta — Update-systeem met kanalen en één-klik bijwerken
|
# v0.5.10-beta — Update-systeem met kanalen en één-klik bijwerken
|
||||||
|
|
||||||
## 🔄 Kanalen
|
## 🔄 Kanalen
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.5.10-beta
|
0.5.20-beta
|
||||||
|
|
|
||||||
138
docs/backups.md
Normal file
138
docs/backups.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Backups
|
||||||
|
|
||||||
|
Server Up maakt backups van je stacks: handmatig, automatisch vóór risicovolle
|
||||||
|
acties, of op een schema. Terugzetten kan met één klik.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Lees dit eerst: wat zit er wél en niet in
|
||||||
|
|
||||||
|
Een backup is een `.tar.gz` van de **stackmap** in je library. Daar zitten in:
|
||||||
|
|
||||||
|
- het compose-bestand
|
||||||
|
- het `.env`-bestand (met je wachtwoorden en tokens)
|
||||||
|
- eventuele configuratiebestanden die naast de compose staan
|
||||||
|
- `.serverup.json` met de metadata van de installatie
|
||||||
|
|
||||||
|
Wat er **niet** in zit: de Docker-volumes met de eigenlijke gegevens van de app.
|
||||||
|
|
||||||
|
| Voorbeeld | In de backup? |
|
||||||
|
|---|---|
|
||||||
|
| De compose van Vaultwarden | ✅ |
|
||||||
|
| Je `ADMIN_TOKEN` uit `.env` | ✅ |
|
||||||
|
| De wachtwoordkluis van Vaultwarden zelf | ❌ |
|
||||||
|
| De database van Paperless | ❌ |
|
||||||
|
| Je foto's in Immich | ❌ |
|
||||||
|
|
||||||
|
Je kunt hiermee dus een stack terugzetten zoals hij geconfigureerd stond, maar
|
||||||
|
niet de inhoud ervan herstellen. Wil je dat ook, dan zijn er twee routes:
|
||||||
|
|
||||||
|
1. **Bind mounts in plaats van named volumes.** Staat je data in
|
||||||
|
`/opt/serverup/appdata/<app>` en zit die map binnen je stackmap, dan gaat hij
|
||||||
|
wél mee. De meeste templates in `apps/` gebruiken `data_dir` daarvoor.
|
||||||
|
2. **Een aparte volume-backup** met bijvoorbeeld `restic` of `borg` naast Server
|
||||||
|
Up. Voor grote datasets is dat sowieso beter dan een tar per keer.
|
||||||
|
|
||||||
|
> Omdat een backup je `.env` bevat, staan er wachtwoorden in. `BACKUP_DIR` hoort
|
||||||
|
> dus dezelfde bescherming te hebben als de rest van `/data`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Een backup maken
|
||||||
|
|
||||||
|
**Per stack** — de archiefknop op de stackkaart maakt er direct een. De
|
||||||
|
geschiedenisknop ernaast toont alle backups van die stack, met de mogelijkheid
|
||||||
|
om terug te zetten of te verwijderen.
|
||||||
|
|
||||||
|
**Automatisch vóór risicovolle acties** — standaard aan voor:
|
||||||
|
|
||||||
|
- **bijwerken** van een stack (`compose pull` + `up`), want een nieuwe
|
||||||
|
image-versie kan een compose-wijziging nodig hebben;
|
||||||
|
- **verwijderen** van een stack, maar alleen als je "verwijder ook de bestanden"
|
||||||
|
kiest — bij het enkel stoppen van containers valt er niets te verliezen.
|
||||||
|
|
||||||
|
Uit te zetten onder **Instellingen → Backups**. Mislukt zo'n automatische
|
||||||
|
backup, dan gaat de actie alsnog door: anders zou je een kapotte stack niet meer
|
||||||
|
kunnen opruimen. De melding komt wel in het joblog.
|
||||||
|
|
||||||
|
**Gepland** — dagelijks of wekelijks, rond een uur dat je zelf kiest. De
|
||||||
|
planner draait in de applicatie zelf (geen cron nodig) en onthoudt in
|
||||||
|
`config.json` wanneer hij voor het laatst gelopen heeft. Stond de server uit op
|
||||||
|
het geplande moment, dan loopt de backup bij de eerstvolgende gelegenheid alsnog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewaarbeleid
|
||||||
|
|
||||||
|
Twee regels, die allebei tegelijk gelden:
|
||||||
|
|
||||||
|
| Instelling | Betekenis |
|
||||||
|
|---|---|
|
||||||
|
| `BACKUP_KEEP` | Hoeveel backups per stack je bewaart. `0` = onbeperkt. |
|
||||||
|
| `BACKUP_MAX_AGE_DAYS` | Alles ouder dan dit aantal dagen gaat weg. `0` = geen limiet. |
|
||||||
|
|
||||||
|
**De nieuwste backup van een stack blijft altijd staan**, ook als hij ouder is
|
||||||
|
dan de maximale leeftijd. Anders sta je na een lange vakantie met lege handen.
|
||||||
|
|
||||||
|
Opruimen gebeurt na elke nieuwe backup van die stack, en na een geplande ronde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terugzetten
|
||||||
|
|
||||||
|
De geschiedenisknop op de stackkaart → **Terugzetten**. Wat er dan gebeurt:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. docker compose down stack stoppen
|
||||||
|
2. huidige map opzij zetten als <stack>.restore-<tijdstempel>
|
||||||
|
3. archief uitpakken
|
||||||
|
4. opzijgezette map weggooien
|
||||||
|
5. docker compose up -d stack starten
|
||||||
|
```
|
||||||
|
|
||||||
|
Gaat stap 3 mis — een beschadigd archief, of een archief dat buiten de stackmap
|
||||||
|
probeert te schrijven — dan wordt de opzijgezette map teruggeplaatst. Je raakt
|
||||||
|
je stack dus niet kwijt aan een kapotte backup.
|
||||||
|
|
||||||
|
Een backup van stack A kun je niet over stack B heen zetten; dat wordt geweigerd
|
||||||
|
op basis van de metadata.
|
||||||
|
|
||||||
|
### Veiligheid
|
||||||
|
|
||||||
|
Archieven worden uitgepakt met Pythons `tarfile` en `filter="data"`. Dat weigert
|
||||||
|
absolute paden, `..`-ingangen, symlinks die buiten de map wijzen en
|
||||||
|
apparaatbestanden. Met een kaal `tar xzf` zou een geprepareerd archief bestanden
|
||||||
|
elders op de host kunnen neerzetten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instellingen
|
||||||
|
|
||||||
|
| Sleutel | Standaard | Betekenis |
|
||||||
|
|---|---|---|
|
||||||
|
| `BACKUP_DIR` | `/opt/serverup/backups` | Waar de archieven komen |
|
||||||
|
| `BACKUP_KEEP` | `5` | Aantal per stack |
|
||||||
|
| `BACKUP_MAX_AGE_DAYS` | `0` | Maximale leeftijd in dagen |
|
||||||
|
| `BACKUP_SCHEDULE` | `off` | `off` / `daily` / `weekly` |
|
||||||
|
| `BACKUP_SCHEDULE_HOUR` | `3` | Rond welk uur de geplande ronde draait |
|
||||||
|
| `BACKUP_BEFORE_UPDATE` | `true` | Backup vóór het bijwerken van een stack |
|
||||||
|
| `BACKUP_BEFORE_REMOVE` | `true` | Backup vóór het verwijderen van bestanden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handmatig
|
||||||
|
|
||||||
|
De archieven zijn gewone tarballs — je hebt Server Up niet nodig om erbij te
|
||||||
|
kunnen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bekijken wat erin zit
|
||||||
|
tar tzf /opt/serverup/backups/vaultwarden_20260726_030000.tar.gz
|
||||||
|
|
||||||
|
# Ergens anders uitpakken
|
||||||
|
tar xzf /opt/serverup/backups/vaultwarden_20260726_030000.tar.gz -C /tmp/herstel
|
||||||
|
```
|
||||||
|
|
||||||
|
Naast elk archief staat een `.json` met de stacknaam, het tijdstip, de grootte
|
||||||
|
en de reden (handmatig, gepland, voor-update, voor-remove). Ontbreekt dat
|
||||||
|
bestand, dan leidt Server Up de gegevens af uit de bestandsnaam.
|
||||||
187
server-up/app.py
187
server-up/app.py
|
|
@ -15,7 +15,8 @@ from flask import Flask, Response, jsonify, render_template, request, session
|
||||||
import core as cfg
|
import core as cfg
|
||||||
from core import audit, jobs, i18n
|
from core import audit, jobs, i18n
|
||||||
from core import auth, docker, git, boilerplates, updater
|
from core import auth, docker, git, boilerplates, updater
|
||||||
from core import compose_transform, networks, selfupdate
|
from core import backups, compose_transform, networks, scheduler, selfupdate
|
||||||
|
from core import stackupdates
|
||||||
from core.modules import Module, CORE, discover
|
from core.modules import Module, CORE, discover
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||||
|
|
@ -507,6 +508,23 @@ def api_stacks():
|
||||||
return jsonify(out)
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_backup(qq, stack: str, config_key: str, reden: str):
|
||||||
|
"""Backup vóór een actie die bestanden overschrijft of weggooit.
|
||||||
|
|
||||||
|
Faalt de backup, dan gaat de actie wél door — anders kun je een kapotte
|
||||||
|
stack niet meer opruimen. De melding komt wel in het joblog.
|
||||||
|
"""
|
||||||
|
if not cfg.load().get(config_key):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
meta = backups.create(stack, reden=reden,
|
||||||
|
log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
|
jobs.log(qq, "ok", f"Backup vooraf: {meta['file']}")
|
||||||
|
backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
|
except Exception as e:
|
||||||
|
jobs.log(qq, "warn", f"Backup vooraf mislukt ({e}) — actie gaat door")
|
||||||
|
|
||||||
|
|
||||||
def _stack_dir(name: str) -> tuple[Path | None, Path | None]:
|
def _stack_dir(name: str) -> tuple[Path | None, Path | None]:
|
||||||
"""Resolve een stacknaam naar een map binnen LIBRARY_DIR.
|
"""Resolve een stacknaam naar een map binnen LIBRARY_DIR.
|
||||||
|
|
||||||
|
|
@ -551,10 +569,13 @@ def api_stack_action(name, action):
|
||||||
docker.compose_down(pdir, log_fn=lf, name=name)
|
docker.compose_down(pdir, log_fn=lf, name=name)
|
||||||
rc = docker.compose_up(pdir, log_fn=lf, name=name)
|
rc = docker.compose_up(pdir, log_fn=lf, name=name)
|
||||||
elif action == "update":
|
elif action == "update":
|
||||||
|
_auto_backup(qq, name, "BACKUP_BEFORE_UPDATE", "voor-update")
|
||||||
docker.compose_pull(pdir, log_fn=lf)
|
docker.compose_pull(pdir, log_fn=lf)
|
||||||
rc = docker.compose_up(pdir, log_fn=lf, name=name)
|
rc = docker.compose_up(pdir, log_fn=lf, name=name)
|
||||||
elif action == "remove":
|
elif action == "remove":
|
||||||
mode = body.get("mode", "stack")
|
mode = body.get("mode", "stack")
|
||||||
|
if mode in ("full", "all"):
|
||||||
|
_auto_backup(qq, name, "BACKUP_BEFORE_REMOVE", "voor-remove")
|
||||||
if docker.has_compose(pdir):
|
if docker.has_compose(pdir):
|
||||||
docker.compose_down(pdir, log_fn=lf, volumes=True, name=name)
|
docker.compose_down(pdir, log_fn=lf, volumes=True, name=name)
|
||||||
if mode in ("full", "all") and pdir.is_dir():
|
if mode in ("full", "all") and pdir.is_dir():
|
||||||
|
|
@ -566,14 +587,12 @@ def api_stack_action(name, action):
|
||||||
shutil.rmtree(dd)
|
shutil.rmtree(dd)
|
||||||
jobs.log(qq, "ok", "Data verwijderd")
|
jobs.log(qq, "ok", "Data verwijderd")
|
||||||
elif action == "backup":
|
elif action == "backup":
|
||||||
bd = Path(cfg.load()["BACKUP_DIR"]).resolve()
|
# Ging eerder via een tar-subprocess waarvan de returncode
|
||||||
bd.mkdir(parents=True, exist_ok=True)
|
# genegeerd werd — een mislukte backup gold dan als succes.
|
||||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
meta = backups.create(name, reden="handmatig",
|
||||||
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
|
log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
subprocess.run(["tar", "czf", str(bd / f"{safe_name}_{ts}.tar.gz"),
|
jobs.log(qq, "ok", f"Backup → {meta['file']} ({meta['size']} bytes)")
|
||||||
"-C", str(pdir.parent), name],
|
backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
capture_output=True, timeout=300)
|
|
||||||
jobs.log(qq, "ok", f"Backup → {bd / f'{safe_name}_{ts}.tar.gz'}")
|
|
||||||
jobs.log(qq, "ok" if rc == 0 else "error",
|
jobs.log(qq, "ok" if rc == 0 else "error",
|
||||||
f"{action} {'klaar' if rc == 0 else 'mislukt'}")
|
f"{action} {'klaar' if rc == 0 else 'mislukt'}")
|
||||||
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
|
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
|
||||||
|
|
@ -1170,6 +1189,117 @@ def api_store_install():
|
||||||
return jsonify(ok=True, job_id=jid)
|
return jsonify(ok=True, job_id=jid)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Updates van geïnstalleerde stacks ────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/api/stacks/updates")
|
||||||
|
def api_stack_updates():
|
||||||
|
"""Bekende updatestatus per stack. `?force=1` doet een verse check."""
|
||||||
|
force = request.args.get("force") in ("1", "true", "yes")
|
||||||
|
if force:
|
||||||
|
return jsonify(ok=True, stacks=stackupdates.check_all(force=True))
|
||||||
|
return jsonify(ok=True, stacks=stackupdates.cached())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stacks/updates/check", methods=["POST"])
|
||||||
|
def api_stack_updates_check():
|
||||||
|
"""Verse check als job, want dit doet een netwerkverzoek per image."""
|
||||||
|
jid, q = jobs.create("stack-updates")
|
||||||
|
|
||||||
|
def _run(jid):
|
||||||
|
qq = jobs.get_queue(jid)
|
||||||
|
jobs.log(qq, "section", "Updates controleren")
|
||||||
|
res = stackupdates.check_all(force=True,
|
||||||
|
log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
|
met_update = [n for n, r in res.items() if r.get("update_available")]
|
||||||
|
jobs.log(qq, "ok", f"{len(met_update)} van {len(res)} stacks heeft een update"
|
||||||
|
+ (f": {', '.join(met_update)}" if met_update else ""))
|
||||||
|
jobs.finish(jid, "done")
|
||||||
|
jobs.done(qq)
|
||||||
|
|
||||||
|
jobs.run(_run, jid)
|
||||||
|
return jsonify(ok=True, job_id=jid)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Backups ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/api/backups")
|
||||||
|
def api_backups():
|
||||||
|
stack = request.args.get("stack", "").strip()
|
||||||
|
lijst = backups.list_for(stack) if stack else backups.list_all()
|
||||||
|
c = cfg.load()
|
||||||
|
return jsonify(backups=lijst, settings={
|
||||||
|
k: c.get(k) for k in ("BACKUP_KEEP", "BACKUP_MAX_AGE_DAYS",
|
||||||
|
"BACKUP_SCHEDULE", "BACKUP_SCHEDULE_HOUR",
|
||||||
|
"BACKUP_BEFORE_UPDATE", "BACKUP_BEFORE_REMOVE")})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backups/<stack>/create", methods=["POST"])
|
||||||
|
def api_backup_create(stack):
|
||||||
|
jid, q = jobs.create(f"backup:{stack}")
|
||||||
|
|
||||||
|
def _run(jid):
|
||||||
|
qq = jobs.get_queue(jid)
|
||||||
|
try:
|
||||||
|
jobs.log(qq, "section", f"Backup: {stack}")
|
||||||
|
meta = backups.create(stack, reden="handmatig",
|
||||||
|
log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
|
for weg in backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)):
|
||||||
|
pass
|
||||||
|
jobs.log(qq, "ok", f"Backup klaar ({meta['file']})")
|
||||||
|
audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr)
|
||||||
|
jobs.finish(jid, "done")
|
||||||
|
except backups.BackupError as e:
|
||||||
|
jobs.log(qq, "error", str(e))
|
||||||
|
audit.log("backups", "create", "error", ref=stack, detail=str(e))
|
||||||
|
jobs.finish(jid, "error")
|
||||||
|
jobs.done(qq)
|
||||||
|
|
||||||
|
jobs.run(_run, jid)
|
||||||
|
return jsonify(ok=True, job_id=jid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backups/<stack>/restore", methods=["POST"])
|
||||||
|
def api_backup_restore(stack):
|
||||||
|
bestand = ((request.json or {}).get("file") or "").strip()
|
||||||
|
if not bestand:
|
||||||
|
return jsonify(ok=False, msg="Geen backup opgegeven"), 400
|
||||||
|
try:
|
||||||
|
backups._archive_path(bestand) # vroege validatie
|
||||||
|
except backups.BackupError as e:
|
||||||
|
return jsonify(ok=False, msg=str(e)), 400
|
||||||
|
|
||||||
|
jid, q = jobs.create(f"restore:{stack}")
|
||||||
|
|
||||||
|
def _run(jid):
|
||||||
|
qq = jobs.get_queue(jid)
|
||||||
|
try:
|
||||||
|
jobs.log(qq, "section", f"Terugzetten: {stack}")
|
||||||
|
ok, msg = backups.restore(stack, bestand,
|
||||||
|
log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
|
jobs.log(qq, "ok" if ok else "error", msg)
|
||||||
|
audit.log("backups", "restore", "ok" if ok else "error", ref=stack,
|
||||||
|
detail=bestand)
|
||||||
|
jobs.finish(jid, "done" if ok else "error")
|
||||||
|
except backups.BackupError as e:
|
||||||
|
jobs.log(qq, "error", str(e))
|
||||||
|
jobs.finish(jid, "error")
|
||||||
|
jobs.done(qq)
|
||||||
|
|
||||||
|
jobs.run(_run, jid)
|
||||||
|
return jsonify(ok=True, job_id=jid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backups/<path:bestand>", methods=["DELETE"])
|
||||||
|
def api_backup_delete(bestand):
|
||||||
|
try:
|
||||||
|
ok, msg = backups.delete(bestand)
|
||||||
|
except backups.BackupError as e:
|
||||||
|
return jsonify(ok=False, msg=str(e)), 400
|
||||||
|
if ok:
|
||||||
|
audit.log("backups", "delete", "ok", ref=bestand, ip=request.remote_addr)
|
||||||
|
return jsonify(ok=ok, msg=msg), (200 if ok else 404)
|
||||||
|
|
||||||
|
|
||||||
# ── Netwerken (eigen IP-adres per stack) ─────────────────────────────────────
|
# ── Netwerken (eigen IP-adres per stack) ─────────────────────────────────────
|
||||||
|
|
||||||
def _network_by_name(name: str) -> dict | None:
|
def _network_by_name(name: str) -> dict | None:
|
||||||
|
|
@ -1981,6 +2111,41 @@ def _apply_boot_repos():
|
||||||
audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url"))
|
audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url"))
|
||||||
|
|
||||||
|
|
||||||
|
def _geplande_backups():
|
||||||
|
"""Backup van alle stacks, aangestuurd door de scheduler."""
|
||||||
|
c = cfg.load()
|
||||||
|
plan = (c.get("BACKUP_SCHEDULE") or "off").lower()
|
||||||
|
if plan not in ("daily", "weekly"):
|
||||||
|
return
|
||||||
|
if not scheduler.binnen_venster(int(c.get("BACKUP_SCHEDULE_HOUR") or 3)):
|
||||||
|
return
|
||||||
|
lib = Path(c["LIBRARY_DIR"]).resolve()
|
||||||
|
if not lib.is_dir():
|
||||||
|
return
|
||||||
|
for d in sorted(lib.iterdir()):
|
||||||
|
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta = backups.create(d.name, reden=f"gepland-{plan}")
|
||||||
|
print(f" [backup] {meta['file']}")
|
||||||
|
audit.log("backups", "scheduled", "ok", ref=d.name)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [backup] {d.name} mislukt: {e}")
|
||||||
|
audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e))
|
||||||
|
backups.prune()
|
||||||
|
|
||||||
|
|
||||||
|
def _registreer_taken():
|
||||||
|
"""Periodieke taken aanmelden bij de scheduler."""
|
||||||
|
c = cfg.load()
|
||||||
|
# Dagelijks/wekelijks; de venstercheck in _geplande_backups bepaalt het uur.
|
||||||
|
uren = 24.0 if (c.get("BACKUP_SCHEDULE") or "off") != "weekly" else 24.0 * 7
|
||||||
|
scheduler.register("backups", _geplande_backups, interval_uren=uren)
|
||||||
|
scheduler.register("stack_updates",
|
||||||
|
lambda: stackupdates.check_all(force=True),
|
||||||
|
interval_uren=24.0, enabled_key="STACK_UPDATE_CHECK")
|
||||||
|
|
||||||
|
|
||||||
def _boot_sync():
|
def _boot_sync():
|
||||||
"""Sync alle geconfigureerde repos bij elke start (achtergrond-thread).
|
"""Sync alle geconfigureerde repos bij elke start (achtergrond-thread).
|
||||||
|
|
||||||
|
|
@ -2033,6 +2198,10 @@ if __name__ == "__main__":
|
||||||
if auth.needs_setup():
|
if auth.needs_setup():
|
||||||
print("\n⚠ Nog geen account — open de webinterface en maak er één aan.")
|
print("\n⚠ Nog geen account — open de webinterface en maak er één aan.")
|
||||||
print(" Tot dat moment kan iedereen die deze poort bereikt het account claimen.\n")
|
print(" Tot dat moment kan iedereen die deze poort bereikt het account claimen.\n")
|
||||||
|
print("Geplande taken:")
|
||||||
|
_registreer_taken()
|
||||||
|
scheduler.start()
|
||||||
|
print(" backups + updatecheck geregistreerd")
|
||||||
print("Boot sync (achtergrond):")
|
print("Boot sync (achtergrond):")
|
||||||
jobs.run(_boot_sync)
|
jobs.run(_boot_sync)
|
||||||
print(f"\n→ http://localhost:{port}\n")
|
print(f"\n→ http://localhost:{port}\n")
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,18 @@ DEFAULTS = {
|
||||||
"AUTO_SYNC_ON_BOOT": False,
|
"AUTO_SYNC_ON_BOOT": False,
|
||||||
# Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen.
|
# Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen.
|
||||||
"NETWORKS": [],
|
"NETWORKS": [],
|
||||||
|
# Backups. KEEP=0 of MAX_AGE=0 schakelt die regel uit; de nieuwste backup
|
||||||
|
# van een stack wordt nooit opgeruimd.
|
||||||
|
"BACKUP_KEEP": 5,
|
||||||
|
"BACKUP_MAX_AGE_DAYS": 0,
|
||||||
|
"BACKUP_SCHEDULE": "off", # off | daily | weekly
|
||||||
|
"BACKUP_SCHEDULE_HOUR": 3,
|
||||||
|
"BACKUP_BEFORE_UPDATE": True,
|
||||||
|
"BACKUP_BEFORE_REMOVE": True,
|
||||||
|
# Laatste uitvoering van geplande taken (beheerd door core.scheduler).
|
||||||
|
"SCHEDULER_LAST": {},
|
||||||
|
# Updatecheck voor de geïnstalleerde stacks (image-digests).
|
||||||
|
"STACK_UPDATE_CHECK": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sleutels die de client noch mag lezen noch mag schrijven; AUTH bevat
|
# Sleutels die de client noch mag lezen noch mag schrijven; AUTH bevat
|
||||||
|
|
|
||||||
267
server-up/core/backups.py
Normal file
267
server-up/core/backups.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""Backups van stacks: maken, tonen, terugzetten en opruimen.
|
||||||
|
|
||||||
|
Een backup is een `.tar.gz` van de stackmap plus een `.json` met metadata
|
||||||
|
ernaast. Dat betekent: **de compose- en `.env`-bestanden zitten erin, de
|
||||||
|
Docker-volumes met de eigenlijke appdata niet.** Zie docs/backups.md — dat
|
||||||
|
verschil is het belangrijkste dat een gebruiker moet weten.
|
||||||
|
|
||||||
|
Terugzetten gebeurt met Pythons `tarfile` en `filter="data"` (PEP 706): dat
|
||||||
|
weigert absolute paden en `..` in archiefingangen. Met een kaal `tar xzf` kan
|
||||||
|
een geprepareerd archief buiten de stackmap schrijven.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import core as cfg
|
||||||
|
from core import docker
|
||||||
|
|
||||||
|
SUFFIX = ".tar.gz"
|
||||||
|
META_SUFFIX = ".json"
|
||||||
|
|
||||||
|
# <stack>_<JJJJMMDD>_<UUMMSS>[-n].tar.gz — ruimer dan cfg.safe_name() omdat de
|
||||||
|
# tijdstempel er nog achter komt, maar even streng op padtekens.
|
||||||
|
_BACKUP_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}_\d{8}_\d{6}(-\d+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
class BackupError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Paden ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def backup_dir() -> Path:
|
||||||
|
return Path(cfg.load()["BACKUP_DIR"]).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_path(bestandsnaam: str) -> Path:
|
||||||
|
"""Valideer een backupnaam en geef het volledige pad.
|
||||||
|
|
||||||
|
De naam komt uit een request, dus hij mag nooit uit BACKUP_DIR wijzen.
|
||||||
|
"""
|
||||||
|
naam = (bestandsnaam or "").strip()
|
||||||
|
if not naam.endswith(SUFFIX):
|
||||||
|
raise BackupError("Geen geldige backupnaam.")
|
||||||
|
kern = naam[: -len(SUFFIX)]
|
||||||
|
if not _BACKUP_RE.match(kern):
|
||||||
|
raise BackupError("Geen geldige backupnaam.")
|
||||||
|
bd = backup_dir()
|
||||||
|
pad = (bd / naam).resolve()
|
||||||
|
if not pad.is_relative_to(bd):
|
||||||
|
raise BackupError("Geen geldige backupnaam.")
|
||||||
|
return pad
|
||||||
|
|
||||||
|
|
||||||
|
def _stack_dir(stack: str) -> Path:
|
||||||
|
if not cfg.safe_name(stack):
|
||||||
|
raise BackupError("Ongeldige stacknaam.")
|
||||||
|
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
|
||||||
|
d = (lib / stack).resolve()
|
||||||
|
if not d.is_relative_to(lib) or d == lib:
|
||||||
|
raise BackupError("Ongeldige stacknaam.")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ── Maken ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict:
|
||||||
|
"""Maak een backup van de stackmap. Retourneert de metadata."""
|
||||||
|
bron = _stack_dir(stack)
|
||||||
|
if not bron.is_dir():
|
||||||
|
raise BackupError(f"Stack '{stack}' bestaat niet.")
|
||||||
|
bd = backup_dir()
|
||||||
|
bd.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
naam = f"{stack}_{ts}{SUFFIX}"
|
||||||
|
doel = bd / naam
|
||||||
|
# Twee backups binnen dezelfde seconde (bv. handmatig vlak na een
|
||||||
|
# automatische backup vóór een update) zouden elkaar overschrijven.
|
||||||
|
teller = 2
|
||||||
|
while doel.exists():
|
||||||
|
naam = f"{stack}_{ts}-{teller}{SUFFIX}"
|
||||||
|
doel = bd / naam
|
||||||
|
teller += 1
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"Backup maken van {stack}…")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Python's tarfile in plaats van het tar-commando: de returncode van dat
|
||||||
|
# commando werd eerder genegeerd, zodat een mislukte backup als succes
|
||||||
|
# gold.
|
||||||
|
with tarfile.open(doel, "w:gz") as tar:
|
||||||
|
tar.add(bron, arcname=stack)
|
||||||
|
except Exception as e:
|
||||||
|
doel.unlink(missing_ok=True)
|
||||||
|
raise BackupError(f"Backup mislukt: {e}") from e
|
||||||
|
|
||||||
|
meta = {"stack": stack, "file": naam, "ts": time.time(),
|
||||||
|
"size": doel.stat().st_size, "reason": reden}
|
||||||
|
doel.with_suffix("").with_suffix(META_SUFFIX).write_text(
|
||||||
|
json.dumps(meta, indent=2), encoding="utf-8")
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})")
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _leesbaar(bytes_: int) -> str:
|
||||||
|
eenheid = ["B", "KB", "MB", "GB"]
|
||||||
|
n = float(bytes_)
|
||||||
|
for e in eenheid:
|
||||||
|
if n < 1024 or e == eenheid[-1]:
|
||||||
|
return f"{n:.0f} {e}" if e == "B" else f"{n:.1f} {e}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tonen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _meta_voor(archief: Path) -> dict:
|
||||||
|
mf = archief.with_suffix("").with_suffix(META_SUFFIX)
|
||||||
|
meta = {}
|
||||||
|
if mf.exists():
|
||||||
|
try:
|
||||||
|
meta = json.loads(mf.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
meta = {}
|
||||||
|
st = archief.stat()
|
||||||
|
# Ontbreekt de metadata (backup van vóór deze versie), leid af uit de naam.
|
||||||
|
meta.setdefault("file", archief.name)
|
||||||
|
meta.setdefault("stack", archief.name.rsplit("_", 2)[0])
|
||||||
|
meta.setdefault("ts", st.st_mtime)
|
||||||
|
meta.setdefault("reason", "")
|
||||||
|
meta["size"] = st.st_size
|
||||||
|
meta["size_human"] = _leesbaar(st.st_size)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def list_all() -> list[dict]:
|
||||||
|
bd = backup_dir()
|
||||||
|
if not bd.is_dir():
|
||||||
|
return []
|
||||||
|
uit = []
|
||||||
|
for p in bd.iterdir():
|
||||||
|
if p.is_file() and p.name.endswith(SUFFIX):
|
||||||
|
try:
|
||||||
|
uit.append(_meta_voor(p))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return sorted(uit, key=lambda m: m["ts"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def list_for(stack: str) -> list[dict]:
|
||||||
|
return [m for m in list_all() if m.get("stack") == stack]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Terugzetten ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
|
||||||
|
"""Zet een backup terug: stack stoppen, uitpakken, weer starten.
|
||||||
|
|
||||||
|
De huidige map wordt eerst opzijgezet. Mislukt het uitpakken, dan komt de
|
||||||
|
oude situatie terug — je raakt dus nooit alles kwijt door een kapot archief.
|
||||||
|
"""
|
||||||
|
archief = _archive_path(bestandsnaam)
|
||||||
|
if not archief.is_file():
|
||||||
|
return False, "Backup niet gevonden."
|
||||||
|
doel = _stack_dir(stack)
|
||||||
|
|
||||||
|
meta = _meta_voor(archief)
|
||||||
|
if meta.get("stack") and meta["stack"] != stack:
|
||||||
|
return False, (f"Deze backup hoort bij '{meta['stack']}', niet bij "
|
||||||
|
f"'{stack}'.")
|
||||||
|
|
||||||
|
if doel.is_dir() and docker.has_compose(doel):
|
||||||
|
if log_fn:
|
||||||
|
log_fn("Stack stoppen…")
|
||||||
|
docker.compose_down(doel, log_fn=log_fn, name=stack)
|
||||||
|
|
||||||
|
opzij = doel.with_name(f"{doel.name}.restore-{int(time.time())}")
|
||||||
|
verplaatst = False
|
||||||
|
if doel.exists():
|
||||||
|
doel.rename(opzij)
|
||||||
|
verplaatst = True
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"Huidige map opzijgezet als {opzij.name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tarfile.open(archief, "r:gz") as tar:
|
||||||
|
# filter="data" weigert absolute paden, '..' en apparaatbestanden.
|
||||||
|
tar.extractall(doel.parent, filter="data")
|
||||||
|
if not doel.is_dir():
|
||||||
|
raise BackupError(
|
||||||
|
"Het archief bevatte geen map met de naam van de stack.")
|
||||||
|
except Exception as e:
|
||||||
|
# Terug naar de oude situatie.
|
||||||
|
if doel.exists():
|
||||||
|
shutil.rmtree(doel, ignore_errors=True)
|
||||||
|
if verplaatst:
|
||||||
|
opzij.rename(doel)
|
||||||
|
if log_fn:
|
||||||
|
log_fn("Oude situatie teruggezet.")
|
||||||
|
return False, f"Terugzetten mislukt: {e}"
|
||||||
|
|
||||||
|
if verplaatst:
|
||||||
|
shutil.rmtree(opzij, ignore_errors=True)
|
||||||
|
|
||||||
|
if log_fn:
|
||||||
|
log_fn("Bestanden teruggezet — stack starten…")
|
||||||
|
rc = docker.compose_up(doel, log_fn=log_fn, name=stack)
|
||||||
|
if rc != 0:
|
||||||
|
return False, ("Bestanden zijn teruggezet, maar de stack startte niet. "
|
||||||
|
"Bekijk de logs.")
|
||||||
|
return True, f"'{stack}' teruggezet uit {bestandsnaam}."
|
||||||
|
|
||||||
|
|
||||||
|
# ── Opruimen ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def delete(bestandsnaam: str) -> tuple[bool, str]:
|
||||||
|
archief = _archive_path(bestandsnaam)
|
||||||
|
if not archief.is_file():
|
||||||
|
return False, "Backup niet gevonden."
|
||||||
|
archief.unlink()
|
||||||
|
archief.with_suffix("").with_suffix(META_SUFFIX).unlink(missing_ok=True)
|
||||||
|
return True, "Backup verwijderd."
|
||||||
|
|
||||||
|
|
||||||
|
def prune(stack: str | None = None, log_fn=None) -> list[str]:
|
||||||
|
"""Pas het retentiebeleid toe. Retourneert de verwijderde bestandsnamen.
|
||||||
|
|
||||||
|
Twee regels, die allebei mogen gelden: hoogstens BACKUP_KEEP backups per
|
||||||
|
stack, en niets ouder dan BACKUP_MAX_AGE_DAYS.
|
||||||
|
"""
|
||||||
|
c = cfg.load()
|
||||||
|
keep = int(c.get("BACKUP_KEEP") or 0)
|
||||||
|
max_age = int(c.get("BACKUP_MAX_AGE_DAYS") or 0)
|
||||||
|
if keep <= 0 and max_age <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
verwijderd: list[str] = []
|
||||||
|
per_stack: dict[str, list[dict]] = {}
|
||||||
|
for m in list_all():
|
||||||
|
if stack and m.get("stack") != stack:
|
||||||
|
continue
|
||||||
|
per_stack.setdefault(m.get("stack", "?"), []).append(m)
|
||||||
|
|
||||||
|
grens = time.time() - max_age * 86400 if max_age > 0 else None
|
||||||
|
for naam, lijst in per_stack.items():
|
||||||
|
lijst.sort(key=lambda m: m["ts"], reverse=True) # nieuwste eerst
|
||||||
|
for i, m in enumerate(lijst):
|
||||||
|
te_veel = keep > 0 and i >= keep
|
||||||
|
te_oud = grens is not None and m["ts"] < grens
|
||||||
|
# De nieuwste backup van een stack blijft altijd staan, ook als hij
|
||||||
|
# te oud is — anders houd je na een vakantie niets over.
|
||||||
|
if (te_veel or te_oud) and i > 0:
|
||||||
|
try:
|
||||||
|
delete(m["file"])
|
||||||
|
verwijderd.append(m["file"])
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"Opgeruimd: {m['file']}")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return verwijderd
|
||||||
|
|
@ -325,6 +325,37 @@ def validate_compose_text(content: str, d: Path) -> tuple[bool, str]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def image_digest_local(image: str) -> str:
|
||||||
|
"""Digest van het lokaal aanwezige image, of "" als het er niet is.
|
||||||
|
|
||||||
|
Een lokaal gebouwd image heeft geen RepoDigest — dan valt er niets met een
|
||||||
|
registry te vergelijken.
|
||||||
|
"""
|
||||||
|
r = _run([_bin(), "image", "inspect", image,
|
||||||
|
"--format", "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}"],
|
||||||
|
timeout=15)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return ""
|
||||||
|
uit = r.stdout.strip()
|
||||||
|
return uit.split("@", 1)[1] if "@" in uit else ""
|
||||||
|
|
||||||
|
|
||||||
|
def image_digest_remote(image: str) -> str:
|
||||||
|
"""Digest van het image in de registry, of "" als dat niet te bepalen is.
|
||||||
|
|
||||||
|
Vraagt de registry rechtstreeks (Registry API v2) en leest de
|
||||||
|
`Docker-Content-Digest`-header. Dat is precies de waarde die ook in
|
||||||
|
`RepoDigests` van een gepulld image staat, dus de twee zijn vergelijkbaar.
|
||||||
|
|
||||||
|
`docker manifest inspect --verbose` is hiervoor níét bruikbaar: dat geeft
|
||||||
|
per platform een aparte descriptor-digest terug, terwijl RepoDigests de
|
||||||
|
digest van de manifest-list bevat. Die vergelijken zou bij elk multi-arch
|
||||||
|
image permanent "update beschikbaar" opleveren.
|
||||||
|
"""
|
||||||
|
from core import registry
|
||||||
|
return registry.manifest_digest(image)
|
||||||
|
|
||||||
|
|
||||||
def used_ports() -> set[int]:
|
def used_ports() -> set[int]:
|
||||||
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
|
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
|
||||||
ports = set()
|
ports = set()
|
||||||
|
|
|
||||||
127
server-up/core/registry.py
Normal file
127
server-up/core/registry.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""Registry API v2 — de manifest-digest van een image opvragen (stdlib only).
|
||||||
|
|
||||||
|
Wordt gebruikt om te bepalen of er een nieuwer image is voor een geïnstalleerde
|
||||||
|
stack. De `Docker-Content-Digest`-header die de registry teruggeeft is dezelfde
|
||||||
|
waarde die Docker in `RepoDigests` zet na een pull, dus die twee zijn direct
|
||||||
|
vergelijkbaar.
|
||||||
|
|
||||||
|
Ondersteunt anonieme toegang (Docker Hub, ghcr, publieke Forgejo-registries) en
|
||||||
|
Bearer-token-auth via de `WWW-Authenticate`-uitdaging.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
DOCKER_HUB = "registry-1.docker.io"
|
||||||
|
|
||||||
|
# Accept-header met alle manifest-types, zodat de registry de manifest-list
|
||||||
|
# teruggeeft in plaats van een enkel platform-manifest.
|
||||||
|
_ACCEPT = ", ".join([
|
||||||
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
"application/vnd.oci.image.manifest.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
])
|
||||||
|
|
||||||
|
_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def parse_image(image: str) -> tuple[str, str, str]:
|
||||||
|
"""Splits een image-referentie in (host, repository, tag).
|
||||||
|
|
||||||
|
nginx → registry-1.docker.io, library/nginx, latest
|
||||||
|
bes-r/app:1.2 → registry-1.docker.io, bes-r/app, 1.2
|
||||||
|
git.example.com:3000/a/b:dev → git.example.com:3000, a/b, dev
|
||||||
|
"""
|
||||||
|
ref = (image or "").strip()
|
||||||
|
if not ref:
|
||||||
|
return "", "", ""
|
||||||
|
# Digest-referenties hebben geen tag om op te vragen.
|
||||||
|
if "@" in ref:
|
||||||
|
ref = ref.split("@", 1)[0]
|
||||||
|
|
||||||
|
tag = "latest"
|
||||||
|
delen = ref.split("/")
|
||||||
|
eerste = delen[0]
|
||||||
|
# Een host herken je aan een punt, een dubbele punt of 'localhost'.
|
||||||
|
heeft_host = len(delen) > 1 and ("." in eerste or ":" in eerste or eerste == "localhost")
|
||||||
|
host = eerste if heeft_host else DOCKER_HUB
|
||||||
|
rest = "/".join(delen[1:]) if heeft_host else ref
|
||||||
|
|
||||||
|
if ":" in rest.split("/")[-1]:
|
||||||
|
rest, tag = rest.rsplit(":", 1)
|
||||||
|
if host == DOCKER_HUB and "/" not in rest:
|
||||||
|
rest = f"library/{rest}"
|
||||||
|
return host, rest, tag
|
||||||
|
|
||||||
|
|
||||||
|
def _scheme(host: str) -> str:
|
||||||
|
"""Lokale registries draaien vaak zonder TLS."""
|
||||||
|
if host.startswith("localhost") or host.startswith("127."):
|
||||||
|
return "http"
|
||||||
|
return "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header(creds: tuple[str, str] | None) -> dict:
|
||||||
|
if not creds:
|
||||||
|
return {}
|
||||||
|
blob = base64.b64encode(f"{creds[0]}:{creds[1]}".encode()).decode()
|
||||||
|
return {"Authorization": f"Basic {blob}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer_token(uitdaging: str, creds: tuple[str, str] | None) -> str:
|
||||||
|
"""Haal een token op met de gegevens uit een WWW-Authenticate-header."""
|
||||||
|
velden = dict(re.findall(r'(\w+)="([^"]*)"', uitdaging or ""))
|
||||||
|
realm = velden.get("realm")
|
||||||
|
if not realm:
|
||||||
|
return ""
|
||||||
|
params = {k: v for k, v in velden.items() if k in ("service", "scope") and v}
|
||||||
|
url = f"{realm}?{urllib.parse.urlencode(params)}" if params else realm
|
||||||
|
req = urllib.request.Request(url, headers=_auth_header(creds))
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
|
||||||
|
data = json.loads(r.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return data.get("token") or data.get("access_token") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_digest(image: str, creds: tuple[str, str] | None = None) -> str:
|
||||||
|
"""Digest van de manifest(-list) voor deze image-tag, of "" bij twijfel.
|
||||||
|
|
||||||
|
Retourneert bewust "" in plaats van te gokken: de aanroeper toont dan
|
||||||
|
"onbekend" en niet ten onrechte "update beschikbaar".
|
||||||
|
"""
|
||||||
|
host, repo, tag = parse_image(image)
|
||||||
|
if not repo:
|
||||||
|
return ""
|
||||||
|
url = f"{_scheme(host)}://{host}/v2/{repo}/manifests/{urllib.parse.quote(tag)}"
|
||||||
|
|
||||||
|
def _vraag(extra: dict) -> tuple[str, str]:
|
||||||
|
"""Retourneert (digest, www_authenticate)."""
|
||||||
|
req = urllib.request.Request(url, method="HEAD",
|
||||||
|
headers={"Accept": _ACCEPT, **extra})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
|
||||||
|
return r.headers.get("Docker-Content-Digest", ""), ""
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
return "", e.headers.get("WWW-Authenticate", "")
|
||||||
|
return "", ""
|
||||||
|
except Exception:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
digest, uitdaging = _vraag(_auth_header(creds))
|
||||||
|
if digest:
|
||||||
|
return digest
|
||||||
|
if uitdaging:
|
||||||
|
token = _bearer_token(uitdaging, creds)
|
||||||
|
if token:
|
||||||
|
digest, _ = _vraag({"Authorization": f"Bearer {token}"})
|
||||||
|
return digest
|
||||||
|
return ""
|
||||||
110
server-up/core/scheduler.py
Normal file
110
server-up/core/scheduler.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Periodieke taken in één achtergrondthread.
|
||||||
|
|
||||||
|
Geen cron en geen extra dependency: één daemon-thread die elke paar minuten
|
||||||
|
kijkt of er iets te doen is. De laatste uitvoering staat in `config.json`, dus
|
||||||
|
een herstart laat de planning niet opnieuw beginnen — en een taak die door een
|
||||||
|
herstart gemist is, loopt bij de eerstvolgende tick alsnog.
|
||||||
|
|
||||||
|
Taken registreren zich met `register()`; `start()` zet de lus in gang.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import core as cfg
|
||||||
|
|
||||||
|
TICK_SECONDS = 300 # elke 5 minuten kijken of er iets moet
|
||||||
|
_TAKEN: dict[str, dict] = {}
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_gestart = False
|
||||||
|
|
||||||
|
|
||||||
|
def register(naam: str, fn, interval_uren: float = 24.0, enabled_key: str = ""):
|
||||||
|
"""Registreer een periodieke taak.
|
||||||
|
|
||||||
|
`enabled_key` verwijst naar een config-sleutel; staat die op iets falsy, dan
|
||||||
|
wordt de taak overgeslagen (maar blijft hij geregistreerd).
|
||||||
|
"""
|
||||||
|
with _LOCK:
|
||||||
|
_TAKEN[naam] = {"fn": fn, "interval": max(0.05, float(interval_uren)),
|
||||||
|
"enabled_key": enabled_key}
|
||||||
|
|
||||||
|
|
||||||
|
def _laatste() -> dict:
|
||||||
|
d = cfg.load().get("SCHEDULER_LAST") or {}
|
||||||
|
return d if isinstance(d, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _markeer(naam: str):
|
||||||
|
d = dict(_laatste())
|
||||||
|
d[naam] = time.time()
|
||||||
|
cfg.patch({"SCHEDULER_LAST": d})
|
||||||
|
|
||||||
|
|
||||||
|
def due(naam: str, taak: dict, nu: float | None = None) -> bool:
|
||||||
|
"""Moet deze taak nu draaien?"""
|
||||||
|
nu = nu if nu is not None else time.time()
|
||||||
|
if taak.get("enabled_key") and not cfg.load().get(taak["enabled_key"]):
|
||||||
|
return False
|
||||||
|
laatste = _laatste().get(naam)
|
||||||
|
if not laatste:
|
||||||
|
return True
|
||||||
|
return (nu - float(laatste)) >= taak["interval"] * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def run_due(log_fn=None) -> list[str]:
|
||||||
|
"""Draai alle taken die aan de beurt zijn. Retourneert de namen."""
|
||||||
|
gedraaid = []
|
||||||
|
with _LOCK:
|
||||||
|
items = list(_TAKEN.items())
|
||||||
|
for naam, taak in items:
|
||||||
|
try:
|
||||||
|
if not due(naam, taak):
|
||||||
|
continue
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"[scheduler] {naam}")
|
||||||
|
taak["fn"]()
|
||||||
|
_markeer(naam)
|
||||||
|
gedraaid.append(naam)
|
||||||
|
except Exception:
|
||||||
|
# Eén kapotte taak mag de lus niet stoppen.
|
||||||
|
print(f" [scheduler] {naam} faalde:\n{traceback.format_exc()}")
|
||||||
|
_markeer(naam) # niet blijven herhalen in een lus
|
||||||
|
return gedraaid
|
||||||
|
|
||||||
|
|
||||||
|
def _lus():
|
||||||
|
# Even wachten tot de app volledig op is.
|
||||||
|
time.sleep(30)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
run_due()
|
||||||
|
except Exception:
|
||||||
|
print(f" [scheduler] tick faalde:\n{traceback.format_exc()}")
|
||||||
|
time.sleep(TICK_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def start():
|
||||||
|
"""Start de achtergrondlus (eenmalig)."""
|
||||||
|
global _gestart
|
||||||
|
if _gestart:
|
||||||
|
return
|
||||||
|
_gestart = True
|
||||||
|
threading.Thread(target=_lus, daemon=True, name="su-scheduler").start()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hulp voor dagelijkse taken op een vast tijdstip ──────────────────────────
|
||||||
|
|
||||||
|
def binnen_venster(uur: int, marge_minuten: int = 30, nu: datetime | None = None) -> bool:
|
||||||
|
"""True als de huidige tijd binnen `marge` minuten ná `uur`:00 ligt.
|
||||||
|
|
||||||
|
Voor 'draai dit rond 03:00'. De tick is 5 minuten, dus een venster van een
|
||||||
|
half uur vangt ruim af — combineer met de interval-check zodat een taak niet
|
||||||
|
meerdere keren in hetzelfde venster draait.
|
||||||
|
"""
|
||||||
|
nu = nu or datetime.now()
|
||||||
|
minuten_na = (nu.hour - uur) * 60 + nu.minute
|
||||||
|
return 0 <= minuten_na < marge_minuten
|
||||||
135
server-up/core/stackupdates.py
Normal file
135
server-up/core/stackupdates.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Updatecheck voor de geïnstalleerde stacks.
|
||||||
|
|
||||||
|
Per stack worden de images uit het compose-bestand gehaald en vergeleken:
|
||||||
|
de lokaal aanwezige digest (`RepoDigests`) tegen die in de registry
|
||||||
|
(`Docker-Content-Digest`). Zijn ze verschillend, dan is er een nieuwer image.
|
||||||
|
|
||||||
|
Drie statussen per service, zodat de UI nooit ten onrechte een update meldt:
|
||||||
|
|
||||||
|
* `update` — beide digests bekend en verschillend
|
||||||
|
* `current` — beide bekend en gelijk
|
||||||
|
* `unknown` — lokaal gebouwd image, registry onbereikbaar, of image nog niet
|
||||||
|
gepulld. Wordt niet als update getoond.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
import core as cfg
|
||||||
|
from core import docker
|
||||||
|
|
||||||
|
_CACHE: dict[str, dict] = {}
|
||||||
|
_CACHE_TS: float = 0.0
|
||||||
|
_CACHE_TTL = 6 * 3600
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _images_uit_compose(stack_dir) -> list[tuple[str, str]]:
|
||||||
|
"""[(servicenaam, image), …] uit het compose-bestand van deze stack."""
|
||||||
|
cf = docker.find_compose(stack_dir)
|
||||||
|
if not cf:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(cf.read_text(encoding="utf-8")) or {}
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
uit = []
|
||||||
|
for naam, svc in (doc.get("services") or {}).items():
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
continue
|
||||||
|
img = svc.get("image")
|
||||||
|
# Niet-ingevulde variabelen (${TAG}) kunnen we niet opzoeken.
|
||||||
|
if isinstance(img, str) and img and "${" not in img:
|
||||||
|
uit.append((naam, img))
|
||||||
|
return uit
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_creds(image: str) -> tuple[str, str] | None:
|
||||||
|
"""Inloggegevens hergebruiken als het image uit dezelfde registry komt als
|
||||||
|
waar Server Up zelf vandaan wordt bijgewerkt."""
|
||||||
|
c = cfg.load()
|
||||||
|
gebruiker = (c.get("UPDATE_REGISTRY_USER") or "").strip()
|
||||||
|
token = (c.get("UPDATE_REGISTRY_TOKEN") or "").strip()
|
||||||
|
if not gebruiker or not token:
|
||||||
|
return None
|
||||||
|
eigen = (c.get("UPDATE_IMAGE") or "").strip()
|
||||||
|
if not eigen:
|
||||||
|
return None
|
||||||
|
from core import registry
|
||||||
|
host_eigen, _, _ = registry.parse_image(eigen)
|
||||||
|
host_img, _, _ = registry.parse_image(image)
|
||||||
|
return (gebruiker, token) if host_eigen and host_eigen == host_img else None
|
||||||
|
|
||||||
|
|
||||||
|
def check_stack(stack: str, stack_dir) -> dict:
|
||||||
|
"""Vergelijk alle images van één stack. Doet netwerkverzoeken."""
|
||||||
|
diensten = []
|
||||||
|
for service, image in _images_uit_compose(stack_dir):
|
||||||
|
lokaal = docker.image_digest_local(image)
|
||||||
|
if not lokaal:
|
||||||
|
# Nooit gepulld of lokaal gebouwd — niets om mee te vergelijken.
|
||||||
|
diensten.append({"service": service, "image": image,
|
||||||
|
"status": "unknown", "reason": "geen lokale digest"})
|
||||||
|
continue
|
||||||
|
from core import registry
|
||||||
|
extern = registry.manifest_digest(image, _registry_creds(image))
|
||||||
|
if not extern:
|
||||||
|
diensten.append({"service": service, "image": image,
|
||||||
|
"status": "unknown", "reason": "registry onbereikbaar"})
|
||||||
|
continue
|
||||||
|
diensten.append({"service": service, "image": image,
|
||||||
|
"status": "update" if extern != lokaal else "current",
|
||||||
|
"local": lokaal, "remote": extern})
|
||||||
|
return {
|
||||||
|
"stack": stack,
|
||||||
|
"services": diensten,
|
||||||
|
"update_available": any(d["status"] == "update" for d in diensten),
|
||||||
|
"checked_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_all(force: bool = False, log_fn=None) -> dict[str, dict]:
|
||||||
|
"""Alle stacks langs. Resultaat wordt gecached (TTL 6 uur)."""
|
||||||
|
global _CACHE, _CACHE_TS
|
||||||
|
with _LOCK:
|
||||||
|
if not force and _CACHE and (time.time() - _CACHE_TS) < _CACHE_TTL:
|
||||||
|
return dict(_CACHE)
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
|
||||||
|
resultaat: dict[str, dict] = {}
|
||||||
|
if lib.is_dir():
|
||||||
|
for d in sorted(lib.iterdir()):
|
||||||
|
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
|
||||||
|
continue
|
||||||
|
if log_fn:
|
||||||
|
log_fn(f"Controleren: {d.name}")
|
||||||
|
try:
|
||||||
|
resultaat[d.name] = check_stack(d.name, d)
|
||||||
|
except Exception as e:
|
||||||
|
# Eén onbereikbare registry mag de rest niet blokkeren.
|
||||||
|
resultaat[d.name] = {"stack": d.name, "services": [],
|
||||||
|
"update_available": False,
|
||||||
|
"error": str(e)[:200],
|
||||||
|
"checked_at": time.time()}
|
||||||
|
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE = dict(resultaat)
|
||||||
|
_CACHE_TS = time.time()
|
||||||
|
return resultaat
|
||||||
|
|
||||||
|
|
||||||
|
def cached() -> dict[str, dict]:
|
||||||
|
"""Wat we weten zonder netwerkverzoeken te doen."""
|
||||||
|
with _LOCK:
|
||||||
|
return dict(_CACHE)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate():
|
||||||
|
global _CACHE, _CACHE_TS
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE = {}
|
||||||
|
_CACHE_TS = 0.0
|
||||||
|
|
@ -80,21 +80,18 @@ tailwind.config = {
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<template x-if="auth.needsSetup">
|
<template x-if="auth.needsSetup">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1">Eerste account aanmaken</div>
|
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1" x-text="t('setup_title')">Eerste account aanmaken</div>
|
||||||
<p class="text-xs text-t2 dark:text-t2-dark mb-4">
|
<p class="text-xs text-t2 dark:text-t2-dark mb-4" x-text="t('setup_hint')"></p>
|
||||||
Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan —
|
|
||||||
zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.
|
|
||||||
</p>
|
|
||||||
<form @submit.prevent="doSetup()" class="grid gap-3">
|
<form @submit.prevent="doSetup()" class="grid gap-3">
|
||||||
<div><label class="form-label">Gebruikersnaam</label>
|
<div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
|
||||||
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
|
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
|
||||||
<div><label class="form-label">Wachtwoord (min. 10 tekens)</label>
|
<div><label class="form-label" x-text="t('password_min')">Wachtwoord</label>
|
||||||
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
|
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
|
||||||
<div><label class="form-label">Wachtwoord herhalen</label>
|
<div><label class="form-label" x-text="t('password_repeat')">Wachtwoord herhalen</label>
|
||||||
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
|
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
|
||||||
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
|
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
|
||||||
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
|
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
|
||||||
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span>Account aanmaken
|
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span><span x-text="t('create_account')">Account aanmaken</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,15 +99,15 @@ tailwind.config = {
|
||||||
|
|
||||||
<template x-if="!auth.needsSetup">
|
<template x-if="!auth.needsSetup">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4">Inloggen</div>
|
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4" x-text="t('login')">Inloggen</div>
|
||||||
<form @submit.prevent="doLogin()" class="grid gap-3">
|
<form @submit.prevent="doLogin()" class="grid gap-3">
|
||||||
<div><label class="form-label">Gebruikersnaam</label>
|
<div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
|
||||||
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
|
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
|
||||||
<div><label class="form-label">Wachtwoord</label>
|
<div><label class="form-label" x-text="t('password')">Wachtwoord</label>
|
||||||
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
|
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
|
||||||
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
|
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
|
||||||
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
|
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
|
||||||
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span>Inloggen
|
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span><span x-text="t('login')">Inloggen</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,7 +159,7 @@ tailwind.config = {
|
||||||
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? '–'"></span> images</div>
|
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? '–'"></span> images</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
|
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
|
||||||
<button @click="logout()" :title="'Uitloggen (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button>
|
<button @click="logout()" :title="t('logout') + ' (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -246,6 +243,10 @@ tailwind.config = {
|
||||||
<input x-model="stackQuery" :placeholder="t('search')+'…'" class="form-input !pl-10">
|
<input x-model="stackQuery" :placeholder="t('search')+'…'" class="form-input !pl-10">
|
||||||
</div>
|
</div>
|
||||||
<button @click="loadStacks()" class="btn"><span class="mdi mdi-refresh"></span> <span x-text="t('refresh')">Vernieuwen</span></button>
|
<button @click="loadStacks()" class="btn"><span class="mdi mdi-refresh"></span> <span x-text="t('refresh')">Vernieuwen</span></button>
|
||||||
|
<button @click="checkStackUpdates()" class="btn" :disabled="checkingUpd">
|
||||||
|
<span class="mdi" :class="checkingUpd ? 'mdi-loading mdi-spin' : 'mdi-package-up'"></span>
|
||||||
|
<span x-text="t('check_updates')">Updates controleren</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0">
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0">
|
||||||
<template x-for="s in filteredStacks" :key="s.name">
|
<template x-for="s in filteredStacks" :key="s.name">
|
||||||
|
|
@ -257,7 +258,10 @@ tailwind.config = {
|
||||||
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
|
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
|
||||||
<!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? -->
|
<!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? -->
|
||||||
<span x-show="s.ip" class="badge badge-info font-mono !text-[10px]"
|
<span x-show="s.ip" class="badge badge-info font-mono !text-[10px]"
|
||||||
:title="'Eigen IP op netwerk ' + s.network" x-text="s.ip"></span>
|
:title="t('own_ip') + ': ' + s.network" x-text="s.ip"></span>
|
||||||
|
<span x-show="stackUpd[s.name] && stackUpd[s.name].update_available"
|
||||||
|
class="badge badge-ok" :title="stackUpdTitle(s.name)"
|
||||||
|
x-text="t('update_available')">update</span>
|
||||||
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
|
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 flex flex-col gap-3 flex-1">
|
<div class="p-4 flex flex-col gap-3 flex-1">
|
||||||
|
|
@ -276,7 +280,7 @@ tailwind.config = {
|
||||||
class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden">
|
class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden">
|
||||||
<template x-if="cpanel[s.name] && cpanel[s.name].loading">
|
<template x-if="cpanel[s.name] && cpanel[s.name].loading">
|
||||||
<div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2">
|
<div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2">
|
||||||
<span class="mdi mdi-loading mdi-spin"></span> Laden…
|
<span class="mdi mdi-loading mdi-spin"></span> <span x-text="t('loading')">Laden…</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name">
|
<template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name">
|
||||||
|
|
@ -314,6 +318,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="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('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('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-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 btn-danger ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
|
<button class="btn-sm btn-danger ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -467,12 +472,12 @@ tailwind.config = {
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||||
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
|
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
|
||||||
<span class="font-bold text-wh dark:text-wh-dark text-sm">Beveiliging</span>
|
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('security')">Beveiliging</span>
|
||||||
<span class="badge badge-dim ml-auto" x-text="'ingelogd als ' + auth.user"></span>
|
<span class="badge badge-dim ml-auto" x-text="t('logged_in_as', {user: auth.user})"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 grid md:grid-cols-2 gap-6">
|
<div class="p-5 grid md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<div class="form-label">Gebruikers</div>
|
<div class="form-label" x-text="t('users')">Gebruikers</div>
|
||||||
<div class="space-y-1.5 mb-3">
|
<div class="space-y-1.5 mb-3">
|
||||||
<template x-for="u in users" :key="u">
|
<template x-for="u in users" :key="u">
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
|
@ -484,36 +489,111 @@ tailwind.config = {
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<input class="form-input" x-model="newUser.username" placeholder="Gebruikersnaam">
|
<input class="form-input" x-model="newUser.username" :placeholder="t('username')">
|
||||||
<input type="password" class="form-input" x-model="newUser.password" placeholder="Wachtwoord (min. 10 tekens)">
|
<input type="password" class="form-input" x-model="newUser.password" :placeholder="t('password_min')">
|
||||||
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> Gebruiker toevoegen</button>
|
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> <span x-text="t('add_user')">Gebruiker toevoegen</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="form-label">Eigen wachtwoord wijzigen</div>
|
<div class="form-label" x-text="t('change_own_password')">Eigen wachtwoord wijzigen</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<input type="password" class="form-input" x-model="pwForm.current" placeholder="Huidig wachtwoord">
|
<input type="password" class="form-input" x-model="pwForm.current" :placeholder="t('current_password')">
|
||||||
<input type="password" class="form-input" x-model="pwForm.new" placeholder="Nieuw wachtwoord">
|
<input type="password" class="form-input" x-model="pwForm.new" :placeholder="t('new_password')">
|
||||||
<input type="password" class="form-input" x-model="pwForm.new2" placeholder="Nieuw wachtwoord herhalen">
|
<input type="password" class="form-input" x-model="pwForm.new2" :placeholder="t('new_password_repeat')">
|
||||||
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> Wijzigen</button>
|
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> <span x-text="t('change')">Wijzigen</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reverse-proxy-SSO. De header wordt alleen vertrouwd vanaf de
|
||||||
|
adressen hieronder; zonder die lijst weigert de server de modus. -->
|
||||||
|
<div class="px-5 pb-5 pt-4 border-t border-bd dark:border-bd-dark">
|
||||||
|
<div class="form-label" x-text="t('auth_mode')">Hoe wordt er ingelogd?</div>
|
||||||
|
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
|
||||||
|
<button class="seg" :class="authCfg.mode==='local' ? 'seg-on' : ''"
|
||||||
|
@click="authCfg.mode='local'"><span class="mdi mdi-account-key-outline"></span> <span x-text="t('auth_mode_local')">Lokaal</span></button>
|
||||||
|
<button class="seg" :class="authCfg.mode==='proxy' ? 'seg-on' : ''"
|
||||||
|
@click="authCfg.mode='proxy'"><span class="mdi mdi-shield-link-variant-outline"></span> <span x-text="t('auth_mode_proxy')">Proxy</span></button>
|
||||||
|
<button class="seg" :class="authCfg.mode==='both' ? 'seg-on' : ''"
|
||||||
|
@click="authCfg.mode='both'"><span class="mdi mdi-check-all"></span> <span x-text="t('auth_mode_both')">Beide</span></button>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-dm dark:text-dm-dark mb-3" x-text="t('auth_mode_hint')"></p>
|
||||||
|
<div x-show="authCfg.mode !== 'local'" x-cloak class="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" x-text="t('proxy_header')">Identiteitsheader</label>
|
||||||
|
<input class="form-input font-mono" x-model="authCfg.proxy_header" placeholder="Remote-User">
|
||||||
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('proxy_header_hint')"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" x-text="t('trusted_proxies')">Vertrouwde proxy-adressen</label>
|
||||||
|
<textarea class="form-input font-mono !h-auto py-2" rows="3"
|
||||||
|
x-model="authCfg.trusted_text" placeholder="10.0.20.1 172.18.0.0/16"></textarea>
|
||||||
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('trusted_proxies_hint')"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm mt-3" @click="saveAuthMode()">
|
||||||
|
<span class="mdi mdi-content-save-outline"></span> <span x-text="t('save')">Opslaan</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backups: bewaarbeleid en planning -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||||
|
<span class="mdi mdi-archive-outline text-ac dark:text-ac-dark text-lg"></span>
|
||||||
|
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('backups')">Backups</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<p class="text-[11px] text-wr flex items-start gap-1.5">
|
||||||
|
<span class="mdi mdi-information-outline shrink-0"></span>
|
||||||
|
<span x-text="t('backup_scope_warning')"></span>
|
||||||
|
</p>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" x-text="t('backup_keep')">Aantal per stack</label>
|
||||||
|
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_KEEP" @change="autoSave()">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" x-text="t('backup_max_age')">Maximale leeftijd</label>
|
||||||
|
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_MAX_AGE_DAYS" @change="autoSave()">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" x-text="t('backup_schedule')">Geplande backups</label>
|
||||||
|
<select class="form-input" x-model="settings.BACKUP_SCHEDULE" @change="autoSave()">
|
||||||
|
<option value="off" x-text="t('backup_schedule_off')">Uit</option>
|
||||||
|
<option value="daily" x-text="t('backup_schedule_daily')">Dagelijks</option>
|
||||||
|
<option value="weekly" x-text="t('backup_schedule_weekly')">Wekelijks</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="settings.BACKUP_SCHEDULE && settings.BACKUP_SCHEDULE !== 'off'">
|
||||||
|
<label class="form-label" x-text="t('backup_schedule_hour')">Rond welk uur</label>
|
||||||
|
<input type="number" min="0" max="23" class="form-input" x-model.number="settings.BACKUP_SCHEDULE_HOUR" @change="autoSave()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_UPDATE" @change="settings.BACKUP_BEFORE_UPDATE=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
|
||||||
|
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_before_update')"></span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_REMOVE" @change="settings.BACKUP_BEFORE_REMOVE=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
|
||||||
|
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_before_remove')"></span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="!!settings.STACK_UPDATE_CHECK" @change="settings.STACK_UPDATE_CHECK=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
|
||||||
|
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('stack_update_check')"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Netwerken: stacks een eigen IP-adres geven -->
|
<!-- Netwerken: stacks een eigen IP-adres geven -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||||
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
|
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
|
||||||
<span class="font-bold text-wh dark:text-wh-dark text-sm">Netwerken</span>
|
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('networks')">Netwerken</span>
|
||||||
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> Netwerk</button>
|
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> <span x-text="t('network')">Netwerk</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<p class="text-xs text-t2 dark:text-t2-dark mb-3">
|
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('networks_hint')"></p>
|
||||||
Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN
|
|
||||||
in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app
|
|
||||||
firewallen. De Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.
|
|
||||||
</p>
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<template x-for="n in netList" :key="n.name">
|
<template x-for="n in netList" :key="n.name">
|
||||||
<div class="flex flex-wrap 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">
|
<div class="flex flex-wrap 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">
|
||||||
|
|
@ -521,12 +601,12 @@ tailwind.config = {
|
||||||
<span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span>
|
<span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span>
|
||||||
<span class="badge badge-dim" x-text="n.driver"></span>
|
<span class="badge badge-dim" x-text="n.driver"></span>
|
||||||
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span>
|
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span>
|
||||||
<span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip">volgend vrij: <span class="font-mono" x-text="n.next_ip"></span></span>
|
<span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip"><span x-text="t('next_free')"></span>: <span class="font-mono" x-text="n.next_ip"></span></span>
|
||||||
<button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button>
|
<button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark">Nog geen netwerken.</div>
|
<div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark" x-text="t('no_networks')">Nog geen netwerken.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -553,7 +633,7 @@ tailwind.config = {
|
||||||
<div class="flex flex-wrap gap-2" x-show="update.update_available">
|
<div class="flex flex-wrap gap-2" x-show="update.update_available">
|
||||||
<button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating">
|
<button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating">
|
||||||
<span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span>
|
<span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span>
|
||||||
<span x-text="updating ? 'Bezig…' : ('Nu bijwerken naar v'+update.latest)"></span>
|
<span x-text="updating ? t('updating') : t('update_now', {version: update.latest})"></span>
|
||||||
</button>
|
</button>
|
||||||
<a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a>
|
<a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -562,27 +642,24 @@ tailwind.config = {
|
||||||
<p x-show="update.update_available && !update.can_apply"
|
<p x-show="update.update_available && !update.can_apply"
|
||||||
class="text-xs text-wr flex items-start gap-1.5">
|
class="text-xs text-wr flex items-start gap-1.5">
|
||||||
<span class="mdi mdi-alert-outline shrink-0"></span>
|
<span class="mdi mdi-alert-outline shrink-0"></span>
|
||||||
<span><span x-text="update.apply_reason"></span>
|
<span><span x-text="update.apply_reason"></span> <span x-text="t('update_cannot_apply')"></span></span>
|
||||||
Bijwerken kan wel via een git-tag; zie <span class="font-mono">docs/updates.md</span>.</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p x-show="update.error" class="text-xs text-er" x-text="update.error"></p>
|
<p x-show="update.error" class="text-xs text-er" x-text="update.error"></p>
|
||||||
<p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark">
|
<p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark">
|
||||||
Laatst gecontroleerd: <span x-text="fmtTime(update.checked_at)"></span>
|
<span x-text="t('last_checked')"></span>: <span x-text="fmtTime(update.checked_at)"></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
|
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Kanaal</label>
|
<label class="form-label" x-text="t('channel')">Kanaal</label>
|
||||||
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
|
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
|
||||||
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
|
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
|
||||||
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
|
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
|
||||||
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
|
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
|
||||||
@click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button>
|
@click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"
|
||||||
<span x-show="updChannel()==='stable'">Alleen stabiele releases.</span>
|
x-text="updChannel()==='beta' ? t('channel_beta_hint') : t('channel_stable_hint')"></p>
|
||||||
<span x-show="updChannel()==='beta'">Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" x-text="t('update_api_url')">Releases-API URL</label>
|
<label class="form-label" x-text="t('update_api_url')">Releases-API URL</label>
|
||||||
|
|
@ -590,31 +667,29 @@ tailwind.config = {
|
||||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p>
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Image (voor bijwerken vanuit de interface)</label>
|
<label class="form-label" x-text="t('update_image')">Image</label>
|
||||||
<input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up">
|
<input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up">
|
||||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_image_hint')"></p>
|
||||||
Zonder tag. Leeg = afleiden uit de draaiende container.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid sm:grid-cols-2 gap-3">
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Registry-gebruiker (optioneel)</label>
|
<label class="form-label" x-text="t('registry_user')">Registry-gebruiker</label>
|
||||||
<input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r">
|
<input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Registry-token</label>
|
<label class="form-label" x-text="t('registry_token')">Registry-token</label>
|
||||||
<input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()"
|
<input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()"
|
||||||
:placeholder="settings.has_update_registry_token ? '•••••••• (ingesteld)' : 'alleen bij een privé registry'">
|
:placeholder="settings.has_update_registry_token ? t('registry_token_set') : t('registry_token_ph')">
|
||||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">Leeg laten = ongewijzigd.</p>
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('registry_token_hint')"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark">
|
<div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark">
|
||||||
<label class="form-label">Terugrollen</label>
|
<label class="form-label" x-text="t('rollback')">Terugrollen</label>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="text-xs text-t2 dark:text-t2-dark">Vorige versie:
|
<span class="text-xs text-t2 dark:text-t2-dark"><span x-text="t('previous_version')"></span>:
|
||||||
<span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span>
|
<span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span>
|
||||||
<button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating">
|
<button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating">
|
||||||
<span class="mdi mdi-undo-variant"></span> Terug naar deze versie
|
<span class="mdi mdi-undo-variant"></span> <span x-text="t('rollback_button')">Terug naar deze versie</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -739,37 +814,78 @@ tailwind.config = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Backups van één stack -->
|
||||||
|
<div x-show="modal==='backups'" class="modal max-w-xl flex flex-col max-h-[85vh]">
|
||||||
|
<div class="modal-head">
|
||||||
|
<span class="mdi mdi-history text-ac dark:text-ac-dark"></span>
|
||||||
|
<span x-text="t('backups')">Backups</span>
|
||||||
|
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="bkStack"></span>
|
||||||
|
<button class="btn-sm ml-auto" @click="createBackup(bkStack)">
|
||||||
|
<span class="mdi mdi-archive-arrow-down-outline"></span> <span x-text="t('backup')">Backup</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body overflow-y-auto">
|
||||||
|
<p class="text-[11px] text-wr mb-3 flex items-start gap-1.5">
|
||||||
|
<span class="mdi mdi-information-outline shrink-0"></span>
|
||||||
|
<span x-text="t('backup_scope_warning')"></span>
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="b in bkList" :key="b.file">
|
||||||
|
<div class="flex flex-wrap 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">
|
||||||
|
<span class="mdi mdi-package-variant-closed text-t2 dark:text-t2-dark"></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-xs font-mono text-tx dark:text-tx-dark truncate" x-text="b.file"></div>
|
||||||
|
<div class="text-[10px] text-dm dark:text-dm-dark">
|
||||||
|
<span x-text="fmtTime(b.ts)"></span> · <span x-text="b.size_human"></span>
|
||||||
|
<span x-show="b.reason"> · <span x-text="b.reason"></span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm !h-8" @click="restoreBackup(bkStack, b.file)">
|
||||||
|
<span class="mdi mdi-restore"></span> <span x-text="t('restore')">Terugzetten</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm !h-8 !px-2 btn-danger" @click="deleteBackup(b.file)">
|
||||||
|
<span class="mdi mdi-delete-outline"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div x-show="bkList.length === 0" class="empty !py-8">
|
||||||
|
<span class="mdi mdi-archive-outline"></span><p x-text="t('no_backups')">Nog geen backups</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Netwerk toevoegen -->
|
<!-- Netwerk toevoegen -->
|
||||||
<div x-show="modal==='add-network'" class="modal max-w-md">
|
<div x-show="modal==='add-network'" class="modal max-w-md">
|
||||||
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> Netwerk toevoegen</div>
|
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> <span x-text="t('add_network')">Netwerk toevoegen</span></div>
|
||||||
<div class="modal-body space-y-3">
|
<div class="modal-body space-y-3">
|
||||||
<div><label class="form-label">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div>
|
<div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div><label class="form-label">Driver</label>
|
<div><label class="form-label" x-text="t('driver')">Driver</label>
|
||||||
<select class="form-input" x-model="newNet.driver">
|
<select class="form-input" x-model="newNet.driver">
|
||||||
<option value="macvlan">macvlan</option>
|
<option value="macvlan">macvlan</option>
|
||||||
<option value="ipvlan">ipvlan</option>
|
<option value="ipvlan">ipvlan</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label class="form-label">Host-interface</label>
|
<div><label class="form-label" x-text="t('host_interface')">Host-interface</label>
|
||||||
<input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces">
|
<input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces">
|
||||||
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
|
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div><label class="form-label">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div>
|
<div><label class="form-label" x-text="t('subnet')">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div>
|
||||||
<div><label class="form-label">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div>
|
<div><label class="form-label" x-text="t('gateway')">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div>
|
||||||
</div>
|
</div>
|
||||||
<div><label class="form-label">IP-range (optioneel)</label>
|
<div><label class="form-label" x-text="t('ip_range')">IP-range</label>
|
||||||
<input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28">
|
<input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28">
|
||||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p>
|
||||||
Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server
|
|
||||||
hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||||||
<button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> Aanmaken</button>
|
<button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Aanmaken</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -790,28 +906,22 @@ tailwind.config = {
|
||||||
|
|
||||||
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
|
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Netwerk</label>
|
<label class="form-label" x-text="t('network')">Netwerk</label>
|
||||||
<select class="form-input" x-model="install.network" @change="pickIp()">
|
<select class="form-input" x-model="install.network" @change="pickIp()">
|
||||||
<option value="">Poorten op de host (standaard)</option>
|
<option value="" x-text="t('ports_on_host')">Poorten op de host</option>
|
||||||
<template x-for="n in netList" :key="n.name">
|
<template x-for="n in netList" :key="n.name">
|
||||||
<option :value="n.name" x-text="n.name + ' — eigen IP (' + n.subnet + ')'"></option>
|
<option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
<template x-if="install.network">
|
<template x-if="install.network">
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<label class="form-label">IP-adres</label>
|
<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">
|
<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">
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
|
||||||
De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app
|
|
||||||
is bereikbaar op zijn eigen poort. Let op: de Docker-host zelf kan een
|
|
||||||
macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p x-show="!install.network && netList.length === 0"
|
<p x-show="!install.network && netList.length === 0"
|
||||||
class="text-[11px] text-dm dark:text-dm-dark mt-1">
|
class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
|
||||||
Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
|
|
@ -845,13 +955,12 @@ tailwind.config = {
|
||||||
<template x-if="f.type === 'str' && f.secret">
|
<template x-if="f.type === 'str' && f.secret">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
|
<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" title="Genereer een willekeurige waarde"
|
<button type="button" class="btn-sm shrink-0" :title="t('generate_value')"
|
||||||
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
|
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1">
|
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
|
||||||
Poort <span x-text="f.port_taken"></span> is al in gebruik — <span x-text="f.suggested"></span> voorgesteld.
|
x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></p>
|
||||||
</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>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1079,10 +1188,15 @@ function app() {
|
||||||
loginForm: { username: '', password: '', password2: '', error: '', busy: false },
|
loginForm: { username: '', password: '', password2: '', error: '', busy: false },
|
||||||
users: [], newUser: { username: '', password: '' },
|
users: [], newUser: { username: '', password: '' },
|
||||||
pwForm: { current: '', new: '', new2: '' },
|
pwForm: { current: '', new: '', new2: '' },
|
||||||
|
authCfg: { mode: 'local', proxy_header: 'Remote-User', trusted_text: '' },
|
||||||
|
|
||||||
// Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} }
|
// Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} }
|
||||||
cpanel: {},
|
cpanel: {},
|
||||||
|
|
||||||
|
// Backups en updatestatus per stack
|
||||||
|
bkStack: '', bkList: [], bkSettings: {},
|
||||||
|
stackUpd: {}, checkingUpd: false,
|
||||||
|
|
||||||
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
|
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
|
||||||
netList: [], netIfaces: [],
|
netList: [], netIfaces: [],
|
||||||
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
|
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
|
||||||
|
|
@ -1156,7 +1270,7 @@ function app() {
|
||||||
this.loadUpdate();
|
this.loadUpdate();
|
||||||
setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000);
|
setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000);
|
||||||
this.$watch('page', async (p) => {
|
this.$watch('page', async (p) => {
|
||||||
if (p === 'stacks') this.loadStacks();
|
if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); }
|
||||||
if (p === 'app_store') this.loadAppStore();
|
if (p === 'app_store') this.loadAppStore();
|
||||||
if (p === 'docker') this.loadImages();
|
if (p === 'docker') this.loadImages();
|
||||||
if (p === 'audit') this.loadAudit();
|
if (p === 'audit') this.loadAudit();
|
||||||
|
|
@ -1171,7 +1285,15 @@ function app() {
|
||||||
setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); },
|
setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); },
|
||||||
toggleTheme() { this.setThemePref(this.theme === 'dark' ? 'light' : 'dark'); },
|
toggleTheme() { this.setThemePref(this.theme === 'dark' ? 'light' : 'dark'); },
|
||||||
|
|
||||||
t(k) { return this.T[k] || k; },
|
// t('key') zoekt de vertaling op; t('key', {n: 3}) vervangt daarnaast
|
||||||
|
// {n}-plaatshouders, zodat een zin niet in losse stukjes hoeft.
|
||||||
|
t(k, vars) {
|
||||||
|
let s = this.T[k] || k;
|
||||||
|
if (vars) for (const [naam, waarde] of Object.entries(vars)) {
|
||||||
|
s = s.split('{' + naam + '}').join(waarde);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
},
|
||||||
async loadLangs() { try { const d = await fetch('/api/i18n').then(r=>r.json()); this.languages = d.languages || []; } catch(e) {} },
|
async loadLangs() { try { const d = await fetch('/api/i18n').then(r=>r.json()); this.languages = d.languages || []; } catch(e) {} },
|
||||||
async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} },
|
async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} },
|
||||||
setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); },
|
setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); },
|
||||||
|
|
@ -1248,7 +1370,7 @@ function app() {
|
||||||
const d = await this.req('/api/auth/login', { method:'POST', json: {
|
const d = await this.req('/api/auth/login', { method:'POST', json: {
|
||||||
username: this.loginForm.username, password: this.loginForm.password } });
|
username: this.loginForm.username, password: this.loginForm.password } });
|
||||||
this.loginForm.busy = false;
|
this.loginForm.busy = false;
|
||||||
if (!d.ok) { this.loginForm.error = d.msg || 'Inloggen mislukt'; return; }
|
if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
|
||||||
this.auth.authenticated = true;
|
this.auth.authenticated = true;
|
||||||
this.auth.user = d.user; this.auth.csrf = d.csrf_token || '';
|
this.auth.user = d.user; this.auth.csrf = d.csrf_token || '';
|
||||||
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
|
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
|
||||||
|
|
@ -1257,13 +1379,13 @@ function app() {
|
||||||
async doSetup() {
|
async doSetup() {
|
||||||
this.loginForm.error = '';
|
this.loginForm.error = '';
|
||||||
if (this.loginForm.password !== this.loginForm.password2) {
|
if (this.loginForm.password !== this.loginForm.password2) {
|
||||||
this.loginForm.error = 'De wachtwoorden komen niet overeen.'; return;
|
this.loginForm.error = this.t('passwords_differ'); return;
|
||||||
}
|
}
|
||||||
this.loginForm.busy = true;
|
this.loginForm.busy = true;
|
||||||
const d = await this.req('/api/auth/setup', { method:'POST', json: {
|
const d = await this.req('/api/auth/setup', { method:'POST', json: {
|
||||||
username: this.loginForm.username, password: this.loginForm.password } });
|
username: this.loginForm.username, password: this.loginForm.password } });
|
||||||
this.loginForm.busy = false;
|
this.loginForm.busy = false;
|
||||||
if (!d.ok) { this.loginForm.error = d.msg || 'Aanmaken mislukt'; return; }
|
if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
|
||||||
this.auth.authenticated = true; this.auth.needsSetup = false;
|
this.auth.authenticated = true; this.auth.needsSetup = false;
|
||||||
this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || '';
|
this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || '';
|
||||||
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
|
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
|
||||||
|
|
@ -1273,23 +1395,47 @@ function app() {
|
||||||
await this.req('/api/auth/logout', { method:'POST', json: {} });
|
await this.req('/api/auth/logout', { method:'POST', json: {} });
|
||||||
this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = '';
|
this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = '';
|
||||||
},
|
},
|
||||||
async loadUsers() { const d = await this.rpc('/api/auth/users'); this.users = d.users || []; },
|
async loadUsers() {
|
||||||
|
const d = await this.rpc('/api/auth/users');
|
||||||
|
this.users = d.users || [];
|
||||||
|
this.authCfg = {
|
||||||
|
mode: d.mode || 'local',
|
||||||
|
proxy_header: d.proxy_header || 'Remote-User',
|
||||||
|
trusted_text: (d.trusted_proxies || []).join('\n'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async saveAuthMode() {
|
||||||
|
const body = {
|
||||||
|
mode: this.authCfg.mode,
|
||||||
|
proxy_header: this.authCfg.proxy_header,
|
||||||
|
trusted_proxies: this.authCfg.trusted_text.split('\n').map(s => s.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
// De server weigert 'proxy' zonder bevestiging: die modus schakelt de
|
||||||
|
// lokale login uit, dus je sluit jezelf buiten als de proxy niet werkt.
|
||||||
|
if (body.mode === 'proxy') {
|
||||||
|
if (!confirm(this.t('proxy_lockout_confirm'))) return;
|
||||||
|
body.confirm_lockout = true;
|
||||||
|
}
|
||||||
|
const d = await this.req('/api/auth/mode', { method: 'PUT', json: body });
|
||||||
|
this.toast(d.msg || this.t(d.ok ? 'save' : 'error'), d.ok ? 'ok' : 'er');
|
||||||
|
if (d.ok) this.loadUsers();
|
||||||
|
},
|
||||||
async addUser() {
|
async addUser() {
|
||||||
const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser });
|
const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser });
|
||||||
this.toast(d.msg || (d.ok ? 'Toegevoegd' : 'Fout'), d.ok ? 'ok' : 'er');
|
this.toast(d.msg || this.t(d.ok ? 'add_user' : 'error'), d.ok ? 'ok' : 'er');
|
||||||
if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); }
|
if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); }
|
||||||
},
|
},
|
||||||
async delUser(name) {
|
async delUser(name) {
|
||||||
if (!confirm('Gebruiker ' + name + ' verwijderen?')) return;
|
if (!confirm(this.t('delete_user_confirm', {name}))) return;
|
||||||
const d = await this.req('/api/auth/users/' + encodeURIComponent(name), { method:'DELETE' });
|
const d = await this.req('/api/auth/users/' + encodeURIComponent(name), { method:'DELETE' });
|
||||||
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
|
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
},
|
},
|
||||||
async changePassword() {
|
async changePassword() {
|
||||||
if (this.pwForm.new !== this.pwForm.new2) { this.toast('Wachtwoorden komen niet overeen', 'er'); return; }
|
if (this.pwForm.new !== this.pwForm.new2) { this.toast(this.t('passwords_differ'), 'er'); return; }
|
||||||
const d = await this.req('/api/auth/password', { method:'POST', json: {
|
const d = await this.req('/api/auth/password', { method:'POST', json: {
|
||||||
current: this.pwForm.current, new: this.pwForm.new } });
|
current: this.pwForm.current, new: this.pwForm.new } });
|
||||||
this.toast(d.msg || (d.ok ? 'Gewijzigd' : 'Fout'), d.ok ? 'ok' : 'er');
|
this.toast(d.msg || this.t(d.ok ? 'change' : 'error'), d.ok ? 'ok' : 'er');
|
||||||
if (d.ok) this.pwForm = { current:'', new:'', new2:'' };
|
if (d.ok) this.pwForm = { current:'', new:'', new2:'' };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1309,7 +1455,7 @@ function app() {
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
this.auth.authenticated = false;
|
this.auth.authenticated = false;
|
||||||
await this.loadAuth();
|
await this.loadAuth();
|
||||||
return { ok: false, msg: 'Sessie verlopen — log opnieuw in' };
|
return { ok: false, msg: this.t('session_expired') };
|
||||||
}
|
}
|
||||||
return await r.json();
|
return await r.json();
|
||||||
} catch(e) { return { ok: false, error: e.message, msg: e.message }; }
|
} catch(e) { return { ok: false, error: e.message, msg: e.message }; }
|
||||||
|
|
@ -1379,6 +1525,60 @@ function app() {
|
||||||
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
|
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
|
||||||
else this.toast(d.msg || 'Fout bij opslaan', 'er');
|
else this.toast(d.msg || 'Fout bij opslaan', 'er');
|
||||||
},
|
},
|
||||||
|
// ── Backups ──────────────────────────────────────────────────────────────
|
||||||
|
async openBackups(stack) {
|
||||||
|
this.bkStack = stack;
|
||||||
|
this.bkList = [];
|
||||||
|
this.modal = 'backups';
|
||||||
|
await this.loadBackups(stack);
|
||||||
|
},
|
||||||
|
async loadBackups(stack) {
|
||||||
|
const d = await this.rpc('/api/backups?stack=' + encodeURIComponent(stack));
|
||||||
|
this.bkList = d.backups || [];
|
||||||
|
this.bkSettings = d.settings || {};
|
||||||
|
},
|
||||||
|
async createBackup(stack) {
|
||||||
|
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/create`, {});
|
||||||
|
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||||
|
this.pollJob(d.job_id, () => this.loadBackups(stack));
|
||||||
|
},
|
||||||
|
async restoreBackup(stack, file) {
|
||||||
|
if (!confirm(this.t('restore_confirm', {file}))) return;
|
||||||
|
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/restore`, {file});
|
||||||
|
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||||
|
this.modal = null;
|
||||||
|
this.termOpen = true;
|
||||||
|
this.pollJob(d.job_id, () => { this.loadStacks(); this.toast(this.t('restore'), 'ok'); });
|
||||||
|
},
|
||||||
|
async deleteBackup(file) {
|
||||||
|
if (!confirm(this.t('delete_backup_confirm'))) return;
|
||||||
|
const d = await this.req('/api/backups/' + encodeURIComponent(file), { method:'DELETE' });
|
||||||
|
this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
|
||||||
|
this.loadBackups(this.bkStack);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Updates van geïnstalleerde stacks ────────────────────────────────────
|
||||||
|
async loadStackUpdates() {
|
||||||
|
const d = await this.rpc('/api/stacks/updates');
|
||||||
|
this.stackUpd = d.stacks || {};
|
||||||
|
},
|
||||||
|
async checkStackUpdates() {
|
||||||
|
this.checkingUpd = true;
|
||||||
|
const d = await this.rpc('/api/stacks/updates/check', {});
|
||||||
|
if (!d.job_id) { this.checkingUpd = false; this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||||
|
this.termOpen = true;
|
||||||
|
this.pollJob(d.job_id, async () => {
|
||||||
|
this.checkingUpd = false;
|
||||||
|
await this.loadStackUpdates();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stackUpdTitle(naam) {
|
||||||
|
const r = this.stackUpd[naam];
|
||||||
|
if (!r) return '';
|
||||||
|
return (r.services || []).filter(s => s.status === 'update')
|
||||||
|
.map(s => s.service + ': ' + s.image).join('\n');
|
||||||
|
},
|
||||||
|
|
||||||
// ── Netwerken ────────────────────────────────────────────────────────────
|
// ── Netwerken ────────────────────────────────────────────────────────────
|
||||||
async loadNetworks() {
|
async loadNetworks() {
|
||||||
const d = await this.rpc('/api/networks');
|
const d = await this.rpc('/api/networks');
|
||||||
|
|
@ -1392,7 +1592,7 @@ function app() {
|
||||||
},
|
},
|
||||||
async addNetwork() {
|
async addNetwork() {
|
||||||
const d = await this.rpc('/api/networks', this.newNet);
|
const d = await this.rpc('/api/networks', this.newNet);
|
||||||
this.toast(d.msg || (d.ok ? 'Aangemaakt' : 'Fout'), d.ok ? 'ok' : 'er');
|
this.toast(d.msg || this.t(d.ok ? 'add' : 'error'), d.ok ? 'ok' : 'er');
|
||||||
if (d.ok) {
|
if (d.ok) {
|
||||||
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
|
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
|
|
@ -1400,9 +1600,9 @@ function app() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async deleteNetwork(name) {
|
async deleteNetwork(name) {
|
||||||
if (!confirm('Netwerk ' + name + ' verwijderen?')) return;
|
if (!confirm(this.t('delete_network_confirm', {name}))) return;
|
||||||
const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' });
|
const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' });
|
||||||
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
|
this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
|
||||||
this.loadNetworks();
|
this.loadNetworks();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1431,7 +1631,7 @@ function app() {
|
||||||
async containerAction(stack, container, action) {
|
async containerAction(stack, container, action) {
|
||||||
const d = await this.rpc(
|
const d = await this.rpc(
|
||||||
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {});
|
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {});
|
||||||
this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || 'Fout'), d.ok ? 'ok' : 'er');
|
this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || this.t('error')), d.ok ? 'ok' : 'er');
|
||||||
await this.loadContainers(stack);
|
await this.loadContainers(stack);
|
||||||
this.loadStacks();
|
this.loadStacks();
|
||||||
},
|
},
|
||||||
|
|
@ -1611,8 +1811,7 @@ function app() {
|
||||||
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.
|
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.
|
||||||
async applyUpdate() {
|
async applyUpdate() {
|
||||||
const naar = this.update.latest_tag || this.update.latest;
|
const naar = this.update.latest_tag || this.update.latest;
|
||||||
if (!confirm('Server Up bijwerken naar ' + naar + '?\n\n'
|
if (!confirm(this.t('update_confirm', {tag: naar}))) return;
|
||||||
+ 'De container wordt vervangen en is enkele seconden niet bereikbaar.')) return;
|
|
||||||
this.updating = true;
|
this.updating = true;
|
||||||
const d = await this.rpc('/api/update/apply', { tag: naar });
|
const d = await this.rpc('/api/update/apply', { tag: naar });
|
||||||
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
|
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
|
||||||
|
|
@ -1620,7 +1819,7 @@ function app() {
|
||||||
this.pollJob(d.job_id, () => this.waitForRestart());
|
this.pollJob(d.job_id, () => this.waitForRestart());
|
||||||
},
|
},
|
||||||
async rollbackUpdate() {
|
async rollbackUpdate() {
|
||||||
if (!confirm('Terugrollen naar ' + this.update.previous_tag + '?')) return;
|
if (!confirm(this.t('rollback_confirm', {tag: this.update.previous_tag}))) return;
|
||||||
this.updating = true;
|
this.updating = true;
|
||||||
const d = await this.rpc('/api/update/rollback', {});
|
const d = await this.rpc('/api/update/rollback', {});
|
||||||
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
|
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
|
||||||
|
|
@ -1628,7 +1827,7 @@ function app() {
|
||||||
this.pollJob(d.job_id, () => this.waitForRestart());
|
this.pollJob(d.job_id, () => this.waitForRestart());
|
||||||
},
|
},
|
||||||
async waitForRestart() {
|
async waitForRestart() {
|
||||||
this.toast('Server Up herstart…', 'in');
|
this.toast(this.t('restarting'), 'in');
|
||||||
// Even wachten tot de oude container écht weg is, anders zien we hem nog.
|
// Even wachten tot de oude container écht weg is, anders zien we hem nog.
|
||||||
await new Promise(r => setTimeout(r, 8000));
|
await new Promise(r => setTimeout(r, 8000));
|
||||||
for (let i = 0; i < 60; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
|
|
@ -1638,7 +1837,7 @@ function app() {
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.version && d.version !== this.update.current) {
|
if (d.version && d.version !== this.update.current) {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
this.toast('Bijgewerkt naar v' + d.version, 'ok');
|
this.toast(this.t('updated_to', {version: d.version}), 'ok');
|
||||||
setTimeout(() => location.reload(), 1200);
|
setTimeout(() => location.reload(), 1200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1647,7 +1846,7 @@ function app() {
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
}
|
}
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
this.toast('Herstart duurt lang — ververs de pagina zelf', 'wr');
|
this.toast(this.t('restart_slow'), 'wr');
|
||||||
},
|
},
|
||||||
|
|
||||||
async checkWizard() {
|
async checkWizard() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
{
|
{
|
||||||
"_meta": {"code": "en", "name": "English", "flag": "🇬🇧"},
|
"_meta": {
|
||||||
|
"code": "en",
|
||||||
|
"name": "English",
|
||||||
|
"flag": "🇬🇧"
|
||||||
|
},
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"stacks": "Stacks",
|
"stacks": "Stacks",
|
||||||
"app_store": "App Store",
|
"app_store": "App Store",
|
||||||
|
|
@ -115,5 +119,97 @@
|
||||||
"view_release": "View release",
|
"view_release": "View release",
|
||||||
"update_api_url": "Releases API URL",
|
"update_api_url": "Releases API URL",
|
||||||
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.",
|
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.",
|
||||||
"include_prerelease": "Include pre-releases (beta)"
|
"include_prerelease": "Include pre-releases (beta)",
|
||||||
|
"login": "Sign in",
|
||||||
|
"login_failed": "Sign-in failed",
|
||||||
|
"logout": "Sign out",
|
||||||
|
"session_expired": "Session expired — please sign in again",
|
||||||
|
"setup_title": "Create the first account",
|
||||||
|
"setup_hint": "Server Up manages Docker on this machine. Create an administrator account now — until you do, anyone who can reach this page can claim it.",
|
||||||
|
"create_account": "Create account",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"password_min": "Password (min. 10 characters)",
|
||||||
|
"password_repeat": "Repeat password",
|
||||||
|
"passwords_differ": "The passwords do not match.",
|
||||||
|
"security": "Security",
|
||||||
|
"logged_in_as": "signed in as {user}",
|
||||||
|
"users": "Users",
|
||||||
|
"add_user": "Add user",
|
||||||
|
"delete_user_confirm": "Delete user {name}?",
|
||||||
|
"change_own_password": "Change your password",
|
||||||
|
"current_password": "Current password",
|
||||||
|
"new_password": "New password",
|
||||||
|
"new_password_repeat": "Repeat new password",
|
||||||
|
"change": "Change",
|
||||||
|
"auth_mode": "How do people sign in?",
|
||||||
|
"auth_mode_local": "Local login",
|
||||||
|
"auth_mode_proxy": "Reverse proxy",
|
||||||
|
"auth_mode_both": "Both",
|
||||||
|
"auth_mode_hint": "With a reverse proxy (Authelia, Authentik, Cloudflare Access) the identity comes from a header. Pick 'Both' to test that without losing your local login.",
|
||||||
|
"proxy_header": "Identity header",
|
||||||
|
"proxy_header_hint": "Usually Remote-User. Make sure your proxy strips this header from incoming requests and sets it itself.",
|
||||||
|
"trusted_proxies": "Trusted proxy addresses",
|
||||||
|
"trusted_proxies_hint": "One per line, IP or CIDR. The header is only trusted from these addresses — without this list anyone could impersonate an administrator.",
|
||||||
|
"proxy_lockout_confirm": "In 'Reverse proxy' mode the local login is disabled. Are you sure your proxy works?",
|
||||||
|
"networks": "Networks",
|
||||||
|
"networks_hint": "A macvlan or ipvlan network gives a stack its own IP address on your LAN instead of ports on the host. No more port conflicts, and you can firewall per app. Note: the Docker host itself cannot reach macvlan containers without a shim interface.",
|
||||||
|
"add_network": "Add network",
|
||||||
|
"network": "Network",
|
||||||
|
"no_networks": "No networks yet.",
|
||||||
|
"delete_network_confirm": "Delete network {name}?",
|
||||||
|
"driver": "Driver",
|
||||||
|
"host_interface": "Host interface",
|
||||||
|
"subnet": "Subnet",
|
||||||
|
"gateway": "Gateway",
|
||||||
|
"ip_range": "IP range (optional)",
|
||||||
|
"ip_range_hint": "Limits the addresses Docker hands out to a block you reserved in your DHCP server. Without a range Docker uses the whole subnet.",
|
||||||
|
"next_free": "next free",
|
||||||
|
"ports_on_host": "Ports on the host (default)",
|
||||||
|
"own_ip": "own IP",
|
||||||
|
"ip_address": "IP address",
|
||||||
|
"own_ip_hint": "The stack gets this address on your LAN. Port mappings are dropped — the app is reachable on its own port. The Docker host itself cannot reach a macvlan container without a shim interface (see docs/netwerken.md).",
|
||||||
|
"no_networks_hint": "Want to give stacks their own IP? Add a network first under Settings → Networks.",
|
||||||
|
"generate_value": "Generate a random value",
|
||||||
|
"port_taken": "Port {port} is already in use — {suggested} suggested.",
|
||||||
|
"channel": "Channel",
|
||||||
|
"channel_stable_hint": "Stable releases only.",
|
||||||
|
"channel_beta_hint": "Also pre-releases (e.g. v0.5.10-beta1). Useful for testing, less predictable.",
|
||||||
|
"update_now": "Update now to v{version}",
|
||||||
|
"updating": "Working…",
|
||||||
|
"update_confirm": "Update Server Up to {tag}?\n\nThe container will be replaced and unreachable for a few seconds.",
|
||||||
|
"update_cannot_apply": "You can still update with a git tag; see docs/updates.md.",
|
||||||
|
"update_image": "Image (for updating from the interface)",
|
||||||
|
"update_image_hint": "Without a tag. Empty = derive from the running container.",
|
||||||
|
"registry_user": "Registry user (optional)",
|
||||||
|
"registry_token": "Registry token",
|
||||||
|
"registry_token_set": "•••••••• (set)",
|
||||||
|
"registry_token_ph": "only for a private registry",
|
||||||
|
"registry_token_hint": "Leave empty = unchanged.",
|
||||||
|
"last_checked": "Last checked",
|
||||||
|
"rollback": "Roll back",
|
||||||
|
"previous_version": "Previous version",
|
||||||
|
"rollback_button": "Back to this version",
|
||||||
|
"rollback_confirm": "Roll back to {tag}?",
|
||||||
|
"restarting": "Server Up is restarting…",
|
||||||
|
"updated_to": "Updated to v{version}",
|
||||||
|
"restart_slow": "Restart is taking a while — please refresh the page",
|
||||||
|
"backups": "Backups",
|
||||||
|
"no_backups": "No backups yet",
|
||||||
|
"restore": "Restore",
|
||||||
|
"restore_confirm": "Restore backup {file}?\n\nThe current files of this stack will be replaced and the stack restarted.",
|
||||||
|
"delete_backup_confirm": "Delete this backup?",
|
||||||
|
"backup_scope_warning": "A backup contains the stack's compose and .env files, not the Docker volumes holding the actual app data. See docs/backups.md.",
|
||||||
|
"backup_retention": "Retention",
|
||||||
|
"backup_keep": "Backups to keep per stack",
|
||||||
|
"backup_max_age": "Maximum age (days, 0 = no limit)",
|
||||||
|
"backup_schedule": "Scheduled backups",
|
||||||
|
"backup_schedule_off": "Off",
|
||||||
|
"backup_schedule_daily": "Daily",
|
||||||
|
"backup_schedule_weekly": "Weekly",
|
||||||
|
"backup_schedule_hour": "Around which hour",
|
||||||
|
"backup_before_update": "Automatically back up before updating a stack",
|
||||||
|
"backup_before_remove": "Automatically back up before removing a stack",
|
||||||
|
"check_updates": "Check for updates",
|
||||||
|
"stack_update_check": "Check daily whether newer images are available"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
{
|
{
|
||||||
"_meta": {"code": "nl", "name": "Nederlands", "flag": "🇳🇱"},
|
"_meta": {
|
||||||
|
"code": "nl",
|
||||||
|
"name": "Nederlands",
|
||||||
|
"flag": "🇳🇱"
|
||||||
|
},
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"stacks": "Stacks",
|
"stacks": "Stacks",
|
||||||
"app_store": "App Store",
|
"app_store": "App Store",
|
||||||
|
|
@ -115,5 +119,97 @@
|
||||||
"view_release": "Bekijk release",
|
"view_release": "Bekijk release",
|
||||||
"update_api_url": "Releases-API URL",
|
"update_api_url": "Releases-API URL",
|
||||||
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.",
|
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.",
|
||||||
"include_prerelease": "Pre-releases (beta) meenemen"
|
"include_prerelease": "Pre-releases (beta) meenemen",
|
||||||
|
"login": "Inloggen",
|
||||||
|
"login_failed": "Inloggen mislukt",
|
||||||
|
"logout": "Uitloggen",
|
||||||
|
"session_expired": "Sessie verlopen — log opnieuw in",
|
||||||
|
"setup_title": "Eerste account aanmaken",
|
||||||
|
"setup_hint": "Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan — zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.",
|
||||||
|
"create_account": "Account aanmaken",
|
||||||
|
"username": "Gebruikersnaam",
|
||||||
|
"password": "Wachtwoord",
|
||||||
|
"password_min": "Wachtwoord (min. 10 tekens)",
|
||||||
|
"password_repeat": "Wachtwoord herhalen",
|
||||||
|
"passwords_differ": "De wachtwoorden komen niet overeen.",
|
||||||
|
"security": "Beveiliging",
|
||||||
|
"logged_in_as": "ingelogd als {user}",
|
||||||
|
"users": "Gebruikers",
|
||||||
|
"add_user": "Gebruiker toevoegen",
|
||||||
|
"delete_user_confirm": "Gebruiker {name} verwijderen?",
|
||||||
|
"change_own_password": "Eigen wachtwoord wijzigen",
|
||||||
|
"current_password": "Huidig wachtwoord",
|
||||||
|
"new_password": "Nieuw wachtwoord",
|
||||||
|
"new_password_repeat": "Nieuw wachtwoord herhalen",
|
||||||
|
"change": "Wijzigen",
|
||||||
|
"auth_mode": "Hoe wordt er ingelogd?",
|
||||||
|
"auth_mode_local": "Lokale login",
|
||||||
|
"auth_mode_proxy": "Reverse proxy",
|
||||||
|
"auth_mode_both": "Beide",
|
||||||
|
"auth_mode_hint": "Met een reverse proxy (Authelia, Authentik, Cloudflare Access) komt de identiteit uit een header. Kies 'Beide' om dat te testen zonder je lokale login te verliezen.",
|
||||||
|
"proxy_header": "Identiteitsheader",
|
||||||
|
"proxy_header_hint": "Meestal Remote-User. Zorg dat je proxy deze header wist en zelf opnieuw zet.",
|
||||||
|
"trusted_proxies": "Vertrouwde proxy-adressen",
|
||||||
|
"trusted_proxies_hint": "Eén per regel, IP of CIDR. Alleen vanaf deze adressen wordt de header vertrouwd — zonder deze lijst kan iedereen zich voordoen als beheerder.",
|
||||||
|
"proxy_lockout_confirm": "In modus 'Reverse proxy' vervalt de lokale login. Weet je zeker dat je proxy werkt?",
|
||||||
|
"networks": "Netwerken",
|
||||||
|
"networks_hint": "Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app firewallen. Let op: de Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.",
|
||||||
|
"add_network": "Netwerk toevoegen",
|
||||||
|
"network": "Netwerk",
|
||||||
|
"no_networks": "Nog geen netwerken.",
|
||||||
|
"delete_network_confirm": "Netwerk {name} verwijderen?",
|
||||||
|
"driver": "Driver",
|
||||||
|
"host_interface": "Host-interface",
|
||||||
|
"subnet": "Subnet",
|
||||||
|
"gateway": "Gateway",
|
||||||
|
"ip_range": "IP-range (optioneel)",
|
||||||
|
"ip_range_hint": "Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.",
|
||||||
|
"next_free": "volgend vrij",
|
||||||
|
"ports_on_host": "Poorten op de host (standaard)",
|
||||||
|
"own_ip": "eigen IP",
|
||||||
|
"ip_address": "IP-adres",
|
||||||
|
"own_ip_hint": "De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app is bereikbaar op zijn eigen poort. De Docker-host zelf kan een macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).",
|
||||||
|
"no_networks_hint": "Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.",
|
||||||
|
"generate_value": "Genereer een willekeurige waarde",
|
||||||
|
"port_taken": "Poort {port} is al in gebruik — {suggested} voorgesteld.",
|
||||||
|
"channel": "Kanaal",
|
||||||
|
"channel_stable_hint": "Alleen stabiele releases.",
|
||||||
|
"channel_beta_hint": "Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.",
|
||||||
|
"update_now": "Nu bijwerken naar v{version}",
|
||||||
|
"updating": "Bezig…",
|
||||||
|
"update_confirm": "Server Up bijwerken naar {tag}?\n\nDe container wordt vervangen en is enkele seconden niet bereikbaar.",
|
||||||
|
"update_cannot_apply": "Bijwerken kan wel via een git-tag; zie docs/updates.md.",
|
||||||
|
"update_image": "Image (voor bijwerken vanuit de interface)",
|
||||||
|
"update_image_hint": "Zonder tag. Leeg = afleiden uit de draaiende container.",
|
||||||
|
"registry_user": "Registry-gebruiker (optioneel)",
|
||||||
|
"registry_token": "Registry-token",
|
||||||
|
"registry_token_set": "•••••••• (ingesteld)",
|
||||||
|
"registry_token_ph": "alleen bij een privé registry",
|
||||||
|
"registry_token_hint": "Leeg laten = ongewijzigd.",
|
||||||
|
"last_checked": "Laatst gecontroleerd",
|
||||||
|
"rollback": "Terugrollen",
|
||||||
|
"previous_version": "Vorige versie",
|
||||||
|
"rollback_button": "Terug naar deze versie",
|
||||||
|
"rollback_confirm": "Terugrollen naar {tag}?",
|
||||||
|
"restarting": "Server Up herstart…",
|
||||||
|
"updated_to": "Bijgewerkt naar v{version}",
|
||||||
|
"restart_slow": "Herstart duurt lang — ververs de pagina zelf",
|
||||||
|
"backups": "Backups",
|
||||||
|
"no_backups": "Nog geen backups",
|
||||||
|
"restore": "Terugzetten",
|
||||||
|
"restore_confirm": "Backup {file} terugzetten?\n\nDe huidige bestanden van deze stack worden vervangen en de stack wordt herstart.",
|
||||||
|
"delete_backup_confirm": "Deze backup verwijderen?",
|
||||||
|
"backup_scope_warning": "Een backup bevat de compose- en .env-bestanden van de stack, niet de Docker-volumes met de eigenlijke appdata. Zie docs/backups.md.",
|
||||||
|
"backup_retention": "Bewaarbeleid",
|
||||||
|
"backup_keep": "Aantal backups per stack",
|
||||||
|
"backup_max_age": "Maximale leeftijd (dagen, 0 = geen limiet)",
|
||||||
|
"backup_schedule": "Geplande backups",
|
||||||
|
"backup_schedule_off": "Uit",
|
||||||
|
"backup_schedule_daily": "Dagelijks",
|
||||||
|
"backup_schedule_weekly": "Wekelijks",
|
||||||
|
"backup_schedule_hour": "Rond welk uur",
|
||||||
|
"backup_before_update": "Automatisch een backup vóór het bijwerken van een stack",
|
||||||
|
"backup_before_remove": "Automatisch een backup vóór het verwijderen van een stack",
|
||||||
|
"check_updates": "Updates controleren",
|
||||||
|
"stack_update_check": "Dagelijks controleren of er nieuwere images zijn"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
186
tests/test_backups.py
Normal file
186
tests/test_backups.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Backups: maken, tonen, terugzetten, retentie — en de veiligheid daarvan."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import login
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bk(env, monkeypatch):
|
||||||
|
from core import backups, docker
|
||||||
|
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
|
||||||
|
# Geen echte docker in de tests: stack stoppen/starten is een no-op.
|
||||||
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
||||||
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
||||||
|
return backups
|
||||||
|
|
||||||
|
|
||||||
|
def _maak_stack(env, naam="vaultwarden", inhoud="services:\n app:\n image: nginx\n"):
|
||||||
|
d = env["lib"] / naam
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
(d / "docker-compose.yml").write_text(inhoud, encoding="utf-8")
|
||||||
|
(d / ".env").write_text("PORT=8222\n", encoding="utf-8")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def test_maken_en_tonen(bk, env):
|
||||||
|
_maak_stack(env)
|
||||||
|
meta = bk.create("vaultwarden")
|
||||||
|
assert meta["stack"] == "vaultwarden"
|
||||||
|
assert meta["size"] > 0
|
||||||
|
lijst = bk.list_for("vaultwarden")
|
||||||
|
assert len(lijst) == 1
|
||||||
|
assert lijst[0]["file"] == meta["file"]
|
||||||
|
assert lijst[0]["size_human"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_maken_van_onbekende_stack(bk):
|
||||||
|
with pytest.raises(bk.BackupError):
|
||||||
|
bk.create("bestaatniet")
|
||||||
|
|
||||||
|
|
||||||
|
def test_terugzetten_herstelt_gewijzigd_bestand(bk, env):
|
||||||
|
d = _maak_stack(env)
|
||||||
|
meta = bk.create("vaultwarden")
|
||||||
|
(d / "docker-compose.yml").write_text("KAPOT", encoding="utf-8")
|
||||||
|
|
||||||
|
ok, msg = bk.restore("vaultwarden", meta["file"])
|
||||||
|
assert ok, msg
|
||||||
|
assert "image: nginx" in (d / "docker-compose.yml").read_text()
|
||||||
|
assert (d / ".env").read_text() == "PORT=8222\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_terugzetten_van_backup_van_andere_stack(bk, env):
|
||||||
|
_maak_stack(env, "app-a")
|
||||||
|
_maak_stack(env, "app-b")
|
||||||
|
meta = bk.create("app-a")
|
||||||
|
ok, msg = bk.restore("app-b", meta["file"])
|
||||||
|
assert ok is False
|
||||||
|
assert "hoort bij 'app-a'" in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("naam", [
|
||||||
|
"../../etc/passwd.tar.gz", "/etc/shadow.tar.gz", "geen-tijdstempel.tar.gz",
|
||||||
|
"vaultwarden_20260101_120000.txt", "", "..tar.gz",
|
||||||
|
])
|
||||||
|
def test_ongeldige_backupnaam(bk, naam):
|
||||||
|
with pytest.raises(bk.BackupError):
|
||||||
|
bk._archive_path(naam)
|
||||||
|
|
||||||
|
|
||||||
|
def test_terugzetten_weigert_archief_dat_buiten_de_map_schrijft(bk, env):
|
||||||
|
"""Een geprepareerd archief met '..' mag niets buiten de stackmap raken.
|
||||||
|
|
||||||
|
Met een kaal `tar xzf` zou dit bestand op de host belanden; tarfile met
|
||||||
|
filter='data' weigert de ingang.
|
||||||
|
"""
|
||||||
|
_maak_stack(env)
|
||||||
|
bd = Path(bk.backup_dir())
|
||||||
|
bd.mkdir(parents=True, exist_ok=True)
|
||||||
|
boos = bd / "vaultwarden_20260101_120000.tar.gz"
|
||||||
|
with tarfile.open(boos, "w:gz") as tar:
|
||||||
|
data = b"ontsnapt"
|
||||||
|
info = tarfile.TarInfo("../../ontsnapt.txt")
|
||||||
|
info.size = len(data)
|
||||||
|
tar.addfile(info, io.BytesIO(data))
|
||||||
|
|
||||||
|
ok, msg = bk.restore("vaultwarden", boos.name)
|
||||||
|
assert ok is False
|
||||||
|
assert not (env["tmp"] / "ontsnapt.txt").exists()
|
||||||
|
assert not (env["lib"].parent / "ontsnapt.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mislukte_restore_zet_de_oude_map_terug(bk, env):
|
||||||
|
"""Een kapot archief mag je niet zonder stack achterlaten."""
|
||||||
|
d = _maak_stack(env)
|
||||||
|
bd = Path(bk.backup_dir())
|
||||||
|
bd.mkdir(parents=True, exist_ok=True)
|
||||||
|
stuk = bd / "vaultwarden_20260101_120000.tar.gz"
|
||||||
|
stuk.write_bytes(b"dit is geen gzip")
|
||||||
|
|
||||||
|
ok, _ = bk.restore("vaultwarden", stuk.name)
|
||||||
|
assert ok is False
|
||||||
|
assert d.is_dir()
|
||||||
|
assert "image: nginx" in (d / "docker-compose.yml").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verwijderen(bk, env):
|
||||||
|
_maak_stack(env)
|
||||||
|
meta = bk.create("vaultwarden")
|
||||||
|
ok, _ = bk.delete(meta["file"])
|
||||||
|
assert ok
|
||||||
|
assert bk.list_for("vaultwarden") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_retentie_houdt_er_hoogstens_keep(bk, env):
|
||||||
|
_maak_stack(env)
|
||||||
|
env["core"].patch({"BACKUP_KEEP": 3})
|
||||||
|
metas = []
|
||||||
|
for i in range(5):
|
||||||
|
m = bk.create("vaultwarden")
|
||||||
|
# Tijdstempels uit elkaar trekken; de naam heeft secondeprecisie.
|
||||||
|
m["ts"] = time.time() - (5 - i)
|
||||||
|
_herschrijf_ts(bk, m)
|
||||||
|
metas.append(m)
|
||||||
|
bk.prune("vaultwarden")
|
||||||
|
assert len(bk.list_for("vaultwarden")) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_retentie_bewaart_altijd_de_nieuwste(bk, env):
|
||||||
|
"""Ook als alles te oud is, blijft er één over."""
|
||||||
|
_maak_stack(env)
|
||||||
|
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 1})
|
||||||
|
for i in range(3):
|
||||||
|
m = bk.create("vaultwarden")
|
||||||
|
m["ts"] = time.time() - 30 * 86400
|
||||||
|
_herschrijf_ts(bk, m)
|
||||||
|
bk.prune("vaultwarden")
|
||||||
|
assert len(bk.list_for("vaultwarden")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_retentie_uit_doet_niets(bk, env):
|
||||||
|
_maak_stack(env)
|
||||||
|
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 0})
|
||||||
|
for _ in range(3):
|
||||||
|
bk.create("vaultwarden")
|
||||||
|
assert bk.prune("vaultwarden") == []
|
||||||
|
|
||||||
|
|
||||||
|
def _herschrijf_ts(bk, meta):
|
||||||
|
"""Zet de tijdstempel in het metadata-bestand terug in de tijd."""
|
||||||
|
p = Path(bk.backup_dir()) / meta["file"]
|
||||||
|
mf = p.with_suffix("").with_suffix(".json")
|
||||||
|
d = json.loads(mf.read_text())
|
||||||
|
d["ts"] = meta["ts"]
|
||||||
|
mf.write_text(json.dumps(d))
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_endpoint_lijst(client, env, bk):
|
||||||
|
login(client)
|
||||||
|
_maak_stack(env)
|
||||||
|
bk.create("vaultwarden")
|
||||||
|
d = client.get("/api/backups").get_json()
|
||||||
|
assert len(d["backups"]) == 1
|
||||||
|
assert "BACKUP_KEEP" in d["settings"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_restore_weigert_ongeldige_naam(client, bk):
|
||||||
|
csrf = login(client)
|
||||||
|
r = client.post("/api/backups/vaultwarden/restore",
|
||||||
|
json={"file": "../../etc/passwd.tar.gz"},
|
||||||
|
headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_restore_zonder_bestand(client, bk):
|
||||||
|
csrf = login(client)
|
||||||
|
r = client.post("/api/backups/vaultwarden/restore", json={},
|
||||||
|
headers={"X-CSRF-Token": csrf})
|
||||||
|
assert r.status_code == 400
|
||||||
81
tests/test_i18n.py
Normal file
81
tests/test_i18n.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Vertalingen: nl en en moeten dezelfde sleutels hebben, en de UI mag geen
|
||||||
|
sleutels gebruiken die nergens gedefinieerd zijn.
|
||||||
|
|
||||||
|
Zonder deze test loopt de vertaling stilletjes scheef zodra iemand een scherm
|
||||||
|
toevoegt — precies wat er bij de auth-, netwerk- en update-schermen gebeurde.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
WORTEL = Path(__file__).resolve().parent.parent / "server-up"
|
||||||
|
VERTALINGEN = WORTEL / "translations"
|
||||||
|
TEMPLATE = WORTEL / "templates" / "index.html"
|
||||||
|
|
||||||
|
|
||||||
|
def _laad(code: str) -> dict:
|
||||||
|
data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
|
||||||
|
return {k: v for k, v in data.items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_nl_en_en_hebben_dezelfde_sleutels():
|
||||||
|
nl, en = _laad("nl"), _laad("en")
|
||||||
|
alleen_nl = sorted(set(nl) - set(en))
|
||||||
|
alleen_en = sorted(set(en) - set(nl))
|
||||||
|
assert not alleen_nl, f"Ontbreekt in en.json: {alleen_nl}"
|
||||||
|
assert not alleen_en, f"Ontbreekt in nl.json: {alleen_en}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", ["nl", "en"])
|
||||||
|
def test_geen_lege_vertalingen(code):
|
||||||
|
leeg = [k for k, v in _laad(code).items() if not str(v).strip()]
|
||||||
|
assert not leeg, f"Lege waarden in {code}.json: {leeg}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", ["nl", "en"])
|
||||||
|
def test_meta_is_compleet(code):
|
||||||
|
data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
|
||||||
|
meta = data.get("_meta", {})
|
||||||
|
assert meta.get("code") == code
|
||||||
|
assert meta.get("name")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plaatshouders_komen_overeen():
|
||||||
|
"""`{user}` in het Nederlands moet ook in het Engels staan, anders valt er
|
||||||
|
bij het vertalen een variabele weg."""
|
||||||
|
nl, en = _laad("nl"), _laad("en")
|
||||||
|
for sleutel, tekst in nl.items():
|
||||||
|
vars_nl = set(re.findall(r"\{(\w+)\}", str(tekst)))
|
||||||
|
vars_en = set(re.findall(r"\{(\w+)\}", str(en.get(sleutel, ""))))
|
||||||
|
assert vars_nl == vars_en, f"{sleutel}: nl={vars_nl} en={vars_en}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_gebruikt_alleen_bestaande_sleutels():
|
||||||
|
regels = TEMPLATE.read_text(encoding="utf-8").splitlines()
|
||||||
|
# Commentaarregels overslaan: die noemen t('key') als voorbeeld.
|
||||||
|
html = "\n".join(r for r in regels if not r.lstrip().startswith(("//", "*", "/*")))
|
||||||
|
gebruikt = set(re.findall(r"\bt\('([a-z0-9_]+)'", html))
|
||||||
|
bekend = set(_laad("nl"))
|
||||||
|
onbekend = sorted(gebruikt - bekend)
|
||||||
|
assert not onbekend, f"t() verwijst naar niet-bestaande sleutels: {onbekend}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nieuwe_schermen_zijn_vertaald():
|
||||||
|
"""Steekproef op de schermen die in v0.5.x zijn toegevoegd."""
|
||||||
|
nl = _laad("nl")
|
||||||
|
for sleutel in ("login", "setup_title", "security", "auth_mode",
|
||||||
|
"trusted_proxies", "networks", "own_ip_hint", "channel",
|
||||||
|
"update_now", "rollback", "port_taken", "loading"):
|
||||||
|
assert sleutel in nl, f"vertaalsleutel ontbreekt: {sleutel}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i18n_module_leest_beide_talen(env):
|
||||||
|
from core import i18n
|
||||||
|
i18n.load()
|
||||||
|
codes = {t["code"] for t in i18n.available()}
|
||||||
|
assert {"nl", "en"} <= codes
|
||||||
|
# strings() valt terug op het Engels voor ontbrekende sleutels.
|
||||||
|
assert i18n.strings("nl")["login"]
|
||||||
|
assert i18n.strings("en")["login"]
|
||||||
173
tests/test_stackupdates.py
Normal file
173
tests/test_stackupdates.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""Updates per app: image-parsing, digest-vergelijking en randgevallen."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
||||||
|
|
||||||
|
from core import registry
|
||||||
|
|
||||||
|
|
||||||
|
# ── Image-referenties ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ref,verwacht", [
|
||||||
|
("nginx", ("registry-1.docker.io", "library/nginx", "latest")),
|
||||||
|
("nginx:1.27", ("registry-1.docker.io", "library/nginx", "1.27")),
|
||||||
|
("bes-r/app:2.0", ("registry-1.docker.io", "bes-r/app", "2.0")),
|
||||||
|
("ghcr.io/org/app", ("ghcr.io", "org/app", "latest")),
|
||||||
|
("ghcr.io/org/app:dev", ("ghcr.io", "org/app", "dev")),
|
||||||
|
("git.example.com:3000/bes-r/server-up:0.5.10",
|
||||||
|
("git.example.com:3000", "bes-r/server-up", "0.5.10")),
|
||||||
|
("localhost:5000/mijn/app", ("localhost:5000", "mijn/app", "latest")),
|
||||||
|
("vaultwarden/server:latest", ("registry-1.docker.io", "vaultwarden/server", "latest")),
|
||||||
|
])
|
||||||
|
def test_parse_image(ref, verwacht):
|
||||||
|
assert registry.parse_image(ref) == verwacht
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_image_negeert_digest():
|
||||||
|
host, repo, tag = registry.parse_image("nginx@sha256:" + "a" * 64)
|
||||||
|
assert (host, repo) == ("registry-1.docker.io", "library/nginx")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_image_leeg():
|
||||||
|
assert registry.parse_image("") == ("", "", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lokale_registry_gebruikt_http():
|
||||||
|
assert registry._scheme("localhost:5000") == "http"
|
||||||
|
assert registry._scheme("127.0.0.1:5000") == "http"
|
||||||
|
assert registry._scheme("git.example.com") == "https"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Digest-vergelijking ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def su(env):
|
||||||
|
from core import stackupdates
|
||||||
|
stackupdates.invalidate()
|
||||||
|
return stackupdates
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reg(env):
|
||||||
|
"""Het registry-module-object ná de herimport door de env-fixture."""
|
||||||
|
from core import registry as verse_registry
|
||||||
|
return verse_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _stack(env, naam="web", compose="services:\n app:\n image: nginx:1.27\n"):
|
||||||
|
d = env["lib"] / naam
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
(d / "docker-compose.yml").write_text(compose, encoding="utf-8")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gedetecteerd(su, reg, env, monkeypatch):
|
||||||
|
from core import docker
|
||||||
|
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
|
||||||
|
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:nieuw")
|
||||||
|
res = su.check_stack("web", _stack(env))
|
||||||
|
assert res["update_available"] is True
|
||||||
|
assert res["services"][0]["status"] == "update"
|
||||||
|
|
||||||
|
|
||||||
|
def test_geen_update_bij_gelijke_digest(su, reg, env, monkeypatch):
|
||||||
|
from core import docker
|
||||||
|
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:zelfde")
|
||||||
|
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:zelfde")
|
||||||
|
res = su.check_stack("web", _stack(env))
|
||||||
|
assert res["update_available"] is False
|
||||||
|
assert res["services"][0]["status"] == "current"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lokaal_gebouwd_image_is_onbekend_geen_update(su, reg, env, monkeypatch):
|
||||||
|
"""Zonder RepoDigest valt er niets te vergelijken — dat mag geen update
|
||||||
|
melden, anders staat er permanent een badge."""
|
||||||
|
from core import docker
|
||||||
|
monkeypatch.setattr(docker, "image_digest_local", lambda i: "")
|
||||||
|
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:x")
|
||||||
|
res = su.check_stack("web", _stack(env))
|
||||||
|
assert res["update_available"] is False
|
||||||
|
assert res["services"][0]["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_onbereikbare_registry_is_onbekend_geen_update(su, reg, env, monkeypatch):
|
||||||
|
from core import docker
|
||||||
|
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
|
||||||
|
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "")
|
||||||
|
res = su.check_stack("web", _stack(env))
|
||||||
|
assert res["update_available"] is False
|
||||||
|
assert res["services"][0]["status"] == "unknown"
|
||||||
|
assert "registry" in res["services"][0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_variabele_in_image_wordt_overgeslagen(su, env):
|
||||||
|
d = _stack(env, compose="services:\n app:\n image: nginx:${TAG}\n")
|
||||||
|
assert su._images_uit_compose(d) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_meerdere_services(su, reg, env, monkeypatch):
|
||||||
|
from core import docker
|
||||||
|
compose = ("services:\n"
|
||||||
|
" web:\n image: nginx:1.27\n"
|
||||||
|
" db:\n image: postgres:16\n")
|
||||||
|
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
|
||||||
|
monkeypatch.setattr(reg, "manifest_digest",
|
||||||
|
lambda i, c=None: "sha256:nieuw" if "nginx" in i else "sha256:oud")
|
||||||
|
res = su.check_stack("web", _stack(env, compose=compose))
|
||||||
|
statussen = {d["service"]: d["status"] for d in res["services"]}
|
||||||
|
assert statussen == {"web": "update", "db": "current"}
|
||||||
|
assert res["update_available"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_all_cachet(su, reg, env, monkeypatch):
|
||||||
|
from core import docker
|
||||||
|
_stack(env)
|
||||||
|
tellers = {"n": 0}
|
||||||
|
|
||||||
|
def _tel(image):
|
||||||
|
tellers["n"] += 1
|
||||||
|
return "sha256:oud"
|
||||||
|
|
||||||
|
monkeypatch.setattr(docker, "image_digest_local", _tel)
|
||||||
|
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:oud")
|
||||||
|
su.check_all(force=True)
|
||||||
|
n_na_eerste = tellers["n"]
|
||||||
|
su.check_all()
|
||||||
|
assert tellers["n"] == n_na_eerste
|
||||||
|
|
||||||
|
|
||||||
|
def test_kapotte_stack_blokkeert_de_rest_niet(su, reg, env, monkeypatch):
|
||||||
|
from core import docker
|
||||||
|
_stack(env, "goed")
|
||||||
|
_stack(env, "stuk")
|
||||||
|
|
||||||
|
def _ontploft(image):
|
||||||
|
raise RuntimeError("registry weg")
|
||||||
|
|
||||||
|
monkeypatch.setattr(docker, "image_digest_local",
|
||||||
|
lambda i: (_ontploft(i) if False else "sha256:x"))
|
||||||
|
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:x")
|
||||||
|
res = su.check_all(force=True)
|
||||||
|
assert set(res) == {"goed", "stuk"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_endpoint_updates_botst_niet_met_stack_routes(client, su):
|
||||||
|
"""/api/stacks/updates mag niet als stacknaam 'updates' gelezen worden."""
|
||||||
|
from conftest import login
|
||||||
|
login(client)
|
||||||
|
r = client.get("/api/stacks/updates")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "stacks" in r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_credentials_alleen_bij_zelfde_registry(su, env):
|
||||||
|
env["core"].patch({"UPDATE_REGISTRY_USER": "bes-r",
|
||||||
|
"UPDATE_REGISTRY_TOKEN": "geheim",
|
||||||
|
"UPDATE_IMAGE": "git.example.com/bes-r/server-up"})
|
||||||
|
assert su._registry_creds("git.example.com/bes-r/iets") == ("bes-r", "geheim")
|
||||||
|
assert su._registry_creds("nginx:1.27") is None
|
||||||
Loading…
Reference in a new issue