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
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:
parent
ff1979ba51
commit
2ba15418ab
13 changed files with 1170 additions and 20 deletions
101
CHANGELOG.md
101
CHANGELOG.md
|
|
@ -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
|
# 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
|
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
|
# 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
|
"Ik wil van 10.0.20.200 tot .254" is een volstrekt redelijke wens, maar bestaat
|
||||||
|
|
|
||||||
104
README.md
Normal file
104
README.md
Normal 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.
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.7.10-beta
|
0.7.20-beta
|
||||||
|
|
|
||||||
14
docs/README.md
Normal file
14
docs/README.md
Normal 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
305
docs/apps-maken.md
Normal 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
|
||||||
|
```
|
||||||
222
server-up/app.py
222
server-up/app.py
|
|
@ -621,6 +621,8 @@ def api_stacks():
|
||||||
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
|
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
|
||||||
# Bij installatie vastgelegd; ontbreekt dat (oudere installatie),
|
# Bij installatie vastgelegd; ontbreekt dat (oudere installatie),
|
||||||
# dan leiden we het alsnog af uit de opgeslagen naam.
|
# dan leiden we het alsnog af uit de opgeslagen naam.
|
||||||
|
"connected": bool(meta.get("connected")),
|
||||||
|
"editable": bool(meta.get("values")),
|
||||||
"categories": categories.normalize(
|
"categories": categories.normalize(
|
||||||
meta.get("categories"), meta.get("tags"), meta.get("name", "")),
|
meta.get("categories"), meta.get("tags"), meta.get("name", "")),
|
||||||
})
|
})
|
||||||
|
|
@ -766,6 +768,202 @@ def api_stack_compose_put(name):
|
||||||
return jsonify(ok=True)
|
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 ──────────────────────────────────────────────
|
# ── Containers binnen een stack ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _stack_containers(name: str) -> list[str]:
|
def _stack_containers(name: str) -> list[str]:
|
||||||
|
|
@ -1313,6 +1511,11 @@ def api_store_install():
|
||||||
"connected": bool(verbinden and gedeeld_net),
|
"connected": bool(verbinden and gedeeld_net),
|
||||||
"categories": meta.get("categories") or [],
|
"categories": meta.get("categories") or [],
|
||||||
"tags": meta.get("tags") 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")
|
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -1984,7 +2187,24 @@ def api_mod_settings_put(mid):
|
||||||
|
|
||||||
@app.route("/api/audit")
|
@app.route("/api/audit")
|
||||||
def 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"])
|
@app.route("/api/audit/clear", methods=["POST"])
|
||||||
|
|
|
||||||
|
|
@ -58,25 +58,67 @@ def log(src: str, action: str, status="ok", ref="", detail=None, ip=""):
|
||||||
pass
|
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:
|
try:
|
||||||
with _lock:
|
with _lock:
|
||||||
rows = _connect().execute(
|
rows = _connect().execute(
|
||||||
"SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?",
|
f"SELECT * FROM log{where} ORDER BY ts DESC LIMIT ? OFFSET ?",
|
||||||
(limit, offset)).fetchall()
|
(*params, limit, offset)).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def count() -> int:
|
def count(**filters) -> int:
|
||||||
|
where, params = _filter(**filters)
|
||||||
try:
|
try:
|
||||||
with _lock:
|
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:
|
except Exception:
|
||||||
return 0
|
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():
|
def clear():
|
||||||
try:
|
try:
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
if not isinstance(doc, dict) or not isinstance(doc.get("services"), dict):
|
||||||
raise TransformError("compose bevat geen services")
|
raise TransformError("compose bevat geen services")
|
||||||
|
|
||||||
|
aangesloten = 0
|
||||||
for svc in doc["services"].values():
|
for svc in doc["services"].values():
|
||||||
if not isinstance(svc, dict):
|
if not isinstance(svc, dict):
|
||||||
continue
|
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.
|
# `networks:` mag een lijst of een mapping zijn; allebei aanvullen.
|
||||||
huidig = svc.get("networks")
|
huidig = svc.get("networks")
|
||||||
if huidig is None:
|
if huidig is None:
|
||||||
|
|
@ -103,7 +111,12 @@ def add_shared_network(compose_text: str, network: str) -> str:
|
||||||
huidig.append(network)
|
huidig.append(network)
|
||||||
elif isinstance(huidig, dict):
|
elif isinstance(huidig, dict):
|
||||||
huidig.setdefault(network, None)
|
huidig.setdefault(network, None)
|
||||||
|
aangesloten += 1
|
||||||
|
|
||||||
|
# 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")
|
netwerken = doc.get("networks")
|
||||||
if not isinstance(netwerken, dict):
|
if not isinstance(netwerken, dict):
|
||||||
netwerken = {}
|
netwerken = {}
|
||||||
|
|
|
||||||
|
|
@ -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('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" :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>
|
<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>
|
||||||
|
|
@ -531,7 +538,33 @@ tailwind.config = {
|
||||||
|
|
||||||
<!-- ══ Audit ══ -->
|
<!-- ══ Audit ══ -->
|
||||||
<section x-show="page === 'audit'" x-cloak class="anim">
|
<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>
|
<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="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>
|
<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 x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<span class="mdi mdi-download-outline text-ac dark:text-ac-dark"></span>
|
<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>
|
<span x-show="install.format === 'boilerplate'" class="badge badge-info">Boilerplate</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body overflow-y-auto space-y-3">
|
<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><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>
|
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
|
||||||
<input class="form-input" x-model="install.instance">
|
<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>
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
|
||||||
</div>
|
</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 -->
|
<!-- Uitleg bij het veld waar je op het info-icoon klikte -->
|
||||||
<div x-show="fieldInfo" x-cloak
|
<div x-show="fieldInfo" x-cloak
|
||||||
|
|
@ -1396,7 +1435,10 @@ tailwind.config = {
|
||||||
</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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1609,7 +1651,8 @@ function app() {
|
||||||
storeQuery: '', storeTag: '', storeCat: '',
|
storeQuery: '', storeTag: '', storeCat: '',
|
||||||
categories: [], catMap: {},
|
categories: [], catMap: {},
|
||||||
images: [],
|
images: [],
|
||||||
audit: [],
|
audit: [], auditTotal: 0, auditFacets: {}, auditTimer: null,
|
||||||
|
auditFilter: { q: '', src: '', action: '', status: '', days: '' },
|
||||||
repos: [],
|
repos: [],
|
||||||
storeStacks: {},
|
storeStacks: {},
|
||||||
settings: {},
|
settings: {},
|
||||||
|
|
@ -2269,6 +2312,7 @@ function app() {
|
||||||
|
|
||||||
async openInstall(stack, rid) {
|
async openInstall(stack, rid) {
|
||||||
this.install = {
|
this.install = {
|
||||||
|
mode: 'new',
|
||||||
stack: stack.dir || stack.name, repo_id: rid,
|
stack: stack.dir || stack.name, repo_id: rid,
|
||||||
instance: stack.dir || stack.name,
|
instance: stack.dir || stack.name,
|
||||||
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
|
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
|
||||||
|
|
@ -2335,6 +2379,46 @@ function app() {
|
||||||
const f = this.install.preview_files || {};
|
const f = this.install.preview_files || {};
|
||||||
return f['compose.yaml'] || f['docker-compose.yml'] || f['compose.yml'] || Object.values(f)[0] || '(geen preview)';
|
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() {
|
async doInstall() {
|
||||||
const body = {
|
const body = {
|
||||||
stack: this.install.stack,
|
stack: this.install.stack,
|
||||||
|
|
@ -2366,7 +2450,23 @@ function app() {
|
||||||
else this.toast(d.msg || 'Fout', 'er');
|
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() {
|
async clearAudit() {
|
||||||
if (!confirm('Audit log wissen?')) return;
|
if (!confirm('Audit log wissen?')) return;
|
||||||
await this.rpc('/api/audit/clear', {});
|
await this.rpc('/api/audit/clear', {});
|
||||||
|
|
|
||||||
|
|
@ -255,5 +255,17 @@
|
||||||
"connect_apps": "Connect to other apps",
|
"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.",
|
"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",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,5 +255,17 @@
|
||||||
"connect_apps": "Verbinden met andere apps",
|
"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.",
|
"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",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,3 +204,67 @@ def test_groepsschakelaar_krijgt_een_startwaarde():
|
||||||
assert all(isinstance(f["default"], bool) for f in schakelaars)
|
assert all(isinstance(f["default"], bool) for f in schakelaars)
|
||||||
aan = [f["name"] for f in schakelaars if f["default"]]
|
aan = [f["name"] for f in schakelaars if f["default"]]
|
||||||
assert "enable_sonarr" in aan and "enable_radarr" in aan
|
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 oninstalleerbaar 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
163
tests/test_reconfigure.py
Normal 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
|
||||||
Loading…
Reference in a new issue