5 Commits

Author SHA1 Message Date
karim 94a1617519 v0.1.2 - Update-Modal mit Live-Progress + Error-Display
- AppUpdateBanner: dialog statt window.confirm, full Modal-Overlay
- 3 Steps visualisiert: Pre-Backup -> Download -> Installation
  mit animierten Icons (radio_button -> progress_activity (spin) -> check_circle)
- Live-Download-Progress: 'X.X MB / Y.Y MB (NN%)'
- Errors fett im Modal sichtbar mit pre-formatierter Stack
- Console.log fuer jeden Schritt fuer DevTools-Debugging
2026-05-24 18:19:50 +02:00
karim 0920d68ac1 latest.json -> 0.1.2 2026-05-24 18:15:09 +02:00
karim 2e3c94af64 Bump latest.json to v0.1.1 2026-05-24 17:14:35 +02:00
karim e656505bcc v0.1.1 — Stabilisierungs-Fixes
- Auto-Start beim Boot skipped wenn Daemon nicht erreichbar
  (Wizard erscheint, statt Recovery-Loop-Spam)
- Auto-Recovery skipped wenn Daemon down — keine Counter-Vergeudung
- start_all/stop_all/restart_all geben Supervisor-Mutex waehrend
  langem 'docker compose up' frei → UI bleibt waehrend Container-
  Start responsive, kein 'Keine Services registriert' mehr
- tick_health behaelt Starting-State wenn Container noch nicht in
  compose ps auftaucht (kein Flackern Stopped/Starting)
- Container-State-Cache mit Timeout-Fallback in list_services
- Setup-Wizard triggert keinen Auto-Start mehr nach Install —
  User klickt bewusst 'Alle starten'
- Setup-Wizard: kein Brew mehr, Direct-Downloads von docker.com +
  GitHub fuer Docker/Colima/Lima
- Compose-Stack-Auto-Download von Gitea-Tarball wenn lokal nichts da
- .env-Generation mit JWT-signierten ANON/SERVICE-Keys
- Lima share-Layout: ~/.rapport/share/ statt /lima-share/
2026-05-24 17:11:30 +02:00
karim e2d2fd9fa2 Initial source: RAPPORT Server-App v0.1.0
- Tauri-2-Admin-UI fuer den Rapport-Compose-Stack
- React-Frontend (JSX, kein TS) mit Material-Symbols-Icons
- Service-Cards mit Live-Stats (CPU/RAM), Logs, Restart/Stop
- Backup-/Restore-System mit pg_dumpall + Retention
- Container-Auto-Updates mit Pre-Backup
- App-Auto-Updater (Tauri signiert) gegen latest.json im Repo-Root
- HTTPS-WebUI (axum/rustls) mit Basic-Auth, CSRF, Rate-Limit, Security-Headers
- Setup-Wizard: lädt Docker+Colima+Lima direct von GitHub/docker.com nach ~/.rapport/bin/
- Tray-Modus + macOS-Notifications + Auto-Recovery
- Login-Item via tauri-plugin-autostart
2026-05-24 17:03:50 +02:00
69 changed files with 14543 additions and 2 deletions
+13
View File
@@ -0,0 +1,13 @@
# Vorlage fuer config.env — der Setup-Wizard generiert diese Werte beim
# Erst-Start automatisch (Random-Secrets) und persistiert sie in
# ~/Library/Application Support/com.rapport.server-app/config.env (macOS).
# Diese Datei dient nur als Referenz fuer manuelle Test-Runs.
POSTGRES_PASSWORD=changeme
JWT_SECRET=changeme-min-32-bytes
SITE_URL=http://localhost:8080
API_EXTERNAL_URL=http://localhost:8000
# 127.0.0.1 = nur lokal, 0.0.0.0 = LAN-Zugriff (Vorsicht)
BIND_HOST=127.0.0.1
+31
View File
@@ -0,0 +1,31 @@
# Node
node_modules/
dist/
.vite/
# Rust / Tauri
src-tauri/target/
src-tauri/gen/
# Binaries (download via scripts/download-binaries.sh)
binaries/macos-aarch64/*
binaries/macos-x86_64/*
binaries/linux-x86_64/*
binaries/windows-x86_64/*
!binaries/*/.gitkeep
!binaries/README.md
!binaries/manifest.json
# Local app data (only relevant when running outside the bundle)
data/
# Editor / OS
.DS_Store
.vscode/
.idea/
*.log
# Signing keys (per-developer, never commit)
.rapport-signing/
*.key
*.key.pub
+195
View File
@@ -0,0 +1,195 @@
# RAPPORT Server-App — Architektur
> Wie die App den Compose-Stack im Schwesterrepo [SERVER-CONTAINER](../SERVER-CONTAINER) verwaltet, lokal anzeigt und im LAN per HTTPS erreichbar macht.
---
## 1 · Mentales Modell in einem Absatz
Die Server-App ist eine **Tauri-2-App**, deren Rust-Backend keine Container selbst startet, sondern **`docker compose`** gegen den existierenden Compose-Stack aufruft. Die Compose-Datei und ihr `.env` im `SERVER-CONTAINER`-Repo sind die einzige Konfigurations-Wahrheit für die Services — die App ist eine dünne Steuerschicht obendrauf. Das Tauri-Backend exponiert die Steuerung auf zwei Wegen: über **Tauri-IPC** an die eingebettete React-WebView (Admin-UI auf dem Server-Mac selbst) und über einen **eingebauten HTTPS-Server (axum + rustls, self-signed)** an Browser im LAN (für headless Mac-Mini-Deployments). Beide Endpoints sprechen die identische API, beide rendern dieselbe React-App.
**Konsequenz:** Schliessen des Tauri-Fensters reduziert nur in den System-Tray — Container und HTTPS-Server laufen weiter. Echtes Beenden nur über das Tray-Menü.
---
## 2 · Verzeichnis-Karte
```
SERVER-APP/
├── src/ React-Admin-UI (JSX, kein TS)
│ ├── main.jsx Entry
│ ├── App.jsx Root: Tabs (Status / Logs / Backup / Settings)
│ ├── api.js invoke() in Tauri, fetch() im Browser — gleicher Shape
│ └── components/
│ ├── ServiceCard.jsx eine Karte pro Service mit State-Dot
│ ├── LogViewer.jsx live tail über docker compose logs
│ ├── BackupPanel.jsx (Placeholder)
│ └── SettingsPanel.jsx Admin-WebUI-Sektion + Roh-Editor für config.env
├── src-tauri/ Rust-Backend
│ ├── src/
│ │ ├── main.rs 6 Zeilen
│ │ ├── lib.rs Tauri-Setup, Tray, Tick-Loop, HTTP-Server-Spawn
│ │ ├── supervisor.rs Wrapper um `docker compose` (up/down/stop/ps/logs)
│ │ ├── services.rs ServiceDef-Liste, init_compose_dir() Auto-Detect
│ │ ├── http_server.rs axum + rustls + Basic-Auth + Rate-Limit
│ │ ├── config.rs config.env Lesen/Schreiben + Default-Generation
│ │ ├── paths.rs ~/Library/.../com.rapport.server-app/...
│ │ ├── health.rs (alt: HTTP-Probes — nicht mehr aktiv genutzt,
│ │ │ wir lesen Container-State aus `docker compose ps`)
│ │ └── commands.rs #[tauri::command]-Wrapper für die UI
│ ├── Cargo.toml
│ └── tauri.conf.json
├── README.md
└── ARCHITECTURE.md (diese Datei)
```
---
## 3 · Service-Inventar
Image-Tags und Konfiguration kommen aus [`SERVER-CONTAINER/docker-compose.yml`](../SERVER-CONTAINER/docker-compose.yml) — siehe dort. Aktueller Stand (Mai 2026):
| Compose-Service | Image | Host-Port | Erreichbar |
|---|---|---|---|
| `db` | `supabase/postgres:15.8.1.020` | 15432 | `127.0.0.1` (kein LAN) |
| `auth` (GoTrue) | `supabase/gotrue:v2.158.1` | intern | nur über `kong` |
| `rest` (PostgREST) | `postgrest/postgrest:v12.2.0` | intern | nur über `kong` |
| `realtime` | `supabase/realtime:v2.30.34` | intern | nur über `kong` |
| `storage` | `supabase/storage-api:v1.11.13` | intern | nur über `kong` |
| `kong` | `kong:2.8.1` | 18000 | LAN (`0.0.0.0:18000`) |
| `app` (Frontend) | `rapport-app:main` (lokal gebaut) | 18080 | LAN (`0.0.0.0:18080`) |
`db` ist bewusst nur Loopback — direkter LAN-Zugriff auf die DB wäre Privesc-Surface. Alle App-Clients gehen über Kong.
---
## 4 · Supervisor
Der Rust-Supervisor (`supervisor.rs`) ist im Wesentlichen ein Wrapper um die `docker compose`-CLI:
| Funktion | Compose-Aufruf |
|---|---|
| `start(id)` | `docker compose up -d <id>` |
| `stop(id)` | `docker compose stop <id>` |
| `start_all()` | `docker compose up -d` (Compose erzwingt `depends_on`) |
| `stop_all()` | `docker compose down` |
| `logs(id)` | Hintergrund-Spawn von `docker compose logs -f <id>`, stdout → Ring-Buffer (1000 Zeilen) |
| `tick_health()` | alle 2s: `docker compose ps -a --format json` parsen, Container-State + Health-Status auf die UI-State-Machine mappen |
Pro Service wird die State-Machine `Stopped → Starting → Running` (mit `Error`-Zweig) aus Compose-Output abgeleitet. **Healthchecks definiert Compose** in der yml; unsere App liest nur den Status, probt nicht selber.
### State-Mapping
```
compose.state → ServiceState
─────────────────────────────────────────
nicht in ps → Stopped
running + health=healthy → Running
running + health=starting → Starting
running + (kein health) → Running
restarting / dead → Error
exited / stopped / removing → Stopped
created / paused → Starting
```
---
## 5 · Compose-Dir-Auflösung
`services::init_compose_dir()` sucht beim App-Start in dieser Reihenfolge:
1. `COMPOSE_DIR=...` aus `config.env`
2. Env-Var `RAPPORT_COMPOSE_DIR`
3. `~/RAPPORT/SERVER-CONTAINER/`
4. `<exe-parent>/../SERVER-CONTAINER/` (bundled Layout)
5. `<crate>/../../SERVER-CONTAINER/` (Dev-Layout)
Der erste Pfad mit einer `docker-compose.yml` wird genommen. Hat keiner eine, loggt die App einen Fehler und alle compose-Aufrufe scheitern hart (mit klarer Fehlermeldung).
---
## 6 · HTTP-Server (LAN-WebUI)
`http_server.rs` startet **axum 0.7** mit `axum-server`-rustls-Adapter. Im Detail:
- **Routen**: `GET /api/services`, `POST /api/services/start-all|stop-all|:id/start|:id/stop`, `GET /api/services/:id/logs`, `GET /api/activity`. Plus statisches `dist/` als Fallback (gleiche React-App wie in der Tauri-WebView).
- **TLS**: self-signed Cert via [`rcgen`](https://crates.io/crates/rcgen), SANs für `localhost`, `127.0.0.1`, `<hostname>`, `<hostname>.local`. Cert + Key landen in `~/Library/.../admin-ui-cert.pem` (chmod 600 fürs Key). Browser warnt einmal pro Gerät.
- **Auth**: HTTP-Basic, User `admin`, Passwort aus `ADMIN_UI_PASSWORD`. Pro IP wird ein Fehler-Counter geführt — nach 5 Fehlversuchen in 60 s ist die IP für 5 Min geblockt.
- **Bind**: Default `127.0.0.1:9090`. LAN-Freigabe ist Opt-In via Settings-Toggle (mit Confirm-Dialog).
---
## 7 · Daten- und Konfigurationspfade
| OS | App-Data-Dir |
|---|---|
| **macOS** | `~/Library/Application Support/com.rapport.server-app/` |
| **Linux** | `~/.local/share/rapport-server-app/` |
| **Windows** | `%APPDATA%/rapport/server-app/` |
```
<app-data>/
├── config.env Settings (chmod 600)
├── admin-ui-cert.pem TLS-Cert für WebUI
├── admin-ui-key.pem TLS-Private-Key (chmod 600)
├── postgres/ (nicht genutzt — Compose mountet sein eigenes Volume)
├── storage/ (analog)
├── logs/ (App-eigene Logs, separat von Compose-Logs)
└── backups/ pg_dumpall-Targets (TODO Phase 7)
```
Container-Volumes verwaltet Compose über Docker selbst (`postgres-data`, `storage-data`-Named-Volumes). Wenn die App stoppt, bleiben die Daten erhalten.
---
## 8 · Tray-Modus
Verhalten:
| Aktion | Effekt |
|---|---|
| Fenster X klicken | Fenster wird versteckt, App läuft im Tray weiter |
| Tray-Click (Links) | Fenster wieder zeigen |
| Tray-Rechtsklick → "Show Dashboard" | gleiches Verhalten |
| Tray-Rechtsklick → "Quit RAPPORT Server" | App wirklich beenden (Container laufen weiter — das ist Compose-Sache) |
Effekt: ein **headless Mac Mini** kann die App im Boot-Login starten, sie verschwindet ins Tray, die Container laufen, und vom Laptop aus erreicht man die WebUI per `https://hostname.local:9090`.
---
## 9 · Sicherheit (Stand Hardening Pass 1)
- 🔒 `db` exposed nur auf `127.0.0.1` — kein direkter LAN-DB-Zugriff
- 🔒 WebUI Default `127.0.0.1`; LAN ist bewusste Opt-In-Aktion
- 🔒 WebUI über HTTPS (rustls + self-signed)
- 🔒 Basic-Auth mit IP-basiertem Rate-Limit
- 🔒 `config.env` + TLS-Key chmod 600
- ⚠️ Updater-Plugin: `active: false`, Pubkey-Placeholder noch drin
- ⚠️ Backup-Knopf legt nur Placeholder-Datei an
- ⚠️ Container-Crash-Notification: nur via UI-Status, kein Alert-Push
---
## 10 · Bekannte Lücken / nächste Schritte
- [ ] Setup-Wizard: Docker + Colima auto-install beim Erst-Start, ohne Brew (Direct-Downloads von GitHub-Releases)
- [ ] Echter `pg_dumpall`-Backup + Restore-UI
- [ ] Audit-Log (wer hat wann was gestartet/gestoppt)
- [ ] Updater-Pubkey generieren oder Plugin ganz raus
- [ ] Capabilities tighten (`process:allow-exit` einschränken)
- [ ] Compose-Dir + `.env` ins Bundle inkludieren statt Pfad-Sucherei
- [ ] Linux/Windows getestet (aktuell nur Mac aarch64 verifiziert)
---
## 11 · Bezug zur ursprünglichen "kein Docker"-Vision
Die erste Version dieses Dokuments behauptete `Postgres + GoTrue + ... als plattform-native Subprozesse spawnen, kein Container nötig`. Diese Vision ist **bewusst verworfen**:
- Realtime (Erlang/BEAM) und Storage (Node-Bundle) haben keine offiziellen Mac-aarch64-Binaries
- GoTrue ships zwar offiziell `darwin-arm64`, aber das Asset enthält fälschlicherweise ein Linux-ELF
- Wir müssten eine eigene Build-Pipeline pro Service pro Plattform unterhalten → exponentieller Wartungsaufwand
Pragmatik: **Docker-CLI** (Apache 2.0, ~30 MB) ist universell und open-source, **Colima** liefert den Daemon Mac-nativ ohne Docker Desktop, und unsere App polished das Erlebnis darüber so weit, dass Endnutzer die Container-Schicht nicht mehr sehen.
+19
View File
@@ -0,0 +1,19 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
The RAPPORT Server-App is licensed under the GNU AGPL-3.0-or-later.
The full license text is available at:
https://www.gnu.org/licenses/agpl-3.0.txt
In short:
- You may use, copy, modify, and redistribute this software.
- If you modify and run a modified version as a network-accessible
service, you must offer the corresponding source code to its users.
- Derived works must remain under the AGPL-3.0-or-later.
© 2026 Karim Gabriele Varano
+78 -2
View File
@@ -1,3 +1,79 @@
# RAPPORT-SERVER-APP # RAPPORT Server-App
Tauri-Admin-UI fuer Rapport-Server > **Tauri-Admin-UI für den Rapport-Stack.**
> Eine native Mac/Linux/Windows-App, die den kompletten Rapport-Server (Postgres, GoTrue, PostgREST, Realtime, Storage, Kong, Frontend) als Docker-Compose-Stack startet, überwacht und administriert — lokal mit Native-UI und im LAN über HTTPS-WebUI.
## Status
**Pre-Alpha, aber funktional.** Service-Lifecycle, Live-Logs, TLS-WebUI und Tray-Modus laufen. Backup/Restore ist Placeholder. Auto-Updater ist verdrahtet, aber nicht aktiv (kein Signing-Key).
## Was die App ist
- **Admin-UI auf dem Server-Mac**: Status-Dashboard für alle 7 Services, Live-Log-Tail, Start/Stop pro Service oder bulk, Settings-Editor für `config.env`.
- **HTTPS-WebUI im LAN**: dieselbe UI vom Browser eines anderen Geräts aus. Self-signed Cert, Basic-Auth mit Brute-Force-Lockout. Wichtig für **headless Mac-Mini-Deployments** ohne Bildschirm.
- **Tray-Wrapper**: Fenster schliessen reduziert in den System-Tray; Services laufen weiter. Quit nur über Tray-Menü.
- **Compose-Wrapper**, kein eigener Container-Runtime: die App ruft `docker compose` gegen den `SERVER-CONTAINER`-Stack auf. Die Compose-Datei und das `.env` sind die Source-of-Truth — Änderungen dort werden automatisch übernommen.
## Was die App nicht ist
- ~~"Doppelklick statt Docker"~~ — diese ursprüngliche Idee ist verworfen. Native Service-Binaries gibt's für die meisten Supabase-Komponenten nicht (Realtime/Erlang, Storage/Node, etc.). Pragmatik schlägt Vision: wir setzen einen lokalen Docker-Daemon (OrbStack, Colima oder Docker Desktop) voraus und liefern dafür die polierte UI obendrauf.
## Voraussetzungen
| Komponente | Hinweis |
|---|---|
| **Docker-CLI + Daemon** | OrbStack oder [Colima](https://github.com/abiosoft/colima) (beide Mac-nativ, Colima ist Apache 2.0 / open source). `brew install docker colima && colima start` reicht. |
| **SERVER-CONTAINER-Repo** geklont | Default-Suchpfad: `~/RAPPORT/SERVER-CONTAINER/`. Override via `COMPOSE_DIR=...` in `config.env`. |
| **Node ≥ 20, Rust ≥ 1.77** | nur fürs Bauen aus Source, nicht für den Endnutzer. |
## Lokal starten
```bash
npm install
npm run tauri:dev
```
Beim ersten Klick auf "Alle starten" pullt Compose die Images (~700 MB). Danach geht's instant.
## Bundle
```bash
npm run tauri:build
```
Output unter `src-tauri/target/release/bundle/`. Code-Signing ist noch nicht konfiguriert.
## WebUI-Zugriff (für headless)
- URL lokal: `https://127.0.0.1:9090`
- URL im LAN: `https://<hostname>.local:9090`
- User: `admin`, Passwort steht in `config.env` (`ADMIN_UI_PASSWORD`, auto-generiert beim Erst-Start)
- Browser warnt vor dem self-signed Cert → einmal akzeptieren
LAN-Freigabe ist standardmässig **aus** — in der App unter Settings → Admin-WebUI explizit aktivieren.
## Konfiguration
Alles in `<DATA>/config.env` (auf macOS: `~/Library/Application Support/com.rapport.server-app/config.env`, chmod 600). Wichtige Keys:
| Key | Bedeutung |
|---|---|
| `POSTGRES_PASSWORD`, `JWT_SECRET` | Stack-Secrets (auto-generiert) |
| `ADMIN_UI_BIND` | `127.0.0.1` (default) oder `0.0.0.0` für LAN |
| `ADMIN_UI_PORT` | Default `9090` |
| `ADMIN_UI_TLS` | `true` (default) oder `false` |
| `ADMIN_UI_PASSWORD` | Random; via Settings-UI anzeigbar/änderbar |
| `COMPOSE_DIR` | Pfad zum `SERVER-CONTAINER`-Verzeichnis (nur nötig wenn nicht in `~/RAPPORT/SERVER-CONTAINER/`) |
## Bezug zu anderen Rapport-Repos
| Repo | Rolle |
|---|---|
| [RAPPORT](../APP) | Desktop-Client für Endnutzer (Tauri, JSX) |
| [RAPPORT-SERVER-CONTAINER](../SERVER-CONTAINER) | Der Compose-Stack — Source-of-Truth für DB-Schema und Service-Config |
| **RAPPORT Server-App** *(dieses Repo)* | Polished UI über dem Compose-Stack — lokal + LAN-WebUI |
| [RAPPORT-WEBSITE](../WEBSITE) | Marketing- & Doku-Site |
## Lizenz
GNU AGPL-3.0-or-later — identisch zur restlichen Rapport-Familie.
+37
View File
@@ -0,0 +1,37 @@
# Service-Binaries
Plattform-spezifische Binaries fuer alle gebundleten Rapport-Server-Services.
Diese Verzeichnisse sind **leer im Repo** — sie werden vor jedem Build via
[`scripts/download-binaries.sh`](../scripts/download-binaries.sh) gefuellt.
Pinning erfolgt ueber [`manifest.json`](manifest.json).
## Layout
```
binaries/
├── macos-aarch64/
│ ├── postgres
│ ├── gotrue
│ ├── postgrest
│ ├── realtime
│ ├── storage-api
│ ├── kong
│ └── nginx
├── macos-x86_64/ (gleiche Struktur)
├── linux-x86_64/ (gleiche Struktur)
└── windows-x86_64/ (.exe-Endungen)
```
## Wie es zur Laufzeit funktioniert
`tauri.conf.json` listet `../binaries/**/*` unter `bundle.resources`
dadurch wandert das gesamte `binaries/`-Verzeichnis ins App-Bundle unter
`Resources/binaries/`. Zur Laufzeit loest [`src-tauri/src/paths.rs`](../src-tauri/src/paths.rs)
den plattform-passenden Subpfad auf und der Supervisor spawnt von dort.
## Binaries selbst bauen
Postgres und Kong haben offizielle Builds. Bei den Supabase-Komponenten
(GoTrue, PostgREST, Realtime, Storage) ziehen wir die Releases von GitHub.
Realtime-aarch64 ist die offene Frage — siehe
[ARCHITECTURE.md §12](../ARCHITECTURE.md).
View File
View File
View File
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "./manifest.schema.json",
"comment": "Pinned versions for all bundled service binaries. Bump deliberately; download-binaries.sh consumes this file.",
"services": {
"postgres": { "version": "15.7", "source": "postgresql.org" },
"gotrue": { "version": "2.158.1", "source": "github.com/supabase/gotrue/releases" },
"postgrest": { "version": "12.2.0", "source": "github.com/PostgREST/postgrest/releases" },
"realtime": { "version": "2.30.34", "source": "github.com/supabase/realtime/releases" },
"storage": { "version": "1.11.0", "source": "github.com/supabase/storage-api/releases" },
"kong": { "version": "3.7.1", "source": "konghq.com" },
"nginx": { "version": "1.27.0", "source": "nginx.org" }
},
"platforms": ["macos-aarch64", "macos-x86_64", "linux-x86_64", "windows-x86_64"]
}
View File
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RAPPORT Server</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.1.2",
"notes": "Update-Modal mit Live-Progress + sichtbare Errors.",
"pub_date": "2026-05-24T16:15:09Z",
"platforms": {
"darwin-aarch64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTYUg0T2hQVVkrUTFxS0FKYUdMZlNtY0RCcWRTMXJoL002NGlvaXRHWm5hTmVKd1JQbnJucG84Qm1lZ1lRdG1PZ1orQzZDUXJqNUYzaXpjWTd2R1l4aGkrZi9xVmpBQmc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc5NjM5MTgyCWZpbGU6UkFQUE9SVCBTZXJ2ZXIuYXBwLnRhci5negpnRWRvUlVSeDErQ2szQjZKRC9KSjErNWVYNWhyekdtUlArQnZFa1dwRlMwYUNRdG1hSVBRS0FHSU5wOG01RnBEbklaWlA0TVBxTTlxQmZkbHJpTTBDUT09Cg==",
"url": "https://git.kgva.ch/karim/RAPPORT-SERVER-APP/releases/download/v0.1.2/RAPPORT%20Server.app.tar.gz"
}
}
}
+1242
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "rapport-server-app",
"version": "0.1.2",
"private": true,
"type": "module",
"description": "Doppelklick-Self-Hosting f\u00fcr Rapport \u2014 Tauri-App, die Postgres, GoTrue, PostgREST, Realtime und Storage als Subprozesse b\u00fcndelt.",
"license": "AGPL-3.0-or-later",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"material-symbols": "^0.44.9",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.1",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.0"
}
}
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Laedt fuer die laufende Plattform die Service-Binaries und legt sie unter
# binaries/<platform>/ ab. Pinning kommt aus binaries/manifest.json.
#
# AKTUELL implementiert:
# - postgres : echt, via Zonky embedded-postgres-binaries (Maven Central)
# - alle anderen : Bash-Placeholder (sleep loop) bis ihre Quellen verdrahtet sind
#
# Verzeichnis-Layout fuer Postgres: binaries/<platform>/postgres-bundle/
# ├── bin/ (postgres, initdb, psql, pg_dumpall, ...)
# ├── lib/ (libpq + Sprach-Plugins)
# └── share/ (initdb-Templates etc.)
set -euo pipefail
cd "$(dirname "$0")/.."
uname_s=$(uname -s)
uname_m=$(uname -m)
case "$uname_s-$uname_m" in
Darwin-arm64) PLATFORM="macos-aarch64"; PG_TARGET="aarch64-apple-darwin"; PGRST_ASSET="macos-aarch64" ;;
Darwin-x86_64) PLATFORM="macos-x86_64"; PG_TARGET="x86_64-apple-darwin"; PGRST_ASSET="macos-x86-64" ;;
Linux-x86_64) PLATFORM="linux-x86_64"; PG_TARGET="x86_64-unknown-linux-gnu"; PGRST_ASSET="ubuntu-aarch64" ;;
*) echo "Unsupported platform: $uname_s-$uname_m"; exit 1 ;;
esac
DEST="binaries/$PLATFORM"
mkdir -p "$DEST"
# ---------------------------------------------------------------------------
# Postgres (native binary via theseus-rs/postgresql-binaries)
# Liefert komplettes bin/ (postgres, initdb, psql, pg_dump, pg_dumpall, ...).
# ---------------------------------------------------------------------------
PG_VERSION="15.7.0" # keep in sync with binaries/manifest.json
PG_BUNDLE_DIR="$DEST/postgres-bundle"
PG_VERSION_STAMP="$PG_BUNDLE_DIR/.rapport-version"
if [[ -x "$PG_BUNDLE_DIR/bin/postgres" && "$(cat "$PG_VERSION_STAMP" 2>/dev/null)" == "theseus-$PG_VERSION-$PG_TARGET" ]]; then
echo "Postgres bundle already present (theseus $PG_VERSION $PG_TARGET): $PG_BUNDLE_DIR"
else
echo "Downloading Postgres $PG_VERSION ($PG_TARGET) from theseus-rs ..."
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
URL="https://github.com/theseus-rs/postgresql-binaries/releases/download/$PG_VERSION/postgresql-$PG_VERSION-$PG_TARGET.tar.gz"
curl --fail --location --silent --show-error -o "$TMP/pg.tar.gz" "$URL"
rm -rf "$PG_BUNDLE_DIR"
mkdir -p "$PG_BUNDLE_DIR"
tar -xzf "$TMP/pg.tar.gz" -C "$PG_BUNDLE_DIR" --strip-components=1
echo "theseus-$PG_VERSION-$PG_TARGET" > "$PG_VERSION_STAMP"
echo "Postgres extracted to $PG_BUNDLE_DIR"
fi
# ---------------------------------------------------------------------------
# PostgREST (native binary via PostgREST GitHub releases)
# ---------------------------------------------------------------------------
PGRST_VERSION="v14.12" # keep in sync with binaries/manifest.json
PGRST_BIN="$DEST/postgrest"
PGRST_STAMP="$DEST/.postgrest-version"
if [[ -x "$PGRST_BIN" && "$(cat "$PGRST_STAMP" 2>/dev/null)" == "$PGRST_VERSION-$PGRST_ASSET" ]]; then
echo "PostgREST already present ($PGRST_VERSION-$PGRST_ASSET): $PGRST_BIN"
else
echo "Downloading PostgREST $PGRST_VERSION ($PGRST_ASSET) ..."
TMP_R=$(mktemp -d)
URL="https://github.com/PostgREST/postgrest/releases/download/$PGRST_VERSION/postgrest-$PGRST_VERSION-$PGRST_ASSET.tar.xz"
curl --fail --location --silent --show-error -o "$TMP_R/pgrst.tar.xz" "$URL"
tar -xJf "$TMP_R/pgrst.tar.xz" -C "$TMP_R"
mv "$TMP_R/postgrest" "$PGRST_BIN"
chmod +x "$PGRST_BIN"
rm -f "$DEST/postgrest.is-placeholder"
echo "$PGRST_VERSION-$PGRST_ASSET" > "$PGRST_STAMP"
rm -rf "$TMP_R"
echo "PostgREST extracted to $PGRST_BIN"
fi
# Reste der ehemaligen Placeholder aufraeumen (gotrue/realtime/storage-api/kong/nginx
# laufen jetzt als Docker-Container und brauchen kein lokales Binary mehr).
for stale in gotrue realtime storage-api kong nginx; do
rm -f "$DEST/$stale" "$DEST/$stale.is-placeholder"
done
for svc in "${PLACEHOLDER_SERVICES[@]}"; do
target="$DEST/$svc"
if [[ -x "$target" && ! -f "$target.is-placeholder" ]]; then
continue
fi
cat > "$target" <<'PLACEHOLDER'
#!/usr/bin/env bash
echo "placeholder: $(basename "$0") would run here"
sleep 999999
PLACEHOLDER
chmod +x "$target"
touch "$target.is-placeholder"
done
echo "Done."
echo " Postgres: $PG_BUNDLE_DIR (real)"
echo " GoTrue: $GOTRUE_BIN (real)"
echo " Andere: $DEST/{postgrest,realtime,storage-api,kong,nginx} (placeholder)"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Build + Code-Signing-Helper.
# Ablauf:
# 1. ./scripts/download-binaries.sh (laedt Platzhalter / spaeter echte Binaries)
# 2. npm install
# 3. npm run build (Vite-Frontend-Build)
# 4. cargo tauri build (Tauri-Bundle)
# 5. (macOS) codesign + notarize (nur wenn APPLE_ID/TEAM_ID gesetzt)
set -euo pipefail
cd "$(dirname "$0")/.."
echo "==> binaries"
./scripts/download-binaries.sh
echo "==> npm install"
npm install
echo "==> tauri build"
npm run tauri:build
if [[ "$(uname -s)" == "Darwin" && -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then
echo "==> notarize (TODO — bundle path + xcrun notarytool aufruf)"
# xcrun notarytool submit ... --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --wait
else
echo "Skipping macOS notarization (APPLE_ID/APPLE_TEAM_ID not set)."
fi
echo "Done. Bundle: src-tauri/target/release/bundle/"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Pulled die Docker-Images aller Rapport-Services vorab.
# Image-Tags sind 1:1 aus SERVER-CONTAINER/docker-compose.yml uebernommen
# (und in src-tauri/src/services.rs hardgepinnt). Sollten die hier geaendert
# werden, muss services.rs mitziehen.
#
# Optional: vor `tauri:dev` einmal laufen lassen, damit der erste Start-Klick
# nicht warten muss bis der Pull durch ist.
set -euo pipefail
IMAGES=(
"supabase/postgres:15.8.1.020"
"supabase/gotrue:v2.158.1"
"postgrest/postgrest:v12.2.0"
"supabase/realtime:v2.30.34"
"supabase/storage-api:v1.11.13"
"kong:2.8.1"
"nginx:1.27-alpine"
)
if ! command -v docker >/dev/null 2>&1; then
echo "docker CLI nicht gefunden. Installiere Docker / OrbStack / Colima zuerst." >&2
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Docker-Daemon laeuft nicht. Starte OrbStack/Colima/Docker Desktop." >&2
exit 1
fi
for img in "${IMAGES[@]}"; do
echo "==> pulling $img"
docker pull "$img"
done
echo "Done."
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Release-Pipeline: Version bumpen, signiertes Bundle bauen, latest.json generieren.
#
# Usage: ./scripts/release.sh <version>
# Example: ./scripts/release.sh 0.2.0
#
# Vorausgesetzt:
# - Signing-Key in ~/.rapport-signing/server-app.key (chmod 600)
# - tauri.conf.json hat den passenden Pubkey unter plugins.updater.pubkey
# - Docker-Daemon laeuft (fuer eventuelle Builds), Node + Rust installiert
#
# Output:
# - Signiertes Bundle in src-tauri/target/release/bundle/
# - latest.json im Repo-Root
# - Commit-Hinweise in der Konsole
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>"
echo "Example: $0 0.2.0"
exit 1
fi
VERSION="$1"
KEY_PATH="${TAURI_SIGNING_PRIVATE_KEY_PATH:-$HOME/.rapport-signing/server-app.key}"
if [[ ! -f "$KEY_PATH" ]]; then
echo "Signing-Key nicht gefunden: $KEY_PATH" >&2
echo "Generate via: tauri signer generate -w $KEY_PATH" >&2
exit 1
fi
# --- 1. Version in allen Manifesten bumpen ---------------------------------
echo "==> Bumpe Version auf $VERSION"
# package.json: leeres "version"-Feld auch behandeln
python3 - <<PY
import json, pathlib
p = pathlib.Path("package.json")
data = json.loads(p.read_text())
data["version"] = "$VERSION"
p.write_text(json.dumps(data, indent=2) + "\n")
PY
python3 - <<PY
import json, pathlib
p = pathlib.Path("src-tauri/tauri.conf.json")
data = json.loads(p.read_text())
data["version"] = "$VERSION"
p.write_text(json.dumps(data, indent=2) + "\n")
PY
# Cargo.toml: nur die TOP-Level [package] version (nicht Deps)
sed -i.bak -E '0,/^version = ".*"/{s/^version = ".*"/version = "'"$VERSION"'"/}' src-tauri/Cargo.toml
rm -f src-tauri/Cargo.toml.bak
# --- 2. Build (signiert automatisch durch die env-Var) ---------------------
echo "==> Build + Sign"
# tauri-bundler liest fuer den Updater-Tarball ausschliesslich TAURI_SIGNING_PRIVATE_KEY
# (Content), nicht _PATH. Wir injecten den File-Inhalt direkt.
export TAURI_SIGNING_PRIVATE_KEY="$(cat "$KEY_PATH")"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
npm install --silent
npm run tauri:build
# --- 3. Artefakte finden ---------------------------------------------------
BUNDLE_DIR="src-tauri/target/release/bundle"
DMG=$(find "$BUNDLE_DIR/dmg" -name "*.dmg" 2>/dev/null | head -n 1 || true)
TARBALL=$(find "$BUNDLE_DIR/macos" -name "*.tar.gz" 2>/dev/null | head -n 1 || true)
SIG=$(find "$BUNDLE_DIR/macos" -name "*.tar.gz.sig" 2>/dev/null | head -n 1 || true)
if [[ -z "$TARBALL" || -z "$SIG" ]]; then
echo "Updater-Tarball oder Signatur nicht gefunden!" >&2
echo "Erwartet unter: $BUNDLE_DIR/macos/*.tar.gz(.sig)" >&2
echo "Bundle-Output:" >&2
ls -la "$BUNDLE_DIR" 2>&1 >&2 || true
exit 1
fi
SIGNATURE=$(cat "$SIG")
PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
DOWNLOAD_URL="https://git.kgva.ch/karim/RAPPORT-SERVER-APP/releases/download/v${VERSION}/$(basename "$TARBALL")"
# --- 4. latest.json generieren --------------------------------------------
cat > latest.json <<JSON
{
"version": "$VERSION",
"notes": "Release $VERSION",
"pub_date": "$PUB_DATE",
"platforms": {
"darwin-aarch64": {
"signature": "$SIGNATURE",
"url": "$DOWNLOAD_URL"
}
}
}
JSON
echo ""
echo "================================================================"
echo "Release $VERSION fertig."
echo ""
echo "Artefakte:"
[[ -n "$DMG" ]] && echo " DMG: $DMG"
echo " Tarball: $TARBALL"
echo " Sig: $SIG"
echo " latest.json (im Repo-Root) — committen und nach git.kgva.ch pushen"
echo ""
echo "Naechste Schritte:"
echo " 1. gh release create v$VERSION $TARBALL $SIG ${DMG:+$DMG} -t \"v$VERSION\" -n \"Release $VERSION\""
echo " 2. git add latest.json package.json src-tauri/{Cargo.toml,tauri.conf.json}"
echo " 3. git commit -m \"Release v$VERSION\" && git push"
echo "================================================================"
+6777
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
[package]
name = "rapport-server-app"
version = "0.1.2"
edition = "2021"
authors = ["Karim Gabriele Varano"]
license = "AGPL-3.0-or-later"
description = "RAPPORT Server-App — Tauri-Wrapper für gebundlete Backend-Services"
default-run = "rapport-server-app"
[lib]
name = "app_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "rapport-server-app"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-process = "2"
tauri-plugin-log = "2"
tauri-plugin-updater = "2"
tauri-plugin-autostart = "2"
tauri-plugin-notification = "2"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Process supervisor utilities
nix = { version = "0.29", features = ["signal", "process"], default-features = false }
# Filesystem & paths
directories = "5"
dirs = "5"
# HTTP client (for health checks)
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# Logging
log = "0.4"
env_logger = "0.11"
# Time
chrono = { version = "0.4", features = ["serde"] }
# Crypto utilities (for JWT-secret / random password generation)
rand = "0.8"
base64 = "0.22"
# JWT-Signing fuer ANON_KEY / SERVICE_ROLE_KEY beim Compose-Stack-Bootstrap
jsonwebtoken = "9"
# HTTP server fuer den optionalen Admin-WebUI-Zugang (Mac Mini ohne Display)
axum = { version = "0.7", features = ["macros"] }
axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "auth", "cors", "trace"] }
# Self-signed certs fuer den Admin-WebUI-TLS
rcgen = "0.13"
# Hostname-Lookup fuer Cert-SANs
hostname = "0.4"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities — IPC, Process restart, Log access",
"windows": ["main"],
"permissions": [
"core:default",
"core:webview:allow-print",
"process:allow-restart",
"process:allow-exit",
"updater:default",
"autostart:default",
"notification:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

+317
View File
@@ -0,0 +1,317 @@
//! Backup-Modul.
//!
//! Erstellt SQL-Dumps via `docker compose exec -T db pg_dumpall` und legt
//! sie unter `<data_dir>/backups/` ab. Ein Hintergrund-Scheduler triggert
//! abhaengig von `BACKUP_INTERVAL_HOURS` aus der Config. Retention pruned
//! alle ueber `BACKUP_RETENTION_COUNT` hinaus (aelteste zuerst).
use crate::{paths, services};
use chrono::{DateTime, Local};
use serde::Serialize;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct BackupInfo {
pub filename: String,
pub created_iso: String,
pub bytes: u64,
}
/// Default-Intervall fuer den Scheduler-Tick (1 Stunde). Im Tick wird gepruefte
/// ob das letzte Backup aelter als `BACKUP_INTERVAL_HOURS` ist.
pub const SCHED_TICK: Duration = Duration::from_secs(60 * 60);
const DEFAULT_INTERVAL_HOURS: u64 = 24;
const DEFAULT_RETENTION: usize = 7;
pub fn list() -> Vec<BackupInfo> {
let dir = paths::backups_dir();
let Ok(read) = std::fs::read_dir(&dir) else { return vec![] };
let mut out: Vec<BackupInfo> = read
.filter_map(|e| e.ok())
.filter_map(|e| {
let meta = e.metadata().ok()?;
if !meta.is_file() {
return None;
}
let name = e.file_name().to_string_lossy().to_string();
if !name.starts_with("rapport-") || !name.ends_with(".sql") {
return None;
}
let created: DateTime<Local> = meta.modified().ok()?.into();
Some(BackupInfo {
filename: name,
created_iso: created.to_rfc3339(),
bytes: meta.len(),
})
})
.collect();
out.sort_by(|a, b| b.created_iso.cmp(&a.created_iso));
out
}
/// Erstellt ein neues Backup. Datei-Name `rapport-YYYYmmdd-HHMMSS.sql`.
pub async fn create() -> Result<BackupInfo, String> {
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let filename = format!("rapport-{stamp}.sql");
let path = paths::backups_dir().join(&filename);
log::info!("Backup → {}", path.display());
// `docker compose exec -T db pg_dumpall ...` — `-T` schaltet das TTY ab,
// damit stdout sauber pipebar ist.
let output = Command::new("docker")
.current_dir(services::compose_dir())
.args([
"compose",
"exec",
"-T",
"db",
"pg_dumpall",
"-U",
"supabase_admin",
"--clean",
"--if-exists",
])
.output()
.await
.map_err(|e| format!("spawn pg_dumpall: {e}"))?;
if !output.status.success() {
return Err(format!(
"pg_dumpall exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
std::fs::write(&path, &output.stdout).map_err(|e| format!("write {}: {e}", path.display()))?;
let bytes = output.stdout.len() as u64;
log::info!("Backup geschrieben: {} ({} bytes)", path.display(), bytes);
crate::events::info(format!(
"Backup erstellt: {filename} ({} KB)",
bytes / 1024
))
.await;
Ok(BackupInfo {
filename,
created_iso: Local::now().to_rfc3339(),
bytes,
})
}
/// Loescht die aeltesten Backups bis nur noch `keep` da sind.
pub fn prune(keep: usize) -> Result<usize, String> {
let all = list();
if all.len() <= keep {
return Ok(0);
}
let removed = &all[keep..];
let mut count = 0;
for b in removed {
let path = paths::backups_dir().join(&b.filename);
match std::fs::remove_file(&path) {
Ok(_) => {
log::info!("Backup geprunet: {}", path.display());
count += 1;
}
Err(e) => log::warn!("remove {}: {e}", path.display()),
}
}
Ok(count)
}
/// Liest die Config-Werte mit Fallbacks.
fn config_values() -> (bool, u64, usize) {
let cfg = crate::config::load().unwrap_or_default();
let enabled = cfg
.get("BACKUP_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
let interval = cfg
.get("BACKUP_INTERVAL_HOURS")
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_INTERVAL_HOURS);
let retention = cfg
.get("BACKUP_RETENTION_COUNT")
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_RETENTION);
(enabled, interval, retention)
}
/// Hintergrund-Scheduler. Tickt jede Stunde, schaut ob das aelteste Backup
/// schon `BACKUP_INTERVAL_HOURS` her ist, und triggert dann einen neuen
/// Dump + Prune.
pub async fn scheduler_loop() {
let mut tick = tokio::time::interval(SCHED_TICK);
// Erstes tick() feuert sofort — das ueberspringen wir, damit nicht beim
// App-Start jedes Mal ein Backup laeuft.
tick.tick().await;
loop {
tick.tick().await;
let (enabled, interval, retention) = config_values();
if !enabled {
continue;
}
if !backup_due(interval) {
continue;
}
match create().await {
Ok(b) => log::info!("Auto-Backup: {}", b.filename),
Err(e) => log::warn!("Auto-Backup fehlgeschlagen: {e}"),
}
if let Err(e) = prune(retention) {
log::warn!("Prune fehlgeschlagen: {e}");
}
}
}
fn backup_due(interval_hours: u64) -> bool {
let all = list();
let Some(latest) = all.first() else { return true };
let Ok(latest_dt) = DateTime::parse_from_rfc3339(&latest.created_iso) else {
return true;
};
let now = Local::now();
let age = now.signed_duration_since(latest_dt.with_timezone(&Local));
age.num_hours() >= interval_hours as i64
}
pub fn last_backup_path(filename: &str) -> Option<PathBuf> {
let dir = paths::backups_dir();
let p = dir.join(filename);
if p.exists() && p.starts_with(&dir) {
Some(p)
} else {
None
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RestoreResult {
pub restored_from: String,
pub safety_backup: String,
pub finished_at_iso: String,
pub psql_log: String,
}
/// Service-Liste die einen Restore stoert (haelt DB-Connections oder erwartet Schema).
/// `db` selbst bleibt natuerlich an.
const RESTORE_STOP_SERVICES: &[&str] = &["auth", "rest", "realtime", "storage", "kong", "app"];
/// Spielt einen Snapshot zurueck.
/// Workflow:
/// 1. Sicherheits-Backup vom aktuellen Zustand (kann nicht weh tun)
/// 2. Abhaengige Services stoppen (Connections raus)
/// 3. `psql -d postgres` mit dem Dump auf stdin pipen
/// 4. Alle Services wieder starten
pub async fn restore(filename: &str) -> Result<RestoreResult, String> {
// Pfad-Traversal-Schutz: Filename darf keine Separatoren enthalten.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err("ungueltiger Dateiname".into());
}
let dir = paths::backups_dir();
let path = dir.join(filename);
if !path.starts_with(&dir) || !path.exists() {
return Err(format!("Backup nicht gefunden: {filename}"));
}
let dump = std::fs::read(&path).map_err(|e| format!("read dump: {e}"))?;
// 1) Safety-Backup zuerst — wenn das schiefgeht, gar nicht erst restoren.
crate::events::info(format!("Restore von {filename} startet — erst Safety-Backup")).await;
let safety = create()
.await
.map_err(|e| format!("Safety-Backup vor Restore fehlgeschlagen: {e}"))?;
// 2) Abhaengige Services stoppen.
crate::events::info(format!(
"Restore: stoppe {} (db bleibt an)",
RESTORE_STOP_SERVICES.join(", ")
))
.await;
let mut stop_args = vec!["compose", "stop"];
stop_args.extend(RESTORE_STOP_SERVICES);
let stop_out = Command::new("docker")
.current_dir(services::compose_dir())
.args(&stop_args)
.output()
.await
.map_err(|e| format!("spawn compose stop: {e}"))?;
if !stop_out.status.success() {
return Err(format!(
"compose stop fehlgeschlagen: {}",
String::from_utf8_lossy(&stop_out.stderr).trim()
));
}
// 3) Dump via psql einspielen.
crate::events::info(format!("Restore: spiele Snapshot {filename} ein ({} KB)", dump.len() / 1024)).await;
let mut child = Command::new("docker")
.current_dir(services::compose_dir())
.args([
"compose", "exec", "-T", "db",
"psql", "-U", "supabase_admin", "-d", "postgres",
"-v", "ON_ERROR_STOP=0", // unbekannte Schema-Differenzen weiter durchziehen
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("spawn psql: {e}"))?;
{
let mut stdin = child.stdin.take().ok_or_else(|| "no stdin".to_string())?;
stdin
.write_all(&dump)
.await
.map_err(|e| format!("write dump to psql: {e}"))?;
stdin
.shutdown()
.await
.map_err(|e| format!("psql stdin shutdown: {e}"))?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| format!("wait psql: {e}"))?;
let psql_log = format!(
"{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
// 4) Services wieder hoch — auch wenn psql Warnungen hatte (Datenbank ist
// eingespielt, die Services connecten sich neu).
crate::events::info("Restore: starte Services wieder").await;
let up_out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "up", "-d"])
.output()
.await
.map_err(|e| format!("spawn compose up: {e}"))?;
if !up_out.status.success() {
return Err(format!(
"compose up nach Restore fehlgeschlagen: {}",
String::from_utf8_lossy(&up_out.stderr).trim()
));
}
crate::events::info(format!(
"Restore abgeschlossen aus {filename} (Safety: {})",
safety.filename
))
.await;
Ok(RestoreResult {
restored_from: filename.to_string(),
safety_backup: safety.filename,
finished_at_iso: Local::now().to_rfc3339(),
psql_log,
})
}
+163
View File
@@ -0,0 +1,163 @@
//! Tauri-Commands.
//!
//! Schmaler Layer zwischen WebView-Frontend und Supervisor. Jeder Command
//! ist `async`, sperrt den Supervisor-Mutex moeglichst kurz und gibt das
//! Ergebnis als JSON-serialisierbare Struktur zurueck.
use crate::backup::{self, BackupInfo, RestoreResult};
use crate::config;
use crate::container_update::{self, ApplyResult, CheckResult};
use crate::disk::{self, DiskUsage};
use crate::events::{self, Event};
use crate::firstaid::{self, DiagnoseResult, RecreateResult, ResetResult};
use crate::setup::{self, InstallResult, SetupStatus};
use crate::stats::{self, ContainerStats};
use crate::supervisor::ServiceStatus;
use crate::AppState;
use tauri::State;
#[tauri::command]
pub async fn list_services(state: State<'_, AppState>) -> Result<Vec<ServiceStatus>, String> {
// Mit Timeout + Cache — falls Supervisor-Mutex durch lange Operation belegt.
Ok(crate::supervisor::list_with_timeout(&state.supervisor, 300).await)
}
#[tauri::command]
pub async fn service_status(
state: State<'_, AppState>,
id: String,
) -> Result<Option<ServiceStatus>, String> {
let sv = state.supervisor.lock().await;
Ok(sv.status(&id))
}
#[tauri::command]
pub async fn service_logs(state: State<'_, AppState>, id: String) -> Result<Vec<String>, String> {
let sv = state.supervisor.lock().await;
Ok(sv.logs(&id).await)
}
#[tauri::command]
pub async fn start_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut sv = state.supervisor.lock().await;
sv.start(&id).await
}
#[tauri::command]
pub async fn stop_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut sv = state.supervisor.lock().await;
sv.stop(&id).await
}
#[tauri::command]
pub async fn restart_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut sv = state.supervisor.lock().await;
sv.restart(&id).await
}
#[tauri::command]
pub async fn restart_all(state: State<'_, AppState>) -> Result<(), String> {
crate::supervisor::Supervisor::restart_all_managed(state.supervisor.clone()).await
}
#[tauri::command]
pub async fn start_all(state: State<'_, AppState>) -> Result<(), String> {
crate::supervisor::Supervisor::start_all_managed(state.supervisor.clone()).await
}
#[tauri::command]
pub async fn stop_all(state: State<'_, AppState>) -> Result<(), String> {
crate::supervisor::Supervisor::stop_all_managed(state.supervisor.clone()).await
}
#[tauri::command]
pub async fn get_config() -> Result<config::EnvMap, String> {
let mut map = config::load()?;
config::ensure_defaults(&mut map);
Ok(map)
}
#[tauri::command]
pub async fn set_config_value(key: String, value: String) -> Result<(), String> {
let mut map = config::load()?;
map.insert(key, value);
config::save(&map)
}
#[tauri::command]
pub async fn backup_now() -> Result<BackupInfo, String> {
let info = backup::create().await?;
// Direkt nach manuellem Backup auch prunen (sonst macht's nur der Scheduler).
let retention = config::load()
.ok()
.and_then(|c| c.get("BACKUP_RETENTION_COUNT").cloned())
.and_then(|v| v.parse().ok())
.unwrap_or(7);
let _ = backup::prune(retention);
Ok(info)
}
#[tauri::command]
pub async fn list_backups() -> Result<Vec<BackupInfo>, String> {
Ok(backup::list())
}
#[tauri::command]
pub async fn restore_backup(filename: String) -> Result<RestoreResult, String> {
backup::restore(&filename).await
}
#[tauri::command]
pub async fn check_container_updates() -> Result<CheckResult, String> {
container_update::check().await
}
#[tauri::command]
pub async fn apply_container_updates() -> Result<ApplyResult, String> {
container_update::apply().await
}
#[tauri::command]
pub async fn list_events() -> Result<Vec<Event>, String> {
Ok(events::list().await)
}
#[tauri::command]
pub async fn list_stats() -> Result<Vec<ContainerStats>, String> {
Ok(stats::collect().await)
}
#[tauri::command]
pub async fn disk_usage() -> Result<DiskUsage, String> {
Ok(disk::collect().await)
}
#[tauri::command]
pub async fn firstaid_recreate() -> Result<RecreateResult, String> {
firstaid::recreate_containers().await
}
#[tauri::command]
pub async fn firstaid_reset_pgdata() -> Result<ResetResult, String> {
firstaid::reset_pgdata().await
}
#[tauri::command]
pub async fn firstaid_diagnose() -> Result<DiagnoseResult, String> {
firstaid::diagnose_bundle().await
}
#[tauri::command]
pub async fn setup_status() -> Result<SetupStatus, String> {
Ok(setup::status().await)
}
#[tauri::command]
pub async fn setup_install(state: State<'_, AppState>) -> Result<InstallResult, String> {
// Wir starten die Container hier NICHT automatisch — der User soll bewusst
// "Alle starten" klicken sobald das Dashboard erscheint. Beim spaeteren
// App-Reboot (Daemon schon hochgefahren) feuert die AUTO_START-Logik in
// lib.rs::setup() ganz normal.
let _ = state; // state nur fuer eventual zukuenftige Verwendung
setup::install_and_start().await
}
+121
View File
@@ -0,0 +1,121 @@
//! Konfigurations-Management.
//!
//! Liest und schreibt `config.env` im App-Data-Pfad. Generiert sichere
//! Defaults beim Erst-Start: `POSTGRES_PASSWORD`, `JWT_SECRET`, davon
//! abgeleitet `ANON_KEY` und `SERVICE_ROLE_KEY`.
use crate::paths;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::Rng;
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
/// Geordnete Map (für stabile Datei-Reihenfolge).
pub type EnvMap = BTreeMap<String, String>;
pub fn load() -> Result<EnvMap, String> {
let path = paths::config_env_path();
if !path.exists() {
return Ok(EnvMap::new());
}
let content = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let mut map = EnvMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string());
}
}
Ok(map)
}
pub fn save(map: &EnvMap) -> Result<(), String> {
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let path = paths::config_env_path();
let mut tmp = path.clone();
tmp.set_extension("env.tmp");
let mut file = fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
writeln!(file, "# RAPPORT Server-App — Auto-generated config.env").unwrap();
writeln!(file, "# Aenderungen ueberleben App-Updates.").unwrap();
writeln!(file).unwrap();
for (k, v) in map {
writeln!(file, "{k}={v}").unwrap();
}
file.sync_all().map_err(|e| format!("sync: {e}"))?;
drop(file);
fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
let _ = fs::set_permissions(&path, perms);
}
Ok(())
}
/// Erst-Start: fehlende Schluessel mit sicheren Defaults befuellen.
/// Aendert keine existierenden Werte.
pub fn ensure_defaults(map: &mut EnvMap) {
map.entry("POSTGRES_PASSWORD".into())
.or_insert_with(random_password);
map.entry("JWT_SECRET".into()).or_insert_with(random_jwt_secret);
map.entry("SITE_URL".into())
.or_insert_with(|| "http://localhost:8080".into());
map.entry("API_EXTERNAL_URL".into())
.or_insert_with(|| "http://localhost:8000".into());
map.entry("BIND_HOST".into())
.or_insert_with(|| "127.0.0.1".into());
map.entry("ADMIN_UI_PASSWORD".into())
.or_insert_with(random_password);
map.entry("ADMIN_UI_PORT".into())
.or_insert_with(|| "9090".into());
// Default 127.0.0.1: WebUI nur ueber Loopback. Fuer LAN-Zugriff manuell
// auf 0.0.0.0 stellen (Settings → Im LAN freigeben).
map.entry("ADMIN_UI_BIND".into())
.or_insert_with(|| "127.0.0.1".into());
// TLS ist Default: das WebUI laeuft ueber HTTPS mit selbst-signiertem Cert.
map.entry("ADMIN_UI_TLS".into())
.or_insert_with(|| "true".into());
map.entry("BACKUP_ENABLED".into())
.or_insert_with(|| "true".into());
map.entry("BACKUP_INTERVAL_HOURS".into())
.or_insert_with(|| "24".into());
map.entry("BACKUP_RETENTION_COUNT".into())
.or_insert_with(|| "7".into());
// Container-Auto-Update standardmaessig AUS — explizit aktivieren via
// Settings, weil ungeplante Migrations potenziell Daten betreffen.
map.entry("CONTAINER_AUTOUPDATE_ENABLED".into())
.or_insert_with(|| "false".into());
map.entry("CONTAINER_AUTOUPDATE_INTERVAL_HOURS".into())
.or_insert_with(|| "24".into());
map.entry("AUTO_START_CONTAINERS_ON_LAUNCH".into())
.or_insert_with(|| "true".into());
// Auto-Recovery: Container die in 'error' haengen werden automatisch
// neu gestartet (exponential backoff, dann aufgeben + Notification).
map.entry("AUTO_RECOVERY_ENABLED".into())
.or_insert_with(|| "true".into());
map.entry("AUTO_RECOVERY_BASE_DELAY_SECONDS".into())
.or_insert_with(|| "60".into());
map.entry("AUTO_RECOVERY_MAX_ATTEMPTS".into())
.or_insert_with(|| "5".into());
}
fn random_password() -> String {
let mut rng = rand::thread_rng();
let bytes: [u8; 24] = rng.gen();
URL_SAFE_NO_PAD.encode(bytes)
}
fn random_jwt_secret() -> String {
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 48];
rng.fill(&mut bytes[..]);
URL_SAFE_NO_PAD.encode(bytes)
}
+255
View File
@@ -0,0 +1,255 @@
//! Container-Update-Modul.
//!
//! Workflow:
//! 1. `docker compose pull` zieht Image-Updates
//! 2. Image-IDs vorher/nachher vergleichen — was hat sich geaendert?
//! 3. Wenn was geaendert: erst Backup (pg_dumpall), dann
//! `docker compose up -d` — Compose erkennt selbst was neu erstellt werden
//! muss anhand veraenderter Image-IDs.
//!
//! Scheduler tickt `CONTAINER_AUTOUPDATE_INTERVAL_HOURS` (Default 24h).
//! `CONTAINER_AUTOUPDATE_ENABLED=false` schaltet den Auto-Teil ab — manuelles
//! `check_now` / `apply_now` geht trotzdem.
use crate::{backup, services};
use chrono::Local;
use serde::Serialize;
use std::collections::HashMap;
use std::time::Duration;
use tokio::process::Command;
pub const SCHED_TICK: Duration = Duration::from_secs(60 * 60);
const DEFAULT_INTERVAL_HOURS: u64 = 24;
#[derive(Debug, Clone, Serialize)]
pub struct UpdateAvailable {
pub service: String,
pub image: String,
pub old_id: String,
pub new_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckResult {
pub checked_at_iso: String,
pub updates: Vec<UpdateAvailable>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplyResult {
pub applied_at_iso: String,
pub updated_services: Vec<String>,
pub backup_filename: Option<String>,
pub recreate_log: String,
}
#[derive(Debug, Clone, Serialize, Default)]
struct ImageRow {
service: String,
image: String,
id: String,
}
/// Liefert die Image-ID pro Compose-Service.
async fn snapshot_images() -> Result<HashMap<String, ImageRow>, String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "images", "--format", "json"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
if !out.status.success() {
return Err(format!(
"compose images: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut map = HashMap::new();
// Compose kann ein JSON-Array ODER JSON-Lines liefern — beide Faelle abfangen.
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(&stdout) {
for v in arr {
if let Some(row) = parse_row(&v) {
map.insert(row.service.clone(), row);
}
}
} else {
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(row) = parse_row(&v) {
map.insert(row.service.clone(), row);
}
}
}
}
Ok(map)
}
fn parse_row(v: &serde_json::Value) -> Option<ImageRow> {
let service = v.get("Service").and_then(|x| x.as_str())?.to_string();
let repo = v.get("Repository").and_then(|x| x.as_str()).unwrap_or("");
let tag = v.get("Tag").and_then(|x| x.as_str()).unwrap_or("");
let id = v.get("ID").or_else(|| v.get("ImageId"))
.and_then(|x| x.as_str()).unwrap_or("").to_string();
Some(ImageRow {
service,
image: format!("{repo}:{tag}"),
id,
})
}
/// Pullt alle Compose-Images. Bei `app` (lokal gebaut, nicht in der Registry)
/// kommt ein Fehler — ignorieren wir, das ist erwartet.
async fn compose_pull() -> Result<(), String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "pull", "--ignore-pull-failures"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
// Auch bei non-zero exit ignorieren wir — solange wir die Image-IDs danach
// einsammeln koennen, ist alles weitere ableitbar.
if !out.status.success() {
log::warn!(
"compose pull non-zero (oft `app`-Image — egal): {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
async fn compose_up() -> Result<String, String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "up", "-d"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
let log = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if !out.status.success() {
return Err(format!("compose up failed: {log}"));
}
Ok(log)
}
/// Prueft auf Image-Updates ohne sie anzuwenden. `docker compose pull` laeuft,
/// danach vergleichen wir die Image-IDs.
pub async fn check() -> Result<CheckResult, String> {
let before = snapshot_images().await?;
compose_pull().await?;
let after = snapshot_images().await?;
let mut updates = vec![];
for (svc, new_row) in &after {
let old_id = before.get(svc).map(|r| r.id.as_str()).unwrap_or("");
if !new_row.id.is_empty() && old_id != new_row.id {
updates.push(UpdateAvailable {
service: svc.clone(),
image: new_row.image.clone(),
old_id: old_id.to_string(),
new_id: new_row.id.clone(),
});
}
}
Ok(CheckResult {
checked_at_iso: Local::now().to_rfc3339(),
updates,
})
}
/// Pullt + (falls Updates da) Backup + Compose-Up. Liefert was wirklich
/// passiert ist — empty `updated_services` = nichts zu tun.
pub async fn apply() -> Result<ApplyResult, String> {
let check_res = check().await?;
if check_res.updates.is_empty() {
return Ok(ApplyResult {
applied_at_iso: Local::now().to_rfc3339(),
updated_services: vec![],
backup_filename: None,
recreate_log: "Keine Image-Updates — nichts zu tun.".into(),
});
}
// Pre-Backup. Wenn das schiefgeht, brechen wir ab — keine Updates ohne Safety-Net.
log::info!(
"Container-Update: Backup vor Recreate ({} Services updates)",
check_res.updates.len()
);
let backup = backup::create()
.await
.map_err(|e| format!("Pre-Backup fehlgeschlagen, Update abgebrochen: {e}"))?;
let log = compose_up().await?;
let services_list: Vec<String> =
check_res.updates.iter().map(|u| u.service.clone()).collect();
crate::events::info(format!(
"Container-Update angewendet auf {} (Backup: {})",
services_list.join(", "),
backup.filename
))
.await;
Ok(ApplyResult {
applied_at_iso: Local::now().to_rfc3339(),
updated_services: services_list,
backup_filename: Some(backup.filename),
recreate_log: log,
})
}
fn config_values() -> (bool, u64) {
let cfg = crate::config::load().unwrap_or_default();
let enabled = cfg
.get("CONTAINER_AUTOUPDATE_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(false);
let interval = cfg
.get("CONTAINER_AUTOUPDATE_INTERVAL_HOURS")
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_INTERVAL_HOURS);
(enabled, interval)
}
pub async fn scheduler_loop() {
let mut tick = tokio::time::interval(SCHED_TICK);
tick.tick().await; // initial tick verwerfen (siehe backup::scheduler_loop)
let mut last_check: Option<chrono::DateTime<Local>> = None;
loop {
tick.tick().await;
let (enabled, interval) = config_values();
if !enabled {
continue;
}
let now = Local::now();
let due = match last_check {
None => true,
Some(t) => now.signed_duration_since(t).num_hours() >= interval as i64,
};
if !due {
continue;
}
last_check = Some(now);
match apply().await {
Ok(res) if res.updated_services.is_empty() => {
log::info!("Auto-Update: keine neuen Images");
}
Ok(res) => {
log::info!(
"Auto-Update angewendet auf: {} (Backup: {:?})",
res.updated_services.join(", "),
res.backup_filename
);
}
Err(e) => log::warn!("Auto-Update fehlgeschlagen: {e}"),
}
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Disk-Usage-Sammler fuer's Backup-Panel und (spaeter) Warn-Banner.
//!
//! Vier Metriken:
//! - Postgres logische DB-Groesse (`pg_database_size('postgres')`)
//! - Backup-Verzeichnis-Groesse (Summe aller .sql-Files)
//! - Docker-Volumes-Total (alle Volumes des Daemons, via `docker system df`)
//! - Freier Disk-Space im Backup-Verzeichnis (via `df -k`)
use crate::{paths, services};
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct DiskUsage {
/// Logische Groesse der `postgres`-DB (None wenn db nicht erreichbar).
pub postgres_db_bytes: Option<u64>,
/// Summe aller `rapport-*.sql`-Backups.
pub backups_total_bytes: u64,
pub backup_count: u32,
/// Freier Platz auf dem Disk wo die Backups liegen.
pub host_free_bytes: u64,
/// Gesamt-Platz auf demselben Disk.
pub host_total_bytes: u64,
/// Totaler Docker-Volumes-Verbrauch (alle Compose-Stacks, nicht nur unsere).
pub docker_volumes_bytes: Option<u64>,
}
pub async fn collect() -> DiskUsage {
let (host_free, host_total) = host_disk(&paths::backups_dir());
DiskUsage {
postgres_db_bytes: pg_db_size().await.ok(),
backups_total_bytes: backups_size(),
backup_count: backup_count(),
host_free_bytes: host_free,
host_total_bytes: host_total,
docker_volumes_bytes: docker_volumes_size().await.ok(),
}
}
async fn pg_db_size() -> Result<u64, String> {
// Postgres-Passwort fuer psql via -e PGPASSWORD durchreichen — sonst
// promptet psql interaktiv und wir kriegen einen Auth-Error.
let cfg = crate::config::load().unwrap_or_default();
let pw = cfg
.get("POSTGRES_PASSWORD")
.cloned()
.unwrap_or_default();
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args([
"compose", "exec",
"-e", &format!("PGPASSWORD={pw}"),
"-T", "db",
"psql", "-U", "supabase_admin", "-d", "postgres",
"-tAc", "SELECT pg_database_size('postgres');",
])
.output()
.await
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<u64>()
.map_err(|e| e.to_string())
}
fn backups_size() -> u64 {
let dir = paths::backups_dir();
std::fs::read_dir(&dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
}
fn backup_count() -> u32 {
crate::backup::list().len() as u32
}
fn host_disk(path: &std::path::Path) -> (u64, u64) {
// `df -k <path>` → 2. Zeile: FS 1k-blocks Used Avail Capacity ... Mounted-on
let out = std::process::Command::new("df")
.arg("-k")
.arg(path)
.output();
let Ok(o) = out else { return (0, 0) };
if !o.status.success() {
return (0, 0);
}
let stdout = String::from_utf8_lossy(&o.stdout);
let mut lines = stdout.lines();
let _header = lines.next();
let Some(data_line) = lines.next() else { return (0, 0) };
let cols: Vec<&str> = data_line.split_whitespace().collect();
if cols.len() < 4 {
return (0, 0);
}
let total = cols[1].parse::<u64>().unwrap_or(0).saturating_mul(1024);
let avail = cols[3].parse::<u64>().unwrap_or(0).saturating_mul(1024);
(avail, total)
}
async fn docker_volumes_size() -> Result<u64, String> {
let out = Command::new("docker")
.args(["system", "df", "--format", "json"])
.output()
.await
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err("docker system df fehlgeschlagen".into());
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
// Docker nennt's "Local Volumes" (Mac/Linux); aelter manchmal "Volumes".
let t = v.get("Type").and_then(|x| x.as_str()).unwrap_or("");
if t == "Local Volumes" || t == "Volumes" {
let size_str = v.get("Size").and_then(|x| x.as_str()).unwrap_or("");
return Ok(parse_human_size(size_str));
}
}
Err("Volumes-Row nicht gefunden".into())
}
/// "1.5GB" / "850MB" / "0B" → Bytes
fn parse_human_size(s: &str) -> u64 {
let s = s.trim();
let split = s
.find(|c: char| c.is_alphabetic())
.unwrap_or(s.len());
let (num, unit) = s.split_at(split);
let n: f64 = num.parse().unwrap_or(0.0);
let mul = match unit.trim().to_uppercase().as_str() {
"B" => 1.0,
"KB" | "K" => 1000.0,
"MB" | "M" => 1000.0 * 1000.0,
"GB" | "G" => 1000.0 * 1000.0 * 1000.0,
"TB" | "T" => 1000.0_f64.powi(4),
"KIB" => 1024.0,
"MIB" => 1024.0 * 1024.0,
"GIB" => 1024.0 * 1024.0 * 1024.0,
_ => 1.0,
};
(n * mul) as u64
}
+57
View File
@@ -0,0 +1,57 @@
//! Globaler App-Event-Log fuer das Status-Dashboard.
//!
//! Ringbuffer (max 100 Eintraege) mit Timestamp + Severity + Message.
//! Wird von verschiedenen Stellen geschrieben (Supervisor, Backup-Scheduler,
//! Container-Update, Pre-Pull, App-Startup) und vom Frontend gepollt.
use chrono::Local;
use serde::Serialize;
use std::collections::VecDeque;
use std::sync::OnceLock;
use tokio::sync::Mutex;
const CAPACITY: usize = 100;
#[derive(Debug, Clone, Serialize)]
pub struct Event {
pub ts_iso: String,
pub kind: &'static str,
pub message: String,
}
static EVENTS: OnceLock<Mutex<VecDeque<Event>>> = OnceLock::new();
fn store() -> &'static Mutex<VecDeque<Event>> {
EVENTS.get_or_init(|| Mutex::new(VecDeque::with_capacity(CAPACITY)))
}
pub async fn append(kind: &'static str, message: impl Into<String>) {
let msg = message.into();
log::info!("[event] {}", msg);
let mut q = store().lock().await;
if q.len() >= CAPACITY {
q.pop_front();
}
q.push_back(Event {
ts_iso: Local::now().to_rfc3339(),
kind,
message: msg,
});
}
pub async fn info(msg: impl Into<String>) {
append("info", msg).await
}
pub async fn warn(msg: impl Into<String>) {
append("warn", msg).await
}
pub async fn error(msg: impl Into<String>) {
append("error", msg).await
}
pub async fn list() -> Vec<Event> {
let q = store().lock().await;
q.iter().rev().cloned().collect()
}
+177
View File
@@ -0,0 +1,177 @@
//! Erste-Hilfe-Aktionen fuer den Notfall.
//!
//! Drei destruktive Aktionen mit unterschiedlichem Risiko:
//! - `recreate_containers` (mild): compose down + up --force-recreate.
//! Container neu, Volumes bleiben → Daten safe.
//! - `reset_pgdata` (NUKE): compose down -v + up. Volumes WEG → Postgres
//! wird von Grund auf neu initialisiert. Erfordert Pre-Backup.
//! - `diagnose_bundle`: kein Schaden — sammelt Logs, ps, version, config
//! (redacted) in eine Text-Datei unter backups/.
use crate::{backup::BackupInfo, paths, services};
use chrono::Local;
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct RecreateResult {
pub log: String,
pub finished_at_iso: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ResetResult {
pub safety_backup: BackupInfo,
pub log: String,
pub finished_at_iso: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiagnoseResult {
pub filename: String,
pub bytes: u64,
}
async fn compose(args: &[&str]) -> Result<String, String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.arg("compose")
.args(args)
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if !out.status.success() {
return Err(format!("compose {args:?} failed: {combined}"));
}
Ok(combined)
}
/// Container neu erstellen — Volumes bleiben.
pub async fn recreate_containers() -> Result<RecreateResult, String> {
crate::events::warn("Erste Hilfe: Container neu erstellen ...").await;
let _ = compose(&["down"]).await;
let log = compose(&["up", "-d", "--force-recreate"]).await?;
crate::events::info("Erste Hilfe: Container neu erstellt").await;
Ok(RecreateResult {
log,
finished_at_iso: Local::now().to_rfc3339(),
})
}
/// VOLL DESTRUKTIV: Volumes platt + frisches Init.
pub async fn reset_pgdata() -> Result<ResetResult, String> {
crate::events::warn("Erste Hilfe: PGDATA-RESET — erst Safety-Backup").await;
let safety = crate::backup::create()
.await
.map_err(|e| format!("Safety-Backup vor PGDATA-Reset fehlgeschlagen — Reset abgebrochen: {e}"))?;
crate::events::warn(format!(
"Erste Hilfe: stoppe Stack inkl. Volumes (Safety: {})",
safety.filename
))
.await;
let _ = compose(&["down", "-v"]).await?;
let log = compose(&["up", "-d"]).await?;
crate::events::error(format!(
"Erste Hilfe: PGDATA platt gemacht und neu initialisiert (Safety: {})",
safety.filename
))
.await;
Ok(ResetResult {
safety_backup: safety,
log,
finished_at_iso: Local::now().to_rfc3339(),
})
}
/// Sammelt Diagnose-Informationen in eine Text-Datei unter backups/.
pub async fn diagnose_bundle() -> Result<DiagnoseResult, String> {
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let filename = format!("diagnose-{stamp}.txt");
let path = paths::backups_dir().join(&filename);
let mut out = String::new();
out.push_str(&format!("# RAPPORT Server-App Diagnose ({})\n\n", Local::now().to_rfc3339()));
out.push_str("## docker version\n```\n");
out.push_str(&run_capture(&["docker", "version"]).await);
out.push_str("\n```\n\n");
out.push_str("## docker compose ps -a\n```\n");
out.push_str(&run_capture(&["docker", "compose", "ps", "-a"]).await);
out.push_str("\n```\n\n");
out.push_str("## docker system df\n```\n");
out.push_str(&run_capture(&["docker", "system", "df"]).await);
out.push_str("\n```\n\n");
out.push_str("## config.env (redacted)\n```\n");
out.push_str(&redacted_config());
out.push_str("\n```\n\n");
out.push_str("## App-Events (letzte 100)\n```\n");
for e in crate::events::list().await {
out.push_str(&format!("[{}] {}: {}\n", e.ts_iso, e.kind, e.message));
}
out.push_str("\n```\n\n");
out.push_str("## Container-Logs (letzte 50 Zeilen pro Service)\n");
for svc in crate::services::default_services() {
out.push_str(&format!("\n### {}\n```\n", svc.id));
out.push_str(&run_capture(&[
"docker", "compose", "logs", "--no-color", "--tail", "50", &svc.id,
]).await);
out.push_str("\n```\n");
}
std::fs::write(&path, out.as_bytes()).map_err(|e| format!("write {}: {e}", path.display()))?;
let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
crate::events::info(format!("Diagnose-Bundle: {filename} ({} KB)", bytes / 1024)).await;
Ok(DiagnoseResult { filename, bytes })
}
async fn run_capture(argv: &[&str]) -> String {
let mut cmd = Command::new(argv[0]);
cmd.current_dir(services::compose_dir());
for a in &argv[1..] {
cmd.arg(a);
}
match cmd.output().await {
Ok(o) => format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
),
Err(e) => format!("(spawn fehlgeschlagen: {e})"),
}
}
const REDACT_PREFIXES: &[&str] = &[
"POSTGRES_PASSWORD",
"JWT_SECRET",
"ADMIN_UI_PASSWORD",
"ANON_KEY",
"SERVICE_ROLE_KEY",
"SMTP_PASS",
];
fn redacted_config() -> String {
let map = crate::config::load().unwrap_or_default();
let mut out = String::new();
for (k, v) in &map {
let redacted = REDACT_PREFIXES.iter().any(|p| k.contains(p));
if redacted {
out.push_str(&format!("{k}=<REDACTED {} chars>\n", v.len()));
} else {
out.push_str(&format!("{k}={v}\n"));
}
}
out
}
+57
View File
@@ -0,0 +1,57 @@
//! Health-Probes für Subprozesse.
//!
//! Jeder Service hat einen Probe-Typ. `HealthProbe::check()` ist async und
//! liefert `Ok(())` wenn der Service als gesund gilt, sonst eine Fehler-
//! beschreibung.
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::net::TcpStream;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum HealthProbe {
/// HTTP GET, erwartet 2xx-Status.
Http { url: String },
/// TCP-Connect, dann (optional) `SELECT 1` via psql.
/// Für Postgres: aktuell nur TCP-Connect — `psql`-Query käme dazu, wenn
/// libpq oder ein eingebetteter Client verfügbar ist.
TcpAndQuery { host: String, port: u16, db: String },
}
const TIMEOUT: Duration = Duration::from_secs(3);
impl HealthProbe {
pub async fn check(&self) -> Result<(), String> {
match self {
HealthProbe::Http { url } => check_http(url).await,
HealthProbe::TcpAndQuery { host, port, .. } => check_tcp(host, *port).await,
}
}
}
async fn check_http(url: &str) -> Result<(), String> {
let client = reqwest::Client::builder()
.timeout(TIMEOUT)
.build()
.map_err(|e| format!("reqwest build: {e}"))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("http get {url}: {e}"))?;
if resp.status().is_success() || resp.status().is_redirection() {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
async fn check_tcp(host: &str, port: u16) -> Result<(), String> {
let addr = format!("{host}:{port}");
tokio::time::timeout(TIMEOUT, TcpStream::connect(&addr))
.await
.map_err(|_| format!("tcp connect {addr}: timeout"))?
.map_err(|e| format!("tcp connect {addr}: {e}"))?;
Ok(())
}
+462
View File
@@ -0,0 +1,462 @@
//! HTTP/HTTPS-Server fuer den Admin-WebUI-Zugang.
//!
//! Spiegelt die Tauri-Commands als REST-Endpoints + serviert das React-`dist/`.
//! Konfiguration aus `config.env`:
//! - `ADMIN_UI_BIND` (Default `127.0.0.1`; LAN-Freigabe → `0.0.0.0`)
//! - `ADMIN_UI_PORT` (Default `9090`)
//! - `ADMIN_UI_PASSWORD` (Auto-generiert, ~32 Bytes random)
//! - `ADMIN_UI_TLS` (Default `true` — self-signed Cert wird automatisch
//! in `<data_dir>/admin-ui-cert.pem` erzeugt)
//!
//! Auth: Basic-Auth mit User `admin`. Fehlversuche werden pro IP gezaehlt;
//! nach `MAX_FAILS` Fehlern in `FAIL_WINDOW` Sekunden ist die IP fuer
//! `LOCKOUT` Sekunden geblockt.
use crate::supervisor::Supervisor;
use axum::{
body::Body,
extract::{ConnectInfo, Path, State},
http::{header, Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use axum_server::tls_rustls::RustlsConfig;
use base64::Engine;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
const MAX_FAILS: u32 = 5;
const FAIL_WINDOW: Duration = Duration::from_secs(60);
const LOCKOUT: Duration = Duration::from_secs(300);
#[derive(Default)]
struct AuthFailure {
count: u32,
first_seen: Option<Instant>,
locked_until: Option<Instant>,
}
type FailMap = Arc<Mutex<HashMap<IpAddr, AuthFailure>>>;
#[derive(Clone)]
pub struct HttpState {
pub supervisor: Arc<Mutex<Supervisor>>,
pub password: String,
fails: FailMap,
}
pub async fn serve(
bind: String,
port: u16,
password: String,
tls: bool,
supervisor: Arc<Mutex<Supervisor>>,
static_dir: std::path::PathBuf,
) -> Result<(), String> {
let state = HttpState {
supervisor,
password,
fails: Arc::new(Mutex::new(HashMap::new())),
};
let api = Router::new()
.route("/services", get(list_services))
.route("/services/start-all", post(start_all))
.route("/services/stop-all", post(stop_all))
.route("/services/:id/start", post(start_service))
.route("/services/:id/stop", post(stop_service))
.route("/services/:id/restart", post(restart_service_h))
.route("/services/restart-all", post(restart_all_h))
.route("/services/:id/logs", get(service_logs))
.route("/activity", get(current_activity))
.route("/backups", get(list_backups_h))
.route("/backups/now", post(backup_now_h))
.route("/backups/:filename/restore", post(restore_backup_h))
.route("/container-updates/check", post(check_updates_h))
.route("/container-updates/apply", post(apply_updates_h))
.route("/events", get(list_events_h))
.route("/stats", get(list_stats_h))
.route("/disk", get(disk_usage_h))
.route("/firstaid/recreate", post(firstaid_recreate_h))
.route("/firstaid/reset-pgdata", post(firstaid_reset_pgdata_h))
.route("/firstaid/diagnose", post(firstaid_diagnose_h))
.route("/setup/status", get(setup_status_h))
.route("/setup/install", post(setup_install_h));
let static_service = ServeDir::new(&static_dir).append_index_html_on_directories(true);
// Middleware-Reihenfolge (innen → aussen):
// csrf_check — state-changing Requests brauchen den Custom-Header
// basic_auth — IP-Lockout + Credential-Check
// security_headers — auf allen Antworten (auch 401 etc.)
let app = Router::new()
.nest("/api", api)
.fallback_service(static_service)
.layer(middleware::from_fn(csrf_check))
.layer(middleware::from_fn_with_state(state.clone(), basic_auth))
.layer(middleware::from_fn(security_headers))
.with_state(state);
let addr: SocketAddr = format!("{bind}:{port}")
.parse()
.map_err(|e| format!("bad bind addr {bind}:{port}: {e}"))?;
if tls {
// rustls 0.23 verlangt expliziten Crypto-Provider. Egal welcher zuerst —
// ignore_err falls schon installiert.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (cert_path, key_path) = ensure_tls_cert()?;
let config = RustlsConfig::from_pem_file(cert_path, key_path)
.await
.map_err(|e| format!("rustls config: {e}"))?;
log::info!("Admin WebUI listening on https://{addr} (user: admin)");
axum_server::bind_rustls(addr, config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.map_err(|e| format!("axum_server: {e}"))?;
} else {
log::info!("Admin WebUI listening on http://{addr} (user: admin) — TLS off!");
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("listen {addr}: {e}"))?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.map_err(|e| format!("axum serve: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// TLS cert provisioning
// ---------------------------------------------------------------------------
fn ensure_tls_cert() -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
let cert = crate::paths::admin_ui_cert_path();
let key = crate::paths::admin_ui_key_path();
if cert.exists() && key.exists() {
return Ok((cert, key));
}
log::info!("Generating self-signed TLS cert for Admin WebUI ...");
crate::paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let hostname = hostname::get()
.ok()
.and_then(|n| n.into_string().ok())
.unwrap_or_else(|| "localhost".into());
let san = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
hostname.clone(),
format!("{hostname}.local"),
];
let cert_kp = rcgen::generate_simple_self_signed(san)
.map_err(|e| format!("rcgen: {e}"))?;
std::fs::write(&cert, cert_kp.cert.pem()).map_err(|e| format!("write cert: {e}"))?;
std::fs::write(&key, cert_kp.key_pair.serialize_pem())
.map_err(|e| format!("write key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600));
}
Ok((cert, key))
}
// ---------------------------------------------------------------------------
// Basic-Auth middleware + per-IP rate limit
// ---------------------------------------------------------------------------
async fn basic_auth(
State(state): State<HttpState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
let ip = peer.ip();
// Lockout-Check.
{
let mut fails = state.fails.lock().await;
if let Some(rec) = fails.get_mut(&ip) {
if let Some(until) = rec.locked_until {
if Instant::now() < until {
return Ok(too_many("Too many failed attempts. Locked out."));
}
// Lockout abgelaufen — Reset.
*rec = AuthFailure::default();
}
}
}
let ok = check_auth(req.headers().get(header::AUTHORIZATION), &state.password);
if ok {
let mut fails = state.fails.lock().await;
fails.remove(&ip);
return Ok(next.run(req).await);
}
// Failure registrieren.
let mut fails = state.fails.lock().await;
let rec = fails.entry(ip).or_default();
let now = Instant::now();
match rec.first_seen {
Some(first) if now.duration_since(first) < FAIL_WINDOW => {
rec.count += 1;
}
_ => {
rec.count = 1;
rec.first_seen = Some(now);
}
}
if rec.count >= MAX_FAILS {
rec.locked_until = Some(now + LOCKOUT);
log::warn!(
"Auth lockout: {} after {} fails — blocked for {}s",
ip,
rec.count,
LOCKOUT.as_secs()
);
}
Ok(unauthorized())
}
fn check_auth(header: Option<&axum::http::HeaderValue>, password: &str) -> bool {
let s = match header.and_then(|h| h.to_str().ok()) {
Some(s) => s,
None => return false,
};
let Some(b64) = s.strip_prefix("Basic ") else { return false };
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64) else { return false };
let Ok(s) = std::str::from_utf8(&decoded) else { return false };
let Some((user, pass)) = s.split_once(':') else { return false };
user == "admin" && pass == password
}
fn unauthorized() -> Response {
let mut resp = Response::new(Body::from("Authentication required"));
*resp.status_mut() = StatusCode::UNAUTHORIZED;
resp.headers_mut().insert(
header::WWW_AUTHENTICATE,
"Basic realm=\"RAPPORT Server Admin\"".parse().unwrap(),
);
resp
}
fn too_many(msg: &'static str) -> Response {
let mut resp = Response::new(Body::from(msg));
*resp.status_mut() = StatusCode::TOO_MANY_REQUESTS;
resp
}
// ---------------------------------------------------------------------------
// CSRF — Custom-Header-Pflicht fuer state-changing Methoden
// ---------------------------------------------------------------------------
//
// Browser senden Authorization-Header automatisch wenn sie Credentials gecacht
// haben. Damit eine boesartige Seite nicht via auto-submittendem `<form>` einen
// `start-all` triggern kann, verlangen wir auf POST/PUT/DELETE/PATCH den
// Custom-Header `X-Rapport-Csrf: 1`. Cross-origin `<form>` kann diesen nicht
// setzen; cross-origin `fetch()` triggert dafuer CORS-Preflight, das wir nicht
// allowen — also auch geblockt.
async fn csrf_check(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
let method = req.method();
let is_writing = matches!(
method.as_str(),
"POST" | "PUT" | "DELETE" | "PATCH"
);
if is_writing && req.headers().get("x-rapport-csrf").is_none() {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}
// ---------------------------------------------------------------------------
// Security-Header — Defense-in-Depth fuer den WebUI-Browser-Tab
// ---------------------------------------------------------------------------
async fn security_headers(req: Request<Body>, next: Next) -> Response {
let mut resp = next.run(req).await;
let h = resp.headers_mut();
// Clickjacking
h.insert("x-frame-options", "DENY".parse().unwrap());
// MIME-Sniffing
h.insert("x-content-type-options", "nosniff".parse().unwrap());
// Kein Referer rauslassen (privates LAN)
h.insert("referrer-policy", "no-referrer".parse().unwrap());
// Strict-Transport-Security — Browser merken sich, dass nur HTTPS gilt.
// 1 Woche reicht; spaeter koennen wir hochdrehen wenn das setup stabil ist.
h.insert(
"strict-transport-security",
"max-age=604800".parse().unwrap(),
);
// CSP: kein externes JS, kein eval, keine inline-Scripts.
// Inline-Styles sind erlaubt weil React StyleSheets manchmal so generiert.
h.insert(
"content-security-policy",
"default-src 'self'; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; \
connect-src 'self'; \
font-src 'self'; \
object-src 'none'; \
base-uri 'self'; \
form-action 'self'; \
frame-ancestors 'none'"
.parse()
.unwrap(),
);
resp
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn list_services(State(s): State<HttpState>) -> impl IntoResponse {
Json(crate::supervisor::list_with_timeout(&s.supervisor, 300).await)
}
async fn start_all(State(s): State<HttpState>) -> impl IntoResponse {
map_result(crate::supervisor::Supervisor::start_all_managed(s.supervisor.clone()).await)
}
async fn stop_all(State(s): State<HttpState>) -> impl IntoResponse {
map_result(crate::supervisor::Supervisor::stop_all_managed(s.supervisor.clone()).await)
}
async fn start_service(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut sv = s.supervisor.lock().await;
map_result(sv.start(&id).await)
}
async fn stop_service(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut sv = s.supervisor.lock().await;
map_result(sv.stop(&id).await)
}
async fn restart_service_h(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut sv = s.supervisor.lock().await;
map_result(sv.restart(&id).await)
}
async fn restart_all_h(State(s): State<HttpState>) -> impl IntoResponse {
map_result(crate::supervisor::Supervisor::restart_all_managed(s.supervisor.clone()).await)
}
async fn service_logs(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let sv = s.supervisor.lock().await;
Json(sv.logs(&id).await)
}
async fn current_activity(State(s): State<HttpState>) -> impl IntoResponse {
let sv = s.supervisor.lock().await;
Json(sv.current_activity().await)
}
async fn list_backups_h() -> impl IntoResponse {
Json(crate::backup::list())
}
async fn backup_now_h() -> Response {
match crate::backup::create().await {
Ok(info) => Json(info).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn restore_backup_h(Path(filename): Path<String>) -> Response {
match crate::backup::restore(&filename).await {
Ok(res) => Json(res).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn check_updates_h() -> Response {
match crate::container_update::check().await {
Ok(res) => Json(res).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn apply_updates_h() -> Response {
match crate::container_update::apply().await {
Ok(res) => Json(res).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn list_events_h() -> impl IntoResponse {
Json(crate::events::list().await)
}
async fn list_stats_h() -> impl IntoResponse {
Json(crate::stats::collect().await)
}
async fn disk_usage_h() -> impl IntoResponse {
Json(crate::disk::collect().await)
}
async fn firstaid_recreate_h() -> Response {
match crate::firstaid::recreate_containers().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn firstaid_reset_pgdata_h() -> Response {
match crate::firstaid::reset_pgdata().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn firstaid_diagnose_h() -> Response {
match crate::firstaid::diagnose_bundle().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn setup_status_h() -> impl IntoResponse {
Json(crate::setup::status().await)
}
async fn setup_install_h(State(s): State<HttpState>) -> Response {
let _ = s;
match crate::setup::install_and_start().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
fn map_result(r: Result<(), String>) -> Response {
match r {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
+476
View File
@@ -0,0 +1,476 @@
//! RAPPORT Server-App — Tauri-Entry-Point.
//!
//! Hier wird der Process-Supervisor initialisiert, in den App-State gehängt,
//! und alle `#[tauri::command]`-Handler registriert.
mod backup;
mod commands;
mod config;
mod container_update;
mod disk;
mod events;
mod firstaid;
mod health;
mod http_server;
mod paths;
mod services;
mod setup;
mod stats;
mod supervisor;
use std::sync::Arc;
use supervisor::Supervisor;
use tauri::Manager;
use tokio::sync::Mutex;
/// Geteilter Supervisor-State über `tauri::State`.
pub struct AppState {
pub supervisor: Arc<Mutex<Supervisor>>,
}
/// Aggregat-Status fuer die Tray-Icon-Farbe.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrayStatus {
/// keine Services laufen (alle stopped) oder Liste leer
Off,
/// alle laufen, niemand error/starting
Ok,
/// gemischt: einige laufen, andere stopped/starting (oder noch nicht alle ready)
Warn,
/// mind. ein service in error
Error,
}
fn aggregate_status(statuses: &[supervisor::ServiceStatus]) -> TrayStatus {
use supervisor::ServiceState as S;
if statuses.is_empty() {
return TrayStatus::Off;
}
let mut has_error = false;
let mut has_running = false;
let mut has_transitioning = false;
let mut has_stopped = false;
for s in statuses {
match s.state {
S::Error => has_error = true,
S::Running => has_running = true,
S::Starting | S::Stopping => has_transitioning = true,
S::Stopped => has_stopped = true,
}
}
if has_error {
TrayStatus::Error
} else if has_running && !has_transitioning && !has_stopped {
TrayStatus::Ok
} else if !has_running && !has_transitioning && has_stopped {
TrayStatus::Off
} else {
TrayStatus::Warn
}
}
/// 44x44 Farb-PNG-Bytes (Pillow-generiert, in `icons/` checked-in).
const TRAY_OFF_PNG: &[u8] = include_bytes!("../icons/tray-off@2x.png");
const TRAY_OK_PNG: &[u8] = include_bytes!("../icons/tray-ok@2x.png");
const TRAY_WARN_PNG: &[u8] = include_bytes!("../icons/tray-warn@2x.png");
const TRAY_ERROR_PNG: &[u8] = include_bytes!("../icons/tray-error@2x.png");
fn set_tray_icon(app: &tauri::AppHandle, status: TrayStatus) {
let bytes = match status {
TrayStatus::Off => TRAY_OFF_PNG,
TrayStatus::Ok => TRAY_OK_PNG,
TrayStatus::Warn => TRAY_WARN_PNG,
TrayStatus::Error => TRAY_ERROR_PNG,
};
let tooltip = match status {
TrayStatus::Off => "RAPPORT Server — gestoppt",
TrayStatus::Ok => "RAPPORT Server — alle Services laufen",
TrayStatus::Warn => "RAPPORT Server — teilweise gestartet / im Uebergang",
TrayStatus::Error => "RAPPORT Server — Fehler in mindestens einem Service",
};
let Some(tray) = app.tray_by_id("main-tray") else { return };
if let Ok(image) = tauri::image::Image::from_bytes(bytes) {
let _ = tray.set_icon(Some(image));
let _ = tray.set_icon_as_template(false);
}
let _ = tray.set_tooltip(Some(tooltip));
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
log::info!("RAPPORT Server-App starting ...");
// PATH IMMER erweitern (auch wenn ~/.rapport/bin/ noch nicht existiert) —
// sonst klappt der Auto-Start nach dem Setup-Wizard nicht weil unser
// Prozess den frisch installierten docker/colima nicht findet.
let rapport_bin = setup::bin_dir();
let cur = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{cur}", rapport_bin.display());
std::env::set_var("PATH", new_path);
log::info!("PATH erweitert um {}", rapport_bin.display());
let supervisor = Arc::new(Mutex::new(Supervisor::new()));
tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
// Autostart: macOS legt einen LaunchAgent unter
// ~/Library/LaunchAgents/com.rapport.server-app.plist an.
// Beim Login-Launch passen wir `--hidden` mit, damit das Fenster
// direkt im Tray landet (kein Popup im Login-Flow).
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--hidden"]),
))
.manage(AppState {
supervisor: supervisor.clone(),
})
.setup(move |app| {
// App-Data-Verzeichnisse (PGDATA-Volume-Mount, logs, backups) anlegen.
if let Err(e) = paths::ensure_dirs() {
log::error!("ensure_dirs: {e}");
}
// `--hidden` aus dem Login-Launch: Fenster sofort verstecken,
// App lebt nur im Tray weiter.
let launched_hidden = std::env::args().any(|a| a == "--hidden");
if launched_hidden {
if let Some(w) = app.get_webview_window("main") {
let _ = w.hide();
}
}
// Compose-Verzeichnis aufloesen (kommt aus config.env oder
// Auto-Detect-Reihe). Ohne das gibt's nichts zu supervisen.
let compose_override = config::load()
.ok()
.and_then(|c| c.get("COMPOSE_DIR").cloned());
match services::init_compose_dir(compose_override) {
Ok(p) => log::info!("Compose-Dir: {}", p.display()),
Err(e) => log::error!("Compose-Dir nicht gefunden: {e}"),
}
// config.env vorhanden? Sonst Defaults generieren und persistieren.
// Sonst landen Template-Platzhalter ({POSTGRES_PASSWORD}) literal
// in den Container-Envs.
match config::load() {
Ok(mut map) => {
let before = map.len();
config::ensure_defaults(&mut map);
if map.len() != before {
if let Err(e) = config::save(&map) {
log::error!("config save: {e}");
} else {
log::info!("Initialized config.env with defaults");
}
}
}
Err(e) => log::error!("config load: {e}"),
}
// Auto-register alle Services beim Start (noch ohne sie zu starten).
let supervisor_clone = supervisor.clone();
let auto_start_containers = config::load()
.ok()
.and_then(|c| c.get("AUTO_START_CONTAINERS_ON_LAUNCH").cloned())
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
tauri::async_runtime::spawn(async move {
let mut sv = supervisor_clone.lock().await;
let n = services::default_services().len();
for service_def in services::default_services() {
sv.register(service_def);
}
let how = if launched_hidden { " (hidden launch)" } else { "" };
events::info(format!(
"RAPPORT Server-App gestartet{how}{n} Services registriert"
)).await;
drop(sv);
if auto_start_containers {
// Nur autostart wenn Daemon erreichbar ist — sonst landen
// alle Services in Error, Auto-Recovery spinnt nutzlos und
// der Setup-Wizard kommt nie zum Zug.
let daemon_ok = tokio::process::Command::new("docker")
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if daemon_ok {
events::info("Auto-Start: Container werden hochgefahren ...").await;
if let Err(e) = supervisor::Supervisor::start_all_managed(supervisor_clone.clone()).await {
events::warn(format!("Auto-Start fehlgeschlagen: {e}")).await;
}
} else {
events::info("Auto-Start uebersprungen — Docker-Daemon nicht erreichbar (Setup noetig?)").await;
}
}
});
// Pre-pull aller Compose-Images im Hintergrund. Erste Klicks
// sollen nicht hinter einem 150-MB-Pull haengen bleiben.
tauri::async_runtime::spawn(async move {
events::info("Pre-pull Docker-Images gestartet").await;
let out = tokio::process::Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "pull"])
.output()
.await;
match out {
Ok(o) if o.status.success() => events::info("Pre-pull fertig").await,
Ok(o) => {
events::warn(format!(
"Pre-pull mit Fehlern: {}",
String::from_utf8_lossy(&o.stderr).trim().chars().take(200).collect::<String>()
)).await;
}
Err(e) => events::warn(format!("Pre-pull konnte nicht starten: {e}")).await,
}
});
// Backup-Scheduler — pg_dumpall alle BACKUP_INTERVAL_HOURS Stunden.
tauri::async_runtime::spawn(async move {
backup::scheduler_loop().await;
});
// Container-Update-Scheduler — alle CONTAINER_AUTOUPDATE_INTERVAL_HOURS:
// docker compose pull + (falls Updates) Backup + Compose-Up.
tauri::async_runtime::spawn(async move {
container_update::scheduler_loop().await;
});
// Health-Tick-Loop: alle 2s State aus compose ps, daraus:
// - Tray-Icon-Farbe (gruen/gelb/rot)
// - macOS-Notification fuer NEU in Error gerutschte Services
let supervisor_for_health = supervisor.clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
use tauri_plugin_notification::NotificationExt;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
let mut last_state: Option<TrayStatus> = None;
loop {
interval.tick().await;
let mut sv = supervisor_for_health.lock().await;
let newly_errored = sv.tick_health().await;
let agg = aggregate_status(&sv.list());
// Display-Names holen bevor wir den Lock abgeben.
let error_titles: Vec<(String, String)> = newly_errored
.iter()
.map(|id| (id.clone(), sv.display_name(id).unwrap_or_else(|| id.clone())))
.collect();
drop(sv);
if Some(agg) != last_state {
set_tray_icon(&app_handle, agg);
last_state = Some(agg);
}
for (id, name) in error_titles {
events::error(format!("Service {name} ist in den Fehler-State gewechselt")).await;
let _ = app_handle
.notification()
.builder()
.title("RAPPORT Server — Fehler")
.body(format!("{name} ({id}) ist auf 'unhealthy'/'restarting'."))
.show();
}
}
});
// Auto-Recovery-Loop: alle 15s pruefen ob Services in Error sind die
// jetzt einen Restart-Versuch bekommen sollen (mit exponential backoff).
let supervisor_for_recovery = supervisor.clone();
let app_handle_for_recovery = app.handle().clone();
tauri::async_runtime::spawn(async move {
use tauri_plugin_notification::NotificationExt;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
interval.tick().await; // initial tick verwerfen
loop {
interval.tick().await;
let cfg = config::load().unwrap_or_default();
let enabled = cfg
.get("AUTO_RECOVERY_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
if !enabled {
continue;
}
let base_delay = std::time::Duration::from_secs(
cfg.get("AUTO_RECOVERY_BASE_DELAY_SECONDS")
.and_then(|v| v.parse().ok())
.unwrap_or(60),
);
let max_attempts: u32 = cfg
.get("AUTO_RECOVERY_MAX_ATTEMPTS")
.and_then(|v| v.parse().ok())
.unwrap_or(5);
// Wenn der Daemon gerade aus ist (Wizard noch nicht durch),
// gar nicht erst versuchen — sonst pumpen wir die Versuchs-
// zaehler hoch und geben spaeter auf wenn's eigentlich
// ginge.
let daemon_ok = tokio::process::Command::new("docker")
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if !daemon_ok {
continue;
}
let mut sv = supervisor_for_recovery.lock().await;
let report = sv.recovery_candidates(max_attempts, base_delay);
drop(sv);
for id in &report.to_restart {
events::warn(format!("Auto-Recovery: Restart-Versuch fuer {id}")).await;
let mut sv = supervisor_for_recovery.lock().await;
let display = sv.display_name(id).unwrap_or_else(|| id.clone());
if let Err(e) = sv.restart(id).await {
events::warn(format!(
"Auto-Recovery {display}: {e}"
)).await;
}
}
for id in &report.maxed_out {
events::error(format!("Auto-Recovery aufgegeben fuer {id} — manueller Eingriff noetig")).await;
let sv = supervisor_for_recovery.lock().await;
let display = sv.display_name(id).unwrap_or_else(|| id.clone());
drop(sv);
let _ = app_handle_for_recovery
.notification()
.builder()
.title("RAPPORT Server — Auto-Recovery aufgegeben")
.body(format!(
"{display} ({id}) crasht wiederholt. Bitte manuell pruefen."
))
.show();
}
}
});
// HTTP-Admin-WebUI (LAN-Zugriff fuer headless Mac Mini).
let supervisor_for_http = supervisor.clone();
let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("dist");
tauri::async_runtime::spawn(async move {
let cfg = config::load().unwrap_or_default();
let bind = cfg
.get("ADMIN_UI_BIND")
.cloned()
.unwrap_or_else(|| "127.0.0.1".into());
let port: u16 = cfg
.get("ADMIN_UI_PORT")
.and_then(|p| p.parse().ok())
.unwrap_or(9090);
let password = cfg
.get("ADMIN_UI_PASSWORD")
.cloned()
.unwrap_or_default();
let tls = cfg
.get("ADMIN_UI_TLS")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
if password.is_empty() {
log::warn!("ADMIN_UI_PASSWORD leer — WebUI nicht gestartet");
return;
}
if let Err(e) = http_server::serve(
bind, port, password, tls, supervisor_for_http, static_dir,
)
.await
{
log::error!("http_server: {e}");
}
});
// Tray-Icon mit Show/Quit-Menue. Fenster schliessen reduziert in
// den Tray (Container laufen weiter). Quit aus dem Tray beendet
// die App tatsaechlich.
#[cfg(desktop)]
{
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
let show_item = MenuItem::with_id(app, "show", "Show Dashboard", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit RAPPORT Server", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let _ = TrayIconBuilder::with_id("main-tray")
.tooltip("RAPPORT Server")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event {
if let Some(window) = tray.app_handle().get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
}
Ok(())
})
.on_window_event(|window, event| {
// Fenster schliessen → in den Tray verstecken statt App killen.
// So laufen die Docker-Container weiter, der HTTP-WebUI-Server
// bleibt erreichbar, und der Health-Tick-Loop tickt weiter.
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![
commands::list_services,
commands::start_service,
commands::stop_service,
commands::restart_service,
commands::start_all,
commands::stop_all,
commands::restart_all,
commands::service_logs,
commands::service_status,
commands::backup_now,
commands::list_backups,
commands::restore_backup,
commands::check_container_updates,
commands::apply_container_updates,
commands::list_events,
commands::list_stats,
commands::disk_usage,
commands::firstaid_recreate,
commands::firstaid_reset_pgdata,
commands::firstaid_diagnose,
commands::setup_status,
commands::setup_install,
commands::get_config,
commands::set_config_value,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run()
}
+63
View File
@@ -0,0 +1,63 @@
//! Plattform-spezifische Pfade fuer den App-Data-Bereich.
//!
//! Die App selber liest/schreibt nur in `data_dir()` — Postgres-Volume,
//! Storage-Files, Backups, `config.env`. Container greifen ueber `-v`-Mounts
//! auf diese Pfade zu.
use directories::ProjectDirs;
use std::path::PathBuf;
const QUALIFIER: &str = "com";
const ORG: &str = "rapport";
const APP: &str = "server-app";
/// `~/Library/Application Support/com.rapport.server-app/` (macOS),
/// `~/.local/share/rapport-server-app/` (Linux),
/// `%APPDATA%/rapport/server-app/` (Windows).
pub fn data_dir() -> PathBuf {
ProjectDirs::from(QUALIFIER, ORG, APP)
.map(|d| d.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("./data"))
}
pub fn postgres_data_dir() -> PathBuf {
data_dir().join("postgres")
}
pub fn storage_data_dir() -> PathBuf {
data_dir().join("storage")
}
pub fn logs_dir() -> PathBuf {
data_dir().join("logs")
}
pub fn backups_dir() -> PathBuf {
data_dir().join("backups")
}
pub fn config_env_path() -> PathBuf {
data_dir().join("config.env")
}
pub fn admin_ui_cert_path() -> PathBuf {
data_dir().join("admin-ui-cert.pem")
}
pub fn admin_ui_key_path() -> PathBuf {
data_dir().join("admin-ui-key.pem")
}
/// Erzeugt alle noetigen Verzeichnisse falls sie noch nicht existieren.
pub fn ensure_dirs() -> std::io::Result<()> {
for d in [
data_dir(),
postgres_data_dir(),
storage_data_dir(),
logs_dir(),
backups_dir(),
] {
std::fs::create_dir_all(&d)?;
}
Ok(())
}
+164
View File
@@ -0,0 +1,164 @@
//! Service-Inventar.
//!
//! Die App ist eine duenne UI ueber dem Compose-Stack im
//! `RAPPORT/SERVER-CONTAINER`-Repo. `id` ist hier 1:1 der Compose-Service-Name —
//! der Supervisor bildet daraus `docker compose <id>`-Aufrufe. Image-, Env- und
//! Mount-Konfiguration kommt komplett aus `SERVER-CONTAINER/docker-compose.yml`
//! plus dortiger `.env`.
use crate::health::HealthProbe;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Wird beim App-Start aus Konfig / Auto-Detect gesetzt — danach read-only.
static COMPOSE_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Compose-Projektverzeichnis. Liefert den vorher gesetzten Pfad, oder
/// panickt wenn `init_compose_dir()` noch nicht gelaufen ist.
pub fn compose_dir() -> &'static Path {
COMPOSE_DIR
.get()
.map(|p| p.as_path())
.expect("compose_dir not initialized — call init_compose_dir() first")
}
/// Initialisiert den Compose-Pfad. Reihenfolge:
/// 1. expliziter `override` (z.B. aus `config.env` `COMPOSE_DIR`)
/// 2. Env-Variable `RAPPORT_COMPOSE_DIR`
/// 3. `~/RAPPORT/SERVER-CONTAINER/`
/// 4. `<exe-parent>/../SERVER-CONTAINER/`
/// 5. `<crate-dir>/../../SERVER-CONTAINER/` (Dev-Layout)
///
/// Jeder Kandidat muss eine `docker-compose.yml` enthalten.
pub fn init_compose_dir(override_path: Option<String>) -> Result<&'static Path, String> {
if let Some(p) = COMPOSE_DIR.get() {
return Ok(p.as_path());
}
let candidates = collect_candidates(override_path);
for c in &candidates {
if c.join("docker-compose.yml").exists() {
let _ = COMPOSE_DIR.set(c.clone());
return Ok(COMPOSE_DIR.get().unwrap().as_path());
}
}
Err(format!(
"no docker-compose.yml found — tried: {}",
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
))
}
fn collect_candidates(override_path: Option<String>) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(p) = override_path {
if !p.is_empty() {
out.push(PathBuf::from(p));
}
}
if let Ok(p) = std::env::var("RAPPORT_COMPOSE_DIR") {
out.push(PathBuf::from(p));
}
if let Some(home) = std::env::var_os("HOME") {
// Eigener Dev-Klon hat Vorrang (Source-Edits sollen sichtbar bleiben).
out.push(PathBuf::from(&home).join("RAPPORT/SERVER-CONTAINER"));
// Setup-Wizard-Default: vom Gitea-Tarball nach ~/.rapport/compose/ extrahiert.
out.push(PathBuf::from(&home).join(".rapport/compose"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
out.push(parent.join("../SERVER-CONTAINER"));
}
}
out.push(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("SERVER-CONTAINER"),
);
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDef {
/// Compose-Service-Name. Wird so als Argument an `docker compose` gegeben.
pub id: String,
/// Anzeige-Name fuer die UI.
pub display_name: String,
/// Host-Port fuer die UI-Anzeige + Health-Probe.
pub port: u16,
/// Andere Services die laut Compose vorher laufen sollten — nur
/// informativ fuer Sortierung in der UI. Echte Dep-Order erzwingt Compose.
pub depends_on: Vec<String>,
/// Health-Probe (HTTP/TCP) fuer "echt up" jenseits von `docker ps`.
pub health: HealthProbe,
}
pub fn default_services() -> Vec<ServiceDef> {
vec![
ServiceDef {
id: "db".into(),
display_name: "Postgres".into(),
port: 15432, // Host-Port aus SERVER-CONTAINER/.env (DB_PORT)
depends_on: vec![],
health: HealthProbe::TcpAndQuery {
host: "127.0.0.1".into(),
port: 5432,
db: "postgres".into(),
},
},
ServiceDef {
id: "auth".into(),
display_name: "GoTrue (Auth)".into(),
port: 9999,
depends_on: vec!["db".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:9999/health".into(),
},
},
ServiceDef {
id: "rest".into(),
display_name: "PostgREST".into(),
port: 3000,
depends_on: vec!["db".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:3000/".into(),
},
},
ServiceDef {
id: "realtime".into(),
display_name: "Realtime".into(),
port: 4000,
depends_on: vec!["db".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:4000/api/health".into(),
},
},
ServiceDef {
id: "storage".into(),
display_name: "Storage".into(),
port: 5000,
depends_on: vec!["db".into(), "rest".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:5000/status".into(),
},
},
ServiceDef {
id: "kong".into(),
display_name: "Kong (API-Gateway)".into(),
port: 18000, // KONG_HTTP_PORT
depends_on: vec!["auth".into(), "rest".into(), "realtime".into(), "storage".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:8000/".into(),
},
},
ServiceDef {
id: "app".into(),
display_name: "Frontend (rapport-app)".into(),
port: 18080, // APP_PORT
depends_on: vec!["kong".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:8080/".into(),
},
},
]
}
+537
View File
@@ -0,0 +1,537 @@
//! Setup-Wizard: Detection + Direct-Download-Installation von Docker-CLI,
//! Colima und Lima. Kein Brew, keine externen Package-Manager.
//!
//! Layout:
//! ~/.rapport/
//! ├── bin/
//! │ ├── docker
//! │ ├── docker-compose
//! │ ├── colima
//! │ └── limactl
//! ├── lima-share/ (von Lima-Tarball, share/lima/)
//! └── home/ (HOME-Override fuer Colima/Lima Konfig)
//!
//! `lib.rs::run()` prependet `~/.rapport/bin` an PATH und setzt `LIMA_HOME`
//! etc. damit die Tools die mitgelieferten Files finden.
use serde::Serialize;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
// ---- Pinned Versionen ----------------------------------------------------
const DOCKER_VERSION: &str = "29.5.2";
const COLIMA_VERSION: &str = "v0.10.1";
const LIMA_VERSION: &str = "v2.1.1";
// Quelle fuer den Compose-Stack — wird beim Setup-Wizard automatisch nach
// ~/.rapport/compose/ heruntergeladen wenn lokal noch nichts gefunden wird.
const COMPOSE_TARBALL_URL: &str =
"https://git.kgva.ch/karim/RAPPORT-SERVER/archive/main.tar.gz";
#[derive(Debug, Clone, Serialize)]
pub struct SetupStatus {
pub docker_cli: bool,
pub colima_installed: bool,
pub limactl_installed: bool,
pub daemon_running: bool,
pub ready: bool,
pub recommended_action: RecommendedAction,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RecommendedAction {
/// Alles ok — UI rendert das Dashboard.
NoneReady,
/// Tools alle installiert, nur Daemon ist aus → `colima start`.
StartColima,
/// Tools fehlen → Direct-Download.
InstallAll,
}
pub fn install_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".rapport")
}
pub fn bin_dir() -> PathBuf {
install_dir().join("bin")
}
pub fn compose_dir() -> PathBuf {
install_dir().join("compose")
}
/// Lima erwartet die Templates relativ zur Binary unter `<bin>/../share/lima/`.
/// Wir halten uns an dieses Layout in `~/.rapport/share/`.
fn lima_share_dir() -> PathBuf {
install_dir().join("share")
}
pub async fn status() -> SetupStatus {
let docker_cli = bin_exists("docker") || which("docker").await;
let colima_installed = bin_exists("colima") || which("colima").await;
let limactl_installed = bin_exists("limactl") || which("limactl").await;
let daemon_running = if docker_cli { docker_info_ok().await } else { false };
let ready = daemon_running;
let action = if ready {
RecommendedAction::NoneReady
} else if docker_cli && colima_installed && limactl_installed {
RecommendedAction::StartColima
} else {
RecommendedAction::InstallAll
};
SetupStatus {
docker_cli,
colima_installed,
limactl_installed,
daemon_running,
ready,
recommended_action: action,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallResult {
pub finished_at_iso: String,
pub log: String,
}
pub async fn install_and_start() -> Result<InstallResult, String> {
let s = status().await;
crate::events::info(format!("Setup: Aktion = {:?}", s.recommended_action)).await;
let mut log = String::new();
if matches!(s.recommended_action, RecommendedAction::InstallAll) {
download_all(&mut log).await?;
}
// Compose-Stack-Bootstrap: nur wenn noch nichts gefunden wird (z.B.
// Erst-Install). Wer eine eigene SERVER-CONTAINER-Repo hat (lokaler
// Klon unter ~/RAPPORT/SERVER-CONTAINER/) bleibt unberuehrt.
if let Err(_) = crate::services::init_compose_dir(None) {
bootstrap_compose_stack(&mut log).await?;
}
crate::events::info("Setup: colima start ...").await;
log.push_str("\n=== colima start ===\n");
let colima = pick_path("colima");
let limactl = pick_path("limactl");
let extra_path = format!(
"{}:{}",
bin_dir().display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(&colima)
.args(["start", "--cpu", "2", "--memory", "4", "--disk", "30"])
.env("PATH", &extra_path)
.env("LIMA_HOME", install_dir().join("lima-home"))
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.await
.map_err(|e| format!("spawn colima ({}): {e}", colima.display()))?;
log.push_str(&format!(
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
));
if !out.status.success() {
return Err(format!("colima start failed:\n{log}\n\nlimactl: {}", limactl.display()));
}
let after = status().await;
if !after.daemon_running {
return Err(format!(
"Daemon nach Setup nicht erreichbar. Log:\n{log}"
));
}
crate::events::info("Setup fertig: Daemon laeuft").await;
Ok(InstallResult {
finished_at_iso: chrono::Local::now().to_rfc3339(),
log,
})
}
// ---------------------------------------------------------------------------
// Direct-Download-Sektion
// ---------------------------------------------------------------------------
async fn download_all(log: &mut String) -> Result<(), String> {
let arch = detect_arch()?;
std::fs::create_dir_all(bin_dir()).map_err(|e| format!("mkdir bin: {e}"))?;
std::fs::create_dir_all(lima_share_dir()).map_err(|e| format!("mkdir lima-share: {e}"))?;
// Lima zuerst (Colima braucht es).
if !file_exists(&bin_dir().join("limactl")) {
install_lima(&arch, log).await?;
}
if !file_exists(&bin_dir().join("colima")) {
install_colima(&arch, log).await?;
}
if !file_exists(&bin_dir().join("docker")) {
install_docker(&arch, log).await?;
}
Ok(())
}
struct Arch {
/// `arm64` oder `amd64`
docker_label: &'static str,
/// `Darwin-arm64` oder `Darwin-x86_64`
colima_label: &'static str,
/// `Darwin-arm64` oder `Darwin-x86_64`
lima_label: &'static str,
/// `aarch64` oder `x86_64` (subdir auf download.docker.com)
docker_subdir: &'static str,
}
fn detect_arch() -> Result<Arch, String> {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Ok(Arch {
docker_label: "arm64",
colima_label: "Darwin-arm64",
lima_label: "Darwin-arm64",
docker_subdir: "aarch64",
})
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
Ok(Arch {
docker_label: "amd64",
colima_label: "Darwin-x86_64",
lima_label: "Darwin-x86_64",
docker_subdir: "x86_64",
})
} else {
Err("Setup-Wizard unterstuetzt aktuell nur macOS (Mac Intel + Apple Silicon)".into())
}
}
async fn install_lima(arch: &Arch, log: &mut String) -> Result<(), String> {
let url = format!(
"https://github.com/lima-vm/lima/releases/download/{LIMA_VERSION}/lima-{ver}-{lbl}.tar.gz",
ver = LIMA_VERSION.trim_start_matches('v'),
lbl = arch.lima_label
);
crate::events::info(format!("Setup: lade Lima {LIMA_VERSION} ...")).await;
log.push_str(&format!("=== lima ===\n{url}\n"));
let tmp = tempdir_path("lima.tar.gz");
download_to(&url, &tmp).await?;
let extract_to = install_dir();
extract_tar_gz(&tmp, &extract_to).await?;
// Tarball legt `bin/` und `share/` ab — moven nach unserer Struktur.
let _ = std::fs::rename(extract_to.join("bin/limactl"), bin_dir().join("limactl"));
let _ = std::fs::rename(extract_to.join("share"), lima_share_dir());
let _ = std::fs::remove_dir(extract_to.join("bin"));
let _ = std::fs::remove_file(&tmp);
chmod_x(&bin_dir().join("limactl"))?;
unquarantine(&bin_dir().join("limactl"));
Ok(())
}
async fn install_colima(arch: &Arch, log: &mut String) -> Result<(), String> {
let url = format!(
"https://github.com/abiosoft/colima/releases/download/{COLIMA_VERSION}/colima-{lbl}",
lbl = arch.colima_label
);
crate::events::info(format!("Setup: lade Colima {COLIMA_VERSION} ...")).await;
log.push_str(&format!("\n=== colima ===\n{url}\n"));
let dest = bin_dir().join("colima");
download_to(&url, &dest).await?;
chmod_x(&dest)?;
unquarantine(&dest);
Ok(())
}
async fn install_docker(arch: &Arch, log: &mut String) -> Result<(), String> {
let url = format!(
"https://download.docker.com/mac/static/stable/{sub}/docker-{ver}.tgz",
sub = arch.docker_subdir,
ver = DOCKER_VERSION
);
crate::events::info(format!("Setup: lade Docker-CLI {DOCKER_VERSION} ...")).await;
log.push_str(&format!("\n=== docker ===\n{url}\n"));
let tmp = tempdir_path("docker.tgz");
download_to(&url, &tmp).await?;
let extract_to = tempdir_path("docker-extract");
std::fs::create_dir_all(&extract_to).map_err(|e| format!("mkdir extract: {e}"))?;
extract_tar_gz(&tmp, &extract_to).await?;
// Tarball-Layout: docker/{docker,docker-compose,docker-buildx,...}
let src = extract_to.join("docker");
for bin in &["docker", "docker-compose", "docker-buildx"] {
let from = src.join(bin);
let to = bin_dir().join(bin);
if from.exists() {
let _ = std::fs::rename(&from, &to);
chmod_x(&to)?;
unquarantine(&to);
}
}
let _ = std::fs::remove_dir_all(&extract_to);
let _ = std::fs::remove_file(&tmp);
Ok(())
}
// ---------------------------------------------------------------------------
// Compose-Stack-Bootstrap
// ---------------------------------------------------------------------------
async fn bootstrap_compose_stack(log: &mut String) -> Result<(), String> {
crate::events::info("Setup: lade Compose-Stack von Gitea ...").await;
log.push_str("\n=== compose-stack download ===\n");
let dest = compose_dir();
std::fs::create_dir_all(&dest).map_err(|e| format!("mkdir compose dir: {e}"))?;
let tmp = tempdir_path("compose.tar.gz");
download_to(COMPOSE_TARBALL_URL, &tmp).await?;
// Gitea-Archiv legt einen Top-Level-Ordner an — strip-components=1
let status = Command::new("tar")
.args(["-xzf"])
.arg(&tmp)
.args(["-C"])
.arg(&dest)
.arg("--strip-components=1")
.status()
.await
.map_err(|e| format!("spawn tar: {e}"))?;
if !status.success() {
return Err(format!("tar -xzf {} failed", tmp.display()));
}
let _ = std::fs::remove_file(&tmp);
// .env aus .env.example mit unseren Secrets generieren — sonst startet
// compose nicht.
let env_path = dest.join(".env");
if !env_path.exists() {
let example_path = dest.join(".env.example");
let template = if example_path.exists() {
std::fs::read_to_string(&example_path)
.map_err(|e| format!("read .env.example: {e}"))?
} else {
String::new()
};
let env_content = render_compose_env(&template)?;
std::fs::write(&env_path, env_content).map_err(|e| format!("write .env: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600));
}
crate::events::info(format!(".env generiert: {}", env_path.display())).await;
}
crate::events::info(format!("Compose-Stack bereit unter {}", dest.display())).await;
Ok(())
}
/// Generiert eine `.env` aus dem Template — bekannte Keys mit Werten aus
/// unserer `config.env` oder mit sinnvollen Defaults, unbekannte werden 1:1
/// uebernommen falls schon ein Wert dort steht, sonst leer gelassen.
fn render_compose_env(template: &str) -> Result<String, String> {
let app_cfg = crate::config::load().unwrap_or_default();
let pg_pw = app_cfg
.get("POSTGRES_PASSWORD")
.cloned()
.ok_or_else(|| "POSTGRES_PASSWORD fehlt in config.env".to_string())?;
let jwt = app_cfg
.get("JWT_SECRET")
.cloned()
.ok_or_else(|| "JWT_SECRET fehlt in config.env".to_string())?;
let (anon, service) = generate_supabase_keys(&jwt)?;
let defaults: std::collections::HashMap<&str, String> = [
("POSTGRES_PASSWORD", pg_pw),
("JWT_SECRET", jwt),
("ANON_KEY", anon),
("SERVICE_ROLE_KEY", service),
("SITE_URL", "http://localhost:18080".into()),
("API_EXTERNAL_URL", "http://localhost:18000".into()),
("APP_PORT", "18080".into()),
("KONG_HTTP_PORT", "18000".into()),
("KONG_HTTPS_PORT", "18443".into()),
("DB_PORT", "15432".into()),
("RAPPORT_APP_TAG", "main".into()),
("SMTP_HOST", String::new()),
("SMTP_PORT", "587".into()),
("SMTP_USER", String::new()),
("SMTP_PASS", String::new()),
("SMTP_SENDER_NAME", "RAPPORT".into()),
("SMTP_ADMIN_EMAIL", String::new()),
]
.into_iter()
.collect();
let mut out = String::from("# Auto-generiert vom RAPPORT Server-App Setup-Wizard.\n");
out.push_str("# Werte koennen hier oder im Settings-Tab der App geaendert werden.\n\n");
for line in template.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
out.push_str(line);
out.push('\n');
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
if let Some(val) = defaults.get(key) {
out.push_str(&format!("{key}={val}\n"));
} else {
// Unbekannter Key — 1:1 uebernehmen
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
}
Ok(out)
}
/// Generiert die zwei Standard-Supabase-JWTs ('anon' + 'service_role') die
/// signiert mit `jwt_secret` sind. Beide haben sehr lange Laufzeit (10 Jahre).
fn generate_supabase_keys(jwt_secret: &str) -> Result<(String, String), String> {
use jsonwebtoken::{encode, EncodingKey, Header};
#[derive(serde::Serialize)]
struct Claims {
role: &'static str,
iss: &'static str,
iat: u64,
exp: u64,
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("time: {e}"))?
.as_secs();
let exp = now + 60 * 60 * 24 * 365 * 10; // 10 Jahre
let key = EncodingKey::from_secret(jwt_secret.as_bytes());
let make = |role: &'static str| -> Result<String, String> {
encode(
&Header::default(),
&Claims { role, iss: "supabase", iat: now, exp },
&key,
)
.map_err(|e| format!("encode JWT: {e}"))
};
Ok((make("anon")?, make("service_role")?))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn bin_exists(name: &str) -> bool {
file_exists(&bin_dir().join(name))
}
fn file_exists(p: &std::path::Path) -> bool {
p.is_file()
}
fn pick_path(name: &str) -> PathBuf {
let local = bin_dir().join(name);
if local.is_file() {
local
} else {
PathBuf::from(name) // PATH-Lookup
}
}
async fn which(cmd: &str) -> bool {
match Command::new("which").arg(cmd).output().await {
Ok(o) => o.status.success() && !o.stdout.is_empty(),
Err(_) => false,
}
}
async fn docker_info_ok() -> bool {
let docker = pick_path("docker");
let extra_path = format!(
"{}:{}",
bin_dir().display(),
std::env::var("PATH").unwrap_or_default()
);
match Command::new(&docker)
.arg("info")
.env("PATH", &extra_path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
{
Ok(s) => s.success(),
Err(_) => false,
}
}
fn tempdir_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("rapport-setup-{name}"))
}
async fn download_to(url: &str, dest: &std::path::Path) -> Result<(), String> {
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| format!("reqwest build: {e}"))?
.get(url)
.send()
.await
.map_err(|e| format!("http get {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {} fuer {url}", resp.status()));
}
let mut out = tokio::fs::File::create(dest)
.await
.map_err(|e| format!("create {}: {e}", dest.display()))?;
let bytes = resp
.bytes()
.await
.map_err(|e| format!("read body: {e}"))?;
out.write_all(&bytes)
.await
.map_err(|e| format!("write {}: {e}", dest.display()))?;
out.flush().await.map_err(|e| format!("flush: {e}"))?;
Ok(())
}
async fn extract_tar_gz(tar_path: &std::path::Path, out_dir: &std::path::Path) -> Result<(), String> {
let status = Command::new("tar")
.args(["-xzf"])
.arg(tar_path)
.arg("-C")
.arg(out_dir)
.status()
.await
.map_err(|e| format!("spawn tar: {e}"))?;
if !status.success() {
return Err(format!("tar -xzf {} failed", tar_path.display()));
}
let _ = AsyncReadExt::read(&mut tokio::io::empty(), &mut [0u8; 0]).await; // silence unused
Ok(())
}
fn chmod_x(p: &std::path::Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(p, perms).map_err(|e| format!("chmod {}: {e}", p.display()))?;
}
let _ = p;
Ok(())
}
fn unquarantine(p: &std::path::Path) {
// Macht aus dem GitHub-Download eine "vertrauenswuerdige" Datei, sonst
// blockt macOS Gatekeeper das Ausfuehren beim ersten Mal.
let _ = std::process::Command::new("xattr")
.args(["-d", "com.apple.quarantine"])
.arg(p)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
+85
View File
@@ -0,0 +1,85 @@
//! Container-Live-Stats via `docker stats --no-stream`.
//!
//! Wird auf Demand abgerufen (separater Tauri-Command + HTTP-Endpoint),
//! NICHT in den 2s-Health-Tick eingehaengt — `docker stats` braucht selbst
//! ~500ms-1s und wuerde den UI-Poll ausbremsen.
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct ContainerStats {
/// Compose-Service-ID (z.B. `db`, `auth`) — abgeleitet vom Container-Namen.
pub service_id: String,
pub cpu_percent: f32,
pub mem_bytes: u64,
pub mem_percent: f32,
}
pub async fn collect() -> Vec<ContainerStats> {
let out = Command::new("docker")
.args(["stats", "--no-stream", "--format", "{{json .}}"])
.output()
.await;
let stdout = match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => return vec![],
};
let mut stats = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let name = v.get("Name").and_then(|x| x.as_str()).unwrap_or("");
let Some(service_id) = name.strip_prefix("rapport-").map(str::to_string) else {
continue;
};
// Sonderfall: das Frontend-Image heisst `rapport-server-app` in compose
// (container_name), aber Compose-Service ist `app`. Manuelles Mapping.
let service_id = if service_id == "server-app" {
"app".into()
} else {
service_id
};
let cpu = parse_percent(v.get("CPUPerc").and_then(|x| x.as_str()).unwrap_or(""));
let mem_pct = parse_percent(v.get("MemPerc").and_then(|x| x.as_str()).unwrap_or(""));
let mem_bytes = parse_mem_usage(v.get("MemUsage").and_then(|x| x.as_str()).unwrap_or(""));
stats.push(ContainerStats {
service_id,
cpu_percent: cpu,
mem_bytes,
mem_percent: mem_pct,
});
}
stats
}
fn parse_percent(s: &str) -> f32 {
s.trim_end_matches('%').trim().parse().unwrap_or(0.0)
}
/// "55.32MiB / 7.756GiB" → 55.32 MiB in Bytes
fn parse_mem_usage(s: &str) -> u64 {
let part = s.split('/').next().unwrap_or("").trim();
parse_size(part)
}
fn parse_size(s: &str) -> u64 {
let split_idx = s.find(|c: char| c.is_alphabetic()).unwrap_or(s.len());
let (num_str, unit) = s.split_at(split_idx);
let n: f64 = num_str.parse().unwrap_or(0.0);
let mul = match unit.trim().to_lowercase().as_str() {
"b" => 1.0,
"kib" | "kb" | "k" => 1024.0,
"mib" | "mb" | "m" => 1024.0 * 1024.0,
"gib" | "gb" | "g" => 1024.0 * 1024.0 * 1024.0,
"tib" | "tb" | "t" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => 1.0,
};
(n * mul) as u64
}
+603
View File
@@ -0,0 +1,603 @@
//! Process-Supervisor — duenner Wrapper um `docker compose`.
//!
//! Statt selbst Container-Argumente zu bauen, delegieren wir Start/Stop/Logs
//! an die Compose-Datei in `services::compose_dir()`. Das hat den Vorteil dass
//! Image-/Env-/Volume-Konfiguration nur einmal existiert (im SERVER-CONTAINER-
//! Repo) und nicht hier und dort gepflegt werden muss.
use crate::services::{self, ServiceDef};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
const LOG_RING_CAPACITY: usize = 1000;
/// Globaler Status-Cache fuer schnelles `list_services` ohne den Supervisor-Mutex
/// blockieren zu muessen. Wird von list_with_timeout() gefuellt; bei lang
/// laufenden compose-Calls (z.B. Erst-Start mit Image-Pull) liefert der Cache
/// die letzte bekannte Liste so dass die UI nicht hangs zeigt.
static STATUS_CACHE: std::sync::OnceLock<Arc<Mutex<Vec<ServiceStatus>>>> =
std::sync::OnceLock::new();
fn status_cache() -> &'static Arc<Mutex<Vec<ServiceStatus>>> {
STATUS_CACHE.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
}
/// Versucht den Supervisor-Mutex in `timeout_ms` zu kriegen — wenn nicht,
/// gibt den Cache zurueck. Aktualisiert Cache bei jedem Erfolg.
pub async fn list_with_timeout(
supervisor: &Arc<Mutex<Supervisor>>,
timeout_ms: u64,
) -> Vec<ServiceStatus> {
let timeout = std::time::Duration::from_millis(timeout_ms);
match tokio::time::timeout(timeout, supervisor.lock()).await {
Ok(sv) => {
let list = sv.list();
*status_cache().lock().await = list.clone();
list
}
Err(_) => status_cache().lock().await.clone(),
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ServiceState {
Stopped,
Starting,
Running,
Stopping,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServiceStatus {
pub id: String,
pub display_name: String,
pub state: ServiceState,
pub pid: Option<u32>,
pub port: u16,
pub last_error: Option<String>,
}
struct ServiceEntry {
def: ServiceDef,
state: ServiceState,
log_pump: Option<Child>,
pid: Option<u32>,
last_error: Option<String>,
logs: Arc<Mutex<VecDeque<String>>>,
/// Wann zum erstmals in Error gerutscht — gesetzt bei Transition INTO Error,
/// gecleart wenn raus.
errored_since: Option<Instant>,
recovery_attempts: u32,
/// Wann der naechste Auto-Restart-Versuch faellig ist.
next_recovery_at: Option<Instant>,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct RecoveryReport {
pub to_restart: Vec<String>,
pub maxed_out: Vec<String>,
}
pub struct Supervisor {
services: HashMap<String, ServiceEntry>,
pub activity: Arc<Mutex<String>>,
}
impl Supervisor {
pub fn new() -> Self {
Self {
services: HashMap::new(),
activity: Arc::new(Mutex::new(String::new())),
}
}
pub fn activity_handle(&self) -> Arc<Mutex<String>> {
self.activity.clone()
}
pub async fn current_activity(&self) -> String {
self.activity.lock().await.clone()
}
pub fn register(&mut self, def: ServiceDef) {
let id = def.id.clone();
self.services.insert(
id,
ServiceEntry {
def,
state: ServiceState::Stopped,
log_pump: None,
pid: None,
last_error: None,
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY))),
errored_since: None,
recovery_attempts: 0,
next_recovery_at: None,
},
);
}
pub fn list(&self) -> Vec<ServiceStatus> {
let mut out: Vec<_> = self
.services
.values()
.map(|e| ServiceStatus {
id: e.def.id.clone(),
display_name: e.def.display_name.clone(),
state: e.state,
pid: e.pid,
port: e.def.port,
last_error: e.last_error.clone(),
})
.collect();
out.sort_by(|a, b| a.port.cmp(&b.port));
out
}
pub fn status(&self, id: &str) -> Option<ServiceStatus> {
let e = self.services.get(id)?;
Some(ServiceStatus {
id: e.def.id.clone(),
display_name: e.def.display_name.clone(),
state: e.state,
pid: e.pid,
port: e.def.port,
last_error: e.last_error.clone(),
})
}
pub async fn logs(&self, id: &str) -> Vec<String> {
let Some(e) = self.services.get(id) else { return vec![] };
e.logs.lock().await.iter().cloned().collect()
}
pub async fn start(&mut self, id: &str) -> Result<(), String> {
log::info!("start({id})");
let entry = self
.services
.get_mut(id)
.ok_or_else(|| format!("unknown service: {id}"))?;
if matches!(entry.state, ServiceState::Running | ServiceState::Starting) {
return Ok(());
}
entry.state = ServiceState::Starting;
entry.last_error = None;
let logs_ref = entry.logs.clone();
let result = compose_up(&entry.def.id, logs_ref).await;
let entry = self.services.get_mut(id).unwrap();
match result {
Ok(log_pump) => {
entry.pid = log_pump.id();
entry.log_pump = Some(log_pump);
Ok(())
}
Err(e) => {
entry.state = ServiceState::Error;
entry.last_error = Some(e.clone());
Err(e)
}
}
}
/// Drop-in replacement fuer `start_all` als freie Funktion — haelt den
/// Supervisor-Mutex NUR fuer kurze State-Updates, NICHT waehrend `docker
/// compose up -d`. So bleibt die UI responsive auch beim Erst-Pull.
pub async fn start_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
log::info!("start_all_managed (docker compose up -d, lock-frei waehrend compose)");
crate::events::info("Container werden hochgefahren (kann beim Erst-Start bis zu 1 Min dauern) ...").await;
// 1) Quick lock: mark all Starting + prime Cache
{
let mut sv = arc.lock().await;
sv.mark_all_starting();
*status_cache().lock().await = sv.list();
}
// 2) Slow compose call — KEIN Lock gehalten
let compose_result = compose(&["up", "-d"]).await;
// 3) Quick lock: finalize
let mut sv = arc.lock().await;
match compose_result {
Err(e) => {
sv.mark_all_error(&e);
*status_cache().lock().await = sv.list();
crate::events::error(format!("Start fehlgeschlagen: {e}")).await;
Err(e)
}
Ok(_) => {
sv.ensure_log_pumps().await;
*status_cache().lock().await = sv.list();
drop(sv);
crate::events::info("Alle Services gestartet").await;
Ok(())
}
}
}
pub async fn stop_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
log::info!("stop_all_managed (docker compose down, lock-frei waehrend compose)");
crate::events::info("Alle Services stoppen ...").await;
{
let mut sv = arc.lock().await;
sv.mark_all_stopping().await;
*status_cache().lock().await = sv.list();
}
let res = compose(&["down"]).await;
let mut sv = arc.lock().await;
sv.mark_all_stopped();
*status_cache().lock().await = sv.list();
res.map(|_| ())
}
pub async fn restart_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
log::info!("restart_all_managed (docker compose restart, lock-frei waehrend compose)");
crate::events::info("Alle Services neu starten ...").await;
{
let mut sv = arc.lock().await;
for entry in sv.services.values_mut() {
entry.state = ServiceState::Starting;
entry.last_error = None;
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
}
*status_cache().lock().await = sv.list();
}
let res = compose(&["restart"]).await;
let mut sv = arc.lock().await;
match res {
Err(e) => {
sv.mark_all_error(&e);
*status_cache().lock().await = sv.list();
crate::events::error(format!("Restart-All fehlgeschlagen: {e}")).await;
Err(e)
}
Ok(_) => {
sv.ensure_log_pumps().await;
*status_cache().lock().await = sv.list();
drop(sv);
crate::events::info("Alle Services neu gestartet").await;
Ok(())
}
}
}
pub async fn restart(&mut self, id: &str) -> Result<(), String> {
log::info!("restart({id})");
crate::events::info(format!("Service {id} neu starten ...")).await;
let entry = self
.services
.get_mut(id)
.ok_or_else(|| format!("unknown service: {id}"))?;
entry.state = ServiceState::Starting;
entry.last_error = None;
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
let logs = entry.logs.clone();
let id_clone = entry.def.id.clone();
let res = compose(&["restart", &id_clone]).await;
let entry = self.services.get_mut(id).unwrap();
if let Err(ref e) = res {
entry.state = ServiceState::Error;
entry.last_error = Some(e.clone());
return Err(e.clone());
}
// Log-Pump neu starten — der alte Stream ist tot.
match spawn_log_pump(&id_clone, logs).await {
Ok(lp) => {
entry.pid = lp.id();
entry.log_pump = Some(lp);
}
Err(e) => log::warn!("log pump restart {id_clone}: {e}"),
}
crate::events::info(format!("Service {id} neu gestartet")).await;
Ok(())
}
pub async fn stop(&mut self, id: &str) -> Result<(), String> {
log::info!("stop({id})");
let entry = self
.services
.get_mut(id)
.ok_or_else(|| format!("unknown service: {id}"))?;
if matches!(entry.state, ServiceState::Stopped) {
return Ok(());
}
entry.state = ServiceState::Stopping;
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
let id_clone = entry.def.id.clone();
let _ = compose(&["stop", &id_clone]).await;
entry.pid = None;
entry.state = ServiceState::Stopped;
Ok(())
}
/// Quick: alle Services auf `Starting` setzen + Cache update.
/// Wird vor dem langen compose-Call aufgerufen damit die UI nicht hangs sieht.
fn mark_all_starting(&mut self) {
for entry in self.services.values_mut() {
entry.state = ServiceState::Starting;
entry.last_error = None;
}
}
/// Quick: alle auf `Stopping` setzen + Log-Pumps killen.
async fn mark_all_stopping(&mut self) {
for entry in self.services.values_mut() {
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
entry.state = ServiceState::Stopping;
}
}
/// Quick: alle als Stopped markieren (Endzustand nach compose down).
fn mark_all_stopped(&mut self) {
for entry in self.services.values_mut() {
entry.state = ServiceState::Stopped;
entry.pid = None;
}
}
/// Quick: alle als Error markieren (Endzustand wenn compose-Call schiefging).
fn mark_all_error(&mut self, msg: &str) {
for entry in self.services.values_mut() {
entry.state = ServiceState::Error;
entry.last_error = Some(msg.to_string());
}
}
/// Quick: Log-Pumps fuer alle Services nachstarten falls noch keiner laeuft.
async fn ensure_log_pumps(&mut self) {
let ids: Vec<String> = self.services.keys().cloned().collect();
for id in ids {
let Some(entry) = self.services.get_mut(&id) else { continue };
if entry.log_pump.is_none() {
if let Ok(lp) = spawn_log_pump(&entry.def.id, entry.logs.clone()).await {
entry.pid = lp.id();
entry.log_pump = Some(lp);
}
}
}
}
/// Fragt `docker compose ps` nach dem aktuellen Stand und uebernimmt
/// die State-Machine daraus. Liefert die Liste der Services die in
/// diesem Tick NEU in den Error-State gewechselt sind — der Caller
/// dispatched daraus z.B. eine macOS-Notification.
pub async fn tick_health(&mut self) -> Vec<String> {
let mut newly_errored = Vec::new();
let statuses = match query_compose_status().await {
Ok(m) => m,
Err(e) => {
log::warn!("compose ps failed: {e}");
return newly_errored;
}
};
let ids: Vec<String> = self.services.keys().cloned().collect();
for id in ids {
let Some(e) = self.services.get_mut(&id) else { continue };
let cs = statuses.get(&id);
let new_state = match cs {
// Container nicht in compose ps:
// - wenn wir gerade auf Starting sind (z.B. compose up haengt
// noch im Pull) → bleibt Starting (sonst flackert die UI)
// - sonst → Stopped
None => {
if e.state == ServiceState::Starting {
ServiceState::Starting
} else {
ServiceState::Stopped
}
}
Some(s) if s.health == Some("unhealthy".to_string()) => ServiceState::Error,
Some(s) if s.state == "restarting" || s.state == "dead" => ServiceState::Error,
Some(s) if s.state == "exited" || s.state == "stopped" || s.state == "removing" => {
ServiceState::Stopped
}
Some(s) if s.state == "created" || s.state == "paused" => ServiceState::Starting,
Some(s) if s.state == "running" => match s.health.as_deref() {
Some("starting") => ServiceState::Starting,
_ => ServiceState::Running,
},
Some(_) => ServiceState::Starting,
};
if e.state != new_state {
let was_error = matches!(e.state, ServiceState::Error);
if new_state == ServiceState::Error {
if let Some(s) = cs {
e.last_error = Some(format!("{}: {}", s.state, s.health.clone().unwrap_or_default()));
}
if !was_error {
// Transition INTO Error: Recovery-Counter starten.
e.errored_since = Some(Instant::now());
e.recovery_attempts = 0;
// Erste Recovery erst nach base-delay (lib.rs setzt das spaeter neu).
e.next_recovery_at = Some(Instant::now() + Duration::from_secs(60));
newly_errored.push(id.clone());
}
} else {
// Out of Error: Recovery-State resetten.
e.errored_since = None;
e.recovery_attempts = 0;
e.next_recovery_at = None;
if matches!(new_state, ServiceState::Running | ServiceState::Stopped) {
e.last_error = None;
}
}
e.state = new_state;
}
}
newly_errored
}
/// Display-Name fuer eine Service-ID (fuer Notifications, UI etc.).
pub fn display_name(&self, id: &str) -> Option<String> {
self.services.get(id).map(|e| e.def.display_name.clone())
}
/// Bewertet welche Services jetzt einen Recovery-Restart bekommen sollen.
/// Inkrementiert dabei den Versuchszaehler atomisch und plant den naechsten
/// Versuch via exponential backoff. Wenn `max_attempts` erreicht ist,
/// landet die Service-ID in `maxed_out` und es wird nicht weiter versucht.
pub fn recovery_candidates(
&mut self,
max_attempts: u32,
base_delay: Duration,
) -> RecoveryReport {
let now = Instant::now();
let mut report = RecoveryReport::default();
for entry in self.services.values_mut() {
if !matches!(entry.state, ServiceState::Error) {
continue;
}
if entry.recovery_attempts >= max_attempts {
continue; // bereits aufgegeben — keine Doppel-Notification
}
let due = entry.next_recovery_at.map(|t| now >= t).unwrap_or(true);
if !due {
continue;
}
entry.recovery_attempts += 1;
if entry.recovery_attempts >= max_attempts {
entry.next_recovery_at = None;
report.maxed_out.push(entry.def.id.clone());
} else {
// exponential backoff: 1×, 2×, 4×, 8×, ... base_delay (capped 8x)
let exp = (entry.recovery_attempts.saturating_sub(1)).min(8);
let backoff = base_delay.saturating_mul(1u32 << exp);
entry.next_recovery_at = Some(now + backoff);
}
report.to_restart.push(entry.def.id.clone());
}
report
}
}
// ---------------------------------------------------------------------------
// Compose helpers
// ---------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize)]
struct ComposeStatusRaw {
#[serde(rename = "Service")]
service: String,
#[serde(rename = "State")]
state: String,
#[serde(rename = "Health", default)]
health: String,
}
struct ComposeStatus {
state: String,
health: Option<String>,
}
async fn query_compose_status() -> Result<HashMap<String, ComposeStatus>, String> {
let output = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "ps", "-a", "--format", "json"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let mut out = HashMap::new();
// Compose gibt JSON-Lines aus (ein Objekt pro Zeile), nicht ein Array.
for line in String::from_utf8_lossy(&output.stdout).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<ComposeStatusRaw>(line) {
Ok(raw) => {
out.insert(
raw.service,
ComposeStatus {
state: raw.state,
health: if raw.health.is_empty() {
None
} else {
Some(raw.health)
},
},
);
}
Err(e) => log::warn!("compose ps json parse: {e} | line={line}"),
}
}
Ok(out)
}
async fn compose(args: &[&str]) -> Result<std::process::Output, String> {
let mut cmd = Command::new("docker");
cmd.current_dir(services::compose_dir()).arg("compose");
for a in args {
cmd.arg(a);
}
let output = cmd
.output()
.await
.map_err(|e| format!("spawn docker compose: {e}"))?;
if !output.status.success() {
return Err(format!(
"docker compose {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(output)
}
async fn compose_up(service: &str, logs: Arc<Mutex<VecDeque<String>>>) -> Result<Child, String> {
compose(&["up", "-d", service]).await?;
spawn_log_pump(service, logs).await
}
async fn spawn_log_pump(
service: &str,
logs: Arc<Mutex<VecDeque<String>>>,
) -> Result<Child, String> {
let mut cmd = Command::new("docker");
cmd.current_dir(services::compose_dir())
.args(["compose", "logs", "-f", "--no-log-prefix", service])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| format!("spawn docker compose logs: {e}"))?;
if let Some(stdout) = child.stdout.take() {
tokio::spawn(pump_lines(stdout, logs.clone()));
}
if let Some(stderr) = child.stderr.take() {
tokio::spawn(pump_lines(stderr, logs));
}
Ok(child)
}
async fn pump_lines<R: tokio::io::AsyncRead + Unpin + Send + 'static>(
reader: R,
logs: Arc<Mutex<VecDeque<String>>>,
) {
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut buf = logs.lock().await;
if buf.len() >= LOG_RING_CAPACITY {
buf.pop_front();
}
buf.push_back(line);
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "RAPPORT Server",
"version": "0.1.2",
"identifier": "com.rapport.server-app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:3001",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "RAPPORT Server",
"width": 1100,
"height": 760,
"minWidth": 760,
"minHeight": 500,
"decorations": true,
"resizable": true,
"fullscreen": false
}
],
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true,
"menuOnLeftClick": false,
"tooltip": "RAPPORT Server"
},
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"category": "DeveloperTool",
"shortDescription": "Doppelklick-Self-Hosting f\u00fcr Rapport",
"longDescription": "Admin-UI f\u00fcr den Rapport-Server-Stack. Verwaltet Postgres, GoTrue, PostgREST, Realtime, Storage, Kong und nginx als Docker-Container. Setzt einen lokalen Docker-Daemon voraus (Colima / OrbStack / Docker Desktop).",
"copyright": "\u00a9 2026 Karim Gabriele Varano \u2014 AGPL-3.0-or-later",
"resources": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"endpoints": [
"https://git.kgva.ch/karim/RAPPORT-SERVER-APP/raw/branch/main/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQzM0U0NjNEQTE4MzFGOUEKUldTYUg0T2hQVVkrUS95Z3JXdmJQVWxkejhQNHFHWkswVndmMVpPV01TZ3NvWVo5UkZlQ1kwOUoK"
}
}
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react'
import { api } from './api.js'
import ServiceCard from './components/ServiceCard.jsx'
import LogViewer from './components/LogViewer.jsx'
import BackupPanel from './components/BackupPanel.jsx'
import SettingsPanel from './components/SettingsPanel.jsx'
import FirstAidPanel from './components/FirstAidPanel.jsx'
import AppUpdateBanner from './components/AppUpdateBanner.jsx'
import EventFeed from './components/EventFeed.jsx'
import SetupWizard from './components/SetupWizard.jsx'
const TABS = ['status', 'logs', 'backup', 'firstaid', 'settings']
const TAB_LABELS = {
status: 'Status',
logs: 'Logs',
backup: 'Backup',
firstaid: 'Erste Hilfe',
settings: 'Settings',
}
const TAB_ICONS = {
status: 'dashboard',
logs: 'description',
backup: 'backup',
firstaid: 'medical_services',
settings: 'settings',
}
export default function App() {
const [tab, setTab] = useState('status')
const [services, setServices] = useState([])
const [stats, setStats] = useState({}) // service_id → ContainerStats
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [setupReady, setSetupReady] = useState(null) // null = pruefe, true = bereit, false = Wizard
async function refresh() {
try {
setServices(await api.listServices())
setError(null)
} catch (e) {
setError(String(e))
}
}
async function refreshStats() {
try {
const list = await api.listStats()
const m = {}
for (const s of list ?? []) m[s.service_id] = s
setStats(m)
} catch { /* leise — docker stats kann mal scheitern */ }
}
useEffect(() => {
// Erst Setup-Status pruefen — wenn nicht ready, blockt der Wizard die UI.
let cancelled = false
async function checkSetup() {
try {
const s = await api.setupStatus()
if (!cancelled) setSetupReady(s.ready)
} catch { if (!cancelled) setSetupReady(false) }
}
checkSetup()
return () => { cancelled = true }
}, [])
useEffect(() => {
if (!setupReady) return
refresh()
const t = setInterval(refresh, 2000)
return () => clearInterval(t)
}, [setupReady])
useEffect(() => {
if (!setupReady) return
refreshStats()
const t = setInterval(refreshStats, 5000)
return () => clearInterval(t)
}, [setupReady])
async function startAll() {
setBusy(true)
try { await api.startAll() } catch (e) { setError(String(e)) }
finally { setBusy(false); refresh() }
}
async function stopAll() {
setBusy(true)
try { await api.stopAll() } catch (e) { setError(String(e)) }
finally { setBusy(false); refresh() }
}
async function restartAll() {
setBusy(true)
try { await api.restartAll() } catch (e) { setError(String(e)) }
finally { setBusy(false); refresh() }
}
const allRunning = services.length > 0 && services.every(s => s.state === 'running')
const anyRunning = services.some(s => s.state === 'running' || s.state === 'starting')
// Setup blockt das normale Dashboard bis Docker-Daemon erreichbar ist.
if (setupReady === false) {
return (
<div className="app">
<SetupWizard onReady={() => setSetupReady(true)} />
</div>
)
}
if (setupReady === null) {
return <div className="app"><p className="muted" style={{ padding: 40 }}>Lade ...</p></div>
}
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="brand-dot" data-state={allRunning ? 'running' : (anyRunning ? 'partial' : 'stopped')} />
<span className="brand-name">RAPPORT Server</span>
</div>
<nav className="tabs">
{TABS.map(t => (
<button key={t}
className={t === tab ? 'tab active' : 'tab'}
onClick={() => setTab(t)}>
<span className="material-symbols-outlined tab-icon">{TAB_ICONS[t]}</span>
{TAB_LABELS[t] ?? t}
</button>
))}
</nav>
<div className="actions">
<button onClick={startAll} disabled={busy || allRunning}>Alle starten</button>
<button onClick={restartAll} disabled={busy || !anyRunning} title="Alle Container neu starten">Alle neu starten</button>
<button onClick={stopAll} disabled={busy || !anyRunning}>Alle stoppen</button>
</div>
</header>
<AppUpdateBanner />
{error && <div className="error-banner">{error}</div>}
<main className="content">
{tab === 'status' && (
<>
<div className="grid">
{services.length === 0 && <p className="muted">Keine Services registriert.</p>}
{services.map(s => (
<ServiceCard
key={s.id}
service={s}
stats={stats[s.id]}
onStart={() => api.startService(s.id).then(refresh)}
onStop={() => api.stopService(s.id).then(refresh)}
onRestart={() => api.restartService(s.id).then(refresh)}
/>
))}
</div>
<EventFeed limit={8} />
</>
)}
{tab === 'logs' && <LogViewer services={services} />}
{tab === 'backup' && <BackupPanel />}
{tab === 'firstaid' && <FirstAidPanel />}
{tab === 'settings' && <SettingsPanel />}
</main>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
// API-Wrapper. In der Tauri-WebView: via IPC (`invoke`). Im Browser (HTTP-WebUI
// vom headless Mac Mini): via fetch — Basic-Auth-Cookie/Header verwaltet der
// Browser selbst nach der ersten Login-Prompt.
const inTauri = typeof window !== 'undefined' && (
'__TAURI_INTERNALS__' in window || '__TAURI__' in window
)
let invoke = null
if (inTauri) {
const mod = await import('@tauri-apps/api/core')
invoke = mod.invoke
}
async function fetchJson(path, opts = {}) {
const method = (opts.method ?? 'GET').toUpperCase()
const writing = method !== 'GET' && method !== 'HEAD'
const resp = await fetch(`/api${path}`, {
...opts,
headers: {
'content-type': 'application/json',
// CSRF: state-changing Requests muessen den Custom-Header tragen.
// Cross-origin Forms koennen ihn nicht setzen, cross-origin fetch
// triggert Preflight, der serverseitig nicht erlaubt ist.
...(writing ? { 'x-rapport-csrf': '1' } : {}),
...(opts.headers ?? {}),
},
})
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`)
}
const text = await resp.text()
if (!text) return null
try { return JSON.parse(text) } catch { return text }
}
export const api = inTauri
? {
listServices: () => invoke('list_services'),
serviceStatus: (id) => invoke('service_status', { id }),
serviceLogs: (id) => invoke('service_logs', { id }),
startService: (id) => invoke('start_service', { id }),
stopService: (id) => invoke('stop_service', { id }),
restartService: (id) => invoke('restart_service', { id }),
startAll: () => invoke('start_all'),
stopAll: () => invoke('stop_all'),
restartAll: () => invoke('restart_all'),
getConfig: () => invoke('get_config'),
setConfigValue: (key, value) => invoke('set_config_value', { key, value }),
backupNow: () => invoke('backup_now'),
listBackups: () => invoke('list_backups'),
restoreBackup: (filename) => invoke('restore_backup', { filename }),
checkContainerUpdates: () => invoke('check_container_updates'),
applyContainerUpdates: () => invoke('apply_container_updates'),
listEvents: () => invoke('list_events'),
listStats: () => invoke('list_stats'),
diskUsage: () => invoke('disk_usage'),
firstaidRecreate: () => invoke('firstaid_recreate'),
firstaidResetPgdata: () => invoke('firstaid_reset_pgdata'),
firstaidDiagnose: () => invoke('firstaid_diagnose'),
setupStatus: () => invoke('setup_status'),
setupInstall: () => invoke('setup_install'),
}
: {
listServices: () => fetchJson('/services'),
serviceStatus: async (id) => {
const list = await fetchJson('/services')
return list.find(s => s.id === id) ?? null
},
serviceLogs: (id) => fetchJson(`/services/${id}/logs`),
startService: (id) => fetchJson(`/services/${id}/start`, { method: 'POST' }),
stopService: (id) => fetchJson(`/services/${id}/stop`, { method: 'POST' }),
restartService: (id) => fetchJson(`/services/${id}/restart`, { method: 'POST' }),
startAll: () => fetchJson('/services/start-all', { method: 'POST' }),
stopAll: () => fetchJson('/services/stop-all', { method: 'POST' }),
restartAll: () => fetchJson('/services/restart-all', { method: 'POST' }),
getConfig: () => Promise.resolve({}),
setConfigValue: () => Promise.resolve(null),
backupNow: () => fetchJson('/backups/now', { method: 'POST' }),
listBackups: () => fetchJson('/backups'),
restoreBackup: (filename) => fetchJson(`/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' }),
checkContainerUpdates: () => fetchJson('/container-updates/check', { method: 'POST' }),
applyContainerUpdates: () => fetchJson('/container-updates/apply', { method: 'POST' }),
listEvents: () => fetchJson('/events'),
listStats: () => fetchJson('/stats'),
diskUsage: () => fetchJson('/disk'),
firstaidRecreate: () => fetchJson('/firstaid/recreate', { method: 'POST' }),
firstaidResetPgdata: () => fetchJson('/firstaid/reset-pgdata', { method: 'POST' }),
firstaidDiagnose: () => fetchJson('/firstaid/diagnose', { method: 'POST' }),
setupStatus: () => fetchJson('/setup/status'),
setupInstall: () => fetchJson('/setup/install', { method: 'POST' }),
}
export const runtime = inTauri ? 'tauri' : 'browser'
+182
View File
@@ -0,0 +1,182 @@
// Auto-Update-Banner. Pollt latest.json alle 6h.
// Bei Klick: Modal-Overlay mit Live-Fortschritt + sichtbare Errors.
import { useEffect, useState } from 'react'
import { runtime, api } from '../api.js'
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000
export default function AppUpdateBanner() {
const [update, setUpdate] = useState(null)
const [showModal, setShowModal] = useState(false)
const [step, setStep] = useState('idle') // idle|backup|download|install|done|error
const [progress, setProgress] = useState({ downloaded: 0, total: 0 })
const [error, setError] = useState(null)
useEffect(() => {
if (runtime !== 'tauri') return
let alive = true
let timer = null
async function check() {
try {
const { check } = await import('@tauri-apps/plugin-updater')
const u = await check()
if (alive) setUpdate(u)
} catch (e) {
if (alive) console.warn('updater check:', e)
}
}
check()
timer = setInterval(check, CHECK_INTERVAL_MS)
return () => { alive = false; if (timer) clearInterval(timer) }
}, [])
async function install() {
if (!update) return
setShowModal(true)
setError(null)
setProgress({ downloaded: 0, total: 0 })
try {
setStep('backup')
console.log('[update] starting pre-backup')
const backup = await api.backupNow()
console.log('[update] backup ok:', backup)
setStep('download')
console.log('[update] starting download + install')
let totalBytes = 0
let downloaded = 0
await update.downloadAndInstall((event) => {
console.log('[update] event:', event.event, event.data ?? '')
if (event.event === 'Started') {
totalBytes = event.data?.contentLength ?? 0
setProgress({ downloaded: 0, total: totalBytes })
} else if (event.event === 'Progress') {
downloaded += event.data?.chunkLength ?? 0
setProgress({ downloaded, total: totalBytes })
} else if (event.event === 'Finished') {
setStep('install')
setProgress({ downloaded: totalBytes, total: totalBytes })
}
})
// Tauri restartet automatisch — sollten wir nie sehen
setStep('done')
} catch (e) {
console.error('[update] failed:', e)
setStep('error')
setError(String(e?.message ?? e))
}
}
function close() {
if (step === 'idle' || step === 'error' || step === 'done') {
setShowModal(false)
setStep('idle')
setError(null)
}
}
if (!update) return null
return (
<>
<div className="update-banner">
<span className="material-symbols-outlined update-bell">system_update</span>
<span className="update-text">
Update verfuegbar: <strong>v{update.version}</strong>
{update.body && <span className="muted"> · {update.body.split('\n')[0]}</span>}
</span>
<button onClick={() => setShowModal(true)} disabled={showModal && step !== 'idle' && step !== 'error'}>
Backup + Installieren
</button>
</div>
{showModal && (
<div className="update-modal-backdrop" onClick={step === 'idle' ? close : undefined}>
<div className="update-modal" onClick={e => e.stopPropagation()}>
<h2>Update auf v{update.version}</h2>
{step === 'idle' && (
<>
<p className="muted">Ablauf:</p>
<ol className="update-steps">
<li>pg_dumpall Pre-Backup (~10 Sek)</li>
<li>Neue Binary herunterladen + Signatur pruefen (~10-30 Sek)</li>
<li>App-Restart mit neuer Version</li>
</ol>
<p className="muted">
Container laufen waehrend Restart weiter keine Downtime auf der
Service-Seite. Admin-UI ist ~10s nicht verfuegbar.
</p>
<div className="row" style={{ marginTop: 20, justifyContent: 'flex-end' }}>
<button onClick={close}>Abbrechen</button>
<button onClick={install} className="setup-primary">Backup + Installieren</button>
</div>
</>
)}
{step !== 'idle' && (
<div className="update-progress">
<Step name="Pre-Backup" state={stepState('backup', step)} />
<Step name="Download" state={stepState('download', step)} extra={progressLabel(progress, step)} />
<Step name="Installation" state={stepState('install', step)} />
</div>
)}
{step === 'error' && (
<>
<p className="error-text" style={{ marginTop: 16 }}>
<strong>Fehler:</strong>
</p>
<pre className="update-error-pre">{error}</pre>
<div className="row" style={{ marginTop: 16, justifyContent: 'flex-end' }}>
<button onClick={close}>Schliessen</button>
</div>
</>
)}
{step === 'done' && (
<p className="success-text">App restartet jetzt mit der neuen Version ...</p>
)}
</div>
</div>
)}
</>
)
}
function stepState(target, current) {
const order = ['idle', 'backup', 'download', 'install', 'done', 'error']
const ti = order.indexOf(target)
const ci = order.indexOf(current)
if (current === 'error' && ci > ti) return 'error'
if (ci > ti) return 'done'
if (ci === ti) return 'active'
return 'pending'
}
function progressLabel(p, step) {
if (step !== 'download') return ''
if (p.total === 0) return 'startet ...'
const mb = (b) => (b / 1024 / 1024).toFixed(1) + ' MB'
const pct = p.total > 0 ? Math.floor((p.downloaded / p.total) * 100) : 0
return `${mb(p.downloaded)} / ${mb(p.total)} (${pct}%)`
}
function Step({ name, state, extra }) {
const icon = {
pending: 'radio_button_unchecked',
active: 'progress_activity',
done: 'check_circle',
error: 'cancel',
}[state]
return (
<div className="update-step" data-state={state}>
<span className="material-symbols-outlined update-step-icon">{icon}</span>
<span className="update-step-name">{name}</span>
{extra && <span className="update-step-extra muted">{extra}</span>}
</div>
)
}
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtBytes(n) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
function fmtDate(iso) {
try {
const d = new Date(iso)
return d.toLocaleString('de-CH', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
} catch { return iso }
}
function fmtAge(iso) {
const ms = Date.now() - new Date(iso).getTime()
const h = Math.floor(ms / 3_600_000)
if (h < 1) return `vor ${Math.floor(ms / 60_000)} min`
if (h < 48) return `vor ${h} h`
return `vor ${Math.floor(h / 24)} Tagen`
}
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [lastResult, setLastResult] = useState(null)
const [updateCheck, setUpdateCheck] = useState(null)
const [updateBusy, setUpdateBusy] = useState(false)
const [updateError, setUpdateError] = useState(null)
const [applyResult, setApplyResult] = useState(null)
const [disk, setDisk] = useState(null)
async function refresh() {
try {
const list = await api.listBackups()
setBackups(list ?? [])
} catch (e) {
setError(String(e))
}
}
useEffect(() => {
refresh()
const t = setInterval(refresh, 30000)
return () => clearInterval(t)
}, [])
useEffect(() => {
async function loadDisk() {
try { setDisk(await api.diskUsage()) } catch { /* leise */ }
}
loadDisk()
const t = setInterval(loadDisk, 60000) // disk-Check teuer (psql + df), 1x/min reicht
return () => clearInterval(t)
}, [])
async function runBackup() {
setBusy(true); setError(null); setLastResult(null)
try {
const info = await api.backupNow()
setLastResult(info)
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
async function checkUpdates() {
setUpdateBusy(true); setUpdateError(null); setApplyResult(null)
try {
const res = await api.checkContainerUpdates()
setUpdateCheck(res)
} catch (e) {
setUpdateError(String(e))
} finally {
setUpdateBusy(false)
}
}
async function runRestore(filename) {
const ok = window.confirm(
`Snapshot ${filename} wiederherstellen?\n\n` +
'Achtung — DESTRUKTIV:\n' +
' 1. Sicherheits-Backup vom aktuellen Stand wird automatisch erstellt\n' +
' 2. Services (auth, rest, realtime, storage, kong, app) werden gestoppt\n' +
' 3. Komplette Datenbank wird ueberschrieben mit dem Snapshot-Inhalt\n' +
' 4. Services werden wieder gestartet\n\n' +
'Aktuelle Daten gehen verloren (sind aber im Safety-Backup gesichert).'
)
if (!ok) return
setBusy(true); setError(null); setLastResult(null)
try {
const res = await api.restoreBackup(filename)
setLastResult({
filename: `Restore aus ${res.restored_from}`,
bytes: 0,
})
// Auch das Safety-Backup im Banner anzeigen:
setTimeout(() => {
alert(
`Restore fertig.\n\n` +
`Safety-Backup vom alten Stand: ${res.safety_backup}\n\n` +
`Falls etwas nicht stimmt: das Safety-Backup wiederherstellen ` +
`bringt den Zustand vor dem Restore zurueck.`
)
}, 100)
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
async function applyUpdates() {
const count = updateCheck?.updates?.length ?? 0
if (count > 0) {
const ok = window.confirm(
`${count} Container-Update(s) anwenden?\n\n` +
'Ablauf:\n' +
' 1. pg_dumpall Backup\n' +
' 2. docker compose up -d (recreate)\n\n' +
'Services sind waehrend Recreate kurz weg.'
)
if (!ok) return
}
setUpdateBusy(true); setUpdateError(null); setApplyResult(null)
try {
const res = await api.applyContainerUpdates()
setApplyResult(res)
setUpdateCheck(null)
await refresh()
} catch (e) {
setUpdateError(String(e))
} finally {
setUpdateBusy(false)
}
}
const usedPct = disk && disk.host_total_bytes > 0
? (1 - disk.host_free_bytes / disk.host_total_bytes) * 100
: 0
const diskWarn = usedPct >= 80
const diskCrit = usedPct >= 90
return (
<section className="panel">
<h2>Backup &amp; Restore</h2>
{disk && (
<div className="disk-panel" data-state={diskCrit ? 'crit' : diskWarn ? 'warn' : 'ok'}>
<div className="disk-row">
<span className="disk-label">Festplatte ({fmtBytes(disk.host_free_bytes)} frei von {fmtBytes(disk.host_total_bytes)})</span>
<div className="disk-bar">
<div className="disk-bar-fill" style={{ width: `${usedPct}%` }} data-state={diskCrit ? 'crit' : diskWarn ? 'warn' : 'ok'} />
</div>
<span className="disk-pct mono">{usedPct.toFixed(0)}%</span>
</div>
<div className="disk-meta">
<span>Postgres-DB: <strong>{disk.postgres_db_bytes != null ? fmtBytes(disk.postgres_db_bytes) : '—'}</strong></span>
<span>Backups: <strong>{fmtBytes(disk.backups_total_bytes)}</strong> ({disk.backup_count} Dateien)</span>
<span>Docker-Volumes total: <strong>{disk.docker_volumes_bytes != null ? fmtBytes(disk.docker_volumes_bytes) : '—'}</strong></span>
</div>
{diskCrit && <p className="error-text"><span className="material-symbols-outlined icon-inline">error</span>Disk &gt; 90% voll aelteste Backups manuell pruefen.</p>}
{diskWarn && !diskCrit && <p style={{ color: 'var(--amber)' }}><span className="material-symbols-outlined icon-inline">warning</span>Disk &gt; 80% voll.</p>}
</div>
)}
<p className="muted">
Snapshots werden automatisch alle <code>BACKUP_INTERVAL_HOURS</code> (Default 24h) erstellt
und nach <code>BACKUP_RETENTION_COUNT</code> (Default 7) aelteste-zuerst geprunet.
Speicherort: <code>~/Library/Application&nbsp;Support/com.rapport.server-app/backups/</code>.
Methode: <code>pg_dumpall</code> via <code>docker compose exec db</code>.
</p>
<div className="row">
<button onClick={runBackup} disabled={busy}>
{busy ? 'Backup laeuft ...' : 'Backup jetzt erstellen'}
</button>
<button onClick={refresh} disabled={busy} title="Liste neu laden">
Aktualisieren
</button>
</div>
{lastResult && (
<p className="success-text">
Neues Backup: <code>{lastResult.filename}</code> ({fmtBytes(lastResult.bytes)})
</p>
)}
{error && <p className="error-text">{error}</p>}
<h3 style={{ marginTop: 32 }}>Container-Updates</h3>
<p className="muted">
Prueft ob neuere Image-Tags fuer die Compose-Services verfuegbar sind.
Anwenden macht erst Pre-Backup, dann <code>docker compose up -d</code> (recreate).
Auto-Update kontrolliert via <code>CONTAINER_AUTOUPDATE_ENABLED</code>.
</p>
<div className="row">
<button onClick={checkUpdates} disabled={updateBusy}>
{updateBusy && !applyResult ? 'Pruefe ...' : 'Auf Updates pruefen'}
</button>
{updateCheck && updateCheck.updates.length > 0 && (
<button onClick={applyUpdates} disabled={updateBusy}>
{updateBusy ? 'Anwenden ...' : `${updateCheck.updates.length} Update(s) mit Backup anwenden`}
</button>
)}
</div>
{updateError && <p className="error-text">{updateError}</p>}
{updateCheck && updateCheck.updates.length === 0 && (
<p className="success-text">Alle Images sind aktuell.</p>
)}
{updateCheck && updateCheck.updates.length > 0 && (
<ul className="muted" style={{ fontSize: 13 }}>
{updateCheck.updates.map(u => (
<li key={u.service}>
<strong>{u.service}</strong> ({u.image})
<span style={{ fontFamily: 'ui-monospace,monospace', fontSize: 11, marginLeft: 8 }}>
{u.old_id.slice(0, 19)} {u.new_id.slice(0, 19)}
</span>
</li>
))}
</ul>
)}
{applyResult && applyResult.updated_services.length > 0 && (
<p className="success-text">
Update angewendet: {applyResult.updated_services.join(', ')}
{applyResult.backup_filename && (
<> · Backup: <code>{applyResult.backup_filename}</code></>
)}
</p>
)}
{applyResult && applyResult.updated_services.length === 0 && (
<p className="muted">{applyResult.recreate_log}</p>
)}
<h3 style={{ marginTop: 24 }}>Vorhandene Snapshots ({backups.length})</h3>
{backups.length === 0 ? (
<p className="muted">Noch keine Backups vorhanden.</p>
) : (
<table className="backup-table">
<thead>
<tr>
<th>Datei</th>
<th>Erstellt</th>
<th>Alter</th>
<th style={{ textAlign: 'right' }}>Groesse</th>
<th></th>
</tr>
</thead>
<tbody>
{backups.map(b => (
<tr key={b.filename}>
<td className="mono">{b.filename}</td>
<td>{fmtDate(b.created_iso)}</td>
<td className="muted">{fmtAge(b.created_iso)}</td>
<td style={{ textAlign: 'right' }}>{fmtBytes(b.bytes)}</td>
<td style={{ textAlign: 'right' }}>
<button
className="reveal-toggle"
onClick={() => runRestore(b.filename)}
disabled={busy}
title="Diesen Snapshot wiederherstellen"
>
Wiederherstellen
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)
}
+69
View File
@@ -0,0 +1,69 @@
// Kompakter Event-Feed fuer den Status-Tab.
// Pollt alle 3s die letzten App-Events (Start, Backup, Updates, etc.).
// Default eingeklappt — zeigt nur den neuesten Event als Teaser im Header.
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTime(iso) {
try {
return new Date(iso).toLocaleTimeString('de-CH', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
} catch { return iso }
}
const KIND_ICON = {
info: 'circle',
warn: 'warning',
error: 'error',
}
export default function EventFeed({ limit = 8 }) {
const [events, setEvents] = useState([])
const [open, setOpen] = useState(false)
useEffect(() => {
let alive = true
async function tick() {
try {
const list = await api.listEvents()
if (alive) setEvents(list ?? [])
} catch { /* leise */ }
}
tick()
const t = setInterval(tick, 3000)
return () => { alive = false; clearInterval(t) }
}, [])
const latest = events[0]
const visible = events.slice(0, limit)
return (
<div className="event-feed" data-open={open}>
<button className="event-header" onClick={() => setOpen(o => !o)}>
<span className="event-chevron">{open ? '▾' : '▸'}</span>
<span className="event-header-title">App-Events ({events.length})</span>
{!open && latest && (
<span className="event-teaser">
<span className="event-time">{fmtTime(latest.ts_iso)}</span>
<span className="material-symbols-outlined event-icon" data-kind={latest.kind}>{KIND_ICON[latest.kind] ?? 'circle'}</span>
<span className="event-msg">{latest.message}</span>
</span>
)}
</button>
{open && (
<div className="event-body">
{visible.length === 0 ? (
<span className="muted">Noch keine Events.</span>
) : visible.map((e, i) => (
<div key={`${e.ts_iso}-${i}`} className="event-line" data-kind={e.kind}>
<span className="event-time">{fmtTime(e.ts_iso)}</span>
<span className="material-symbols-outlined event-icon" data-kind={e.kind}>{KIND_ICON[e.kind] ?? 'circle'}</span>
<span className="event-msg">{e.message}</span>
</div>
))}
</div>
)}
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
// Erste-Hilfe-Aktionen — drei Eskalationsstufen.
import { useState } from 'react'
import { api } from '../api.js'
export default function FirstAidPanel() {
const [busy, setBusy] = useState(null)
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
async function runRecreate() {
if (!window.confirm(
'Container neu erstellen?\n\n' +
'Was passiert:\n' +
' • docker compose down\n' +
' • docker compose up -d --force-recreate\n\n' +
'Volumes bleiben → Postgres-Daten + Storage-Files sind safe.\n' +
'Services sind ~30s nicht erreichbar.'
)) return
setBusy('recreate'); setError(null); setResult(null)
try {
const r = await api.firstaidRecreate()
setResult({ kind: 'recreate', msg: 'Container neu erstellt.', detail: r.log })
} catch (e) { setError(String(e)) }
finally { setBusy(null) }
}
async function runReset() {
const word = window.prompt(
'PGDATA komplett zuruecksetzen?\n\n' +
'DESTRUKTIV: alle Datenbank-Inhalte gehen verloren.\n' +
'Was passiert:\n' +
' 1. Safety-Backup vom aktuellen Stand (automatisch)\n' +
' 2. docker compose down -v (Volumes weg!)\n' +
' 3. docker compose up -d (frisches initdb)\n\n' +
'Tippe LOESCHEN ein um zu bestaetigen:'
)
if (word !== 'LOESCHEN') {
if (word !== null) alert('Abgebrochen — du musst exakt LOESCHEN tippen.')
return
}
if (!window.confirm('Letzte Warnung: Datenbank wird zurueckgesetzt. Wirklich?')) return
setBusy('reset'); setError(null); setResult(null)
try {
const r = await api.firstaidResetPgdata()
setResult({
kind: 'reset',
msg: `PGDATA zurueckgesetzt. Safety-Backup: ${r.safety_backup.filename}`,
detail: r.log,
})
} catch (e) { setError(String(e)) }
finally { setBusy(null) }
}
async function runDiagnose() {
setBusy('diagnose'); setError(null); setResult(null)
try {
const r = await api.firstaidDiagnose()
setResult({
kind: 'diagnose',
msg: `Diagnose-Bundle geschrieben: ${r.filename} (${(r.bytes / 1024).toFixed(0)} KB)`,
detail: 'Unter ~/Library/Application Support/com.rapport.server-app/backups/',
})
} catch (e) { setError(String(e)) }
finally { setBusy(null) }
}
return (
<section className="panel">
<h2>Erste Hilfe</h2>
<p className="muted">
Drei Werkzeuge fuer Notfaelle. Reihenfolge der Eskalation: erst <em>Diagnose</em>,
dann <em>Neu erstellen</em>, dann <em>PGDATA-Reset</em>.
</p>
{error && <p className="error-text">{error}</p>}
{result && (
<div className="success-text" style={{ background: 'rgba(91, 208, 122, 0.08)', padding: 10, borderRadius: 6, border: '1px solid var(--green)' }}>
{result.msg}
{result.detail && (
<details style={{ marginTop: 6 }}>
<summary className="muted" style={{ cursor: 'pointer', fontSize: 12 }}>Details</summary>
<pre style={{ fontSize: 11, marginTop: 6, maxHeight: 200, overflow: 'auto' }}>{result.detail}</pre>
</details>
)}
</div>
)}
<div className="firstaid-card">
<h3><span className="material-symbols-outlined icon-heading">assignment</span>Diagnose-Bundle</h3>
<p className="muted">
Sammelt Container-Logs, docker version, ps -a, App-Events und (redacted)
Config in eine Text-Datei unter backups/. Kann als Bug-Report mitgeschickt werden.
</p>
<button onClick={runDiagnose} disabled={busy != null}>
{busy === 'diagnose' ? 'Sammle ...' : 'Diagnose erstellen'}
</button>
</div>
<div className="firstaid-card">
<h3><span className="material-symbols-outlined icon-heading">restart_alt</span>Container neu erstellen</h3>
<p className="muted">
Hilfsmittel wenn Container in komischen Zustaenden haengen (stuck, Memory-Leak, etc.).
<strong> Volumes bleiben</strong> Postgres-Daten sind safe. Ca. 30s Downtime.
</p>
<button onClick={runRecreate} disabled={busy != null}>
{busy === 'recreate' ? 'Erzeuge neu ...' : 'Container neu erstellen'}
</button>
</div>
<div className="firstaid-card firstaid-danger">
<h3><span className="material-symbols-outlined icon-heading" style={{ color: 'var(--red)' }}>delete_forever</span>PGDATA komplett zuruecksetzen</h3>
<p className="muted">
Letzter Ausweg wenn die Datenbank vermurkst ist (Schema-Korruption, Migration-Lock,
Postgres startet nicht mehr). <strong>Alle Daten gehen verloren</strong>
es wird automatisch ein Safety-Backup gemacht, das ist aber nur ein SQL-Dump
des aktuellen (kaputten?) Stands.
</p>
<button onClick={runReset} disabled={busy != null} className="danger">
{busy === 'reset' ? 'Loesche + initialisiere ...' : 'PGDATA zuruecksetzen'}
</button>
</div>
</section>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
export default function LogViewer({ services }) {
const [selected, setSelected] = useState(services[0]?.id ?? '')
const [lines, setLines] = useState([])
const scrollRef = useRef(null)
useEffect(() => {
if (!selected && services.length > 0) {
setSelected(services[0].id)
}
}, [services, selected])
useEffect(() => {
if (!selected) return
let alive = true
async function tick() {
try {
const data = await api.serviceLogs(selected)
if (alive) setLines(data)
} catch { /* ignore */ }
}
tick()
const t = setInterval(tick, 1500)
return () => { alive = false; clearInterval(t) }
}, [selected])
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [lines])
return (
<div className="log-viewer">
<aside className="log-sidebar">
{services.map(s => (
<button key={s.id}
className={s.id === selected ? 'log-tab active' : 'log-tab'}
onClick={() => setSelected(s.id)}>
<span className="state-dot" data-state={s.state} />
{s.display_name}
</button>
))}
</aside>
<pre ref={scrollRef} className="log-stream">
{lines.length === 0
? <span className="muted">Keine Log-Zeilen (Service vermutlich nicht gestartet).</span>
: lines.join('\n')}
</pre>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
const STATE_LABELS = {
stopped: 'Gestoppt',
starting: 'Startet',
running: 'Laeuft',
stopping: 'Stoppt',
error: 'Fehler',
}
function fmtMem(bytes) {
if (!bytes) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
export default function ServiceCard({ service, stats, onStart, onStop, onRestart }) {
const { id, display_name, state, pid, port, last_error } = service
const isRunning = state === 'running' || state === 'starting'
const showStats = stats && state === 'running'
return (
<div className="card" data-state={state}>
<div className="card-head">
<span className="state-dot" data-state={state} />
<h3 className="card-title">{display_name}</h3>
<span className="port">:{port}</span>
</div>
<dl className="card-meta">
<dt>Status</dt><dd>{STATE_LABELS[state] || state}</dd>
{pid != null && (<><dt>PID</dt><dd>{pid}</dd></>)}
<dt>ID</dt><dd className="mono">{id}</dd>
</dl>
{last_error && (
<div className="card-error" title={last_error}>
<pre>{last_error}</pre>
</div>
)}
{showStats && (
<div className="card-stats">
<div className="stat-row">
<span className="stat-label">CPU</span>
<span className="stat-bar">
<span
className="stat-bar-fill"
style={{ width: `${Math.min(100, stats.cpu_percent)}%` }}
data-hot={stats.cpu_percent > 80}
/>
</span>
<span className="stat-value mono">{stats.cpu_percent.toFixed(1)}%</span>
</div>
<div className="stat-row">
<span className="stat-label">RAM</span>
<span className="stat-bar">
<span
className="stat-bar-fill"
style={{ width: `${Math.min(100, stats.mem_percent)}%` }}
data-hot={stats.mem_percent > 80}
/>
</span>
<span className="stat-value mono">{fmtMem(stats.mem_bytes)}</span>
</div>
</div>
)}
<div className="card-actions">
{!isRunning && <button onClick={onStart}>Starten</button>}
{isRunning && (
<>
<button onClick={onRestart} title="Container neu starten">Neu starten</button>
<button onClick={onStop}>Stoppen</button>
</>
)}
</div>
</div>
)
}
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react'
import { api, runtime } from '../api.js'
const SECRET_KEYS = new Set(['POSTGRES_PASSWORD', 'JWT_SECRET', 'ADMIN_UI_PASSWORD'])
const WEBUI_KEYS = ['ADMIN_UI_BIND', 'ADMIN_UI_PORT', 'ADMIN_UI_TLS', 'ADMIN_UI_PASSWORD']
export default function SettingsPanel() {
const [config, setConfig] = useState({})
const [reveal, setReveal] = useState({})
const [savingKey, setSavingKey] = useState(null)
const [error, setError] = useState(null)
const [restartHint, setRestartHint] = useState(false)
const [autostartEnabled, setAutostartEnabled] = useState(null) // null = unbekannt/lade
useEffect(() => {
api.getConfig().then(setConfig).catch(e => setError(String(e)))
if (runtime === 'tauri') {
import('@tauri-apps/plugin-autostart')
.then(m => m.isEnabled())
.then(setAutostartEnabled)
.catch(() => setAutostartEnabled(false))
}
}, [])
async function toggleAutostart() {
if (runtime !== 'tauri') return
try {
const mod = await import('@tauri-apps/plugin-autostart')
if (autostartEnabled) {
await mod.disable()
setAutostartEnabled(false)
} else {
await mod.enable()
setAutostartEnabled(true)
}
} catch (e) {
setError(String(e))
}
}
async function saveKey(key, value) {
setSavingKey(key); setError(null)
try {
await api.setConfigValue(key, value)
if (WEBUI_KEYS.includes(key)) setRestartHint(true)
} catch (e) {
setError(String(e))
} finally {
setSavingKey(null)
}
}
function toggleLan() {
const next = config.ADMIN_UI_BIND === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0'
if (next === '0.0.0.0') {
const ok = window.confirm(
'Im LAN freigeben?\n\n' +
'Damit ist die Admin-UI von allen Geraeten im Netzwerk unter ' +
`https://<hostname>.local:${config.ADMIN_UI_PORT || 9090} erreichbar. ` +
'Login bleibt mit User "admin" und dem Passwort aus dieser Settings-Seite geschuetzt. ' +
'Trotzdem: nur in vertrauenswuerdigen Netzen einschalten.'
)
if (!ok) return
}
setConfig(c => ({ ...c, ADMIN_UI_BIND: next }))
saveKey('ADMIN_UI_BIND', next)
}
const lanOn = config.ADMIN_UI_BIND === '0.0.0.0'
const entries = Object.entries(config)
return (
<section className="panel">
<h2>Einstellungen</h2>
{error && <p className="error-text">{error}</p>}
{restartHint && (
<p className="success-text">
Aenderung gespeichert. <strong>App neu starten</strong>, damit der WebUI-Server mit der neuen Konfiguration laeuft.
</p>
)}
<h3>Auto-Start</h3>
<p className="muted">
Fuer headless Mac-Mini-Deployments: App startet beim Login automatisch
(versteckt im Tray) und faehrt sofort alle Container hoch.
</p>
<div className="webui-grid">
<div className="webui-row">
<div>
<div className="settings-key">App beim Login starten</div>
<div className="muted" style={{ fontSize: 12 }}>
{runtime !== 'tauri'
? 'Nur im Tauri-App-Modus verfuegbar (Browser kann das nicht).'
: autostartEnabled === null
? 'Lade ...'
: autostartEnabled
? `Aktiv: LaunchAgent unter ~/Library/LaunchAgents/`
: 'Aus: App startet nicht automatisch beim Login'}
</div>
</div>
<label className="switch">
<input
type="checkbox"
checked={!!autostartEnabled}
disabled={runtime !== 'tauri' || autostartEnabled === null}
onChange={toggleAutostart}
/>
<span className="slider" />
</label>
</div>
<div className="webui-row">
<div>
<div className="settings-key">Container nach App-Start hochfahren</div>
<div className="muted" style={{ fontSize: 12 }}>
Triggert <code>docker compose up -d</code> sobald die App geladen ist.
Sinnvoll zusammen mit dem Login-Autostart.
</div>
</div>
<label className="switch">
<input
type="checkbox"
checked={config.AUTO_START_CONTAINERS_ON_LAUNCH === 'true'}
onChange={e => {
const v = e.target.checked ? 'true' : 'false'
setConfig(c => ({ ...c, AUTO_START_CONTAINERS_ON_LAUNCH: v }))
saveKey('AUTO_START_CONTAINERS_ON_LAUNCH', v)
}}
/>
<span className="slider" />
</label>
</div>
</div>
<h3 style={{ marginTop: 24 }}>Admin-WebUI</h3>
<p className="muted">
Browser-basierter Zugang zur gleichen Admin-Oberflaeche &mdash; nuetzlich
wenn diese App auf einem Mac Mini ohne Bildschirm laeuft.
</p>
<div className="webui-grid">
<div className="webui-row">
<div>
<div className="settings-key">Im LAN freigeben</div>
<div className="muted" style={{ fontSize: 12 }}>
{lanOn
? `Aktiv: jeder im Netzwerk kann https://<hostname>.local:${config.ADMIN_UI_PORT || 9090} erreichen`
: 'Aus: nur lokal auf diesem Mac (https://127.0.0.1:9090)'}
</div>
</div>
<label className="switch">
<input type="checkbox" checked={lanOn} onChange={toggleLan} />
<span className="slider" />
</label>
</div>
<div className="webui-row">
<div>
<div className="settings-key">Port</div>
<div className="muted" style={{ fontSize: 12 }}>Default 9090</div>
</div>
<input
type="number"
value={config.ADMIN_UI_PORT || ''}
onChange={e => setConfig(c => ({ ...c, ADMIN_UI_PORT: e.target.value }))}
onBlur={e => saveKey('ADMIN_UI_PORT', e.target.value)}
className="settings-input"
style={{ width: 80, textAlign: 'right' }}
/>
</div>
<div className="webui-row">
<div>
<div className="settings-key">TLS / HTTPS</div>
<div className="muted" style={{ fontSize: 12 }}>
Self-signed Cert. Browser warnt einmal, danach akzeptiert.
</div>
</div>
<label className="switch">
<input
type="checkbox"
checked={config.ADMIN_UI_TLS !== 'false'}
onChange={e => {
const v = e.target.checked ? 'true' : 'false'
setConfig(c => ({ ...c, ADMIN_UI_TLS: v }))
saveKey('ADMIN_UI_TLS', v)
}}
/>
<span className="slider" />
</label>
</div>
<div className="webui-row">
<div style={{ flex: 1 }}>
<div className="settings-key">Passwort (User: admin)</div>
<input
type={reveal.ADMIN_UI_PASSWORD ? 'text' : 'password'}
value={config.ADMIN_UI_PASSWORD || ''}
onChange={e => setConfig(c => ({ ...c, ADMIN_UI_PASSWORD: e.target.value }))}
onBlur={e => saveKey('ADMIN_UI_PASSWORD', e.target.value)}
className="settings-input"
style={{ fontFamily: 'ui-monospace, monospace', marginTop: 4 }}
/>
</div>
<button
type="button"
className="reveal-toggle"
onClick={() => setReveal(r => ({ ...r, ADMIN_UI_PASSWORD: !r.ADMIN_UI_PASSWORD }))}
>
{reveal.ADMIN_UI_PASSWORD ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
</div>
<h3 style={{ marginTop: 24 }}>Alle Werte (Rohzugriff)</h3>
<p className="muted">
Direkter Zugriff auf alle <code>config.env</code>-Eintraege. Aenderungen an
DB-relevanten Werten erfordern Stop &amp; Start der Container.
</p>
<div className="settings-grid">
{entries.map(([key, value]) => {
const isSecret = SECRET_KEYS.has(key)
const shown = !isSecret || reveal[key]
return (
<div key={key} className="settings-row">
<label>
<span className="settings-key">{key}</span>
<input
type={shown ? 'text' : 'password'}
value={value}
onChange={e => setConfig(c => ({ ...c, [key]: e.target.value }))}
onBlur={e => saveKey(key, e.target.value)}
className="settings-input"
/>
</label>
{isSecret && (
<button type="button"
className="reveal-toggle"
onClick={() => setReveal(r => ({ ...r, [key]: !r[key] }))}>
{shown ? 'Verbergen' : 'Anzeigen'}
</button>
)}
{savingKey === key && <span className="muted">speichert ...</span>}
</div>
)
})}
</div>
</section>
)
}
+123
View File
@@ -0,0 +1,123 @@
// Setup-Wizard: zeigt sich wenn Docker-Daemon nicht erreichbar ist.
// Bietet einen einzigen Install-Knopf der von GitHub/docker.com direkt
// nach ~/.rapport/bin/ herunterlaedt und Colima startet.
import { useEffect, useState } from 'react'
import { api } from '../api.js'
const ACTION_INFO = {
none_ready: {
title: 'Alles bereit',
desc: 'Docker-Daemon laeuft.',
button: null,
},
start_colima: {
title: 'Colima starten',
desc: 'Docker, Colima und Lima sind installiert — der Daemon ist nur aus. Ein Klick reicht.',
button: 'Colima starten',
},
install_all: {
title: 'Alles automatisch installieren',
desc: 'Wir laden Docker-CLI, Colima und Lima direkt von docker.com und github.com nach ~/.rapport/bin/ herunter — keine externen Package-Manager, kein sudo. Dauert ca. 2-3 Minuten beim ersten Mal.',
button: 'Installieren und starten',
},
}
export default function SetupWizard({ onReady }) {
const [status, setStatus] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [log, setLog] = useState(null)
async function check() {
try {
const s = await api.setupStatus()
setStatus(s)
if (s.ready) onReady?.()
} catch (e) {
setError(String(e))
}
}
useEffect(() => {
check()
const t = setInterval(check, 5000)
return () => clearInterval(t)
}, [])
async function install() {
setBusy(true); setError(null); setLog(null)
try {
const res = await api.setupInstall()
setLog(res.log)
await check()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
if (!status) {
return (
<div className="setup-wizard">
<p className="muted">Pruefe Setup ...</p>
</div>
)
}
if (status.ready) return null
const info = ACTION_INFO[status.recommended_action]
return (
<div className="setup-wizard">
<h1>Willkommen bei RAPPORT Server</h1>
<p className="muted">
Bevor die Server-App den Rapport-Stack verwalten kann, brauchen wir einen
laufenden Docker-Daemon auf diesem Mac.
</p>
<div className="setup-checks">
<Check label="Docker-CLI" ok={status.docker_cli} />
<Check label="Colima installiert" ok={status.colima_installed} />
<Check label="Lima installiert" ok={status.limactl_installed} />
<Check label="Daemon erreichbar" ok={status.daemon_running} />
</div>
<h2>{info.title}</h2>
<p>{info.desc}</p>
{error && <p className="error-text">{error}</p>}
{info.button && (
<button onClick={install} disabled={busy} className="setup-primary">
{busy ? 'Laeuft ...' : info.button}
</button>
)}
{busy && (
<p className="muted" style={{ marginTop: 12, fontSize: 12 }}>
Status live im Status-Tab unter "App-Events" (sobald das Dashboard kommt).
Dauert je nach Internet-Speed paar Minuten.
</p>
)}
{log && (
<details className="setup-log">
<summary>Install-Log anzeigen</summary>
<pre>{log}</pre>
</details>
)}
</div>
)
}
function Check({ label, ok }) {
const iconName = ok ? 'check_circle' : 'cancel'
return (
<div className="setup-check" data-ok={ok} data-optional="false">
<span className="material-symbols-outlined setup-check-icon">{iconName}</span>
<span>{label}</span>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import 'material-symbols/outlined.css'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+497
View File
@@ -0,0 +1,497 @@
:root {
--bg: #0e0f12;
--bg-elev: #16181d;
--bg-card: #1c1f25;
--border: #2a2e36;
--text: #e7e9ee;
--muted: #8b919e;
--accent: #6aa8ff;
--green: #5bd07a;
--amber: #f5b042;
--red: #ef5a5a;
--gray: #6c727b;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.app { display: flex; flex-direction: column; height: 100vh; }
/* Topbar */
.topbar {
display: flex; align-items: center; gap: 24px;
padding: 12px 20px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.brand-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--gray);
}
.brand-dot[data-state="running"] { background: var(--green); box-shadow: 0 0 8px var(--green); }
.brand-dot[data-state="partial"] { background: var(--amber); }
.brand-dot[data-state="stopped"] { background: var(--gray); }
.tabs { display: flex; gap: 4px; flex: 1; }
.tab {
background: transparent; border: 0; color: var(--muted);
padding: 6px 12px; border-radius: 6px; cursor: pointer;
font-size: 13px;
display: inline-flex; align-items: center; gap: 6px;
}
.tab-icon { font-size: 16px; line-height: 1; }
.tab:hover { color: var(--text); background: rgba(255,255,255,0.04); }
.tab.active { color: var(--text); background: var(--bg-card); }
.actions { display: flex; gap: 8px; }
button {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* Content */
.content { flex: 1; overflow: auto; padding: 20px; }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.error-banner {
background: rgba(239, 90, 90, 0.1);
border: 1px solid var(--red);
color: var(--red);
padding: 8px 16px;
margin: 8px 20px 0;
border-radius: 6px;
font-size: 13px;
}
.error-text { color: var(--red); font-size: 12px; margin: 4px 0; }
.success-text { color: var(--green); font-size: 13px; }
/* Service Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.card[data-state="running"] { border-color: rgba(91, 208, 122, 0.35); }
.card[data-state="error"] { border-color: rgba(239, 90, 90, 0.45); }
.card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.card-title { margin: 0; font-size: 14px; flex: 1; }
.port { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; }
.state-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--gray);
}
.state-dot[data-state="running"] { background: var(--green); }
.state-dot[data-state="starting"] { background: var(--amber); animation: pulse 1s infinite; }
.state-dot[data-state="stopping"] { background: var(--amber); }
.state-dot[data-state="error"] { background: var(--red); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.card-meta {
display: grid; grid-template-columns: 60px 1fr; gap: 4px 12px;
font-size: 12px; margin: 8px 0;
}
.card-meta dt { color: var(--muted); }
.card-meta dd { margin: 0; }
.card-actions { display: flex; gap: 6px; margin-top: 10px; }
.card-error {
margin: 8px 0;
max-height: 90px; /* ~5 Zeilen */
overflow-y: auto;
background: rgba(239,90,90,0.06);
border: 1px solid rgba(239,90,90,0.25);
border-radius: 4px;
padding: 6px 8px;
}
.card-error pre {
margin: 0;
font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--red);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
}
.card-stats {
display: flex; flex-direction: column; gap: 6px;
margin: 10px 0 4px;
padding: 8px 10px;
background: rgba(255,255,255,0.02);
border-radius: 6px;
font-size: 11px;
}
.stat-row { display: grid; grid-template-columns: 30px 1fr 70px; align-items: center; gap: 8px; }
.stat-label { color: var(--muted); font-size: 10px; }
.stat-bar {
background: rgba(255,255,255,0.06);
height: 4px; border-radius: 2px; overflow: hidden;
}
.stat-bar-fill {
display: block; height: 100%; background: var(--accent);
transition: width 0.4s;
}
.stat-bar-fill[data-hot="true"] { background: var(--red); }
.stat-value { text-align: right; color: var(--text); }
/* Logs */
.log-viewer {
display: grid; grid-template-columns: 220px 1fr;
gap: 12px; height: 100%;
}
.log-sidebar { display: flex; flex-direction: column; gap: 2px; }
.log-tab {
background: transparent; border: 1px solid transparent;
padding: 8px 10px; text-align: left; border-radius: 6px;
color: var(--muted); display: flex; align-items: center; gap: 8px;
}
.log-tab:hover { color: var(--text); background: rgba(255,255,255,0.03); }
.log-tab.active { color: var(--text); background: var(--bg-card); border-color: var(--border); }
.log-stream {
background: #0a0b0d;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
white-space: pre-wrap;
margin: 0;
}
/* Panel */
.panel { max-width: 720px; }
.panel h2 { margin-top: 0; }
.row { display: flex; gap: 12px; margin: 12px 0; }
/* Settings */
.settings-grid { display: flex; flex-direction: column; gap: 8px; }
.settings-row {
display: flex; align-items: center; gap: 8px;
background: var(--bg-card); border: 1px solid var(--border);
padding: 8px 12px; border-radius: 6px;
}
.settings-row label { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.settings-key { color: var(--muted); font-size: 11px; font-family: ui-monospace, monospace; }
.settings-input {
background: transparent; border: 0; color: var(--text);
font-family: ui-monospace, monospace; font-size: 13px;
width: 100%; padding: 2px 0;
}
.settings-input:focus { outline: 0; }
.reveal-toggle { font-size: 11px; padding: 4px 8px; }
/* Admin WebUI section */
.webui-grid { display: flex; flex-direction: column; gap: 8px; max-width: 640px; }
.webui-row {
display: flex; align-items: center; gap: 16px;
background: var(--bg-card); border: 1px solid var(--border);
padding: 12px 16px; border-radius: 8px;
}
.webui-row > :first-child { flex: 1; }
/* iOS-style toggle */
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--gray); border-radius: 24px; transition: 0.2s;
}
.slider::before {
position: absolute; content: ""; height: 18px; width: 18px;
left: 3px; top: 3px; background: white; border-radius: 50%; transition: 0.2s;
}
.switch input:checked + .slider { background: var(--green); }
.switch input:checked + .slider::before { transform: translateX(20px); }
/* Backup table */
.backup-table {
width: 100%; border-collapse: collapse; margin-top: 8px;
font-size: 13px;
}
.backup-table th, .backup-table td {
padding: 8px 12px; border-bottom: 1px solid var(--border);
text-align: left;
}
.backup-table th {
color: var(--muted); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.05em;
}
.backup-table tbody tr:hover { background: rgba(255,255,255,0.02); }
/* Disk-Usage-Panel */
.disk-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 18px;
font-size: 13px;
display: flex; flex-direction: column; gap: 12px;
}
.disk-panel[data-state="warn"] { border-color: var(--amber); }
.disk-panel[data-state="crit"] { border-color: var(--red); }
.disk-row {
display: grid;
grid-template-columns: 1fr 200px 50px;
align-items: center;
gap: 12px;
}
.disk-label { color: var(--muted); font-size: 12px; }
.disk-bar {
background: rgba(255,255,255,0.06);
height: 8px; border-radius: 4px; overflow: hidden;
}
.disk-bar-fill {
height: 100%; background: var(--accent); transition: width 0.4s;
}
.disk-bar-fill[data-state="warn"] { background: var(--amber); }
.disk-bar-fill[data-state="crit"] { background: var(--red); }
.disk-pct { text-align: right; font-size: 13px; }
.disk-meta {
display: flex; flex-wrap: wrap; gap: 16px;
color: var(--muted);
font-size: 12px;
}
.disk-meta strong { color: var(--text); }
/* First-Aid cards */
.firstaid-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.firstaid-card h3 { margin: 0 0 6px; font-size: 14px; }
.firstaid-card p { margin: 6px 0 12px; font-size: 12px; }
.firstaid-card.firstaid-danger { border-color: rgba(239,90,90,0.4); }
button.danger {
background: rgba(239,90,90,0.12);
border-color: var(--red);
color: var(--red);
}
button.danger:hover:not(:disabled) { border-color: var(--red); background: rgba(239,90,90,0.2); }
/* Setup wizard */
.setup-wizard {
max-width: 640px;
margin: 60px auto;
padding: 32px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
}
.setup-wizard h1 { margin-top: 0; }
.setup-wizard h2 { margin-top: 24px; font-size: 16px; }
.setup-checks {
display: flex; flex-direction: column; gap: 6px;
margin: 20px 0;
padding: 14px;
background: rgba(255,255,255,0.02);
border-radius: 6px;
}
.setup-check { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.setup-check-icon { font-size: 18px; line-height: 1; }
.setup-check[data-ok="true"] .setup-check-icon { color: var(--green); }
.setup-check[data-ok="false"][data-optional="false"] .setup-check-icon { color: var(--red); }
.setup-check[data-ok="false"][data-optional="true"] .setup-check-icon { color: var(--gray); }
button.setup-primary {
padding: 10px 18px; font-size: 14px;
background: var(--accent); color: white; border-color: var(--accent);
}
button.setup-primary:hover:not(:disabled) { background: #4a8fff; }
.setup-manual {
margin-top: 16px; padding: 14px;
background: rgba(245,176,66,0.08);
border: 1px solid var(--amber);
border-radius: 6px;
font-size: 13px;
}
.setup-manual code {
display: block; padding: 6px 10px; margin: 6px 0;
background: var(--bg); border-radius: 4px;
font-size: 11px; overflow-x: auto;
}
.setup-log { margin-top: 16px; }
.setup-log summary { cursor: pointer; font-size: 12px; color: var(--muted); }
.setup-log pre {
margin-top: 8px; padding: 10px;
background: var(--bg); border-radius: 6px;
font-size: 11px; max-height: 300px; overflow: auto;
}
/* Material-Symbols-Defaults — font-family explizit setzen damit's auch dann
rendert wenn der package-CSS-Import irgendwo dazwischen scheitert. */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined', 'Material Icons', sans-serif;
font-weight: normal;
font-style: normal;
font-size: inherit;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
vertical-align: middle;
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24;
}
.icon-inline { font-size: 16px; vertical-align: -3px; margin-right: 6px; }
.icon-heading { font-size: 20px; vertical-align: -4px; margin-right: 8px; }
/* Update modal */
.update-modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
display: flex; align-items: center; justify-content: center;
}
.update-modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px 28px;
width: 480px; max-width: calc(100vw - 40px);
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
}
.update-modal h2 { margin: 0 0 12px; font-size: 18px; }
.update-steps { padding-left: 20px; margin: 8px 0 16px; font-size: 13px; }
.update-steps li { margin: 4px 0; }
.update-progress { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
.update-step {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
font-size: 13px;
}
.update-step[data-state="active"] { background: rgba(106,168,255,0.12); }
.update-step[data-state="done"] { background: rgba(91,208,122,0.10); }
.update-step[data-state="error"] { background: rgba(239,90,90,0.12); }
.update-step-icon { font-size: 20px; line-height: 1; }
.update-step[data-state="pending"] .update-step-icon { color: var(--gray); }
.update-step[data-state="active"] .update-step-icon { color: var(--accent); animation: spin 1.4s linear infinite; }
.update-step[data-state="done"] .update-step-icon { color: var(--green); }
.update-step[data-state="error"] .update-step-icon { color: var(--red); }
.update-step-name { flex: 1; }
.update-step-extra { font-size: 11px; font-family: ui-monospace, monospace; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.update-error-pre {
margin: 6px 0 0; padding: 10px 12px;
background: rgba(239,90,90,0.06);
border: 1px solid rgba(239,90,90,0.3);
border-radius: 4px;
font-size: 11px; max-height: 200px; overflow: auto;
white-space: pre-wrap; word-break: break-word;
color: var(--red);
}
/* App update banner */
.update-banner {
display: flex; align-items: center; gap: 12px;
background: rgba(106, 168, 255, 0.12);
border-bottom: 1px solid var(--accent);
padding: 10px 20px;
font-size: 13px;
}
.update-bell { font-size: 20px; color: var(--accent); }
.update-text { flex: 1; }
/* Event feed (Status tab — eingeklappt unter den Cards) */
.event-feed {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-top: 16px;
font-size: 12px;
overflow: hidden;
}
.event-header {
width: 100%;
display: flex; align-items: center; gap: 10px;
padding: 8px 14px;
background: transparent;
border: 0;
text-align: left;
color: var(--text);
font-size: 12px;
cursor: pointer;
}
.event-header:hover { background: rgba(255,255,255,0.03); }
.event-chevron { color: var(--muted); width: 12px; }
.event-header-title { color: var(--muted); }
.event-teaser {
display: inline-flex; align-items: baseline; gap: 8px;
flex: 1; min-width: 0;
margin-left: 8px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
.event-teaser .event-msg {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.event-body {
padding: 4px 14px 12px;
display: flex; flex-direction: column; gap: 4px;
border-top: 1px solid var(--border);
}
.event-line {
display: grid;
grid-template-columns: 70px 14px 1fr;
align-items: baseline;
gap: 8px;
}
.event-time {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
}
.event-icon {
color: var(--muted);
text-align: center;
font-size: 14px !important;
line-height: 1;
}
.event-icon[data-kind="info"] { color: var(--accent); }
.event-icon[data-kind="warn"] { color: var(--amber); }
.event-icon[data-kind="error"] { color: var(--red); }
.event-line[data-kind="error"] .event-msg { color: var(--red); }
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 3001,
strictPort: true,
host: false,
},
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: 'es2022',
// Vite 8 / Rolldown: 'esbuild' braucht extra Install, 'oxc' ist neuer Default.
minify: 'oxc',
sourcemap: false,
},
})