v0.7.20-beta - instellingen wijzigen, koppelen achteraf, defect uit v0.7.00
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m13s

Defect (v0.7.00): add_shared_network() hing elke service aan het gedeelde
netwerk, ook services met network_mode. Docker compose weigert die combinatie
("declares mutually exclusive network_mode and networks"), waardoor Homebridge,
Scrypted, Beszel en de ARR-stack met Gluetun niet te installeren waren met de
standaardkeuze "Verbinden met andere apps". Die services worden nu overgeslagen;
blijft er niets over, dan ook geen netwerkblok. Test over alle 96 apps.

Instellingen wijzigen na installatie:
- De gemaakte keuzes worden bij installatie opgeslagen in .serverup.json
  (values + format); zonder die waarden viel het formulier niet te heropenen.
- GET /api/stacks/<naam>/config geeft de opgeslagen waarden plus het actuele
  veldschema; POST /reconfigure maakt een backup, rendert opnieuw, past netwerk
  en eigen IP opnieuw toe, valideert en herstart. Mislukt het valideren, dan
  wordt de backup teruggezet.
- Knop Instellingen op de stackkaart opent dezelfde modal in wijzigen-stand,
  met een waarschuwing dat handmatige compose-wijzigingen verloren gaan.
- Stacks van voor deze versie: knop uit met uitleg.

Bestaande stacks koppelen:
- POST /api/stacks/<naam>/connect zet een bestaande stack op het gedeelde
  netwerk, valideert en herstart; wordt de compose ongeldig, dan wordt de
  wijziging teruggedraaid.

Audit-log:
- audit.query()/count() accepteren filters op bron, actie, status, periode en
  vrije tekst; facets() levert de keuzelijsten. /api/audit ondersteunt die als
  queryparameters plus doorbladeren. Filterbalk in de UI.

Documentatie:
- README.md (bestond niet), docs/apps-maken.md met het volledige
  templateformaat, docs/README.md als index.
- Changelog-secties voor v0.5.44/45/46 aangevuld; release.yml haalt de notes
  daaruit, dus een tag daarop gaf een lege release. Test die afdwingt dat het
  huidige VERSION een sectie heeft.

941 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-27 15:43:49 +02:00
parent ff1979ba51
commit 2ba15418ab
13 changed files with 1170 additions and 20 deletions

View file

@ -1,3 +1,59 @@
# v0.7.20-beta — Instellingen wijzigen, koppelen achteraf, en een defect uit v0.7.00
## 🔴 Gedeeld netwerk botste met `network_mode`
De koppeling uit v0.7.00 hing élke service aan het gedeelde netwerk. Maar een
service met `network_mode` deelt al andermans namespace, en docker compose
weigert die combinatie. Met de standaardkeuze "Verbinden met andere apps" waren
**Homebridge**, **Scrypted**, **Beszel** en de **ARR-stack met Gluetun** daardoor
niet te installeren.
Services met `network_mode` worden nu overgeslagen. Een test past het gedeelde
netwerk toe over alle 96 apps en controleert dat die combinatie nooit ontstaat.
## ⚙️ Instellingen wijzigen na installatie
Je koos bij het installeren netjes een poort, map en wachtwoord — maar daarna was
er geen weg terug naar dat formulier. Voor een andere poort moest je het
compose-bestand met de hand bewerken.
De knop **Instellingen** op elke stackkaart opent hetzelfde formulier, voorgevuld
met wat je destijds hebt gekozen. Opslaan maakt eerst een backup, rendert de
stack opnieuw, valideert en herstart. Loopt het valideren mis, dan wordt de
backup automatisch teruggezet.
Stacks die zijn geïnstalleerd voordat Server Up die keuzes bewaarde, hebben de
knop uit staan met uitleg waarom.
## 🔗 Bestaande stacks alsnog koppelen
Het gedeelde netwerk gold alleen voor nieuwe installaties. Een knop op de
stackkaart zet een bestaande stack er alsnog op, valideert en herstart. Wordt de
compose ongeldig, dan wordt de wijziging teruggedraaid.
## 🔍 Audit-log doorzoekbaar
Het log toonde de laatste tweehonderd regels zonder filter, terwijl er inmiddels
logins, rolwijzigingen, backups en updates in landen. Nu filteren op bron, actie,
status en periode, met vrij zoeken door referentie, details en IP-adres, en
doorbladeren voorbij de eerste honderd.
## 📖 Documentatie
- **`README.md`** — die was er niet. Wat Server Up is, installeren, het eerste
account, en waar je verder moet kijken.
- **`docs/apps-maken.md`** — het volledige templateformaat: velden, geavanceerde
opties, uitleg per veld, apps aan elkaar koppelen, onderdelen optioneel maken
met groepsschakelaars, en de valkuilen (named volumes, poorten, `network_mode`).
- **`docs/README.md`** — index over de zeven documenten.
De changelog-secties voor `v0.5.44`, `v0.5.45` en `v0.5.46` ontbraken; omdat
`release.yml` de notes daaruit haalt, zou een tag op die versies een lege release
opleveren. Aangevuld, met een test die afdwingt dat het huidige `VERSION` altijd
een sectie heeft.
---
# v0.7.10-beta — Categorieën met kleur, in de app store en bij je apps
Alle 96 apps zijn ingedeeld in zeventien categorieën, elk met een eigen kleur en
@ -250,6 +306,51 @@ door de test die precies zo'n container als laatste zet.
---
# v0.5.46-beta — Deploy strandde op een .env van een andere gebruiker
De stap die instellingen klaarzet faalde op `touch: cannot touch '.env':
Permission denied`. Het bestand was met sudo aangemaakt en dus van root, terwijl
de runner als gewone gebruiker draait — waarmee de hele deploy afbrak op iets
dat geen blokkade hoort te zijn.
- `SU_TAG` gaat nu altijd via de omgeving naar `docker compose`. Wegschrijven in
`.env` blijft de voorkeur omdat het een handmatige `docker compose up`
overleeft, maar is niet meer nodig om te kunnen deployen.
- Is `.env` niet schrijfbaar, dan meldt de log wie de eigenaar is en welk
`chown`-commando dat rechtzet, en loopt de deploy door.
---
# v0.5.45-beta — Wizard-knop toegevoegd
De wizard was alleen zichtbaar bij een verse installatie: zodra hij een keer was
afgerond, was er geen enkele manier meer om hem te openen. De vertaalsleutels
`wizard_open` en `wizard_reset` bestonden al en `/api/wizard/reset` werkte ook,
maar de knop is nooit gebouwd — ook niet in v0.4.60.
- Blok **Setup Wizard** onder Instellingen met "Wizard openen" en "Opnieuw
uitvoeren".
- Sluitknop in de wizard zelf; die kon je eerder alleen verlaten door hem
helemaal af te ronden.
- De staptitels lopen nu via `t()` in plaats van harde Nederlandse teksten.
---
# v0.5.44-beta — Gedetecteerd netwerk verdween uit het formulier
Zodra je een netwerk had toegevoegd, was het blok "gevonden op deze server" leeg
bij een volgende poging: subnetten die al geconfigureerd waren werden uit de
suggesties gefilterd. Dat pakt verkeerd uit — je kunt prima een tweede netwerk op
hetzelfde subnet willen met een andere range, en zonder dat blok lijkt het
formulier kapot.
- Gedetecteerde netwerken blijven staan en tonen "al toegevoegd" in plaats van
te verdwijnen.
- Levert de detectie niets op zonder dat er een fout is, dan staat er nu uitleg
in plaats van een leeg blok.
---
# v0.5.43-beta — Van-tot omrekenen naar een CIDR-blok
"Ik wil van 10.0.20.200 tot .254" is een volstrekt redelijke wens, maar bestaat

104
README.md Normal file
View file

@ -0,0 +1,104 @@
# Server Up
Zelfgehoste beheerinterface voor Docker: apps installeren uit een catalogus,
stacks starten en stoppen, backups maken en terugzetten, en containers een eigen
IP-adres in je netwerk geven.
> ⚠️ **Toegang tot Server Up staat gelijk aan root op de host.** De applicatie
> beheert de Docker-daemon, en wie containers kan starten kan willekeurige mappen
> van de host mounten. Behandel de interface navenant: maak direct een account
> aan, zet hem niet zonder reverse proxy met TLS op internet, en lees
> [docs/beveiliging.md](docs/beveiliging.md).
---
## Wat het doet
| | |
|---|---|
| **App-catalogus** | 96 kant-en-klare apps, ingedeeld in categorieën met kleur. Zoeken, filteren en installeren met een formulier in plaats van YAML. |
| **Stacks beheren** | Starten, stoppen, bijwerken en herstarten — per stack of per losse container, met logs en live CPU-/geheugengebruik. |
| **Apps koppelen** | Stacks komen op een gedeeld netwerk, zodat ze elkaar op naam bereiken. Een veld dat naar een andere app verwijst toont een keuzelijst met wat je al draait. |
| **Eigen IP per stack** | Met macvlan of ipvlan krijgt een app een eigen adres in je LAN in plaats van poorten op de host. |
| **Backups** | Handmatig, gepland, en automatisch vóór een update of verwijdering. Met terugzetten en een bewaarbeleid. |
| **Updates** | Voor Server Up zelf (stable of beta) en voor de images van je geïnstalleerde stacks. |
| **Toegang** | Lokale accounts met rollen (beheerder, operator, alleen lezen), of SSO via een reverse proxy. |
## Wat je nodig hebt
- Een Linux-server met Docker en de compose-plugin
- Toegang tot `/var/run/docker.sock`
- Ongeveer 300 MB schijfruimte voor de applicatie zelf
## Installeren
```bash
git clone https://github.com/bes-r/server-up.git
cd server-up
cp .env.example .env # instellingen; zie hieronder
docker compose up -d --build
```
Open daarna `http://localhost:5000` en **maak meteen een beheerdersaccount aan.**
Zolang dat niet gebeurd is, kan iedereen die de pagina bereikt het account
claimen — de container waarschuwt daar bij het opstarten ook voor.
### Instellingen
Alles staat met uitleg in [`.env.example`](.env.example). De twee die je
waarschijnlijk meteen nodig hebt:
```bash
BIND=127.0.0.1 # 0.0.0.0 om er van buiten de server bij te kunnen
PORT=5000
```
De poort bindt standaard op `127.0.0.1`. Zet hem pas open als er een reverse
proxy met TLS voor staat.
## Documentatie
| Document | Waarover |
|---|---|
| [docs/beveiliging.md](docs/beveiliging.md) | Accounts, rollen, SSO, reverse proxy, wat wel en niet is afgedekt |
| [docs/apps-maken.md](docs/apps-maken.md) | Eigen apps toevoegen: het volledige templateformaat |
| [docs/netwerken.md](docs/netwerken.md) | Stacks een eigen IP geven met macvlan of ipvlan |
| [docs/backups.md](docs/backups.md) | Wat er wél en niet in een backup zit, terugzetten, bewaarbeleid |
| [docs/updates.md](docs/updates.md) | Update-kanalen en de Forgejo-registry inrichten |
| [docs/synchroniseren.md](docs/synchroniseren.md) | Code van je apparaat naar de server via Forgejo |
| [docs/forgejo-actions-setup.md](docs/forgejo-actions-setup.md) | Runners instellen |
## Eigen apps toevoegen
Wijs Server Up naar je eigen git-repo met app-sjablonen (Instellingen → App
Store → Repository), of voeg een map toe onder `apps/`:
```
apps/mijn-app/
├── template.json metadata en de vragen bij het installeren
└── files/
└── compose.yaml het compose-bestand met << variabelen >>
```
Het volledige formaat staat in [docs/apps-maken.md](docs/apps-maken.md).
## Ontwikkelen
```bash
python3 -m venv venv && venv/bin/pip install -r server-up/requirements.txt pytest
venv/bin/python -m pytest tests -q # de volledige suite
cd server-up && SU_DEV=1 python app.py # lokaal draaien op poort 5000
```
De testsuite draait ook over alle app-sjablonen: elke app moet renderen naar
geldige compose-YAML, met gedeclareerde volumes en zonder onvervangen
variabelen. Voeg je een app toe, dan wordt die automatisch meegenomen.
De deploy-workflows draaien de tests in een container (`python:3.12-slim`), dus
op de runner is verder niets nodig dan Docker.
## Licentie en bijdragen
Zie `CHANGELOG.md` voor de geschiedenis. Werk op de `dev`-branch en bump
`VERSION` bij elke wijziging; een test bewaakt dat er een changelog-sectie bij
hoort.

View file

@ -1 +1 @@
0.7.10-beta
0.7.20-beta

14
docs/README.md Normal file
View file

@ -0,0 +1,14 @@
# Documentatie
| Document | Waarover, en wanneer je het nodig hebt |
|---|---|
| [beveiliging.md](beveiliging.md) | Accounts, rollen, reverse-proxy-SSO en TLS. **Lees dit voordat je Server Up van buiten je netwerk bereikbaar maakt** — toegang tot de interface staat gelijk aan root op de host. |
| [apps-maken.md](apps-maken.md) | Eigen apps toevoegen: het volledige `template.json`-formaat, de compose-syntaxis, apps aan elkaar koppelen en onderdelen optioneel maken. |
| [netwerken.md](netwerken.md) | Stacks een eigen IP-adres geven in je LAN met macvlan of ipvlan. Inclusief de valkuil dat de host zijn eigen macvlan-containers niet bereikt. |
| [backups.md](backups.md) | Wat er wél en niet in een backup zit, terugzetten, bewaarbeleid en geplande backups. |
| [updates.md](updates.md) | Update-kanalen (stable en beta), bijwerken vanuit de interface, en de Forgejo-registry inrichten. |
| [synchroniseren.md](synchroniseren.md) | Code van je eigen apparaat automatisch naar de server krijgen via Forgejo. |
| [forgejo-actions-setup.md](forgejo-actions-setup.md) | De act_runner installeren en instellen. |
Zie [`../README.md`](../README.md) voor installeren en een overzicht van wat
Server Up doet.

305
docs/apps-maken.md Normal file
View file

@ -0,0 +1,305 @@
# Eigen apps toevoegen
Een app in Server Up is een map met twee dingen: `template.json` beschrijft wat
de app is en welke vragen je bij het installeren krijgt, en `files/compose.yaml`
is het compose-bestand met plaatshouders erin.
```
apps/mijn-app/
├── template.json
└── files/
└── compose.yaml
```
Zet die map in `apps/`, of in je eigen git-repo die je toevoegt bij
**Instellingen → App Store → Repository**.
---
## Het kortste voorbeeld dat werkt
**`template.json`**
```json
{
"kind": "compose",
"metadata": {
"name": "Mijn App",
"description": "Wat de app doet, in één zin.",
"tags": ["voorbeeld"],
"categories": ["productiviteit"],
"icon": {"provider": "selfhst", "id": "mijn-app"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam",
"default": "mijn-app", "required": true},
{"name": "port", "type": "int", "title": "Web poort",
"default": 8200, "required": true},
{"name": "data_dir", "type": "str", "title": "Data directory",
"default": "/opt/serverup/appdata", "required": true},
{"name": "timezone", "type": "str", "title": "Tijdzone",
"default": "Europe/Amsterdam", "required": true}
]
}
]
}
```
**`files/compose.yaml`**
```yaml
services:
<< service_name >>:
image: voorbeeld/mijn-app:latest
container_name: << service_name >>
ports:
- "<< port >>:8080"
environment:
- TZ=<< timezone >>
volumes:
- << data_dir >>/<< service_name >>/config:/config
restart: unless-stopped
```
Dat is genoeg. De rest van dit document beschrijft wat je er nog meer mee kunt.
---
## Compose-syntaxis
De sjablonen gebruiken Jinja met afwijkende haakjes, zodat ze niet botsen met
de `${VAR}` van docker compose zelf:
| | |
|---|---|
| `<< variabele >>` | waarde invullen |
| `<%- if variabele %>``<%- endif %>` | blok alleen opnemen als de waarde waar is |
| `<< waarde \| lower >>` | filters van Jinja werken gewoon |
Het renderen gebeurt in een sandbox: je kunt niet bij Python-internals.
### Named volumes moeten gedeclareerd worden
Gebruik je een named volume, zet het dan óók in het top-level `volumes:`-blok.
Vergeet je dat, dan weigert docker compose met *"refers to undefined volume"*:
```yaml
services:
<< service_name >>-db:
image: postgres:16-alpine
volumes:
- << service_name >>_pgdata:/var/lib/postgresql/data
volumes:
<< service_name >>_pgdata:
```
De testsuite controleert dit voor elke app.
---
## Metadata
| Veld | Betekenis |
|---|---|
| `name` | Naam zoals getoond in de catalogus |
| `description` | Eén zin: wat is het, en waarvoor gebruik je het |
| `tags` | Vrije trefwoorden, gebruikt voor zoeken en filteren |
| `categories` | Eén of meer categorie-ids (zie onder). Ontbreken ze, dan worden ze afgeleid uit de tags |
| `icon` | `{"provider": "selfhst", "id": "…"}` of `dashboard-icons`. Bestaat het icoon niet, dan valt de interface terug op een emoji |
| `version` | Alleen ter informatie in de catalogus |
### Categorieën
Een app mag in meerdere categorieën zitten. De ids:
`media` · `fotos` · `downloaden` · `domotica` · `3d-printen` · `netwerk` ·
`beveiliging` · `opslag` · `documenten` · `productiviteit` · `communicatie` ·
`financien` · `gezin` · `monitoring` · `ontwikkeling` · `ai` · `beheer` ·
`overig`
Elke categorie heeft een vaste kleur die overal in de interface terugkomt. De
definities staan in `server-up/core/categories.py`.
---
## Velden
Elk item onder `items` wordt één vraag in het installatieformulier.
```json
{
"name": "port",
"type": "int",
"title": "Web poort",
"default": 8200,
"required": true,
"description": "Korte tekst onder het veld",
"help": "Langere uitleg, zichtbaar achter het info-icoon",
"advanced": false,
"config": {"placeholder": "bv. 8200", "options": ["a", "b"]}
}
```
| Type | Wordt |
|---|---|
| `str` | tekstveld |
| `int` | getalveld |
| `bool` | aanvinkvakje |
| `enum` | keuzelijst, met de waarden uit `config.options` |
### Vaste namen
Deze namen worden speciaal behandeld:
- **`service_name`** — verplicht. Wordt de containernaam en het voorvoegsel van
alles in de stack. Wordt automatisch gevuld met de instantienaam.
- **Velden met `port` in de naam en type `int`** — krijgen bij het installeren
automatisch een vrij poortnummer voorgesteld als de standaard al bezet is.
- **Velden met `token`, `password`, `secret` of `key` in de naam** — krijgen een
knop die een willekeurige waarde genereert.
### Geavanceerde velden
`"advanced": true` zet een veld achter het uitklapblok *Geavanceerde opties*.
Bedoeld voor wat je meestal met rust laat: database-wachtwoorden, PUID/PGID,
USB-paden, bewaartermijnen. Zet niet álles op geavanceerd — een test controleert
dat er per app zichtbare velden overblijven.
Je kunt het ook op een hele groep zetten: `{"title": "…", "advanced": true, …}`.
### Uitleg
`description` staat als kleine tekst onder het veld. `help` is de langere uitleg
achter het info-icoon; ontbreekt die, dan wordt `description` getoond. Gebruik
`help` voor dingen die een gebruiker echt moet weten:
```json
{
"name": "downloads_dir",
"type": "str",
"title": "Downloadmap",
"default": "/mnt/downloads",
"help": "Zet deze map op hetzelfde bestandssysteem als je mediamap. Staan ze op verschillende volumes, dan kopieert elke voltooide download in plaats van te verplaatsen."
}
```
### Velden afhankelijk maken
`needs` verbergt een veld tot een ander veld een bepaalde waarde heeft:
```json
{"name": "smtp_host", "type": "str", "title": "SMTP-server",
"needs": ["mail_enabled == true"]}
```
---
## Apps aan elkaar koppelen
Verwijst een veld naar een andere app, zet er dan `connect` op. De interface
toont dan een keuzelijst met wat er al geïnstalleerd is, in plaats van een leeg
tekstveld:
```json
{
"name": "mqtt_server",
"type": "str",
"title": "MQTT-server",
"default": "mqtt://mosquitto:1883",
"connect": {"app": "mosquitto", "scheme": "mqtt", "port": 1883},
"help": "Heb je de Mosquitto-app geïnstalleerd, kies die dan hier."
}
```
| Sleutel | Betekenis |
|---|---|
| `app` | De mapnaam van de app waarnaar verwezen wordt (`apps/mosquitto`) |
| `scheme` | Wat voor het adres komt: `http`, `mqtt`, … |
| `port` | Poort binnen het gedeelde netwerk (de interne poort, niet die op de host) |
| `path` | Optioneel pad achter het adres |
Dat levert bijvoorbeeld `mqtt://mosquitto:1883` op. Staat de app er nog niet, dan
meldt het veld dat en kun je alsnog handmatig invullen.
**Let op de poort:** dit gaat over de poort *binnen* de container, niet de poort
die op de host gepubliceerd is. De stacks zitten samen op een gedeeld netwerk en
bereiken elkaar op containernaam.
---
## Onderdelen optioneel maken
Voor stacks met meerdere componenten kun je per groep een schakelaar zetten:
```json
{
"title": "Bazarr — ondertitels",
"toggle": "enable_bazarr",
"toggle_default": true,
"description": "Haalt automatisch ondertitels op",
"items": [
{"name": "port_bazarr", "type": "int", "title": "Poort", "default": 6767}
]
}
```
In het compose-bestand gebruik je die schakelaar als voorwaarde:
```yaml
<%- if enable_bazarr %>
<< service_name >>-bazarr:
image: lscr.io/linuxserver/bazarr:latest
ports:
- "<< port_bazarr >>:6767"
<%- endif %>
```
`toggle_default` bepaalt of het onderdeel standaard aan staat. Zonder die sleutel
staat hij aan.
De ARR-stack (`apps/arr-stack`) gebruikt dit voor twintig onderdelen en is het
beste voorbeeld om van af te kijken.
---
## Waar je op moet letten
**Poorten.** Kies een standaardpoort die nog niet door een andere app gebruikt
wordt. De testsuite controleert dat, en meldt welke botsen. Alternatieven voor
dezelfde taak (twee reverse proxies, twee DNS-blokkers) mogen wel dezelfde poort
delen; die staan in een allowlist in `tests/test_apps.py`.
**`network_mode`.** Heeft een service `network_mode: host` of
`network_mode: service:…`, dan kan hij niet aan het gedeelde netwerk hangen.
Server Up slaat die services over, dus het werkt — maar zo'n app kan andere apps
niet op naam bereiken.
**Images.** Gebruik een image waarvan je zeker weet dat hij bestaat en welke
architecturen hij ondersteunt. De testsuite controleert wel de vorm van je
compose, maar haalt geen images op.
---
## Testen
Elke app in `apps/` wordt automatisch meegenomen in de testsuite:
```bash
python -m pytest tests/test_apps.py -q
```
Gecontroleerd wordt: geldige metadata, renderen naar geldige YAML, elke service
heeft een `image` of `build`, named volumes zijn gedeclareerd, geen onvervangen
`<< variabelen >>`, geen onbedoeld dubbele poorten, geldige categorieën, en dat
`network_mode` niet botst met het gedeelde netwerk.
Wil je alleen jouw app zien:
```bash
python -m pytest tests/test_apps.py -q -k mijn-app
```

View file

@ -621,6 +621,8 @@ def api_stacks():
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
# Bij installatie vastgelegd; ontbreekt dat (oudere installatie),
# dan leiden we het alsnog af uit de opgeslagen naam.
"connected": bool(meta.get("connected")),
"editable": bool(meta.get("values")),
"categories": categories.normalize(
meta.get("categories"), meta.get("tags"), meta.get("name", "")),
})
@ -766,6 +768,202 @@ def api_stack_compose_put(name):
return jsonify(ok=True)
def _stack_meta_lezen(d: Path) -> dict:
mf = d / ".serverup.json"
if mf.exists():
try:
return json.loads(mf.read_text("utf-8"))
except Exception:
pass
return {}
def _stack_meta_schrijven(d: Path, updates: dict):
meta = _stack_meta_lezen(d)
meta.update(updates)
(d / ".serverup.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
@app.route("/api/stacks/<name>/config")
def api_stack_config(name):
"""Opgeslagen installatiekeuzes plus het actuele veldschema.
Daarmee kan de interface hetzelfde formulier opnieuw tonen in plaats van de
gebruiker het compose-bestand met de hand te laten bewerken.
"""
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
if not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
meta = _stack_meta_lezen(d)
waarden = meta.get("values") or {}
bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
if not waarden or not bron:
return jsonify(
ok=False, editable=False,
msg=("Deze stack is ge\u00efnstalleerd voordat Server Up de gemaakte "
"keuzes bewaarde, of het bronsjabloon is niet meer beschikbaar. "
"Wijzigen kan via 'compose bewerken'."))
return jsonify(ok=True, editable=True,
fields=_annotate_fields(boilerplates.fields(bron)),
values=waarden,
network=meta.get("network", ""), ip=meta.get("ip", ""),
connected=bool(meta.get("connected")),
source=meta.get("source", ""), name=meta.get("name", name))
@app.route("/api/stacks/<name>/reconfigure", methods=["POST"])
def api_stack_reconfigure(name):
"""Render de stack opnieuw met gewijzigde waarden.
Maakt eerst een backup; loopt het renderen of valideren mis, dan wordt die
teruggezet zodat je nooit met een half werkende stack achterblijft.
"""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
body = request.json or {}
nieuwe_waarden = body.get("values") or {}
meta = _stack_meta_lezen(d)
bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
if not bron:
return jsonify(ok=False, msg="Bronsjabloon niet gevonden"), 400
net_name = (body.get("network") if "network" in body else meta.get("network")) or ""
net_ip = (body.get("ip") if "ip" in body else meta.get("ip")) or ""
verbinden = body.get("connect_apps")
if verbinden is None:
verbinden = bool(meta.get("connected"))
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
jid, q = jobs.create(f"reconfigure:{name}")
def _run(jid):
qq = jobs.get_queue(jid)
lf = lambda m: jobs.log(qq, "dim", m)
backup_naam = ""
try:
jobs.log(qq, "section", f"Instellingen wijzigen: {name}")
try:
backup_naam = backups.create(name, reden="voor-herconfiguratie",
log_fn=lf)["file"]
except Exception as e:
jobs.log(qq, "error", f"Backup mislukt, wijziging afgebroken: {e}")
jobs.finish(jid, "error"); jobs.done(qq); return
boilerplates.render_to_dir(bron, d, nieuwe_waarden)
jobs.log(qq, "ok", "Opnieuw gerenderd")
cf = docker.find_compose(d)
if net_name:
net = _network_by_name(net_name)
if net and net_ip:
cf.write_text(compose_transform.to_dedicated_ip(
cf.read_text(encoding="utf-8"), net_name, net_ip),
encoding="utf-8")
jobs.log(qq, "ok", f"Eigen IP-adres opnieuw toegepast: {net_ip}")
if verbinden and gedeeld_net:
ok_net, _ = networks.ensure_shared(gedeeld_net)
if ok_net:
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), gedeeld_net), encoding="utf-8")
jobs.log(qq, "ok", f"Gekoppeld aan '{gedeeld_net}'")
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
jobs.log(qq, "error", f"Compose is ongeldig:\n{err}")
jobs.log(qq, "dim", "Backup wordt teruggezet\u2026")
backups.restore(name, backup_naam, log_fn=lf)
jobs.finish(jid, "error"); jobs.done(qq); return
_stack_meta_schrijven(d, {
"values": nieuwe_waarden, "network": net_name, "ip": net_ip,
"connected": bool(verbinden and gedeeld_net)})
docker.compose_down(d, log_fn=lf, name=name)
rc = docker.compose_up(d, log_fn=lf, name=name)
jobs.log(qq, "ok" if rc == 0 else "error",
"Herstart met de nieuwe instellingen" if rc == 0
else "Starten mislukt \u2014 zet de backup terug via Backups")
audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error",
ref=name, ip=request.remote_addr)
jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e:
jobs.log(qq, "error", f"{type(e).__name__}: {e}")
if backup_naam:
jobs.log(qq, "dim", "Backup wordt teruggezet\u2026")
try:
backups.restore(name, backup_naam, log_fn=lf)
except Exception:
pass
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/stacks/<name>/connect", methods=["POST"])
def api_stack_connect(name):
"""Zet een bestaande stack op het gedeelde netwerk (of haal hem eraf).
Stacks die al bestonden voordat de koppeling er was, zaten alleen in hun
eigen compose-netwerk en konden geen andere app bereiken.
"""
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
aan = bool((request.json or {}).get("connect", True))
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
jid, q = jobs.create(f"connect:{name}")
def _run(jid):
qq = jobs.get_queue(jid)
lf = lambda m: jobs.log(qq, "dim", m)
try:
if not aan:
jobs.log(qq, "warn",
"Loskoppelen gaat via 'compose bewerken': het "
"networks-blok weghalen en de stack herstarten.")
jobs.finish(jid, "done"); jobs.done(qq); return
jobs.log(qq, "section", f"Koppelen: {name}")
ok_net, fout = networks.ensure_shared(gedeeld_net)
if not ok_net:
jobs.log(qq, "error", f"Netwerk '{gedeeld_net}' niet beschikbaar: {fout}")
jobs.finish(jid, "error"); jobs.done(qq); return
cf = docker.find_compose(d)
origineel = cf.read_text(encoding="utf-8")
cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net),
encoding="utf-8")
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
cf.write_text(origineel, encoding="utf-8")
jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}")
jobs.finish(jid, "error"); jobs.done(qq); return
_stack_meta_schrijven(d, {"connected": True})
docker.compose_up(d, log_fn=lf, name=name)
jobs.log(qq, "ok",
f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu "
"bereikbaar op hun containernaam.")
audit.log("stacks", "connect", "ok", ref=name, ip=request.remote_addr)
jobs.finish(jid, "done")
except Exception 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)
# ── Containers binnen een stack ──────────────────────────────────────────────
def _stack_containers(name: str) -> list[str]:
@ -1313,6 +1511,11 @@ def api_store_install():
"connected": bool(verbinden and gedeeld_net),
"categories": meta.get("categories") or [],
"tags": meta.get("tags") or [],
# Zonder de gebruikte waarden valt het installatieformulier
# later niet opnieuw te openen om iets te wijzigen.
"format": "boilerplate" if is_bp else "compose",
"values": bp_values if is_bp else {},
"env_overrides": {} if is_bp else env_overrides,
}, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
@ -1984,7 +2187,24 @@ def api_mod_settings_put(mid):
@app.route("/api/audit")
def api_audit():
return jsonify(entries=audit.query(200), total=audit.count())
"""Auditregels, optioneel gefilterd en doorbladerbaar."""
a = request.args
filters = {
"src": (a.get("src") or "").strip(),
"action": (a.get("action") or "").strip(),
"status": (a.get("status") or "").strip(),
"zoek": (a.get("q") or "").strip()[:100],
}
dagen = a.get("days", "").strip()
if dagen.isdigit() and int(dagen) > 0:
filters["sinds"] = time.time() - int(dagen) * 86400
limit = min(max(int(a.get("limit", 100)), 1), 500)
offset = max(int(a.get("offset", 0)), 0)
return jsonify(entries=audit.query(limit, offset, **filters),
total=audit.count(**filters),
grand_total=audit.count(),
facets=audit.facets(),
limit=limit, offset=offset)
@app.route("/api/audit/clear", methods=["POST"])

View file

@ -58,25 +58,67 @@ def log(src: str, action: str, status="ok", ref="", detail=None, ip=""):
pass
def query(limit=100, offset=0) -> list[dict]:
def _filter(src="", action="", status="", zoek="", sinds=None):
"""Bouw de WHERE-clausule op uit de opgegeven filters."""
delen, params = [], []
if src:
delen.append("src = ?"); params.append(src)
if action:
delen.append("action = ?"); params.append(action)
if status:
delen.append("status = ?"); params.append(status)
if sinds:
delen.append("ts >= ?"); params.append(float(sinds))
if zoek:
delen.append("(ref LIKE ? OR detail LIKE ? OR ip LIKE ?)")
params += [f"%{zoek}%"] * 3
return (" WHERE " + " AND ".join(delen)) if delen else "", params
def query(limit=100, offset=0, **filters) -> list[dict]:
"""Regels uit het log, nieuwste eerst, optioneel gefilterd.
Zonder filters groeide het log snel onbruikbaar: sinds er logins,
rolwijzigingen, backups en updates in landen zijn de laatste tweehonderd
regels al na een dag beheerwerk niet meer representatief.
"""
where, params = _filter(**filters)
try:
with _lock:
rows = _connect().execute(
"SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?",
(limit, offset)).fetchall()
f"SELECT * FROM log{where} ORDER BY ts DESC LIMIT ? OFFSET ?",
(*params, limit, offset)).fetchall()
return [dict(r) for r in rows]
except Exception:
return []
def count() -> int:
def count(**filters) -> int:
where, params = _filter(**filters)
try:
with _lock:
return _connect().execute("SELECT count(*) FROM log").fetchone()[0]
return _connect().execute(
f"SELECT count(*) FROM log{where}", params).fetchone()[0]
except Exception:
return 0
def facets() -> dict:
"""Welke bronnen, acties en statussen komen voor — voor de filterkeuzes."""
uit = {"src": [], "action": [], "status": []}
try:
with _lock:
c = _connect()
for kolom in uit:
rows = c.execute(
f"SELECT DISTINCT {kolom} FROM log "
f"WHERE {kolom} != '' ORDER BY {kolom}").fetchall()
uit[kolom] = [r[0] for r in rows]
except Exception:
pass
return uit
def clear():
try:
with _lock:

View file

@ -91,9 +91,17 @@ def add_shared_network(compose_text: str, network: str) -> str:
if not isinstance(doc, dict) or not isinstance(doc.get("services"), dict):
raise TransformError("compose bevat geen services")
aangesloten = 0
for svc in doc["services"].values():
if not isinstance(svc, dict):
continue
# Een service met `network_mode` deelt al de namespace van de host of
# van een andere container. Docker compose weigert die combinatie:
# "declares mutually exclusive network_mode and networks". Dit raakt
# Homebridge en Scrypted (host, voor mDNS), de Beszel-agent, en de
# downloadclients van de ARR-stack achter Gluetun.
if svc.get("network_mode"):
continue
# `networks:` mag een lijst of een mapping zijn; allebei aanvullen.
huidig = svc.get("networks")
if huidig is None:
@ -103,12 +111,17 @@ def add_shared_network(compose_text: str, network: str) -> str:
huidig.append(network)
elif isinstance(huidig, dict):
huidig.setdefault(network, None)
aangesloten += 1
netwerken = doc.get("networks")
if not isinstance(netwerken, dict):
netwerken = {}
netwerken.setdefault(network, {"external": True})
doc["networks"] = netwerken
# Sluit geen enkele service aan, dan hoeft het netwerk ook niet gedeclareerd
# te worden — een ongebruikt extern netwerk laat compose alsnog struikelen
# als het niet bestaat.
if aangesloten:
netwerken = doc.get("networks")
if not isinstance(netwerken, dict):
netwerken = {}
netwerken.setdefault(network, {"external": True})
doc["networks"] = netwerken
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
allow_unicode=True, width=120)

View file

@ -356,6 +356,13 @@ tailwind.config = {
<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('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button>
<button class="btn-sm" :title="t('stack_settings')" @click="openReconfigure(s.name)"
:disabled="!s.editable"><span class="mdi mdi-tune"></span></button>
<button class="btn-sm" :title="s.connected ? t('connected_to_apps') : t('connect_to_apps')"
:class="s.connected ? '!text-ok !border-ok/30' : ''"
@click="connectStack(s.name)" :disabled="s.connected">
<span class="mdi" :class="s.connected ? 'mdi-lan-connect' : 'mdi-lan-pending'"></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>
@ -531,7 +538,33 @@ tailwind.config = {
<!-- ══ Audit ══ -->
<section x-show="page === 'audit'" x-cloak class="anim">
<div class="flex items-center gap-2 mb-5">
<div class="flex flex-wrap items-center gap-2 mb-4">
<div class="relative flex-1 min-w-[180px] max-w-xs">
<span class="mdi mdi-magnify absolute left-3.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark text-lg"></span>
<input x-model="auditFilter.q" @input="loadAuditDebounced()" :placeholder="t('search')+'…'" class="form-input !pl-10 !h-9 !text-xs">
</div>
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.src" @change="loadAudit()">
<option value="" x-text="t('all')">Alle</option>
<template x-for="v in (auditFacets.src || [])" :key="v"><option :value="v" x-text="v"></option></template>
</select>
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.action" @change="loadAudit()">
<option value="" x-text="t('all')">Alle</option>
<template x-for="v in (auditFacets.action || [])" :key="v"><option :value="v" x-text="v"></option></template>
</select>
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.status" @change="loadAudit()">
<option value="" x-text="t('all')">Alle</option>
<template x-for="v in (auditFacets.status || [])" :key="v"><option :value="v" x-text="v"></option></template>
</select>
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.days" @change="loadAudit()">
<option value="" x-text="t('all_time')">Altijd</option>
<option value="1" x-text="t('last_day')">24 uur</option>
<option value="7" x-text="t('last_week')">7 dagen</option>
<option value="30" x-text="t('last_month')">30 dagen</option>
</select>
<span class="text-[11px] text-t2 dark:text-t2-dark ml-auto"
x-text="t('audit_count', {shown: audit.length, total: auditTotal})"></span>
</div>
<div class="flex items-center gap-2 mb-5">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('audit')">Audit Log</p>
<button @click="loadAudit()" class="btn-icon"><span class="mdi mdi-refresh"></span></button>
<button @click="clearAudit()" class="btn btn-danger"><span class="mdi mdi-delete-outline"></span> <span x-text="t('delete')">Wissen</span></button>
@ -1162,16 +1195,22 @@ tailwind.config = {
<div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head">
<span class="mdi mdi-download-outline text-ac dark:text-ac-dark"></span>
<span x-text="t('install_stack')">Stack installeren</span>
<span x-text="install.mode === 'edit' ? t('stack_settings') : t('install_stack')">Stack installeren</span>
<span x-show="install.format === 'boilerplate'" class="badge badge-info">Boilerplate</span>
</div>
<div class="modal-body overflow-y-auto space-y-3">
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
<div>
<div x-show="install.mode !== 'edit'">
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
<input class="form-input" x-model="install.instance">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
</div>
<!-- Bij wijzigen wordt de stack opnieuw gerenderd; dat overschrijft
handmatige aanpassingen in het compose-bestand. -->
<p x-show="install.mode === 'edit'" class="text-[11px] text-wr flex items-start gap-1.5">
<span class="mdi mdi-alert-outline shrink-0"></span>
<span x-text="t('reconfigure_warning')"></span>
</p>
<!-- Uitleg bij het veld waar je op het info-icoon klikte -->
<div x-show="fieldInfo" x-cloak
@ -1396,7 +1435,10 @@ tailwind.config = {
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="doInstall()"><span class="mdi mdi-rocket-launch"></span> <span x-text="t('install')">Installeren</span></button>
<button class="btn btn-primary" @click="install.mode === 'edit' ? doReconfigure() : doInstall()">
<span class="mdi" :class="install.mode === 'edit' ? 'mdi-content-save-outline' : 'mdi-rocket-launch'"></span>
<span x-text="install.mode === 'edit' ? t('save') : t('install')">Installeren</span>
</button>
</div>
</div>
@ -1609,7 +1651,8 @@ function app() {
storeQuery: '', storeTag: '', storeCat: '',
categories: [], catMap: {},
images: [],
audit: [],
audit: [], auditTotal: 0, auditFacets: {}, auditTimer: null,
auditFilter: { q: '', src: '', action: '', status: '', days: '' },
repos: [],
storeStacks: {},
settings: {},
@ -2269,6 +2312,7 @@ function app() {
async openInstall(stack, rid) {
this.install = {
mode: 'new',
stack: stack.dir || stack.name, repo_id: rid,
instance: stack.dir || stack.name,
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
@ -2335,6 +2379,46 @@ function app() {
const f = this.install.preview_files || {};
return f['compose.yaml'] || f['docker-compose.yml'] || f['compose.yml'] || Object.values(f)[0] || '(geen preview)';
},
// Bestaande stack opnieuw instellen: hetzelfde formulier, voorgevuld met
// wat er bij de installatie is gekozen.
async openReconfigure(stack) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/config`);
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
if (!this.netList.length) await this.loadNetworks();
await this.loadConnectApps();
this.install = {
mode: 'edit', stack: d.source, instance: stack, repo_id: '',
format: 'boilerplate', loading: false,
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: d.fields || [], values: { ...(d.values || {}) }, preview_files: {},
network: d.network || '', ip: d.ip || '', connect_apps: !!d.connected,
};
this.fieldInfo = null;
this.modal = 'install';
},
async doReconfigure() {
const naam = this.install.instance;
if (!confirm(this.t('reconfigure_confirm', {stack: naam}))) return;
const d = await this.rpc(`/api/stacks/${encodeURIComponent(naam)}/reconfigure`, {
values: this.install.values,
network: this.install.network,
ip: this.install.ip,
connect_apps: this.install.connect_apps !== false,
});
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('saved'), 'ok'); });
},
// Bestaande stack alsnog op het gedeelde netwerk zetten.
async connectStack(stack) {
if (!confirm(this.t('connect_stack_confirm', {stack, net: this.sharedNetwork}))) return;
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/connect`, {connect: true});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.termOpen = true;
this.pollJob(d.job_id, () => this.loadStacks());
},
async doInstall() {
const body = {
stack: this.install.stack,
@ -2366,7 +2450,23 @@ function app() {
else this.toast(d.msg || 'Fout', 'er');
},
async loadAudit() { const d = await this.rpc('/api/audit'); this.audit = d.entries || []; },
async loadAudit(meer) {
const f = this.auditFilter;
const offset = meer ? this.audit.length : 0;
const qs = new URLSearchParams({
q: f.q || '', src: f.src || '', action: f.action || '',
status: f.status || '', days: f.days || '',
limit: 100, offset,
});
const d = await this.rpc('/api/audit?' + qs);
this.audit = meer ? [...this.audit, ...(d.entries || [])] : (d.entries || []);
this.auditTotal = d.total ?? this.audit.length;
this.auditFacets = d.facets || {};
},
loadAuditDebounced() {
if (this.auditTimer) clearTimeout(this.auditTimer);
this.auditTimer = setTimeout(() => this.loadAudit(), 300);
},
async clearAudit() {
if (!confirm('Audit log wissen?')) return;
await this.rpc('/api/audit/clear', {});

View file

@ -255,5 +255,17 @@
"connect_apps": "Connect to other apps",
"connect_apps_hint": "Put this stack on the shared network '{net}' so it can reach other apps by name and vice versa. Without this, every stack stands alone.",
"filter_on_category": "Filter by this category",
"categories": "Categories"
"categories": "Categories",
"stack_settings": "Settings",
"reconfigure_warning": "The stack is rebuilt from its template. Manual changes to the compose file will be lost — a backup is made first automatically.",
"reconfigure_confirm": "Save settings for {stack}?\n\nA backup is made first, then the stack is rebuilt and restarted.",
"connect_to_apps": "Connect to other apps",
"connected_to_apps": "Already connected to other apps",
"connect_stack_confirm": "Put {stack} on the shared network '{net}'?\n\nThe stack will be restarted.",
"all_time": "All time",
"last_day": "24 hours",
"last_week": "7 days",
"last_month": "30 days",
"audit_count": "{shown} of {total}",
"load_more": "Load more"
}

View file

@ -255,5 +255,17 @@
"connect_apps": "Verbinden met andere apps",
"connect_apps_hint": "Zet deze stack op het gedeelde netwerk '{net}', zodat hij andere apps op hun naam kan bereiken en andersom. Zonder dit staat elke stack op zichzelf.",
"filter_on_category": "Filter op deze categorie",
"categories": "Categorieën"
"categories": "Categorieën",
"stack_settings": "Instellingen",
"reconfigure_warning": "De stack wordt opnieuw opgebouwd uit het sjabloon. Handmatige wijzigingen in het compose-bestand gaan daarbij verloren — er wordt eerst automatisch een backup gemaakt.",
"reconfigure_confirm": "Instellingen van {stack} opslaan?\n\nEr wordt eerst een backup gemaakt; daarna wordt de stack opnieuw opgebouwd en herstart.",
"connect_to_apps": "Koppelen aan andere apps",
"connected_to_apps": "Al gekoppeld aan andere apps",
"connect_stack_confirm": "{stack} op het gedeelde netwerk '{net}' zetten?\n\nDe stack wordt daarna herstart.",
"all_time": "Altijd",
"last_day": "24 uur",
"last_week": "7 dagen",
"last_month": "30 dagen",
"audit_count": "{shown} van {total}",
"load_more": "Meer laden"
}

View file

@ -204,3 +204,67 @@ def test_groepsschakelaar_krijgt_een_startwaarde():
assert all(isinstance(f["default"], bool) for f in schakelaars)
aan = [f["name"] for f in schakelaars if f["default"]]
assert "enable_sonarr" in aan and "enable_radarr" in aan
# ── Gedeeld netwerk over alle apps ───────────────────────────────────────────
from core import compose_transform as _ct
def _render_tekst(app: Path, overrides: dict | None = None) -> str:
velden = bp.fields(app)
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
waarden.setdefault("service_name", app.name)
for f in velden:
if f.get("required") and not waarden.get(f["name"]):
waarden[f["name"]] = "testwaarde12345678901234567890ab"
waarden.update(overrides or {})
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
bp.render_to_dir(app, tmp, waarden)
return (tmp / "docker-compose.yml").read_text()
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
def test_gedeeld_netwerk_botst_niet_met_network_mode(app):
"""Een service met network_mode deelt al andermans namespace; compose
weigert die samen met `networks`. Dat maakte Homebridge, Scrypted, Beszel
en de ARR-stack achter Gluetun onin­stalleerbaar met de standaardkeuze
"Verbinden met andere apps"."""
doc = yaml.safe_load(_ct.add_shared_network(_render_tekst(app), "serverup"))
for naam, svc in doc["services"].items():
if svc.get("network_mode"):
assert "networks" not in svc, \
f"{naam}: network_mode={svc['network_mode']!r} én networks"
def test_gedeeld_netwerk_bij_arr_stack_met_vpn():
"""De downloadclients zitten dan op network_mode: service:…-gluetun."""
tekst = _render_tekst(ARR, {s: False for s in _arr_schakelaars()} | {
"enable_gluetun": True, "enable_qbittorrent": True,
"enable_sabnzbd": True, "enable_sonarr": True})
doc = yaml.safe_load(_ct.add_shared_network(tekst, "serverup"))
for client in ("qbittorrent", "sabnzbd"):
svc = doc["services"][f"arr-{client}"]
assert svc["network_mode"].startswith("service:")
assert "networks" not in svc
# Sonarr heeft geen network_mode en hoort er wél op te zitten.
assert "serverup" in doc["services"]["arr-sonarr"]["networks"]
assert doc["networks"]["serverup"] == {"external": True}
def test_alleen_network_mode_services_levert_geen_netwerkblok():
"""Zit geen enkele service op het gedeelde netwerk, dan hoeft het ook niet
gedeclareerd te worden."""
tekst = "services:\n a:\n image: x\n network_mode: host\n"
doc = yaml.safe_load(_ct.add_shared_network(tekst, "serverup"))
assert "networks" not in doc["services"]["a"]
assert "networks" not in doc
def test_huidige_versie_heeft_een_changelog_sectie():
"""release.yml haalt de release-notes uit CHANGELOG.md. Ontbreekt de sectie
voor de versie die je tagt, dan krijg je een lege release."""
versie = (WORTEL / "VERSION").read_text(encoding="utf-8").strip()
changelog = (WORTEL / "CHANGELOG.md").read_text(encoding="utf-8")
assert f"# v{versie}" in changelog, f"geen changelog-sectie voor {versie}"

163
tests/test_reconfigure.py Normal file
View file

@ -0,0 +1,163 @@
"""Instellingen wijzigen na installatie, koppelen, en het audit-filter."""
import json
import sys
import time
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from conftest import login
def _stack(env, naam="web", meta=None, compose=None):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
compose or "services:\n web:\n image: nginx\n ports:\n - \"8080:80\"\n")
if meta is not None:
(d / ".serverup.json").write_text(json.dumps(meta), encoding="utf-8")
return d
# ── Herconfigureren ──────────────────────────────────────────────────────────
def test_config_zonder_opgeslagen_waarden(client, env):
"""Stacks van vóór deze versie hebben geen keuzes bewaard; dan moet de
interface uitleggen waarom de knop niet werkt in plaats van te falen."""
login(client)
_stack(env, "oud", {"source": "oud"})
d = client.get("/api/stacks/oud/config").get_json()
assert d["ok"] is False and d["editable"] is False
assert "compose bewerken" in d["msg"]
def test_config_van_onbekende_stack(client):
login(client)
assert client.get("/api/stacks/bestaatniet/config").status_code == 404
def test_stacklijst_meldt_of_wijzigen_kan(client, env):
login(client)
_stack(env, "nieuw", {"source": "x", "values": {"port": 80}})
_stack(env, "oud", {"source": "x"})
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["nieuw"]["editable"] is True
assert per_naam["oud"]["editable"] is False
def test_reconfigure_zonder_bronsjabloon(client, env):
csrf = login(client)
_stack(env, "web", {"source": "bestaatniet", "repo_id": "x", "values": {"a": 1}})
r = client.post("/api/stacks/web/reconfigure", json={"values": {"a": 2}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "Bronsjabloon" in r.get_json()["msg"]
def test_reconfigure_van_onbekende_stack(client):
csrf = login(client)
r = client.post("/api/stacks/bestaatniet/reconfigure", json={"values": {}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
"""Zonder opgeslagen waarden valt het formulier later niet te heropenen."""
import app as app_module
assert '"values": bp_values if is_bp else {}' in \
(WORTEL / "server-up" / "app.py").read_text(encoding="utf-8")
# ── Koppelen ─────────────────────────────────────────────────────────────────
def test_connect_van_onbekende_stack(client):
csrf = login(client)
r = client.post("/api/stacks/bestaatniet/connect", json={"connect": True},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_connect_start_een_job(client, env):
csrf = login(client)
_stack(env, "web", {"source": "x"})
d = client.post("/api/stacks/web/connect", json={"connect": True},
headers={"X-CSRF-Token": csrf}).get_json()
assert d["ok"] and d["job_id"]
def test_viewer_mag_niet_herconfigureren(client, env):
csrf = login(client)
_stack(env, "web", {"source": "x", "values": {}})
client.post("/api/auth/users",
json={"username": "kijker", "password": "eenlangwachtwoord",
"role": "viewer"}, headers={"X-CSRF-Token": csrf})
kijker = client.post("/api/auth/login",
json={"username": "kijker", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
assert client.post("/api/stacks/web/reconfigure", json={"values": {}},
headers={"X-CSRF-Token": kijker}).status_code == 403
# ── Audit-filter ─────────────────────────────────────────────────────────────
@pytest.fixture
def gevuld(env):
from core import audit
audit.init()
audit.log("auth", "login", "ok", ref="ramon", ip="10.0.0.1")
audit.log("auth", "login", "error", ref="indringer", ip="10.9.9.9")
audit.log("stacks", "start", "ok", ref="jellyfin")
audit.log("backups", "create", "ok", ref="jellyfin")
return audit
def test_filter_op_bron(gevuld):
assert len(gevuld.query(100, src="auth")) == 2
assert gevuld.count(src="auth") == 2
def test_filter_op_actie_en_status(gevuld):
r = gevuld.query(100, src="auth", action="login", status="error")
assert len(r) == 1 and r[0]["ref"] == "indringer"
def test_zoeken_in_referentie_en_ip(gevuld):
assert len(gevuld.query(100, zoek="jellyfin")) == 2
assert len(gevuld.query(100, zoek="10.9.9.9")) == 1
def test_filter_op_periode(gevuld):
assert gevuld.count(sinds=time.time() - 60) == 4
assert gevuld.count(sinds=time.time() + 60) == 0
def test_doorbladeren(gevuld):
eerste = gevuld.query(2, 0)
tweede = gevuld.query(2, 2)
assert len(eerste) == 2 and len(tweede) == 2
assert {r["id"] for r in eerste}.isdisjoint({r["id"] for r in tweede})
def test_facetten_voor_de_keuzelijsten(gevuld):
f = gevuld.facets()
assert set(f["src"]) == {"auth", "stacks", "backups"}
assert "login" in f["action"]
assert set(f["status"]) == {"ok", "error"}
def test_audit_endpoint_filtert(client, env, gevuld):
login(client)
d = client.get("/api/audit?src=auth&status=error").get_json()
assert d["total"] == 1
assert d["entries"][0]["ref"] == "indringer"
assert "auth" in d["facets"]["src"]
assert d["grand_total"] >= d["total"]
def test_audit_endpoint_limiet_wordt_begrensd(client, env, gevuld):
login(client)
d = client.get("/api/audit?limit=99999").get_json()
assert d["limit"] <= 500