Plan: Preview 2.0 mit Preview-Runner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 21:53:41 +00:00
1 parent 651745eccc
commit c402837df6
1 file changed
+191
@@ -0,0 +1,191 @@
# 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.