Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-30-preview-runner.md
T
2026-09-30 21:53:41 +00:00

192 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Preview 2.0 (Preview-Runner) – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Builds und Dev-Server für Previews laufen in abgeschotteten Containern eines neuen Dienstes
`webklar-preview-runner`; Modus und Variablen pro Projekt im WOMS.
**Architecture:** Neuer Node-Dienst in `runner/` (eigenes package.json, keine Portal-Imports, spricht Docker
über die CLI). Das Portal (`server/`) schickt Aufträge per HTTP an den Runner, hält Zustand, Variablen
(verschlüsselt) und Protokolle und schreibt die Traefik-Router. WOMS bekommt einen Preview-Block am Projekt.
**Tech Stack:** Node 20/24 (ESM, `node --test`), Docker-CLI, Express (Portal), React/Vite (WOMS).
**Spec:** `docs/superpowers/specs/2026-09-30-preview-runner-design.md` (Commit 1e9c2bc)
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-preview` (Branch `feature/preview-runner`).
WOMS: `git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/preview-runner ../dev-preview origin/test`,
danach `git ls-files -z node_modules | xargs -0 git update-index --skip-worktree` und eigenes `npm install`
(getrackte node_modules – nie `git add -A`).
## Global Constraints
- Modi exakt `auto` | `build` | `dev` | `off`; leer = `auto`. Vorgabe bei Zuordnung: Art website/webapp ⇒ `auto`, sonst `off`.
- Zustände exakt `online` | `building` | `failed` | `sleeping` | `starting` | `off`.
- Build: `--memory 1g --cpus 1 --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges --user 1000:1000 --network preview-sandbox`, Zeitlimit 15 min, genau 1 gleichzeitig.
- Dev: gleiches Profil mit `--memory 512m`, Name `preview-dev-<sub>`, max. 3, Leerlauf 20 min, Bereitschaft max. 180 s, Standard-Port 5173.
- Schutzschalter: MemAvailable < 1,5 GB ⇒ `503 { error: 'Server ausgelastet' }`, nichts Neues starten.
- Runner-Auth: `Authorization: Bearer <PREVIEW_RUNNER_TOKEN>`; API-Port 8080, Proxy-Port 8081; kein veröffentlichter Port.
- Runner-Eingaben: Subdomain `^[a-z0-9-]{1,63}$`, Repo `^[\w.-]+/[\w.-]+$`, sha `^[0-9a-f]{7,40}$`, Branch `^[\w./-]{1,100}$`.
- Variablen: Name `^[A-Z_][A-Z0-9_]*$`, max. 30, Wert ≤ 4096 Byte, AES-256-GCM, Format `v1:<iv>:<tag>:<ct>` (base64), Schlüssel `PREVIEW_ENV_KEY` (32 Byte base64).
- Protokoll: letzte 300 Zeilen je Lauf, letzte 5 Läufe je Projekt, Variablenwerte (≥ 4 Zeichen) ⇒ `•••`.
- Build-Fehler ⇒ vorhandener Preview-Ordner bleibt; Erfolg ⇒ atomar über `<sub>.new` + rename.
- `PREVIEW_RUNNER=off` (Vorgabe) ⇒ Portal verhält sich exakt wie heute.
- Texte deutsch; kein `confirm()`. Nichts pushen/live schalten ohne OK des Nutzers.
## Review Focus
1. Build scheitert bei bestehender Preview ⇒ alte Dateien bleiben unverändert online. → Task 4
2. 4. Dev-Start bei 3 laufenden ⇒ der am längsten ungenutzte stoppt, nicht der gerade angefragte. → Task 5
3. Böswillige Eingaben (Subdomain `../x`, Repo mit `;`) ⇒ 400, kein Docker-Aufruf. → Task 1
4. Variablenwert erscheint im Build-Protokoll ⇒ im gespeicherten Protokoll maskiert. → Task 7
5. `PREVIEW_RUNNER=off` ⇒ `runDeploy` ruft keinen Runner, alte Tests unverändert grün. → Task 9
---
## Runner (`runner/`)
### Task 1: Gerüst, Eingabeprüfung, Auth, `/status`
**Files:** Create `runner/package.json` (type module, `"test": "node --test test/*.test.js"`, keine Laufzeit-Abhängigkeiten),
`runner/src/config.js`, `runner/src/validate.js`, `runner/src/server.js`, `runner/src/index.js`, `runner/Dockerfile`
(`FROM node:20-alpine`, `apk add --no-cache docker-cli git`), Test `runner/test/server.test.js`.
**Interfaces – Produces:**
- `loadConfig(env) ⇒ { token, giteaUrl, giteaToken, previewsRoot, workRoot, cacheRoot, apiPort:8080, proxyPort:8081, minFreeBytes: 1.5*1024**3, buildTimeoutMs: 15*60e3, devIdleMs: 20*60e3, devMax:3, devReadyMs:180e3 }`
- `validateJob(body) ⇒ { ok:true, job } | { ok:false, error }` (Regeln aus Global Constraints; `mode` ∈ auto|build|dev; `env` Objekt mit gültigen Namen)
- `createApiServer({ config, jobs, dev, mem }) ⇒ http.Server` mit Routen `POST /jobs/build`, `GET /jobs/:runId`, `POST /dev/:sub/start|stop`, `GET /status`; ohne/mit falschem Token ⇒ 401; ungültig ⇒ 400.
- `readMemAvailable(path='/proc/meminfo') ⇒ bytes`
- [ ] **Step 1: Tests** – 401 ohne Token; 400 für Subdomain `../x`, Repo `a/b;rm`, sha `xyz`; 200 `/status` mit `{ dev:[], queue:0, memAvailable }`; `readMemAvailable` aus Beispieltext.
- [ ] **Step 2–4:** FAIL → implementieren (reines `node:http`) → PASS
- [ ] **Step 5:** `git commit -m "Runner: Geruest, Eingabepruefung, Auth"`
### Task 2: Erkennung
**Files:** Create `runner/src/detect.js`, Test `runner/test/detect.test.js`
**Interfaces – Produces:** `detectProject(files: { has(path):bool, read(path):string|null }, { mode, index, previewJson }) ⇒ { kind: 'static'|'build'|'dev', pm: 'npm'|'pnpm'|'yarn'|'bun', install: string[], buildCmd: string[]|null, devCmd: string[]|null, outputDirs: string[], devPort: number }`
Regeln laut Spec 1.2 „Erkennung“; `mode:'build'` erzwingt build (Fehler, wenn kein Build-Skript), `mode:'dev'` erzwingt dev; `auto`: static ⇒ build ⇒ dev. `outputDirs` = `[previewJson.output]` oder `['dist','build','out','web-build']`. npm ohne Lock ⇒ `npm install --include=dev --no-audit --no-fund`, mit Lock ⇒ `npm ci --include=dev`. devCmd z. B. `['npm','run','dev','--','--host','0.0.0.0','--port','5173']`.
- [ ] **Step 1: Tests** – HTML ohne package.json ⇒ static; Vite mit build ⇒ build, pm npm; pnpm-lock ⇒ pnpm; nur `dev`-Skript ⇒ dev; index.html + build-Skript ⇒ build; `mode:'dev'` erzwingt dev; `previewJson.devPort` 3000 ⇒ devPort 3000 und im devCmd.
- [ ] **Step 2–5:** FAIL → implementieren → PASS → `git commit -m "Runner: Projekterkennung"`
### Task 3: Docker-Adapter
**Files:** Create `runner/src/docker.js`, Test `runner/test/docker.test.js`
**Interfaces – Produces:** `buildRunArgs({ image, name, workDir, cacheDir, env, cmd, memory, network }) ⇒ string[]` (enthält exakt das Sicherheitsprofil aus Global Constraints, `--rm`, `-w /work`, Mounts nur `workDir:/work` und `cacheDir:/cache`, `-e NAME` mit Werten per `env` des Kindprozesses, nie auf der Kommandozeile);
`devRunArgs(...)` (ohne `--rm`, `-d`, Name `preview-dev-<sub>`, Label `webklar.preview=<sub>`);
`createDocker({ exec = spawnDocker }) ⇒ { run(args, { env, timeoutMs, onLine }) ⇒ {code}, stop(name), rm(name), ps(label) ⇒ names[], inspectIp(name) }`.
- [ ] **Step 1: Tests** – Argumentliste enthält jede Sicherheitsoption; kein Variablenwert in args; Zeitlimit ⇒ Kill und `code:124`; `ps` parst Ausgabe.
- [ ] **Step 2–5:** FAIL → implementieren (child_process.spawn) → PASS → `git commit -m "Runner: Docker-Adapter mit Sicherheitsprofil"`
### Task 4: Build-Aufträge
**Files:** Create `runner/src/jobs.js`, Test `runner/test/jobs.test.js`
**Interfaces:** Consumes Task 2 `detectProject`, Task 3 `createDocker`. Produces `createJobs({ config, docker, fs, git, mem, now }) ⇒ { submit(job) ⇒ runId, get(runId) ⇒ { state:'queued'|'running'|'done'|'failed', detected, sha, log:string[], error, result:'static'|'build'|'dev' } }`.
Ablauf: Warteschlange, genau 1 aktiv; Schutzschalter vor Start (bleibt queued, erneuter Versuch alle 30 s); `git clone --depth 1 --branch <branch>` mit Token-URL (Token nie im Log); Erkennung; static ⇒ kopieren; build ⇒ install+build im Container, Ausgabeordner suchen, nach `<sub>.new` kopieren, alten Ordner ersetzen; Fehler ⇒ alter Ordner unberührt, `failed` (bei `auto` mit dev-Skript ⇒ Ergebnis `dev`, kein Fehler); Log-Ringpuffer 300 Zeilen; erledigte Läufe 1 h im Speicher. Cache je Repo unter `cacheRoot/<owner>__<repo>` (als `/cache`, Paketmanager-Cache dorthin), `pruneCache()` täglich: Einträge > 14 Tage und, solange gesamt > 5 GB, die ältesten löschen.
- [ ] **Step 1: Tests** – `pruneCache` löscht > 14 Tage und hält 5 GB ein; zwei Aufträge laufen nacheinander; Build-Fehler lässt vorhandenen Ordner byte-gleich; Erfolg ersetzt atomar; Token nicht im Log; wenig Speicher ⇒ bleibt queued; auto+dev-Skript nach Build-Fehler ⇒ result `dev`; Log auf 300 Zeilen gekürzt.
- [ ] **Step 2–5:** FAIL → implementieren → PASS → `git commit -m "Runner: Build-Auftraege mit Warteschlange"`
### Task 5: Dev-Server-Verwaltung
**Files:** Create `runner/src/dev.js`, Test `runner/test/dev.test.js`
**Interfaces – Produces:** `createDev({ config, docker, jobs, mem, now }) ⇒ { ensure(sub, spec) ⇒ { state:'starting'|'online', target? }, touch(sub), stop(sub), list() ⇒ [{sub, lastUsed, state}], sweep() }`.
Specs (Repo, sha, env, devCmd, devPort) setzt das Portal per Build-Auftrag mit Ergebnis dev bzw. `POST /dev/:sub/start` (Body wie build-Job). Max. 3 ⇒ LRU-Verdrängung; `sweep()` (jede Minute) stoppt nach 20 min ohne `touch`; Bereitschaft = TCP-Verbindung zum Port des Containers (über Container-IP im Netz `preview-sandbox`), max. 180 s, sonst Zustand `failed` mit Log. Dev-Container bekommen zusätzlich `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.project.webklar.com` (Basis-Host aus config). `ensure` mit neuem sha für laufenden sub ⇒ Container neu starten.
- [ ] **Step 1: Tests** – 4. Start verdrängt ältesten; `sweep` nach 21 min stoppt, nach 19 min nicht; wenig Speicher ⇒ Fehler `Server ausgelastet`; gleicher sub zweimal ⇒ ein Container.
- [ ] **Step 2–5:** FAIL → implementieren → PASS → `git commit -m "Runner: Dev-Server bei Bedarf"`
### Task 6: Proxy mit Startseite
**Files:** Create `runner/src/proxy.js`, Modify `runner/src/index.js` (API + Proxy + sweep starten), Test `runner/test/proxy.test.js`
**Interfaces – Produces:** `createProxy({ config, dev }) ⇒ http.Server`. Host `<sub>.<baseHost>` ⇒ `dev.touch` + Weiterleitung (HTTP und `upgrade`/WebSocket) an `target`; nicht online ⇒ `dev.ensure` anstoßen und HTML-Seite „Preview startet …“ (Status 503, `Retry-After: 3`, `<meta http-equiv="refresh" content="3">`), nach Fehler Seite „Preview konnte nicht starten“. Unbekannter Host ⇒ 404.
- [ ] **Step 1: Tests** – laufendes Ziel (lokaler Test-HTTP-Server) wird durchgereicht inkl. WebSocket-Echo; nicht laufend ⇒ 503 mit Starttext; fremder Host ⇒ 404.
- [ ] **Step 2–5:** FAIL → implementieren → PASS (`cd runner && npm test`) → `git commit -m "Runner: Proxy und Startseite"`
## Portal (`server/`)
### Task 7: Variablen-Verschlüsselung und Maskierung
**Files:** Create `server/services/previewEnv.js`, Test `test/previewEnv.test.js`
**Interfaces – Produces:** `encryptEnv(obj, key) ⇒ string`, `decryptEnv(str, key) ⇒ obj`, `validateEnvChange(current, { set, remove }) ⇒ { ok, env | error }`, `envNames(str, key) ⇒ string[]`, `maskLog(text, env) ⇒ string`, `envKey() ⇒ Buffer|null` (aus `process.env.PREVIEW_ENV_KEY`).
- [ ] **Step 1: Tests** – Round-Trip; falscher Schlüssel ⇒ Fehler; manipulierter Tag ⇒ Fehler; 31. Variable ⇒ Fehler; Name `abc` ⇒ Fehler; Wert 4097 Byte ⇒ Fehler; `maskLog` ersetzt Werte ≥ 4 Zeichen, lässt kürzere.
- [ ] **Step 2–5:** FAIL → implementieren (node:crypto) → PASS → `git commit -m "Preview: Variablen verschluesselt"`
### Task 8: Schema-Skript + Konfiguration
**Files:** Create `scripts/preview-runner-setup.mjs` (Muster `scripts/projektarten-setup.mjs` inkl. `limit 200`), Modify `server/config.js` (`previewRunner: { enabled: process.env.PREVIEW_RUNNER === 'on', url: process.env.PREVIEW_RUNNER_URL || 'http://webklar-preview-runner:8080', token: process.env.PREVIEW_RUNNER_TOKEN || '' }`), Test `test/previewRunnerSetup.test.js`.
Attribute laut Spec 1.1 (`previewMode` 10, `previewEnv` 20000, `previewState` 20, `previewDetected` 40, `previewBuiltSha` 40, `previewBuiltAt` 40, `previewError` 500; alle optional).
- [ ] **Step 1–5:** Test (7 Schlüssel, Größen, fehlende werden angelegt) → FAIL → implementieren → PASS → `git commit -m "Preview: Schema-Skript und Runner-Konfiguration"`
### Task 9: Runner-Anbindung in `runDeploy`
**Files:** Create `server/services/previewRunnerClient.js`, `server/services/previewLogs.js`, Modify `server/services/previewDeploy.js` (`runDeploy`), Test `test/previewRunner.test.js`
**Interfaces:**
- `previewRunnerClient`: `submitBuild(job) ⇒ { runId }`, `getRun(runId)`, `startDev(sub, job)`, `stopDev(sub)`, `status()`; Fehler tragen `.status`.
- `previewLogs`: `saveRunLog(projectId, lines, env) ⇒ runFile` (maskiert, behält 5), `listRunLogs(projectId)`, `readRunLog(projectId, runFile)` unter `upload-data/preview-logs/`.
- `runDeploy(repoFullName, branch, previewConfig, { project } = {})`: bei `config.previewRunner.enabled` ⇒ Modus aus `project.previewMode` (leer = auto; `off` ⇒ nichts bauen, `previewState:'off'`), Variablen entschlüsseln, Auftrag senden, `previewState:'building'`, bis Ende pollen (alle 2 s, max. 16 min), Ergebnis in Projekt schreiben (`previewState` online|failed|sleeping, `previewDetected`, `previewBuiltSha`, `previewBuiltAt`, `previewError`), Log speichern, danach `syncPreviewTls()`. Ergebnis dev und Dev-Container läuft (`status()`) ⇒ `startDev` mit neuem sha (Neustart mit neuem Code). Aufrufer (Webhook, `updateWebsiteProject`) übergeben das Projekt-Dokument.
- Schalter aus ⇒ bisheriger Code unverändert.
- [ ] **Step 1: Tests** – Schalter aus ⇒ kein Runner-Aufruf (Fake-Runner zählt 0) und `previewDeploy.test.js` bleibt grün; an ⇒ Auftrag mit entschlüsselten Variablen; Ergebnis failed ⇒ `previewState:'failed'`, Fehler gespeichert, Ordner unangetastet; Ergebnis dev ⇒ `sleeping`; Log maskiert und nur 5 behalten; Modus off ⇒ kein Auftrag.
- [ ] **Step 2–5:** FAIL → implementieren → PASS → `git commit -m "Preview: Builds ueber den Runner"`
### Task 10: Traefik-Router für Dev
**Files:** Modify `server/services/previewDeploy.js` (`syncPreviewTls`), Test in `test/previewDeploy.test.js`
`syncPreviewTls()` nimmt zusätzlich alle Subdomains, deren Projekt `previewState` ∈ {sleeping, starting, online} **und** `previewDetected` beginnt mit `dev` (auch ohne Ordner), und schreibt für diese `service: preview-runner@docker` (HTTPS und HTTP-Redirect wie gehabt, Middleware-Regel für öffentlich/nicht öffentlich unverändert). Appwrite nicht erreichbar ⇒ Dev-Liste aus der letzten Datei übernehmen (wie `publicSubdomainsFromHostsFile`).
- [ ] **Step 1–5:** Tests (dev-Projekt ⇒ runner-Service; static ⇒ nginx; Appwrite weg ⇒ vorherige Dev-Hosts bleiben) → FAIL → implementieren → PASS → `git commit -m "Preview: Router fuer Dev-Server"`
### Task 11: Admin-API und Modus-Vorgabe
**Files:** Create `server/routes/admin/preview.js` (eingehängt unter `/api/admin/website-projects` vor den bestehenden `/:id`-Routen oder als eigener Router mit `requireAppwriteStaff`), Modify `server/services/projectKindAssign.js` (Modus-Vorgabe aus Art: website/webapp ⇒ `auto`, sonst `off`, nur wenn `previewMode` leer), Test `test/previewAdmin.test.js`
Routen laut Spec 1.1. `GET …/preview` ⇒ `{ mode, state, detected, builtSha, builtAt, error, envNames, logs:[{runFile, at}] }`; `PATCH …/preview { mode }` (400 bei ungültig) ⇒ Neubau anstoßen (nicht bei off; bei off Dev stoppen); `PUT …/preview/env` ⇒ `validateEnvChange`, speichern, Neubau; ohne `PREVIEW_ENV_KEY` ⇒ 503 `{ error: 'Preview-Variablen sind nicht eingerichtet' }`; `POST …/rebuild`; `POST …/dev/start|stop`; `GET …/logs/:runFile` (Dateiname streng prüfen). Schalter aus ⇒ Schreib-Routen 409 `{ error: 'Preview-Runner ist nicht aktiv' }`, GET funktioniert.
- [ ] **Step 1–5:** Tests (401 ohne JWT, Modus-Validierung, env ohne Schlüssel ⇒ 503, Werte nie in Antwort, Log-Pfad `../x` ⇒ 400, Zuordnen setzt Vorgabe) → FAIL → implementieren → PASS (`npm test` komplett) → `git commit -m "Preview: Admin-API und Modus-Vorgabe"`
## WOMS
### Task 12: Preview-API-Client und Anzeige-Logik
**Files:** Create `src/lib/previewApi.js` (Muster `projectKindsApi.js`, Fehler mit `.status`/`.data`), `src/lib/previewView.js`, Test `tests/unit/previewView.test.mjs`
**Interfaces – Produces:** `previewStatus(p) ⇒ { color:'green'|'yellow'|'red'|'grey', text }` mit exakten Texten: online „Online seit TT.MM. HH:MM (Build <sha7>)“, building „Baut gerade …“, sleeping „Dev-Server schläft, startet beim nächsten Aufruf“, starting „Dev-Server startet …“, failed „Build fehlgeschlagen – letzte funktionierende Version ist online“, off „Keine Preview“; `detectedLabel(detected) ⇒` z. B. `build:vite:npm` ⇒ „Build (Vite, npm)“, `dev:npm` ⇒ „Dev-Server (npm)“, `static` ⇒ „Statisch“; `MODE_OPTIONS` = Automatisch/Build/Dev-Server/Aus.
- [ ] **Step 1–5:** Tests → FAIL → implementieren → PASS → `git commit -m "Preview: Anzeige-Logik und API-Client"`
### Task 13: Preview-Block in Projektkarte, Bearbeiten-Dialog und Services
**Files:** Create `src/components/PreviewBlock.jsx`, Modify `src/components/AssignedProjectCard.jsx`, `src/pages/ProjectsPage.jsx` (Bearbeiten-Modal), `src/pages/ServicesPage.jsx` (Punkt je Zeile aus `previewState`, Portal liefert das Feld in `/api/admin/services` – dort in Task 11 ergänzen, falls fehlend)
Block laut Spec Abschnitt 2 (Modus, Status, Knöpfe, Protokoll-Auswahl der 5 Läufe, Variablen-Tabelle mit `•••`, Hinweis `VITE_`/`NEXT_PUBLIC_`). 409 „Preview-Runner ist nicht aktiv“ ⇒ Block zeigt Hinweis statt Knöpfen. Handy-tauglich (390 px).
- [ ] **Step 1:** implementieren · **Step 2:** `npm test` + Build grün · **Step 3:** `git commit -m "Preview: Block im WOMS"`
### Task 14: Oberflächen-Check (Playwright, Fixture)
WOMS-dist mit gemockten Portal-/Appwrite-Antworten: Modus wechseln (PATCH-Body), Variablen setzen (Werte danach `•••`), Protokoll öffnen, 409-Hinweis, Services-Punkt, 390 px ohne horizontales Scrollen. Screenshots nach `/root/reports/preview-runner-2026-09-30/`.
- [ ] **Step 1:** Checks grün · **Step 2:** Ergebnis im Ledger
## Livegang (jeder Schritt mit OK des Nutzers)
- **L1** Sicherung `docker-compose.yml`, `.env`, `deploy.sh`. `.env`: `PREVIEW_RUNNER_TOKEN` (32 Byte zufällig), `PREVIEW_ENV_KEY` (32 Byte base64). `docker network create preview-sandbox`. Compose-Dienst `webklar-preview-runner` (build `kundenbereich.webklar.com/build/runner`, Netze `webklar-network` + `preview-sandbox`, Mounts Docker-Socket, `/var/www/previews`, Volumes work/cache, Traefik-Labels Service `preview-runner` Port 8081, Env nur Token/Gitea-Deploy-Token/URL). Portal-Compose: `PREVIEW_RUNNER`, `PREVIEW_RUNNER_TOKEN`, `PREVIEW_ENV_KEY`. `deploy.sh`: nach dem Portal-Neustart `docker compose … up -d --build webklar-preview-runner`, wenn sich `runner/` geändert hat. Schema-Skript (`--check` zuerst).
- **L2** Portal pushen (`PREVIEW_RUNNER=off`), Runner-Container starten, `/status` aus dem Portal-Container prüfen.
- **L3** Echte Probe mit drei Wegwerf-Repos (HTML, Vite-Build, nur `dev`) in `WEBklar/` → aufräumen. Dann `PREVIEW_RUNNER=on`, die 12 Previews einzeln neu bauen, jede Seite vorher/nachher per HTTP-Status + Hash der `index.html` vergleichen.
- **L4** WOMS pushen (`feature/preview-runner:test`).
- **L5** Live-Test des Nutzers mit Dev-Modus an einem echten Projekt. Rückweg: `PREVIEW_RUNNER=off` + Portal neu starten.