commit e2d2fd9fa211a0c48d6304c0df3ffca176789383 Author: karim Date: Sun May 24 17:03:50 2026 +0200 Initial source: RAPPORT Server-App v0.1.0 - Tauri-2-Admin-UI fuer den Rapport-Compose-Stack - React-Frontend (JSX, kein TS) mit Material-Symbols-Icons - Service-Cards mit Live-Stats (CPU/RAM), Logs, Restart/Stop - Backup-/Restore-System mit pg_dumpall + Retention - Container-Auto-Updates mit Pre-Backup - App-Auto-Updater (Tauri signiert) gegen latest.json im Repo-Root - HTTPS-WebUI (axum/rustls) mit Basic-Auth, CSRF, Rate-Limit, Security-Headers - Setup-Wizard: lädt Docker+Colima+Lima direct von GitHub/docker.com nach ~/.rapport/bin/ - Tray-Modus + macOS-Notifications + Auto-Recovery - Login-Item via tauri-plugin-autostart diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f6b5c9f --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Vorlage fuer config.env — der Setup-Wizard generiert diese Werte beim +# Erst-Start automatisch (Random-Secrets) und persistiert sie in +# ~/Library/Application Support/com.rapport.server-app/config.env (macOS). +# Diese Datei dient nur als Referenz fuer manuelle Test-Runs. + +POSTGRES_PASSWORD=changeme +JWT_SECRET=changeme-min-32-bytes + +SITE_URL=http://localhost:8080 +API_EXTERNAL_URL=http://localhost:8000 + +# 127.0.0.1 = nur lokal, 0.0.0.0 = LAN-Zugriff (Vorsicht) +BIND_HOST=127.0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e61be8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Node +node_modules/ +dist/ +.vite/ + +# Rust / Tauri +src-tauri/target/ +src-tauri/gen/ + +# Binaries (download via scripts/download-binaries.sh) +binaries/macos-aarch64/* +binaries/macos-x86_64/* +binaries/linux-x86_64/* +binaries/windows-x86_64/* +!binaries/*/.gitkeep +!binaries/README.md +!binaries/manifest.json + +# Local app data (only relevant when running outside the bundle) +data/ + +# Editor / OS +.DS_Store +.vscode/ +.idea/ +*.log + +# Signing keys (per-developer, never commit) +.rapport-signing/ +*.key +*.key.pub diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d3bcb34 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,195 @@ +# RAPPORT Server-App — Architektur + +> Wie die App den Compose-Stack im Schwesterrepo [SERVER-CONTAINER](../SERVER-CONTAINER) verwaltet, lokal anzeigt und im LAN per HTTPS erreichbar macht. + +--- + +## 1 · Mentales Modell in einem Absatz + +Die Server-App ist eine **Tauri-2-App**, deren Rust-Backend keine Container selbst startet, sondern **`docker compose`** gegen den existierenden Compose-Stack aufruft. Die Compose-Datei und ihr `.env` im `SERVER-CONTAINER`-Repo sind die einzige Konfigurations-Wahrheit für die Services — die App ist eine dünne Steuerschicht obendrauf. Das Tauri-Backend exponiert die Steuerung auf zwei Wegen: über **Tauri-IPC** an die eingebettete React-WebView (Admin-UI auf dem Server-Mac selbst) und über einen **eingebauten HTTPS-Server (axum + rustls, self-signed)** an Browser im LAN (für headless Mac-Mini-Deployments). Beide Endpoints sprechen die identische API, beide rendern dieselbe React-App. + +**Konsequenz:** Schliessen des Tauri-Fensters reduziert nur in den System-Tray — Container und HTTPS-Server laufen weiter. Echtes Beenden nur über das Tray-Menü. + +--- + +## 2 · Verzeichnis-Karte + +``` +SERVER-APP/ +├── src/ React-Admin-UI (JSX, kein TS) +│ ├── main.jsx Entry +│ ├── App.jsx Root: Tabs (Status / Logs / Backup / Settings) +│ ├── api.js invoke() in Tauri, fetch() im Browser — gleicher Shape +│ └── components/ +│ ├── ServiceCard.jsx eine Karte pro Service mit State-Dot +│ ├── LogViewer.jsx live tail über docker compose logs +│ ├── BackupPanel.jsx (Placeholder) +│ └── SettingsPanel.jsx Admin-WebUI-Sektion + Roh-Editor für config.env +│ +├── src-tauri/ Rust-Backend +│ ├── src/ +│ │ ├── main.rs 6 Zeilen +│ │ ├── lib.rs Tauri-Setup, Tray, Tick-Loop, HTTP-Server-Spawn +│ │ ├── supervisor.rs Wrapper um `docker compose` (up/down/stop/ps/logs) +│ │ ├── services.rs ServiceDef-Liste, init_compose_dir() Auto-Detect +│ │ ├── http_server.rs axum + rustls + Basic-Auth + Rate-Limit +│ │ ├── config.rs config.env Lesen/Schreiben + Default-Generation +│ │ ├── paths.rs ~/Library/.../com.rapport.server-app/... +│ │ ├── health.rs (alt: HTTP-Probes — nicht mehr aktiv genutzt, +│ │ │ wir lesen Container-State aus `docker compose ps`) +│ │ └── commands.rs #[tauri::command]-Wrapper für die UI +│ ├── Cargo.toml +│ └── tauri.conf.json +│ +├── README.md +└── ARCHITECTURE.md (diese Datei) +``` + +--- + +## 3 · Service-Inventar + +Image-Tags und Konfiguration kommen aus [`SERVER-CONTAINER/docker-compose.yml`](../SERVER-CONTAINER/docker-compose.yml) — siehe dort. Aktueller Stand (Mai 2026): + +| Compose-Service | Image | Host-Port | Erreichbar | +|---|---|---|---| +| `db` | `supabase/postgres:15.8.1.020` | 15432 | `127.0.0.1` (kein LAN) | +| `auth` (GoTrue) | `supabase/gotrue:v2.158.1` | intern | nur über `kong` | +| `rest` (PostgREST) | `postgrest/postgrest:v12.2.0` | intern | nur über `kong` | +| `realtime` | `supabase/realtime:v2.30.34` | intern | nur über `kong` | +| `storage` | `supabase/storage-api:v1.11.13` | intern | nur über `kong` | +| `kong` | `kong:2.8.1` | 18000 | LAN (`0.0.0.0:18000`) | +| `app` (Frontend) | `rapport-app:main` (lokal gebaut) | 18080 | LAN (`0.0.0.0:18080`) | + +`db` ist bewusst nur Loopback — direkter LAN-Zugriff auf die DB wäre Privesc-Surface. Alle App-Clients gehen über Kong. + +--- + +## 4 · Supervisor + +Der Rust-Supervisor (`supervisor.rs`) ist im Wesentlichen ein Wrapper um die `docker compose`-CLI: + +| Funktion | Compose-Aufruf | +|---|---| +| `start(id)` | `docker compose up -d ` | +| `stop(id)` | `docker compose stop ` | +| `start_all()` | `docker compose up -d` (Compose erzwingt `depends_on`) | +| `stop_all()` | `docker compose down` | +| `logs(id)` | Hintergrund-Spawn von `docker compose logs -f `, 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. `/../SERVER-CONTAINER/` (bundled Layout) +5. `/../../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`, ``, `.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/` | + +``` +/ +├── 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7d23e79 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b97200e --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# RAPPORT Server-App + +> **Tauri-Admin-UI für den Rapport-Stack.** +> Eine native Mac/Linux/Windows-App, die den kompletten Rapport-Server (Postgres, GoTrue, PostgREST, Realtime, Storage, Kong, Frontend) als Docker-Compose-Stack startet, überwacht und administriert — lokal mit Native-UI und im LAN über HTTPS-WebUI. + +## Status + +**Pre-Alpha, aber funktional.** Service-Lifecycle, Live-Logs, TLS-WebUI und Tray-Modus laufen. Backup/Restore ist Placeholder. Auto-Updater ist verdrahtet, aber nicht aktiv (kein Signing-Key). + +## Was die App ist + +- **Admin-UI auf dem Server-Mac**: Status-Dashboard für alle 7 Services, Live-Log-Tail, Start/Stop pro Service oder bulk, Settings-Editor für `config.env`. +- **HTTPS-WebUI im LAN**: dieselbe UI vom Browser eines anderen Geräts aus. Self-signed Cert, Basic-Auth mit Brute-Force-Lockout. Wichtig für **headless Mac-Mini-Deployments** ohne Bildschirm. +- **Tray-Wrapper**: Fenster schliessen reduziert in den System-Tray; Services laufen weiter. Quit nur über Tray-Menü. +- **Compose-Wrapper**, kein eigener Container-Runtime: die App ruft `docker compose` gegen den `SERVER-CONTAINER`-Stack auf. Die Compose-Datei und das `.env` sind die Source-of-Truth — Änderungen dort werden automatisch übernommen. + +## Was die App nicht ist + +- ~~"Doppelklick statt Docker"~~ — diese ursprüngliche Idee ist verworfen. Native Service-Binaries gibt's für die meisten Supabase-Komponenten nicht (Realtime/Erlang, Storage/Node, etc.). Pragmatik schlägt Vision: wir setzen einen lokalen Docker-Daemon (OrbStack, Colima oder Docker Desktop) voraus und liefern dafür die polierte UI obendrauf. + +## Voraussetzungen + +| Komponente | Hinweis | +|---|---| +| **Docker-CLI + Daemon** | OrbStack oder [Colima](https://github.com/abiosoft/colima) (beide Mac-nativ, Colima ist Apache 2.0 / open source). `brew install docker colima && colima start` reicht. | +| **SERVER-CONTAINER-Repo** geklont | Default-Suchpfad: `~/RAPPORT/SERVER-CONTAINER/`. Override via `COMPOSE_DIR=...` in `config.env`. | +| **Node ≥ 20, Rust ≥ 1.77** | nur fürs Bauen aus Source, nicht für den Endnutzer. | + +## Lokal starten + +```bash +npm install +npm run tauri:dev +``` + +Beim ersten Klick auf "Alle starten" pullt Compose die Images (~700 MB). Danach geht's instant. + +## Bundle + +```bash +npm run tauri:build +``` + +Output unter `src-tauri/target/release/bundle/`. Code-Signing ist noch nicht konfiguriert. + +## WebUI-Zugriff (für headless) + +- URL lokal: `https://127.0.0.1:9090` +- URL im LAN: `https://.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 `/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. diff --git a/binaries/README.md b/binaries/README.md new file mode 100644 index 0000000..5c577e8 --- /dev/null +++ b/binaries/README.md @@ -0,0 +1,37 @@ +# Service-Binaries + +Plattform-spezifische Binaries fuer alle gebundleten Rapport-Server-Services. +Diese Verzeichnisse sind **leer im Repo** — sie werden vor jedem Build via +[`scripts/download-binaries.sh`](../scripts/download-binaries.sh) gefuellt. +Pinning erfolgt ueber [`manifest.json`](manifest.json). + +## Layout + +``` +binaries/ +├── macos-aarch64/ +│ ├── postgres +│ ├── gotrue +│ ├── postgrest +│ ├── realtime +│ ├── storage-api +│ ├── kong +│ └── nginx +├── macos-x86_64/ (gleiche Struktur) +├── linux-x86_64/ (gleiche Struktur) +└── windows-x86_64/ (.exe-Endungen) +``` + +## Wie es zur Laufzeit funktioniert + +`tauri.conf.json` listet `../binaries/**/*` unter `bundle.resources` — +dadurch wandert das gesamte `binaries/`-Verzeichnis ins App-Bundle unter +`Resources/binaries/`. Zur Laufzeit loest [`src-tauri/src/paths.rs`](../src-tauri/src/paths.rs) +den plattform-passenden Subpfad auf und der Supervisor spawnt von dort. + +## Binaries selbst bauen + +Postgres und Kong haben offizielle Builds. Bei den Supabase-Komponenten +(GoTrue, PostgREST, Realtime, Storage) ziehen wir die Releases von GitHub. +Realtime-aarch64 ist die offene Frage — siehe +[ARCHITECTURE.md §12](../ARCHITECTURE.md). diff --git a/binaries/linux-x86_64/.gitkeep b/binaries/linux-x86_64/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/binaries/macos-aarch64/.gitkeep b/binaries/macos-aarch64/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/binaries/macos-x86_64/.gitkeep b/binaries/macos-x86_64/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/binaries/manifest.json b/binaries/manifest.json new file mode 100644 index 0000000..09ce2ac --- /dev/null +++ b/binaries/manifest.json @@ -0,0 +1,14 @@ +{ + "$schema": "./manifest.schema.json", + "comment": "Pinned versions for all bundled service binaries. Bump deliberately; download-binaries.sh consumes this file.", + "services": { + "postgres": { "version": "15.7", "source": "postgresql.org" }, + "gotrue": { "version": "2.158.1", "source": "github.com/supabase/gotrue/releases" }, + "postgrest": { "version": "12.2.0", "source": "github.com/PostgREST/postgrest/releases" }, + "realtime": { "version": "2.30.34", "source": "github.com/supabase/realtime/releases" }, + "storage": { "version": "1.11.0", "source": "github.com/supabase/storage-api/releases" }, + "kong": { "version": "3.7.1", "source": "konghq.com" }, + "nginx": { "version": "1.27.0", "source": "nginx.org" } + }, + "platforms": ["macos-aarch64", "macos-x86_64", "linux-x86_64", "windows-x86_64"] +} diff --git a/binaries/windows-x86_64/.gitkeep b/binaries/windows-x86_64/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/index.html b/index.html new file mode 100644 index 0000000..064a8e2 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + RAPPORT Server + + +
+ + + diff --git a/latest.json b/latest.json new file mode 100644 index 0000000..59cf908 --- /dev/null +++ b/latest.json @@ -0,0 +1,11 @@ +{ + "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" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..40da24c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1242 @@ +{ + "name": "rapport-server-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rapport-server-app", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "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" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", + "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/material-symbols": { + "version": "0.44.9", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.44.9.tgz", + "integrity": "sha512-2niXijHb2Cucb0K98n6a65TG/DJaxFlg06R93T5AU21HQGGUuWr9YQxK9/pnKNaAP8Dwi1YHqMnNoGTgA8C7iQ==", + "license": "Apache-2.0" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..548ebe2 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "rapport-server-app", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Doppelklick-Self-Hosting für Rapport — Tauri-App, die Postgres, GoTrue, PostgREST, Realtime und Storage als Subprozesse bündelt.", + "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" + } +} diff --git a/scripts/download-binaries.sh b/scripts/download-binaries.sh new file mode 100755 index 0000000..eae9761 --- /dev/null +++ b/scripts/download-binaries.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Laedt fuer die laufende Plattform die Service-Binaries und legt sie unter +# binaries// 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//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)" diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..d7a8cd0 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Build + Code-Signing-Helper. +# Ablauf: +# 1. ./scripts/download-binaries.sh (laedt Platzhalter / spaeter echte Binaries) +# 2. npm install +# 3. npm run build (Vite-Frontend-Build) +# 4. cargo tauri build (Tauri-Bundle) +# 5. (macOS) codesign + notarize (nur wenn APPLE_ID/TEAM_ID gesetzt) + +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "==> binaries" +./scripts/download-binaries.sh + +echo "==> npm install" +npm install + +echo "==> tauri build" +npm run tauri:build + +if [[ "$(uname -s)" == "Darwin" && -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then + echo "==> notarize (TODO — bundle path + xcrun notarytool aufruf)" + # xcrun notarytool submit ... --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --wait +else + echo "Skipping macOS notarization (APPLE_ID/APPLE_TEAM_ID not set)." +fi + +echo "Done. Bundle: src-tauri/target/release/bundle/" diff --git a/scripts/pull-images.sh b/scripts/pull-images.sh new file mode 100755 index 0000000..ac6884b --- /dev/null +++ b/scripts/pull-images.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Pulled die Docker-Images aller Rapport-Services vorab. +# Image-Tags sind 1:1 aus SERVER-CONTAINER/docker-compose.yml uebernommen +# (und in src-tauri/src/services.rs hardgepinnt). Sollten die hier geaendert +# werden, muss services.rs mitziehen. +# +# Optional: vor `tauri:dev` einmal laufen lassen, damit der erste Start-Klick +# nicht warten muss bis der Pull durch ist. + +set -euo pipefail + +IMAGES=( + "supabase/postgres:15.8.1.020" + "supabase/gotrue:v2.158.1" + "postgrest/postgrest:v12.2.0" + "supabase/realtime:v2.30.34" + "supabase/storage-api:v1.11.13" + "kong:2.8.1" + "nginx:1.27-alpine" +) + +if ! command -v docker >/dev/null 2>&1; then + echo "docker CLI nicht gefunden. Installiere Docker / OrbStack / Colima zuerst." >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "Docker-Daemon laeuft nicht. Starte OrbStack/Colima/Docker Desktop." >&2 + exit 1 +fi + +for img in "${IMAGES[@]}"; do + echo "==> pulling $img" + docker pull "$img" +done + +echo "Done." diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..98bfc98 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Release-Pipeline: Version bumpen, signiertes Bundle bauen, latest.json generieren. +# +# Usage: ./scripts/release.sh +# 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 " + 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 - < 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 </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 { + let dir = paths::backups_dir(); + let Ok(read) = std::fs::read_dir(&dir) else { return vec![] }; + let mut out: Vec = 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 = 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 { + 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 { + 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 { + 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 { + // 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, + }) +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..e6412d8 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,163 @@ +//! Tauri-Commands. +//! +//! Schmaler Layer zwischen WebView-Frontend und Supervisor. Jeder Command +//! ist `async`, sperrt den Supervisor-Mutex moeglichst kurz und gibt das +//! Ergebnis als JSON-serialisierbare Struktur zurueck. + +use crate::backup::{self, BackupInfo, RestoreResult}; +use crate::config; +use crate::container_update::{self, ApplyResult, CheckResult}; +use crate::disk::{self, DiskUsage}; +use crate::events::{self, Event}; +use crate::firstaid::{self, DiagnoseResult, RecreateResult, ResetResult}; +use crate::setup::{self, InstallResult, SetupStatus}; +use crate::stats::{self, ContainerStats}; +use crate::supervisor::ServiceStatus; +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub async fn list_services(state: State<'_, AppState>) -> Result, 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, String> { + let sv = state.supervisor.lock().await; + Ok(sv.status(&id)) +} + +#[tauri::command] +pub async fn service_logs(state: State<'_, AppState>, id: String) -> Result, 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 { + 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 { + 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, String> { + Ok(backup::list()) +} + +#[tauri::command] +pub async fn restore_backup(filename: String) -> Result { + backup::restore(&filename).await +} + +#[tauri::command] +pub async fn check_container_updates() -> Result { + container_update::check().await +} + +#[tauri::command] +pub async fn apply_container_updates() -> Result { + container_update::apply().await +} + +#[tauri::command] +pub async fn list_events() -> Result, String> { + Ok(events::list().await) +} + +#[tauri::command] +pub async fn list_stats() -> Result, String> { + Ok(stats::collect().await) +} + +#[tauri::command] +pub async fn disk_usage() -> Result { + Ok(disk::collect().await) +} + +#[tauri::command] +pub async fn firstaid_recreate() -> Result { + firstaid::recreate_containers().await +} + +#[tauri::command] +pub async fn firstaid_reset_pgdata() -> Result { + firstaid::reset_pgdata().await +} + +#[tauri::command] +pub async fn firstaid_diagnose() -> Result { + firstaid::diagnose_bundle().await +} + +#[tauri::command] +pub async fn setup_status() -> Result { + Ok(setup::status().await) +} + +#[tauri::command] +pub async fn setup_install(state: State<'_, AppState>) -> Result { + // 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 +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs new file mode 100644 index 0000000..3991fdb --- /dev/null +++ b/src-tauri/src/config.rs @@ -0,0 +1,121 @@ +//! Konfigurations-Management. +//! +//! Liest und schreibt `config.env` im App-Data-Pfad. Generiert sichere +//! Defaults beim Erst-Start: `POSTGRES_PASSWORD`, `JWT_SECRET`, davon +//! abgeleitet `ANON_KEY` und `SERVICE_ROLE_KEY`. + +use crate::paths; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::Rng; +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; + +/// Geordnete Map (für stabile Datei-Reihenfolge). +pub type EnvMap = BTreeMap; + +pub fn load() -> Result { + 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) +} diff --git a/src-tauri/src/container_update.rs b/src-tauri/src/container_update.rs new file mode 100644 index 0000000..8c00730 --- /dev/null +++ b/src-tauri/src/container_update.rs @@ -0,0 +1,255 @@ +//! Container-Update-Modul. +//! +//! Workflow: +//! 1. `docker compose pull` zieht Image-Updates +//! 2. Image-IDs vorher/nachher vergleichen — was hat sich geaendert? +//! 3. Wenn was geaendert: erst Backup (pg_dumpall), dann +//! `docker compose up -d` — Compose erkennt selbst was neu erstellt werden +//! muss anhand veraenderter Image-IDs. +//! +//! Scheduler tickt `CONTAINER_AUTOUPDATE_INTERVAL_HOURS` (Default 24h). +//! `CONTAINER_AUTOUPDATE_ENABLED=false` schaltet den Auto-Teil ab — manuelles +//! `check_now` / `apply_now` geht trotzdem. + +use crate::{backup, services}; +use chrono::Local; +use serde::Serialize; +use std::collections::HashMap; +use std::time::Duration; +use tokio::process::Command; + +pub const SCHED_TICK: Duration = Duration::from_secs(60 * 60); + +const DEFAULT_INTERVAL_HOURS: u64 = 24; + +#[derive(Debug, Clone, Serialize)] +pub struct UpdateAvailable { + pub service: String, + pub image: String, + pub old_id: String, + pub new_id: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CheckResult { + pub checked_at_iso: String, + pub updates: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ApplyResult { + pub applied_at_iso: String, + pub updated_services: Vec, + pub backup_filename: Option, + 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, 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::>(&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::(line) { + if let Some(row) = parse_row(&v) { + map.insert(row.service.clone(), row); + } + } + } + } + Ok(map) +} + +fn parse_row(v: &serde_json::Value) -> Option { + 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 { + 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 { + 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 { + 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 = + 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> = 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}"), + } + } +} diff --git a/src-tauri/src/disk.rs b/src-tauri/src/disk.rs new file mode 100644 index 0000000..c5a1f7e --- /dev/null +++ b/src-tauri/src/disk.rs @@ -0,0 +1,158 @@ +//! Disk-Usage-Sammler fuer's Backup-Panel und (spaeter) Warn-Banner. +//! +//! Vier Metriken: +//! - Postgres logische DB-Groesse (`pg_database_size('postgres')`) +//! - Backup-Verzeichnis-Groesse (Summe aller .sql-Files) +//! - Docker-Volumes-Total (alle Volumes des Daemons, via `docker system df`) +//! - Freier Disk-Space im Backup-Verzeichnis (via `df -k`) + +use crate::{paths, services}; +use serde::Serialize; +use tokio::process::Command; + +#[derive(Debug, Clone, Serialize)] +pub struct DiskUsage { + /// Logische Groesse der `postgres`-DB (None wenn db nicht erreichbar). + pub postgres_db_bytes: Option, + /// 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, +} + +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 { + // 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::() + .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 ` → 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::().unwrap_or(0).saturating_mul(1024); + let avail = cols[3].parse::().unwrap_or(0).saturating_mul(1024); + (avail, total) +} + +async fn docker_volumes_size() -> Result { + 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::(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 +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs new file mode 100644 index 0000000..4794b28 --- /dev/null +++ b/src-tauri/src/events.rs @@ -0,0 +1,57 @@ +//! Globaler App-Event-Log fuer das Status-Dashboard. +//! +//! Ringbuffer (max 100 Eintraege) mit Timestamp + Severity + Message. +//! Wird von verschiedenen Stellen geschrieben (Supervisor, Backup-Scheduler, +//! Container-Update, Pre-Pull, App-Startup) und vom Frontend gepollt. + +use chrono::Local; +use serde::Serialize; +use std::collections::VecDeque; +use std::sync::OnceLock; +use tokio::sync::Mutex; + +const CAPACITY: usize = 100; + +#[derive(Debug, Clone, Serialize)] +pub struct Event { + pub ts_iso: String, + pub kind: &'static str, + pub message: String, +} + +static EVENTS: OnceLock>> = OnceLock::new(); + +fn store() -> &'static Mutex> { + EVENTS.get_or_init(|| Mutex::new(VecDeque::with_capacity(CAPACITY))) +} + +pub async fn append(kind: &'static str, message: impl Into) { + 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) { + append("info", msg).await +} + +pub async fn warn(msg: impl Into) { + append("warn", msg).await +} + +pub async fn error(msg: impl Into) { + append("error", msg).await +} + +pub async fn list() -> Vec { + let q = store().lock().await; + q.iter().rev().cloned().collect() +} diff --git a/src-tauri/src/firstaid.rs b/src-tauri/src/firstaid.rs new file mode 100644 index 0000000..92ce7ca --- /dev/null +++ b/src-tauri/src/firstaid.rs @@ -0,0 +1,177 @@ +//! Erste-Hilfe-Aktionen fuer den Notfall. +//! +//! Drei destruktive Aktionen mit unterschiedlichem Risiko: +//! - `recreate_containers` (mild): compose down + up --force-recreate. +//! Container neu, Volumes bleiben → Daten safe. +//! - `reset_pgdata` (NUKE): compose down -v + up. Volumes WEG → Postgres +//! wird von Grund auf neu initialisiert. Erfordert Pre-Backup. +//! - `diagnose_bundle`: kein Schaden — sammelt Logs, ps, version, config +//! (redacted) in eine Text-Datei unter backups/. + +use crate::{backup::BackupInfo, paths, services}; +use chrono::Local; +use serde::Serialize; +use tokio::process::Command; + +#[derive(Debug, Clone, Serialize)] +pub struct RecreateResult { + pub log: String, + pub finished_at_iso: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ResetResult { + pub safety_backup: BackupInfo, + pub log: String, + pub finished_at_iso: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DiagnoseResult { + pub filename: String, + pub bytes: u64, +} + +async fn compose(args: &[&str]) -> Result { + 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 { + 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 { + 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 { + 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}=\n", v.len())); + } else { + out.push_str(&format!("{k}={v}\n")); + } + } + out +} diff --git a/src-tauri/src/health.rs b/src-tauri/src/health.rs new file mode 100644 index 0000000..0953ac7 --- /dev/null +++ b/src-tauri/src/health.rs @@ -0,0 +1,57 @@ +//! Health-Probes für Subprozesse. +//! +//! Jeder Service hat einen Probe-Typ. `HealthProbe::check()` ist async und +//! liefert `Ok(())` wenn der Service als gesund gilt, sonst eine Fehler- +//! beschreibung. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::net::TcpStream; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HealthProbe { + /// HTTP GET, erwartet 2xx-Status. + Http { url: String }, + /// TCP-Connect, dann (optional) `SELECT 1` via psql. + /// Für Postgres: aktuell nur TCP-Connect — `psql`-Query käme dazu, wenn + /// libpq oder ein eingebetteter Client verfügbar ist. + TcpAndQuery { host: String, port: u16, db: String }, +} + +const TIMEOUT: Duration = Duration::from_secs(3); + +impl HealthProbe { + pub async fn check(&self) -> Result<(), String> { + match self { + HealthProbe::Http { url } => check_http(url).await, + HealthProbe::TcpAndQuery { host, port, .. } => check_tcp(host, *port).await, + } + } +} + +async fn check_http(url: &str) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(TIMEOUT) + .build() + .map_err(|e| format!("reqwest build: {e}"))?; + let resp = client + .get(url) + .send() + .await + .map_err(|e| format!("http get {url}: {e}"))?; + if resp.status().is_success() || resp.status().is_redirection() { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + +async fn check_tcp(host: &str, port: u16) -> Result<(), String> { + let addr = format!("{host}:{port}"); + tokio::time::timeout(TIMEOUT, TcpStream::connect(&addr)) + .await + .map_err(|_| format!("tcp connect {addr}: timeout"))? + .map_err(|e| format!("tcp connect {addr}: {e}"))?; + Ok(()) +} diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs new file mode 100644 index 0000000..ad045cb --- /dev/null +++ b/src-tauri/src/http_server.rs @@ -0,0 +1,462 @@ +//! HTTP/HTTPS-Server fuer den Admin-WebUI-Zugang. +//! +//! Spiegelt die Tauri-Commands als REST-Endpoints + serviert das React-`dist/`. +//! Konfiguration aus `config.env`: +//! - `ADMIN_UI_BIND` (Default `127.0.0.1`; LAN-Freigabe → `0.0.0.0`) +//! - `ADMIN_UI_PORT` (Default `9090`) +//! - `ADMIN_UI_PASSWORD` (Auto-generiert, ~32 Bytes random) +//! - `ADMIN_UI_TLS` (Default `true` — self-signed Cert wird automatisch +//! in `/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, + locked_until: Option, +} + +type FailMap = Arc>>; + +#[derive(Clone)] +pub struct HttpState { + pub supervisor: Arc>, + pub password: String, + fails: FailMap, +} + +pub async fn serve( + bind: String, + port: u16, + password: String, + tls: bool, + supervisor: Arc>, + 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::()) + .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::(), + ) + .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, + ConnectInfo(peer): ConnectInfo, + req: Request, + next: Next, +) -> Result { + 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 `
` einen +// `start-all` triggern kann, verlangen wir auf POST/PUT/DELETE/PATCH den +// Custom-Header `X-Rapport-Csrf: 1`. Cross-origin `` kann diesen nicht +// setzen; cross-origin `fetch()` triggert dafuer CORS-Preflight, das wir nicht +// allowen — also auch geblockt. + +async fn csrf_check(req: Request, next: Next) -> Result { + 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, 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) -> impl IntoResponse { + Json(crate::supervisor::list_with_timeout(&s.supervisor, 300).await) +} + +async fn start_all(State(s): State) -> impl IntoResponse { + map_result(crate::supervisor::Supervisor::start_all_managed(s.supervisor.clone()).await) +} + +async fn stop_all(State(s): State) -> impl IntoResponse { + map_result(crate::supervisor::Supervisor::stop_all_managed(s.supervisor.clone()).await) +} + +async fn start_service( + State(s): State, + Path(id): Path, +) -> impl IntoResponse { + let mut sv = s.supervisor.lock().await; + map_result(sv.start(&id).await) +} + +async fn stop_service( + State(s): State, + Path(id): Path, +) -> impl IntoResponse { + let mut sv = s.supervisor.lock().await; + map_result(sv.stop(&id).await) +} + +async fn restart_service_h( + State(s): State, + Path(id): Path, +) -> impl IntoResponse { + let mut sv = s.supervisor.lock().await; + map_result(sv.restart(&id).await) +} + +async fn restart_all_h(State(s): State) -> impl IntoResponse { + map_result(crate::supervisor::Supervisor::restart_all_managed(s.supervisor.clone()).await) +} + +async fn service_logs( + State(s): State, + Path(id): Path, +) -> impl IntoResponse { + let sv = s.supervisor.lock().await; + Json(sv.logs(&id).await) +} + +async fn current_activity(State(s): State) -> 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) -> 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) -> 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(), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..356e658 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,476 @@ +//! RAPPORT Server-App — Tauri-Entry-Point. +//! +//! Hier wird der Process-Supervisor initialisiert, in den App-State gehängt, +//! und alle `#[tauri::command]`-Handler registriert. + +mod backup; +mod commands; +mod config; +mod container_update; +mod disk; +mod events; +mod firstaid; +mod health; +mod http_server; +mod paths; +mod services; +mod setup; +mod stats; +mod supervisor; + +use std::sync::Arc; +use supervisor::Supervisor; +use tauri::Manager; +use tokio::sync::Mutex; + +/// Geteilter Supervisor-State über `tauri::State`. +pub struct AppState { + pub supervisor: Arc>, +} + +/// 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::() + )).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 = 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"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..d475a59 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run() +} diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs new file mode 100644 index 0000000..ae4666f --- /dev/null +++ b/src-tauri/src/paths.rs @@ -0,0 +1,63 @@ +//! Plattform-spezifische Pfade fuer den App-Data-Bereich. +//! +//! Die App selber liest/schreibt nur in `data_dir()` — Postgres-Volume, +//! Storage-Files, Backups, `config.env`. Container greifen ueber `-v`-Mounts +//! auf diese Pfade zu. + +use directories::ProjectDirs; +use std::path::PathBuf; + +const QUALIFIER: &str = "com"; +const ORG: &str = "rapport"; +const APP: &str = "server-app"; + +/// `~/Library/Application Support/com.rapport.server-app/` (macOS), +/// `~/.local/share/rapport-server-app/` (Linux), +/// `%APPDATA%/rapport/server-app/` (Windows). +pub fn data_dir() -> PathBuf { + ProjectDirs::from(QUALIFIER, ORG, APP) + .map(|d| d.data_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from("./data")) +} + +pub fn postgres_data_dir() -> PathBuf { + data_dir().join("postgres") +} + +pub fn storage_data_dir() -> PathBuf { + data_dir().join("storage") +} + +pub fn logs_dir() -> PathBuf { + data_dir().join("logs") +} + +pub fn backups_dir() -> PathBuf { + data_dir().join("backups") +} + +pub fn config_env_path() -> PathBuf { + data_dir().join("config.env") +} + +pub fn admin_ui_cert_path() -> PathBuf { + data_dir().join("admin-ui-cert.pem") +} + +pub fn admin_ui_key_path() -> PathBuf { + data_dir().join("admin-ui-key.pem") +} + +/// Erzeugt alle noetigen Verzeichnisse falls sie noch nicht existieren. +pub fn ensure_dirs() -> std::io::Result<()> { + for d in [ + data_dir(), + postgres_data_dir(), + storage_data_dir(), + logs_dir(), + backups_dir(), + ] { + std::fs::create_dir_all(&d)?; + } + Ok(()) +} diff --git a/src-tauri/src/services.rs b/src-tauri/src/services.rs new file mode 100644 index 0000000..897a08a --- /dev/null +++ b/src-tauri/src/services.rs @@ -0,0 +1,164 @@ +//! Service-Inventar. +//! +//! Die App ist eine duenne UI ueber dem Compose-Stack im +//! `RAPPORT/SERVER-CONTAINER`-Repo. `id` ist hier 1:1 der Compose-Service-Name — +//! der Supervisor bildet daraus `docker compose `-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 = 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. `/../SERVER-CONTAINER/` +/// 5. `/../../SERVER-CONTAINER/` (Dev-Layout) +/// +/// Jeder Kandidat muss eine `docker-compose.yml` enthalten. +pub fn init_compose_dir(override_path: Option) -> 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::>().join(", ") + )) +} + +fn collect_candidates(override_path: Option) -> Vec { + 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, + /// Health-Probe (HTTP/TCP) fuer "echt up" jenseits von `docker ps`. + pub health: HealthProbe, +} + +pub fn default_services() -> Vec { + 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(), + }, + }, + ] +} diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs new file mode 100644 index 0000000..bf644f5 --- /dev/null +++ b/src-tauri/src/setup.rs @@ -0,0 +1,537 @@ +//! Setup-Wizard: Detection + Direct-Download-Installation von Docker-CLI, +//! Colima und Lima. Kein Brew, keine externen Package-Manager. +//! +//! Layout: +//! ~/.rapport/ +//! ├── bin/ +//! │ ├── docker +//! │ ├── docker-compose +//! │ ├── colima +//! │ └── limactl +//! ├── lima-share/ (von Lima-Tarball, share/lima/) +//! └── home/ (HOME-Override fuer Colima/Lima Konfig) +//! +//! `lib.rs::run()` prependet `~/.rapport/bin` an PATH und setzt `LIMA_HOME` +//! etc. damit die Tools die mitgelieferten Files finden. + +use serde::Serialize; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; + +// ---- Pinned Versionen ---------------------------------------------------- +const DOCKER_VERSION: &str = "29.5.2"; +const COLIMA_VERSION: &str = "v0.10.1"; +const LIMA_VERSION: &str = "v2.1.1"; +// Quelle fuer den Compose-Stack — wird beim Setup-Wizard automatisch nach +// ~/.rapport/compose/ heruntergeladen wenn lokal noch nichts gefunden wird. +const COMPOSE_TARBALL_URL: &str = + "https://git.kgva.ch/karim/RAPPORT-SERVER/archive/main.tar.gz"; + +#[derive(Debug, Clone, Serialize)] +pub struct SetupStatus { + pub docker_cli: bool, + pub colima_installed: bool, + pub limactl_installed: bool, + pub daemon_running: bool, + pub ready: bool, + pub recommended_action: RecommendedAction, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RecommendedAction { + /// Alles ok — UI rendert das Dashboard. + NoneReady, + /// Tools alle installiert, nur Daemon ist aus → `colima start`. + StartColima, + /// Tools fehlen → Direct-Download. + InstallAll, +} + +pub fn install_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(".rapport") +} + +pub fn bin_dir() -> PathBuf { + install_dir().join("bin") +} + +pub fn compose_dir() -> PathBuf { + install_dir().join("compose") +} + +/// Lima erwartet die Templates relativ zur Binary unter `/../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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs new file mode 100644 index 0000000..cc2e978 --- /dev/null +++ b/src-tauri/src/stats.rs @@ -0,0 +1,85 @@ +//! Container-Live-Stats via `docker stats --no-stream`. +//! +//! Wird auf Demand abgerufen (separater Tauri-Command + HTTP-Endpoint), +//! NICHT in den 2s-Health-Tick eingehaengt — `docker stats` braucht selbst +//! ~500ms-1s und wuerde den UI-Poll ausbremsen. + +use serde::Serialize; +use tokio::process::Command; + +#[derive(Debug, Clone, Serialize)] +pub struct ContainerStats { + /// Compose-Service-ID (z.B. `db`, `auth`) — abgeleitet vom Container-Namen. + pub service_id: String, + pub cpu_percent: f32, + pub mem_bytes: u64, + pub mem_percent: f32, +} + +pub async fn collect() -> Vec { + 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::(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 +} diff --git a/src-tauri/src/supervisor.rs b/src-tauri/src/supervisor.rs new file mode 100644 index 0000000..78a2d4b --- /dev/null +++ b/src-tauri/src/supervisor.rs @@ -0,0 +1,603 @@ +//! Process-Supervisor — duenner Wrapper um `docker compose`. +//! +//! Statt selbst Container-Argumente zu bauen, delegieren wir Start/Stop/Logs +//! an die Compose-Datei in `services::compose_dir()`. Das hat den Vorteil dass +//! Image-/Env-/Volume-Konfiguration nur einmal existiert (im SERVER-CONTAINER- +//! Repo) und nicht hier und dort gepflegt werden muss. + +use crate::services::{self, ServiceDef}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; + +const LOG_RING_CAPACITY: usize = 1000; + +/// Globaler Status-Cache fuer schnelles `list_services` ohne den Supervisor-Mutex +/// blockieren zu muessen. Wird von list_with_timeout() gefuellt; bei lang +/// laufenden compose-Calls (z.B. Erst-Start mit Image-Pull) liefert der Cache +/// die letzte bekannte Liste so dass die UI nicht hangs zeigt. +static STATUS_CACHE: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + +fn status_cache() -> &'static Arc>> { + 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>, + timeout_ms: u64, +) -> Vec { + 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, + pub port: u16, + pub last_error: Option, +} + +struct ServiceEntry { + def: ServiceDef, + state: ServiceState, + log_pump: Option, + pid: Option, + last_error: Option, + logs: Arc>>, + /// Wann zum erstmals in Error gerutscht — gesetzt bei Transition INTO Error, + /// gecleart wenn raus. + errored_since: Option, + recovery_attempts: u32, + /// Wann der naechste Auto-Restart-Versuch faellig ist. + next_recovery_at: Option, +} + +#[derive(Debug, Default, Clone, Serialize)] +pub struct RecoveryReport { + pub to_restart: Vec, + pub maxed_out: Vec, +} + +pub struct Supervisor { + services: HashMap, + pub activity: Arc>, +} + +impl Supervisor { + pub fn new() -> Self { + Self { + services: HashMap::new(), + activity: Arc::new(Mutex::new(String::new())), + } + } + + pub fn activity_handle(&self) -> Arc> { + 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 { + 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 { + 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 { + 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>) -> 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>) -> 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>) -> 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 = 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 { + 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 = 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 { + 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, +} + +async fn query_compose_status() -> Result, 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::(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 { + 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>>) -> Result { + compose(&["up", "-d", service]).await?; + spawn_log_pump(service, logs).await +} + +async fn spawn_log_pump( + service: &str, + logs: Arc>>, +) -> Result { + 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( + reader: R, + logs: Arc>>, +) { + 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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..0f388e7 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schema.tauri.app/config/2.0.0", + "productName": "RAPPORT Server", + "version": "0.1.0", + "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ür Rapport", + "longDescription": "Admin-UI für 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": "© 2026 Karim Gabriele Varano — 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" + } + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..3c43084 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react' +import { api } from './api.js' +import ServiceCard from './components/ServiceCard.jsx' +import LogViewer from './components/LogViewer.jsx' +import BackupPanel from './components/BackupPanel.jsx' +import SettingsPanel from './components/SettingsPanel.jsx' +import FirstAidPanel from './components/FirstAidPanel.jsx' +import AppUpdateBanner from './components/AppUpdateBanner.jsx' +import EventFeed from './components/EventFeed.jsx' +import SetupWizard from './components/SetupWizard.jsx' + +const TABS = ['status', 'logs', 'backup', 'firstaid', 'settings'] +const TAB_LABELS = { + status: 'Status', + logs: 'Logs', + backup: 'Backup', + firstaid: 'Erste Hilfe', + settings: 'Settings', +} +const TAB_ICONS = { + status: 'dashboard', + logs: 'description', + backup: 'backup', + firstaid: 'medical_services', + settings: 'settings', +} + +export default function App() { + const [tab, setTab] = useState('status') + const [services, setServices] = useState([]) + const [stats, setStats] = useState({}) // service_id → ContainerStats + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [setupReady, setSetupReady] = useState(null) // null = pruefe, true = bereit, false = Wizard + + async function refresh() { + try { + setServices(await api.listServices()) + setError(null) + } catch (e) { + setError(String(e)) + } + } + + async function refreshStats() { + try { + const list = await api.listStats() + const m = {} + for (const s of list ?? []) m[s.service_id] = s + setStats(m) + } catch { /* leise — docker stats kann mal scheitern */ } + } + + useEffect(() => { + // Erst Setup-Status pruefen — wenn nicht ready, blockt der Wizard die UI. + let cancelled = false + async function checkSetup() { + try { + const s = await api.setupStatus() + if (!cancelled) setSetupReady(s.ready) + } catch { if (!cancelled) setSetupReady(false) } + } + checkSetup() + return () => { cancelled = true } + }, []) + + useEffect(() => { + if (!setupReady) return + refresh() + const t = setInterval(refresh, 2000) + return () => clearInterval(t) + }, [setupReady]) + + useEffect(() => { + if (!setupReady) return + refreshStats() + const t = setInterval(refreshStats, 5000) + return () => clearInterval(t) + }, [setupReady]) + + async function startAll() { + setBusy(true) + try { await api.startAll() } catch (e) { setError(String(e)) } + finally { setBusy(false); refresh() } + } + + async function stopAll() { + setBusy(true) + try { await api.stopAll() } catch (e) { setError(String(e)) } + finally { setBusy(false); refresh() } + } + + async function restartAll() { + setBusy(true) + try { await api.restartAll() } catch (e) { setError(String(e)) } + finally { setBusy(false); refresh() } + } + + const allRunning = services.length > 0 && services.every(s => s.state === 'running') + const anyRunning = services.some(s => s.state === 'running' || s.state === 'starting') + + // Setup blockt das normale Dashboard bis Docker-Daemon erreichbar ist. + if (setupReady === false) { + return ( +
+ setSetupReady(true)} /> +
+ ) + } + if (setupReady === null) { + return

Lade ...

+ } + + return ( +
+
+
+ + RAPPORT Server +
+ +
+ + + +
+
+ + + {error &&
{error}
} + +
+ {tab === 'status' && ( + <> +
+ {services.length === 0 &&

Keine Services registriert.

} + {services.map(s => ( + api.startService(s.id).then(refresh)} + onStop={() => api.stopService(s.id).then(refresh)} + onRestart={() => api.restartService(s.id).then(refresh)} + /> + ))} +
+ + + )} + {tab === 'logs' && } + {tab === 'backup' && } + {tab === 'firstaid' && } + {tab === 'settings' && } +
+
+ ) +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..3c1575f --- /dev/null +++ b/src/api.js @@ -0,0 +1,94 @@ +// API-Wrapper. In der Tauri-WebView: via IPC (`invoke`). Im Browser (HTTP-WebUI +// vom headless Mac Mini): via fetch — Basic-Auth-Cookie/Header verwaltet der +// Browser selbst nach der ersten Login-Prompt. + +const inTauri = typeof window !== 'undefined' && ( + '__TAURI_INTERNALS__' in window || '__TAURI__' in window +) + +let invoke = null +if (inTauri) { + const mod = await import('@tauri-apps/api/core') + invoke = mod.invoke +} + +async function fetchJson(path, opts = {}) { + const method = (opts.method ?? 'GET').toUpperCase() + const writing = method !== 'GET' && method !== 'HEAD' + const resp = await fetch(`/api${path}`, { + ...opts, + headers: { + 'content-type': 'application/json', + // CSRF: state-changing Requests muessen den Custom-Header tragen. + // Cross-origin Forms koennen ihn nicht setzen, cross-origin fetch + // triggert Preflight, der serverseitig nicht erlaubt ist. + ...(writing ? { 'x-rapport-csrf': '1' } : {}), + ...(opts.headers ?? {}), + }, + }) + if (!resp.ok) { + throw new Error(`${resp.status} ${resp.statusText}`) + } + const text = await resp.text() + if (!text) return null + try { return JSON.parse(text) } catch { return text } +} + +export const api = inTauri + ? { + listServices: () => invoke('list_services'), + serviceStatus: (id) => invoke('service_status', { id }), + serviceLogs: (id) => invoke('service_logs', { id }), + startService: (id) => invoke('start_service', { id }), + stopService: (id) => invoke('stop_service', { id }), + restartService: (id) => invoke('restart_service', { id }), + startAll: () => invoke('start_all'), + stopAll: () => invoke('stop_all'), + restartAll: () => invoke('restart_all'), + getConfig: () => invoke('get_config'), + setConfigValue: (key, value) => invoke('set_config_value', { key, value }), + backupNow: () => invoke('backup_now'), + listBackups: () => invoke('list_backups'), + restoreBackup: (filename) => invoke('restore_backup', { filename }), + checkContainerUpdates: () => invoke('check_container_updates'), + applyContainerUpdates: () => invoke('apply_container_updates'), + listEvents: () => invoke('list_events'), + listStats: () => invoke('list_stats'), + diskUsage: () => invoke('disk_usage'), + firstaidRecreate: () => invoke('firstaid_recreate'), + firstaidResetPgdata: () => invoke('firstaid_reset_pgdata'), + firstaidDiagnose: () => invoke('firstaid_diagnose'), + setupStatus: () => invoke('setup_status'), + setupInstall: () => invoke('setup_install'), + } + : { + listServices: () => fetchJson('/services'), + serviceStatus: async (id) => { + const list = await fetchJson('/services') + return list.find(s => s.id === id) ?? null + }, + serviceLogs: (id) => fetchJson(`/services/${id}/logs`), + startService: (id) => fetchJson(`/services/${id}/start`, { method: 'POST' }), + stopService: (id) => fetchJson(`/services/${id}/stop`, { method: 'POST' }), + restartService: (id) => fetchJson(`/services/${id}/restart`, { method: 'POST' }), + startAll: () => fetchJson('/services/start-all', { method: 'POST' }), + stopAll: () => fetchJson('/services/stop-all', { method: 'POST' }), + restartAll: () => fetchJson('/services/restart-all', { method: 'POST' }), + getConfig: () => Promise.resolve({}), + setConfigValue: () => Promise.resolve(null), + backupNow: () => fetchJson('/backups/now', { method: 'POST' }), + listBackups: () => fetchJson('/backups'), + restoreBackup: (filename) => fetchJson(`/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' }), + checkContainerUpdates: () => fetchJson('/container-updates/check', { method: 'POST' }), + applyContainerUpdates: () => fetchJson('/container-updates/apply', { method: 'POST' }), + listEvents: () => fetchJson('/events'), + listStats: () => fetchJson('/stats'), + diskUsage: () => fetchJson('/disk'), + firstaidRecreate: () => fetchJson('/firstaid/recreate', { method: 'POST' }), + firstaidResetPgdata: () => fetchJson('/firstaid/reset-pgdata', { method: 'POST' }), + firstaidDiagnose: () => fetchJson('/firstaid/diagnose', { method: 'POST' }), + setupStatus: () => fetchJson('/setup/status'), + setupInstall: () => fetchJson('/setup/install', { method: 'POST' }), + } + +export const runtime = inTauri ? 'tauri' : 'browser' diff --git a/src/components/AppUpdateBanner.jsx b/src/components/AppUpdateBanner.jsx new file mode 100644 index 0000000..724c1e1 --- /dev/null +++ b/src/components/AppUpdateBanner.jsx @@ -0,0 +1,90 @@ +// 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 ( +
+ system_update + + Update verfuegbar: v{update.version} + {update.body && · {update.body.split('\n')[0]}} + + + {error && {error}} +
+ ) +} diff --git a/src/components/BackupPanel.jsx b/src/components/BackupPanel.jsx new file mode 100644 index 0000000..0a3277a --- /dev/null +++ b/src/components/BackupPanel.jsx @@ -0,0 +1,282 @@ +import { useEffect, useState } from 'react' +import { api } from '../api.js' + +function fmtBytes(n) { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB` +} + +function fmtDate(iso) { + try { + const d = new Date(iso) + return d.toLocaleString('de-CH', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + } catch { return iso } +} + +function fmtAge(iso) { + const ms = Date.now() - new Date(iso).getTime() + const h = Math.floor(ms / 3_600_000) + if (h < 1) return `vor ${Math.floor(ms / 60_000)} min` + if (h < 48) return `vor ${h} h` + return `vor ${Math.floor(h / 24)} Tagen` +} + +export default function BackupPanel() { + const [backups, setBackups] = useState([]) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [lastResult, setLastResult] = useState(null) + const [updateCheck, setUpdateCheck] = useState(null) + const [updateBusy, setUpdateBusy] = useState(false) + const [updateError, setUpdateError] = useState(null) + const [applyResult, setApplyResult] = useState(null) + const [disk, setDisk] = useState(null) + + async function refresh() { + try { + const list = await api.listBackups() + setBackups(list ?? []) + } catch (e) { + setError(String(e)) + } + } + + useEffect(() => { + refresh() + const t = setInterval(refresh, 30000) + return () => clearInterval(t) + }, []) + + useEffect(() => { + async function loadDisk() { + try { setDisk(await api.diskUsage()) } catch { /* leise */ } + } + loadDisk() + const t = setInterval(loadDisk, 60000) // disk-Check teuer (psql + df), 1x/min reicht + return () => clearInterval(t) + }, []) + + async function runBackup() { + setBusy(true); setError(null); setLastResult(null) + try { + const info = await api.backupNow() + setLastResult(info) + await refresh() + } catch (e) { + setError(String(e)) + } finally { + setBusy(false) + } + } + + async function checkUpdates() { + setUpdateBusy(true); setUpdateError(null); setApplyResult(null) + try { + const res = await api.checkContainerUpdates() + setUpdateCheck(res) + } catch (e) { + setUpdateError(String(e)) + } finally { + setUpdateBusy(false) + } + } + + async function runRestore(filename) { + const ok = window.confirm( + `Snapshot ${filename} wiederherstellen?\n\n` + + 'Achtung — DESTRUKTIV:\n' + + ' 1. Sicherheits-Backup vom aktuellen Stand wird automatisch erstellt\n' + + ' 2. Services (auth, rest, realtime, storage, kong, app) werden gestoppt\n' + + ' 3. Komplette Datenbank wird ueberschrieben mit dem Snapshot-Inhalt\n' + + ' 4. Services werden wieder gestartet\n\n' + + 'Aktuelle Daten gehen verloren (sind aber im Safety-Backup gesichert).' + ) + if (!ok) return + setBusy(true); setError(null); setLastResult(null) + try { + const res = await api.restoreBackup(filename) + setLastResult({ + filename: `Restore aus ${res.restored_from}`, + bytes: 0, + }) + // Auch das Safety-Backup im Banner anzeigen: + setTimeout(() => { + alert( + `Restore fertig.\n\n` + + `Safety-Backup vom alten Stand: ${res.safety_backup}\n\n` + + `Falls etwas nicht stimmt: das Safety-Backup wiederherstellen ` + + `bringt den Zustand vor dem Restore zurueck.` + ) + }, 100) + await refresh() + } catch (e) { + setError(String(e)) + } finally { + setBusy(false) + } + } + + async function applyUpdates() { + const count = updateCheck?.updates?.length ?? 0 + if (count > 0) { + const ok = window.confirm( + `${count} Container-Update(s) anwenden?\n\n` + + 'Ablauf:\n' + + ' 1. pg_dumpall Backup\n' + + ' 2. docker compose up -d (recreate)\n\n' + + 'Services sind waehrend Recreate kurz weg.' + ) + if (!ok) return + } + setUpdateBusy(true); setUpdateError(null); setApplyResult(null) + try { + const res = await api.applyContainerUpdates() + setApplyResult(res) + setUpdateCheck(null) + await refresh() + } catch (e) { + setUpdateError(String(e)) + } finally { + setUpdateBusy(false) + } + } + + const usedPct = disk && disk.host_total_bytes > 0 + ? (1 - disk.host_free_bytes / disk.host_total_bytes) * 100 + : 0 + const diskWarn = usedPct >= 80 + const diskCrit = usedPct >= 90 + + return ( +
+

Backup & Restore

+ + {disk && ( +
+
+ Festplatte ({fmtBytes(disk.host_free_bytes)} frei von {fmtBytes(disk.host_total_bytes)}) +
+
+
+ {usedPct.toFixed(0)}% +
+
+ Postgres-DB: {disk.postgres_db_bytes != null ? fmtBytes(disk.postgres_db_bytes) : '—'} + Backups: {fmtBytes(disk.backups_total_bytes)} ({disk.backup_count} Dateien) + Docker-Volumes total: {disk.docker_volumes_bytes != null ? fmtBytes(disk.docker_volumes_bytes) : '—'} +
+ {diskCrit &&

errorDisk > 90% voll — aelteste Backups manuell pruefen.

} + {diskWarn && !diskCrit &&

warningDisk > 80% voll.

} +
+ )} +

+ Snapshots werden automatisch alle BACKUP_INTERVAL_HOURS (Default 24h) erstellt + und nach BACKUP_RETENTION_COUNT (Default 7) aelteste-zuerst geprunet. + Speicherort: ~/Library/Application Support/com.rapport.server-app/backups/. + Methode: pg_dumpall via docker compose exec db. +

+ +
+ + +
+ + {lastResult && ( +

+ Neues Backup: {lastResult.filename} ({fmtBytes(lastResult.bytes)}) +

+ )} + {error &&

{error}

} + +

Container-Updates

+

+ Prueft ob neuere Image-Tags fuer die Compose-Services verfuegbar sind. + Anwenden macht erst Pre-Backup, dann docker compose up -d (recreate). + Auto-Update kontrolliert via CONTAINER_AUTOUPDATE_ENABLED. +

+
+ + {updateCheck && updateCheck.updates.length > 0 && ( + + )} +
+ {updateError &&

{updateError}

} + {updateCheck && updateCheck.updates.length === 0 && ( +

Alle Images sind aktuell.

+ )} + {updateCheck && updateCheck.updates.length > 0 && ( +
    + {updateCheck.updates.map(u => ( +
  • + {u.service} ({u.image}) + + {u.old_id.slice(0, 19)} → {u.new_id.slice(0, 19)} + +
  • + ))} +
+ )} + {applyResult && applyResult.updated_services.length > 0 && ( +

+ Update angewendet: {applyResult.updated_services.join(', ')} + {applyResult.backup_filename && ( + <> · Backup: {applyResult.backup_filename} + )} +

+ )} + {applyResult && applyResult.updated_services.length === 0 && ( +

{applyResult.recreate_log}

+ )} + +

Vorhandene Snapshots ({backups.length})

+ {backups.length === 0 ? ( +

Noch keine Backups vorhanden.

+ ) : ( + + + + + + + + + + + + {backups.map(b => ( + + + + + + + + ))} + +
DateiErstelltAlterGroesse
{b.filename}{fmtDate(b.created_iso)}{fmtAge(b.created_iso)}{fmtBytes(b.bytes)} + +
+ )} +
+ ) +} diff --git a/src/components/EventFeed.jsx b/src/components/EventFeed.jsx new file mode 100644 index 0000000..48e1179 --- /dev/null +++ b/src/components/EventFeed.jsx @@ -0,0 +1,69 @@ +// Kompakter Event-Feed fuer den Status-Tab. +// Pollt alle 3s die letzten App-Events (Start, Backup, Updates, etc.). +// Default eingeklappt — zeigt nur den neuesten Event als Teaser im Header. +import { useEffect, useState } from 'react' +import { api } from '../api.js' + +function fmtTime(iso) { + try { + return new Date(iso).toLocaleTimeString('de-CH', { + hour: '2-digit', minute: '2-digit', second: '2-digit', + }) + } catch { return iso } +} + +const KIND_ICON = { + info: 'circle', + warn: 'warning', + error: 'error', +} + +export default function EventFeed({ limit = 8 }) { + const [events, setEvents] = useState([]) + const [open, setOpen] = useState(false) + + useEffect(() => { + let alive = true + async function tick() { + try { + const list = await api.listEvents() + if (alive) setEvents(list ?? []) + } catch { /* leise */ } + } + tick() + const t = setInterval(tick, 3000) + return () => { alive = false; clearInterval(t) } + }, []) + + const latest = events[0] + const visible = events.slice(0, limit) + + return ( +
+ + {open && ( +
+ {visible.length === 0 ? ( + Noch keine Events. + ) : visible.map((e, i) => ( +
+ {fmtTime(e.ts_iso)} + {KIND_ICON[e.kind] ?? 'circle'} + {e.message} +
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/FirstAidPanel.jsx b/src/components/FirstAidPanel.jsx new file mode 100644 index 0000000..160e310 --- /dev/null +++ b/src/components/FirstAidPanel.jsx @@ -0,0 +1,124 @@ +// Erste-Hilfe-Aktionen — drei Eskalationsstufen. +import { useState } from 'react' +import { api } from '../api.js' + +export default function FirstAidPanel() { + const [busy, setBusy] = useState(null) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + async function runRecreate() { + if (!window.confirm( + 'Container neu erstellen?\n\n' + + 'Was passiert:\n' + + ' • docker compose down\n' + + ' • docker compose up -d --force-recreate\n\n' + + 'Volumes bleiben → Postgres-Daten + Storage-Files sind safe.\n' + + 'Services sind ~30s nicht erreichbar.' + )) return + setBusy('recreate'); setError(null); setResult(null) + try { + const r = await api.firstaidRecreate() + setResult({ kind: 'recreate', msg: 'Container neu erstellt.', detail: r.log }) + } catch (e) { setError(String(e)) } + finally { setBusy(null) } + } + + async function runReset() { + const word = window.prompt( + 'PGDATA komplett zuruecksetzen?\n\n' + + 'DESTRUKTIV: alle Datenbank-Inhalte gehen verloren.\n' + + 'Was passiert:\n' + + ' 1. Safety-Backup vom aktuellen Stand (automatisch)\n' + + ' 2. docker compose down -v (Volumes weg!)\n' + + ' 3. docker compose up -d (frisches initdb)\n\n' + + 'Tippe LOESCHEN ein um zu bestaetigen:' + ) + if (word !== 'LOESCHEN') { + if (word !== null) alert('Abgebrochen — du musst exakt LOESCHEN tippen.') + return + } + if (!window.confirm('Letzte Warnung: Datenbank wird zurueckgesetzt. Wirklich?')) return + setBusy('reset'); setError(null); setResult(null) + try { + const r = await api.firstaidResetPgdata() + setResult({ + kind: 'reset', + msg: `PGDATA zurueckgesetzt. Safety-Backup: ${r.safety_backup.filename}`, + detail: r.log, + }) + } catch (e) { setError(String(e)) } + finally { setBusy(null) } + } + + async function runDiagnose() { + setBusy('diagnose'); setError(null); setResult(null) + try { + const r = await api.firstaidDiagnose() + setResult({ + kind: 'diagnose', + msg: `Diagnose-Bundle geschrieben: ${r.filename} (${(r.bytes / 1024).toFixed(0)} KB)`, + detail: 'Unter ~/Library/Application Support/com.rapport.server-app/backups/', + }) + } catch (e) { setError(String(e)) } + finally { setBusy(null) } + } + + return ( +
+

Erste Hilfe

+

+ Drei Werkzeuge fuer Notfaelle. Reihenfolge der Eskalation: erst Diagnose, + dann Neu erstellen, dann PGDATA-Reset. +

+ + {error &&

{error}

} + {result && ( +
+ {result.msg} + {result.detail && ( +
+ Details +
{result.detail}
+
+ )} +
+ )} + +
+

assignmentDiagnose-Bundle

+

+ Sammelt Container-Logs, docker version, ps -a, App-Events und (redacted) + Config in eine Text-Datei unter backups/. Kann als Bug-Report mitgeschickt werden. +

+ +
+ +
+

restart_altContainer neu erstellen

+

+ Hilfsmittel wenn Container in komischen Zustaenden haengen (stuck, Memory-Leak, etc.). + Volumes bleiben — Postgres-Daten sind safe. Ca. 30s Downtime. +

+ +
+ +
+

delete_foreverPGDATA komplett zuruecksetzen

+

+ Letzter Ausweg wenn die Datenbank vermurkst ist (Schema-Korruption, Migration-Lock, + Postgres startet nicht mehr). Alle Daten gehen verloren — + es wird automatisch ein Safety-Backup gemacht, das ist aber nur ein SQL-Dump + des aktuellen (kaputten?) Stands. +

+ +
+
+ ) +} diff --git a/src/components/LogViewer.jsx b/src/components/LogViewer.jsx new file mode 100644 index 0000000..9a90e31 --- /dev/null +++ b/src/components/LogViewer.jsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../api.js' + +export default function LogViewer({ services }) { + const [selected, setSelected] = useState(services[0]?.id ?? '') + const [lines, setLines] = useState([]) + const scrollRef = useRef(null) + + useEffect(() => { + if (!selected && services.length > 0) { + setSelected(services[0].id) + } + }, [services, selected]) + + useEffect(() => { + if (!selected) return + let alive = true + async function tick() { + try { + const data = await api.serviceLogs(selected) + if (alive) setLines(data) + } catch { /* ignore */ } + } + tick() + const t = setInterval(tick, 1500) + return () => { alive = false; clearInterval(t) } + }, [selected]) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [lines]) + + return ( +
+ +
+        {lines.length === 0
+          ? Keine Log-Zeilen (Service vermutlich nicht gestartet).
+          : lines.join('\n')}
+      
+
+ ) +} diff --git a/src/components/ServiceCard.jsx b/src/components/ServiceCard.jsx new file mode 100644 index 0000000..229bb7f --- /dev/null +++ b/src/components/ServiceCard.jsx @@ -0,0 +1,80 @@ +const STATE_LABELS = { + stopped: 'Gestoppt', + starting: 'Startet', + running: 'Laeuft', + stopping: 'Stoppt', + error: 'Fehler', +} + +function fmtMem(bytes) { + if (!bytes) return '0 B' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + +export default function ServiceCard({ service, stats, onStart, onStop, onRestart }) { + const { id, display_name, state, pid, port, last_error } = service + const isRunning = state === 'running' || state === 'starting' + const showStats = stats && state === 'running' + + return ( +
+
+ +

{display_name}

+ :{port} +
+ +
+
Status
{STATE_LABELS[state] || state}
+ {pid != null && (<>
PID
{pid}
)} +
ID
{id}
+
+ + {last_error && ( +
+
{last_error}
+
+ )} + + {showStats && ( +
+
+ CPU + + 80} + /> + + {stats.cpu_percent.toFixed(1)}% +
+
+ RAM + + 80} + /> + + {fmtMem(stats.mem_bytes)} +
+
+ )} + +
+ {!isRunning && } + {isRunning && ( + <> + + + + )} +
+
+ ) +} diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx new file mode 100644 index 0000000..2e30658 --- /dev/null +++ b/src/components/SettingsPanel.jsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from 'react' +import { api, runtime } from '../api.js' + +const SECRET_KEYS = new Set(['POSTGRES_PASSWORD', 'JWT_SECRET', 'ADMIN_UI_PASSWORD']) +const WEBUI_KEYS = ['ADMIN_UI_BIND', 'ADMIN_UI_PORT', 'ADMIN_UI_TLS', 'ADMIN_UI_PASSWORD'] + +export default function SettingsPanel() { + const [config, setConfig] = useState({}) + const [reveal, setReveal] = useState({}) + const [savingKey, setSavingKey] = useState(null) + const [error, setError] = useState(null) + const [restartHint, setRestartHint] = useState(false) + const [autostartEnabled, setAutostartEnabled] = useState(null) // null = unbekannt/lade + + useEffect(() => { + api.getConfig().then(setConfig).catch(e => setError(String(e))) + if (runtime === 'tauri') { + import('@tauri-apps/plugin-autostart') + .then(m => m.isEnabled()) + .then(setAutostartEnabled) + .catch(() => setAutostartEnabled(false)) + } + }, []) + + async function toggleAutostart() { + if (runtime !== 'tauri') return + try { + const mod = await import('@tauri-apps/plugin-autostart') + if (autostartEnabled) { + await mod.disable() + setAutostartEnabled(false) + } else { + await mod.enable() + setAutostartEnabled(true) + } + } catch (e) { + setError(String(e)) + } + } + + async function saveKey(key, value) { + setSavingKey(key); setError(null) + try { + await api.setConfigValue(key, value) + if (WEBUI_KEYS.includes(key)) setRestartHint(true) + } catch (e) { + setError(String(e)) + } finally { + setSavingKey(null) + } + } + + function toggleLan() { + const next = config.ADMIN_UI_BIND === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0' + if (next === '0.0.0.0') { + const ok = window.confirm( + 'Im LAN freigeben?\n\n' + + 'Damit ist die Admin-UI von allen Geraeten im Netzwerk unter ' + + `https://.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 ( +
+

Einstellungen

+ {error &&

{error}

} + {restartHint && ( +

+ Aenderung gespeichert. App neu starten, damit der WebUI-Server mit der neuen Konfiguration laeuft. +

+ )} + +

Auto-Start

+

+ Fuer headless Mac-Mini-Deployments: App startet beim Login automatisch + (versteckt im Tray) und faehrt sofort alle Container hoch. +

+
+
+
+
App beim Login starten
+
+ {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'} +
+
+ +
+ +
+
+
Container nach App-Start hochfahren
+
+ Triggert docker compose up -d sobald die App geladen ist. + Sinnvoll zusammen mit dem Login-Autostart. +
+
+ +
+
+ +

Admin-WebUI

+

+ Browser-basierter Zugang zur gleichen Admin-Oberflaeche — nuetzlich + wenn diese App auf einem Mac Mini ohne Bildschirm laeuft. +

+ +
+
+
+
Im LAN freigeben
+
+ {lanOn + ? `Aktiv: jeder im Netzwerk kann https://.local:${config.ADMIN_UI_PORT || 9090} erreichen` + : 'Aus: nur lokal auf diesem Mac (https://127.0.0.1:9090)'} +
+
+ +
+ +
+
+
Port
+
Default 9090
+
+ 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' }} + /> +
+ +
+
+
TLS / HTTPS
+
+ Self-signed Cert. Browser warnt einmal, danach akzeptiert. +
+
+ +
+ +
+
+
Passwort (User: admin)
+ 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 }} + /> +
+ +
+
+ +

Alle Werte (Rohzugriff)

+

+ Direkter Zugriff auf alle config.env-Eintraege. Aenderungen an + DB-relevanten Werten erfordern Stop & Start der Container. +

+
+ {entries.map(([key, value]) => { + const isSecret = SECRET_KEYS.has(key) + const shown = !isSecret || reveal[key] + return ( +
+ + {isSecret && ( + + )} + {savingKey === key && speichert ...} +
+ ) + })} +
+
+ ) +} diff --git a/src/components/SetupWizard.jsx b/src/components/SetupWizard.jsx new file mode 100644 index 0000000..62867b8 --- /dev/null +++ b/src/components/SetupWizard.jsx @@ -0,0 +1,123 @@ +// Setup-Wizard: zeigt sich wenn Docker-Daemon nicht erreichbar ist. +// Bietet einen einzigen Install-Knopf der von GitHub/docker.com direkt +// nach ~/.rapport/bin/ herunterlaedt und Colima startet. +import { useEffect, useState } from 'react' +import { api } from '../api.js' + +const ACTION_INFO = { + none_ready: { + title: 'Alles bereit', + desc: 'Docker-Daemon laeuft.', + button: null, + }, + start_colima: { + title: 'Colima starten', + desc: 'Docker, Colima und Lima sind installiert — der Daemon ist nur aus. Ein Klick reicht.', + button: 'Colima starten', + }, + install_all: { + title: 'Alles automatisch installieren', + desc: 'Wir laden Docker-CLI, Colima und Lima direkt von docker.com und github.com nach ~/.rapport/bin/ herunter — keine externen Package-Manager, kein sudo. Dauert ca. 2-3 Minuten beim ersten Mal.', + button: 'Installieren und starten', + }, +} + +export default function SetupWizard({ onReady }) { + const [status, setStatus] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [log, setLog] = useState(null) + + async function check() { + try { + const s = await api.setupStatus() + setStatus(s) + if (s.ready) onReady?.() + } catch (e) { + setError(String(e)) + } + } + + useEffect(() => { + check() + const t = setInterval(check, 5000) + return () => clearInterval(t) + }, []) + + async function install() { + setBusy(true); setError(null); setLog(null) + try { + const res = await api.setupInstall() + setLog(res.log) + await check() + } catch (e) { + setError(String(e)) + } finally { + setBusy(false) + } + } + + if (!status) { + return ( +
+

Pruefe Setup ...

+
+ ) + } + + if (status.ready) return null + + const info = ACTION_INFO[status.recommended_action] + + return ( +
+

Willkommen bei RAPPORT Server

+

+ Bevor die Server-App den Rapport-Stack verwalten kann, brauchen wir einen + laufenden Docker-Daemon auf diesem Mac. +

+ +
+ + + + +
+ +

{info.title}

+

{info.desc}

+ + {error &&

{error}

} + + {info.button && ( + + )} + + {busy && ( +

+ Status live im Status-Tab unter "App-Events" (sobald das Dashboard kommt). + Dauert je nach Internet-Speed paar Minuten. +

+ )} + + {log && ( +
+ Install-Log anzeigen +
{log}
+
+ )} +
+ ) +} + +function Check({ label, ok }) { + const iconName = ok ? 'check_circle' : 'cancel' + return ( +
+ {iconName} + {label} +
+ ) +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..19258a1 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import 'material-symbols/outlined.css' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..afe8e92 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,450 @@ +: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); } diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..30f00cc --- /dev/null +++ b/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { + port: 3001, + strictPort: true, + host: false, + }, + envPrefix: ['VITE_', 'TAURI_'], + build: { + target: 'es2022', + // Vite 8 / Rolldown: 'esbuild' braucht extra Install, 'oxc' ist neuer Default. + minify: 'oxc', + sourcemap: false, + }, +})