Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be6a34f19a |
@@ -1,13 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,79 +1,3 @@
|
|||||||
# RAPPORT Server-App
|
# RAPPORT-SERVER-APP
|
||||||
|
|
||||||
> **Tauri-Admin-UI für den Rapport-Stack.**
|
Tauri-Admin-UI fuer Rapport-Server
|
||||||
> 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.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"notes": "Initial Release — Tauri-Admin-UI fuer den Rapport-Compose-Stack mit Live-Status, Backup/Restore, Auto-Recovery und LAN-WebUI.",
|
|
||||||
"pub_date": "2026-05-24T13:57:04Z",
|
|
||||||
"platforms": {
|
|
||||||
"darwin-aarch64": {
|
|
||||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTYUg0T2hQVVkrUSthSktZM0xuSWthSzBJdjAyY2g2eVJCd0NJVjB4NXNQQUJEbWIyOEpNem9OQUpWRkFNQS9kWFd0Myt1Y1k4M2Y2ZzFHZFFUVVdMTkJMRzV4Yk1kd3drPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzc5NjI4Njc4CWZpbGU6UkFQUE9SVCBTZXJ2ZXIuYXBwLnRhci5negp3ajh0Ykp0ZDZDRXdlV3VlU2tjYlFYdUxRVThjL29sWVVwbnYzcy9wMzRIVW1nQ0hzOHdOZ3ZKcXpoVE1BNE45Y0VpQkY2M2dCaDlORjF0UjZYK0pEZz09Cg==",
|
|
||||||
"url": "https://git.kgva.ch/karim/RAPPORT-SERVER-APP/releases/download/v0.1.0/RAPPORT%20Server.app.tar.gz"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "rapport-server-app",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
#!/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)"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/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/"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/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 "================================================================"
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "rapport-server-app"
|
|
||||||
version = "0.1.1"
|
|
||||||
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"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 626 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 207 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 246 B |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 249 B |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 250 B |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 250 B |
@@ -1,317 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
//! 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}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
//! 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()
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
//! 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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
app_lib::run()
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
//! 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(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
//! 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();
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}
|
|
||||||
@@ -1,603 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2.0.0",
|
|
||||||
"productName": "RAPPORT Server",
|
|
||||||
"version": "0.1.1",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// 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'
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// Background-Check fuer App-Updates. Im Tauri-Kontext: nutzt das offizielle
|
|
||||||
// @tauri-apps/plugin-updater. Im Browser (Web-UI): kein App-Update moeglich
|
|
||||||
// (Browser kann nicht die Server-Mac-App neu installieren) — Banner versteckt.
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { runtime } from '../api.js'
|
|
||||||
import { api } from '../api.js'
|
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
export default function AppUpdateBanner() {
|
|
||||||
const [update, setUpdate] = useState(null)
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [progress, setProgress] = 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)
|
|
||||||
setError(String(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
check()
|
|
||||||
timer = setInterval(check, CHECK_INTERVAL_MS)
|
|
||||||
return () => { alive = false; if (timer) clearInterval(timer) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
async function install() {
|
|
||||||
if (!update) return
|
|
||||||
const ok = window.confirm(
|
|
||||||
`Update auf v${update.version} installieren?\n\n` +
|
|
||||||
'Ablauf:\n' +
|
|
||||||
' 1. pg_dumpall Pre-Backup (auto)\n' +
|
|
||||||
' 2. Neue Binary herunterladen + Signatur pruefen\n' +
|
|
||||||
' 3. App-Restart mit neuer Version\n\n' +
|
|
||||||
'Container laufen waehrend Restart weiter — keine Downtime\n' +
|
|
||||||
'auf der Service-Seite. Admin-UI ist ~10s nicht verfuegbar.'
|
|
||||||
)
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
setBusy(true); setError(null); setProgress('Pre-Backup...')
|
|
||||||
try {
|
|
||||||
// 1) Pre-Backup
|
|
||||||
await api.backupNow()
|
|
||||||
setProgress('Update wird heruntergeladen...')
|
|
||||||
|
|
||||||
// 2) Download + Install (Tauri plugin uebernimmt restart)
|
|
||||||
await update.downloadAndInstall((event) => {
|
|
||||||
// event.event: 'Started' | 'Progress' | 'Finished'
|
|
||||||
if (event.event === 'Progress') {
|
|
||||||
setProgress(`Download: ${event.data.chunkLength} Bytes`)
|
|
||||||
} else if (event.event === 'Finished') {
|
|
||||||
setProgress('Installiert — Restart...')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// Tauri startet die App automatisch neu, dieser Code-Pfad erreicht's nie
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e))
|
|
||||||
setBusy(false)
|
|
||||||
setProgress(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={install} disabled={busy}>
|
|
||||||
{busy ? (progress ?? 'Installiere ...') : 'Backup + Installieren'}
|
|
||||||
</button>
|
|
||||||
{error && <span className="error-text">{error}</span>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
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 & 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 > 90% voll — aelteste Backups manuell pruefen.</p>}
|
|
||||||
{diskWarn && !diskCrit && <p style={{ color: 'var(--amber)' }}><span className="material-symbols-outlined icon-inline">warning</span>Disk > 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 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
// 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
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 — 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 & 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
// 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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>,
|
|
||||||
)
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
: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; }
|
|
||||||
|
|
||||||
/* 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); }
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||