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
@@ -0,0 +1,72 @@
|
||||
[package]
|
||||
name = "rapport-server-app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Karim Gabriele Varano"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
description = "RAPPORT Server-App — Tauri-Wrapper für gebundlete Backend-Services"
|
||||
default-run = "rapport-server-app"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "rapport-server-app"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Process supervisor utilities
|
||||
nix = { version = "0.29", features = ["signal", "process"], default-features = false }
|
||||
|
||||
# Filesystem & paths
|
||||
directories = "5"
|
||||
dirs = "5"
|
||||
|
||||
# HTTP client (for health checks)
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Crypto utilities (for JWT-secret / random password generation)
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
# JWT-Signing fuer ANON_KEY / SERVICE_ROLE_KEY beim Compose-Stack-Bootstrap
|
||||
jsonwebtoken = "9"
|
||||
|
||||
# HTTP server fuer den optionalen Admin-WebUI-Zugang (Mac Mini ohne Display)
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "auth", "cors", "trace"] }
|
||||
# Self-signed certs fuer den Admin-WebUI-TLS
|
||||
rcgen = "0.13"
|
||||
# Hostname-Lookup fuer Cert-SANs
|
||||
hostname = "0.4"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities — IPC, Process restart, Log access",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:webview:allow-print",
|
||||
"process:allow-restart",
|
||||
"process:allow-exit",
|
||||
"updater:default",
|
||||
"autostart:default",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 626 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 246 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 249 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1,317 @@
|
||||
//! Backup-Modul.
|
||||
//!
|
||||
//! Erstellt SQL-Dumps via `docker compose exec -T db pg_dumpall` und legt
|
||||
//! sie unter `<data_dir>/backups/` ab. Ein Hintergrund-Scheduler triggert
|
||||
//! abhaengig von `BACKUP_INTERVAL_HOURS` aus der Config. Retention pruned
|
||||
//! alle ueber `BACKUP_RETENTION_COUNT` hinaus (aelteste zuerst).
|
||||
|
||||
use crate::{paths, services};
|
||||
use chrono::{DateTime, Local};
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BackupInfo {
|
||||
pub filename: String,
|
||||
pub created_iso: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// Default-Intervall fuer den Scheduler-Tick (1 Stunde). Im Tick wird gepruefte
|
||||
/// ob das letzte Backup aelter als `BACKUP_INTERVAL_HOURS` ist.
|
||||
pub const SCHED_TICK: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
const DEFAULT_INTERVAL_HOURS: u64 = 24;
|
||||
const DEFAULT_RETENTION: usize = 7;
|
||||
|
||||
pub fn list() -> Vec<BackupInfo> {
|
||||
let dir = paths::backups_dir();
|
||||
let Ok(read) = std::fs::read_dir(&dir) else { return vec![] };
|
||||
let mut out: Vec<BackupInfo> = read
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
let meta = e.metadata().ok()?;
|
||||
if !meta.is_file() {
|
||||
return None;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("rapport-") || !name.ends_with(".sql") {
|
||||
return None;
|
||||
}
|
||||
let created: DateTime<Local> = meta.modified().ok()?.into();
|
||||
Some(BackupInfo {
|
||||
filename: name,
|
||||
created_iso: created.to_rfc3339(),
|
||||
bytes: meta.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.created_iso.cmp(&a.created_iso));
|
||||
out
|
||||
}
|
||||
|
||||
/// Erstellt ein neues Backup. Datei-Name `rapport-YYYYmmdd-HHMMSS.sql`.
|
||||
pub async fn create() -> Result<BackupInfo, String> {
|
||||
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
|
||||
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let filename = format!("rapport-{stamp}.sql");
|
||||
let path = paths::backups_dir().join(&filename);
|
||||
|
||||
log::info!("Backup → {}", path.display());
|
||||
|
||||
// `docker compose exec -T db pg_dumpall ...` — `-T` schaltet das TTY ab,
|
||||
// damit stdout sauber pipebar ist.
|
||||
let output = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args([
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"pg_dumpall",
|
||||
"-U",
|
||||
"supabase_admin",
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn pg_dumpall: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"pg_dumpall exited {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::write(&path, &output.stdout).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
|
||||
let bytes = output.stdout.len() as u64;
|
||||
log::info!("Backup geschrieben: {} ({} bytes)", path.display(), bytes);
|
||||
crate::events::info(format!(
|
||||
"Backup erstellt: {filename} ({} KB)",
|
||||
bytes / 1024
|
||||
))
|
||||
.await;
|
||||
|
||||
Ok(BackupInfo {
|
||||
filename,
|
||||
created_iso: Local::now().to_rfc3339(),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Loescht die aeltesten Backups bis nur noch `keep` da sind.
|
||||
pub fn prune(keep: usize) -> Result<usize, String> {
|
||||
let all = list();
|
||||
if all.len() <= keep {
|
||||
return Ok(0);
|
||||
}
|
||||
let removed = &all[keep..];
|
||||
let mut count = 0;
|
||||
for b in removed {
|
||||
let path = paths::backups_dir().join(&b.filename);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(_) => {
|
||||
log::info!("Backup geprunet: {}", path.display());
|
||||
count += 1;
|
||||
}
|
||||
Err(e) => log::warn!("remove {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Liest die Config-Werte mit Fallbacks.
|
||||
fn config_values() -> (bool, u64, usize) {
|
||||
let cfg = crate::config::load().unwrap_or_default();
|
||||
let enabled = cfg
|
||||
.get("BACKUP_ENABLED")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true);
|
||||
let interval = cfg
|
||||
.get("BACKUP_INTERVAL_HOURS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_INTERVAL_HOURS);
|
||||
let retention = cfg
|
||||
.get("BACKUP_RETENTION_COUNT")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_RETENTION);
|
||||
(enabled, interval, retention)
|
||||
}
|
||||
|
||||
/// Hintergrund-Scheduler. Tickt jede Stunde, schaut ob das aelteste Backup
|
||||
/// schon `BACKUP_INTERVAL_HOURS` her ist, und triggert dann einen neuen
|
||||
/// Dump + Prune.
|
||||
pub async fn scheduler_loop() {
|
||||
let mut tick = tokio::time::interval(SCHED_TICK);
|
||||
// Erstes tick() feuert sofort — das ueberspringen wir, damit nicht beim
|
||||
// App-Start jedes Mal ein Backup laeuft.
|
||||
tick.tick().await;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let (enabled, interval, retention) = config_values();
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
if !backup_due(interval) {
|
||||
continue;
|
||||
}
|
||||
match create().await {
|
||||
Ok(b) => log::info!("Auto-Backup: {}", b.filename),
|
||||
Err(e) => log::warn!("Auto-Backup fehlgeschlagen: {e}"),
|
||||
}
|
||||
if let Err(e) = prune(retention) {
|
||||
log::warn!("Prune fehlgeschlagen: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_due(interval_hours: u64) -> bool {
|
||||
let all = list();
|
||||
let Some(latest) = all.first() else { return true };
|
||||
let Ok(latest_dt) = DateTime::parse_from_rfc3339(&latest.created_iso) else {
|
||||
return true;
|
||||
};
|
||||
let now = Local::now();
|
||||
let age = now.signed_duration_since(latest_dt.with_timezone(&Local));
|
||||
age.num_hours() >= interval_hours as i64
|
||||
}
|
||||
|
||||
pub fn last_backup_path(filename: &str) -> Option<PathBuf> {
|
||||
let dir = paths::backups_dir();
|
||||
let p = dir.join(filename);
|
||||
if p.exists() && p.starts_with(&dir) {
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RestoreResult {
|
||||
pub restored_from: String,
|
||||
pub safety_backup: String,
|
||||
pub finished_at_iso: String,
|
||||
pub psql_log: String,
|
||||
}
|
||||
|
||||
/// Service-Liste die einen Restore stoert (haelt DB-Connections oder erwartet Schema).
|
||||
/// `db` selbst bleibt natuerlich an.
|
||||
const RESTORE_STOP_SERVICES: &[&str] = &["auth", "rest", "realtime", "storage", "kong", "app"];
|
||||
|
||||
/// Spielt einen Snapshot zurueck.
|
||||
/// Workflow:
|
||||
/// 1. Sicherheits-Backup vom aktuellen Zustand (kann nicht weh tun)
|
||||
/// 2. Abhaengige Services stoppen (Connections raus)
|
||||
/// 3. `psql -d postgres` mit dem Dump auf stdin pipen
|
||||
/// 4. Alle Services wieder starten
|
||||
pub async fn restore(filename: &str) -> Result<RestoreResult, String> {
|
||||
// Pfad-Traversal-Schutz: Filename darf keine Separatoren enthalten.
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Err("ungueltiger Dateiname".into());
|
||||
}
|
||||
let dir = paths::backups_dir();
|
||||
let path = dir.join(filename);
|
||||
if !path.starts_with(&dir) || !path.exists() {
|
||||
return Err(format!("Backup nicht gefunden: {filename}"));
|
||||
}
|
||||
let dump = std::fs::read(&path).map_err(|e| format!("read dump: {e}"))?;
|
||||
|
||||
// 1) Safety-Backup zuerst — wenn das schiefgeht, gar nicht erst restoren.
|
||||
crate::events::info(format!("Restore von {filename} startet — erst Safety-Backup")).await;
|
||||
let safety = create()
|
||||
.await
|
||||
.map_err(|e| format!("Safety-Backup vor Restore fehlgeschlagen: {e}"))?;
|
||||
|
||||
// 2) Abhaengige Services stoppen.
|
||||
crate::events::info(format!(
|
||||
"Restore: stoppe {} (db bleibt an)",
|
||||
RESTORE_STOP_SERVICES.join(", ")
|
||||
))
|
||||
.await;
|
||||
let mut stop_args = vec!["compose", "stop"];
|
||||
stop_args.extend(RESTORE_STOP_SERVICES);
|
||||
let stop_out = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args(&stop_args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn compose stop: {e}"))?;
|
||||
if !stop_out.status.success() {
|
||||
return Err(format!(
|
||||
"compose stop fehlgeschlagen: {}",
|
||||
String::from_utf8_lossy(&stop_out.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
// 3) Dump via psql einspielen.
|
||||
crate::events::info(format!("Restore: spiele Snapshot {filename} ein ({} KB)", dump.len() / 1024)).await;
|
||||
let mut child = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args([
|
||||
"compose", "exec", "-T", "db",
|
||||
"psql", "-U", "supabase_admin", "-d", "postgres",
|
||||
"-v", "ON_ERROR_STOP=0", // unbekannte Schema-Differenzen weiter durchziehen
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn psql: {e}"))?;
|
||||
{
|
||||
let mut stdin = child.stdin.take().ok_or_else(|| "no stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(&dump)
|
||||
.await
|
||||
.map_err(|e| format!("write dump to psql: {e}"))?;
|
||||
stdin
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|e| format!("psql stdin shutdown: {e}"))?;
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("wait psql: {e}"))?;
|
||||
let psql_log = format!(
|
||||
"{}\n--- stderr ---\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
// 4) Services wieder hoch — auch wenn psql Warnungen hatte (Datenbank ist
|
||||
// eingespielt, die Services connecten sich neu).
|
||||
crate::events::info("Restore: starte Services wieder").await;
|
||||
let up_out = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args(["compose", "up", "-d"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn compose up: {e}"))?;
|
||||
if !up_out.status.success() {
|
||||
return Err(format!(
|
||||
"compose up nach Restore fehlgeschlagen: {}",
|
||||
String::from_utf8_lossy(&up_out.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
crate::events::info(format!(
|
||||
"Restore abgeschlossen aus {filename} (Safety: {})",
|
||||
safety.filename
|
||||
))
|
||||
.await;
|
||||
|
||||
Ok(RestoreResult {
|
||||
restored_from: filename.to_string(),
|
||||
safety_backup: safety.filename,
|
||||
finished_at_iso: Local::now().to_rfc3339(),
|
||||
psql_log,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Tauri-Commands.
|
||||
//!
|
||||
//! Schmaler Layer zwischen WebView-Frontend und Supervisor. Jeder Command
|
||||
//! ist `async`, sperrt den Supervisor-Mutex moeglichst kurz und gibt das
|
||||
//! Ergebnis als JSON-serialisierbare Struktur zurueck.
|
||||
|
||||
use crate::backup::{self, BackupInfo, RestoreResult};
|
||||
use crate::config;
|
||||
use crate::container_update::{self, ApplyResult, CheckResult};
|
||||
use crate::disk::{self, DiskUsage};
|
||||
use crate::events::{self, Event};
|
||||
use crate::firstaid::{self, DiagnoseResult, RecreateResult, ResetResult};
|
||||
use crate::setup::{self, InstallResult, SetupStatus};
|
||||
use crate::stats::{self, ContainerStats};
|
||||
use crate::supervisor::ServiceStatus;
|
||||
use crate::AppState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_services(state: State<'_, AppState>) -> Result<Vec<ServiceStatus>, String> {
|
||||
// Mit Timeout + Cache — falls Supervisor-Mutex durch lange Operation belegt.
|
||||
Ok(crate::supervisor::list_with_timeout(&state.supervisor, 300).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn service_status(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<ServiceStatus>, String> {
|
||||
let sv = state.supervisor.lock().await;
|
||||
Ok(sv.status(&id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn service_logs(state: State<'_, AppState>, id: String) -> Result<Vec<String>, String> {
|
||||
let sv = state.supervisor.lock().await;
|
||||
Ok(sv.logs(&id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let mut sv = state.supervisor.lock().await;
|
||||
sv.start(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let mut sv = state.supervisor.lock().await;
|
||||
sv.stop(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let mut sv = state.supervisor.lock().await;
|
||||
sv.restart(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_all(state: State<'_, AppState>) -> Result<(), String> {
|
||||
crate::supervisor::Supervisor::restart_all_managed(state.supervisor.clone()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_all(state: State<'_, AppState>) -> Result<(), String> {
|
||||
crate::supervisor::Supervisor::start_all_managed(state.supervisor.clone()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_all(state: State<'_, AppState>) -> Result<(), String> {
|
||||
crate::supervisor::Supervisor::stop_all_managed(state.supervisor.clone()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config() -> Result<config::EnvMap, String> {
|
||||
let mut map = config::load()?;
|
||||
config::ensure_defaults(&mut map);
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_config_value(key: String, value: String) -> Result<(), String> {
|
||||
let mut map = config::load()?;
|
||||
map.insert(key, value);
|
||||
config::save(&map)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn backup_now() -> Result<BackupInfo, String> {
|
||||
let info = backup::create().await?;
|
||||
// Direkt nach manuellem Backup auch prunen (sonst macht's nur der Scheduler).
|
||||
let retention = config::load()
|
||||
.ok()
|
||||
.and_then(|c| c.get("BACKUP_RETENTION_COUNT").cloned())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(7);
|
||||
let _ = backup::prune(retention);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_backups() -> Result<Vec<BackupInfo>, String> {
|
||||
Ok(backup::list())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_backup(filename: String) -> Result<RestoreResult, String> {
|
||||
backup::restore(&filename).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_container_updates() -> Result<CheckResult, String> {
|
||||
container_update::check().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn apply_container_updates() -> Result<ApplyResult, String> {
|
||||
container_update::apply().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_events() -> Result<Vec<Event>, String> {
|
||||
Ok(events::list().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_stats() -> Result<Vec<ContainerStats>, String> {
|
||||
Ok(stats::collect().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disk_usage() -> Result<DiskUsage, String> {
|
||||
Ok(disk::collect().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn firstaid_recreate() -> Result<RecreateResult, String> {
|
||||
firstaid::recreate_containers().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn firstaid_reset_pgdata() -> Result<ResetResult, String> {
|
||||
firstaid::reset_pgdata().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn firstaid_diagnose() -> Result<DiagnoseResult, String> {
|
||||
firstaid::diagnose_bundle().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn setup_status() -> Result<SetupStatus, String> {
|
||||
Ok(setup::status().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn setup_install(state: State<'_, AppState>) -> Result<InstallResult, String> {
|
||||
// Wir starten die Container hier NICHT automatisch — der User soll bewusst
|
||||
// "Alle starten" klicken sobald das Dashboard erscheint. Beim spaeteren
|
||||
// App-Reboot (Daemon schon hochgefahren) feuert die AUTO_START-Logik in
|
||||
// lib.rs::setup() ganz normal.
|
||||
let _ = state; // state nur fuer eventual zukuenftige Verwendung
|
||||
setup::install_and_start().await
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Konfigurations-Management.
|
||||
//!
|
||||
//! Liest und schreibt `config.env` im App-Data-Pfad. Generiert sichere
|
||||
//! Defaults beim Erst-Start: `POSTGRES_PASSWORD`, `JWT_SECRET`, davon
|
||||
//! abgeleitet `ANON_KEY` und `SERVICE_ROLE_KEY`.
|
||||
|
||||
use crate::paths;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use rand::Rng;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
/// Geordnete Map (für stabile Datei-Reihenfolge).
|
||||
pub type EnvMap = BTreeMap<String, String>;
|
||||
|
||||
pub fn load() -> Result<EnvMap, String> {
|
||||
let path = paths::config_env_path();
|
||||
if !path.exists() {
|
||||
return Ok(EnvMap::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
let mut map = EnvMap::new();
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub fn save(map: &EnvMap) -> Result<(), String> {
|
||||
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
|
||||
let path = paths::config_env_path();
|
||||
let mut tmp = path.clone();
|
||||
tmp.set_extension("env.tmp");
|
||||
|
||||
let mut file = fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
|
||||
writeln!(file, "# RAPPORT Server-App — Auto-generated config.env").unwrap();
|
||||
writeln!(file, "# Aenderungen ueberleben App-Updates.").unwrap();
|
||||
writeln!(file).unwrap();
|
||||
for (k, v) in map {
|
||||
writeln!(file, "{k}={v}").unwrap();
|
||||
}
|
||||
file.sync_all().map_err(|e| format!("sync: {e}"))?;
|
||||
drop(file);
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = fs::Permissions::from_mode(0o600);
|
||||
let _ = fs::set_permissions(&path, perms);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Erst-Start: fehlende Schluessel mit sicheren Defaults befuellen.
|
||||
/// Aendert keine existierenden Werte.
|
||||
pub fn ensure_defaults(map: &mut EnvMap) {
|
||||
map.entry("POSTGRES_PASSWORD".into())
|
||||
.or_insert_with(random_password);
|
||||
map.entry("JWT_SECRET".into()).or_insert_with(random_jwt_secret);
|
||||
map.entry("SITE_URL".into())
|
||||
.or_insert_with(|| "http://localhost:8080".into());
|
||||
map.entry("API_EXTERNAL_URL".into())
|
||||
.or_insert_with(|| "http://localhost:8000".into());
|
||||
map.entry("BIND_HOST".into())
|
||||
.or_insert_with(|| "127.0.0.1".into());
|
||||
map.entry("ADMIN_UI_PASSWORD".into())
|
||||
.or_insert_with(random_password);
|
||||
map.entry("ADMIN_UI_PORT".into())
|
||||
.or_insert_with(|| "9090".into());
|
||||
// Default 127.0.0.1: WebUI nur ueber Loopback. Fuer LAN-Zugriff manuell
|
||||
// auf 0.0.0.0 stellen (Settings → Im LAN freigeben).
|
||||
map.entry("ADMIN_UI_BIND".into())
|
||||
.or_insert_with(|| "127.0.0.1".into());
|
||||
// TLS ist Default: das WebUI laeuft ueber HTTPS mit selbst-signiertem Cert.
|
||||
map.entry("ADMIN_UI_TLS".into())
|
||||
.or_insert_with(|| "true".into());
|
||||
map.entry("BACKUP_ENABLED".into())
|
||||
.or_insert_with(|| "true".into());
|
||||
map.entry("BACKUP_INTERVAL_HOURS".into())
|
||||
.or_insert_with(|| "24".into());
|
||||
map.entry("BACKUP_RETENTION_COUNT".into())
|
||||
.or_insert_with(|| "7".into());
|
||||
// Container-Auto-Update standardmaessig AUS — explizit aktivieren via
|
||||
// Settings, weil ungeplante Migrations potenziell Daten betreffen.
|
||||
map.entry("CONTAINER_AUTOUPDATE_ENABLED".into())
|
||||
.or_insert_with(|| "false".into());
|
||||
map.entry("CONTAINER_AUTOUPDATE_INTERVAL_HOURS".into())
|
||||
.or_insert_with(|| "24".into());
|
||||
map.entry("AUTO_START_CONTAINERS_ON_LAUNCH".into())
|
||||
.or_insert_with(|| "true".into());
|
||||
// Auto-Recovery: Container die in 'error' haengen werden automatisch
|
||||
// neu gestartet (exponential backoff, dann aufgeben + Notification).
|
||||
map.entry("AUTO_RECOVERY_ENABLED".into())
|
||||
.or_insert_with(|| "true".into());
|
||||
map.entry("AUTO_RECOVERY_BASE_DELAY_SECONDS".into())
|
||||
.or_insert_with(|| "60".into());
|
||||
map.entry("AUTO_RECOVERY_MAX_ATTEMPTS".into())
|
||||
.or_insert_with(|| "5".into());
|
||||
}
|
||||
|
||||
fn random_password() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 24] = rng.gen();
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn random_jwt_secret() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut bytes = [0u8; 48];
|
||||
rng.fill(&mut bytes[..]);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Container-Update-Modul.
|
||||
//!
|
||||
//! Workflow:
|
||||
//! 1. `docker compose pull` zieht Image-Updates
|
||||
//! 2. Image-IDs vorher/nachher vergleichen — was hat sich geaendert?
|
||||
//! 3. Wenn was geaendert: erst Backup (pg_dumpall), dann
|
||||
//! `docker compose up -d` — Compose erkennt selbst was neu erstellt werden
|
||||
//! muss anhand veraenderter Image-IDs.
|
||||
//!
|
||||
//! Scheduler tickt `CONTAINER_AUTOUPDATE_INTERVAL_HOURS` (Default 24h).
|
||||
//! `CONTAINER_AUTOUPDATE_ENABLED=false` schaltet den Auto-Teil ab — manuelles
|
||||
//! `check_now` / `apply_now` geht trotzdem.
|
||||
|
||||
use crate::{backup, services};
|
||||
use chrono::Local;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub const SCHED_TICK: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
const DEFAULT_INTERVAL_HOURS: u64 = 24;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UpdateAvailable {
|
||||
pub service: String,
|
||||
pub image: String,
|
||||
pub old_id: String,
|
||||
pub new_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CheckResult {
|
||||
pub checked_at_iso: String,
|
||||
pub updates: Vec<UpdateAvailable>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ApplyResult {
|
||||
pub applied_at_iso: String,
|
||||
pub updated_services: Vec<String>,
|
||||
pub backup_filename: Option<String>,
|
||||
pub recreate_log: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
struct ImageRow {
|
||||
service: String,
|
||||
image: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Liefert die Image-ID pro Compose-Service.
|
||||
async fn snapshot_images() -> Result<HashMap<String, ImageRow>, String> {
|
||||
let out = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args(["compose", "images", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"compose images: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let mut map = HashMap::new();
|
||||
// Compose kann ein JSON-Array ODER JSON-Lines liefern — beide Faelle abfangen.
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(&stdout) {
|
||||
for v in arr {
|
||||
if let Some(row) = parse_row(&v) {
|
||||
map.insert(row.service.clone(), row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(row) = parse_row(&v) {
|
||||
map.insert(row.service.clone(), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn parse_row(v: &serde_json::Value) -> Option<ImageRow> {
|
||||
let service = v.get("Service").and_then(|x| x.as_str())?.to_string();
|
||||
let repo = v.get("Repository").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let tag = v.get("Tag").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let id = v.get("ID").or_else(|| v.get("ImageId"))
|
||||
.and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
Some(ImageRow {
|
||||
service,
|
||||
image: format!("{repo}:{tag}"),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pullt alle Compose-Images. Bei `app` (lokal gebaut, nicht in der Registry)
|
||||
/// kommt ein Fehler — ignorieren wir, das ist erwartet.
|
||||
async fn compose_pull() -> Result<(), String> {
|
||||
let out = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args(["compose", "pull", "--ignore-pull-failures"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn: {e}"))?;
|
||||
// Auch bei non-zero exit ignorieren wir — solange wir die Image-IDs danach
|
||||
// einsammeln koennen, ist alles weitere ableitbar.
|
||||
if !out.status.success() {
|
||||
log::warn!(
|
||||
"compose pull non-zero (oft `app`-Image — egal): {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn compose_up() -> Result<String, String> {
|
||||
let out = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args(["compose", "up", "-d"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn: {e}"))?;
|
||||
let log = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
if !out.status.success() {
|
||||
return Err(format!("compose up failed: {log}"));
|
||||
}
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
/// Prueft auf Image-Updates ohne sie anzuwenden. `docker compose pull` laeuft,
|
||||
/// danach vergleichen wir die Image-IDs.
|
||||
pub async fn check() -> Result<CheckResult, String> {
|
||||
let before = snapshot_images().await?;
|
||||
compose_pull().await?;
|
||||
let after = snapshot_images().await?;
|
||||
let mut updates = vec![];
|
||||
for (svc, new_row) in &after {
|
||||
let old_id = before.get(svc).map(|r| r.id.as_str()).unwrap_or("");
|
||||
if !new_row.id.is_empty() && old_id != new_row.id {
|
||||
updates.push(UpdateAvailable {
|
||||
service: svc.clone(),
|
||||
image: new_row.image.clone(),
|
||||
old_id: old_id.to_string(),
|
||||
new_id: new_row.id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CheckResult {
|
||||
checked_at_iso: Local::now().to_rfc3339(),
|
||||
updates,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pullt + (falls Updates da) Backup + Compose-Up. Liefert was wirklich
|
||||
/// passiert ist — empty `updated_services` = nichts zu tun.
|
||||
pub async fn apply() -> Result<ApplyResult, String> {
|
||||
let check_res = check().await?;
|
||||
if check_res.updates.is_empty() {
|
||||
return Ok(ApplyResult {
|
||||
applied_at_iso: Local::now().to_rfc3339(),
|
||||
updated_services: vec![],
|
||||
backup_filename: None,
|
||||
recreate_log: "Keine Image-Updates — nichts zu tun.".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-Backup. Wenn das schiefgeht, brechen wir ab — keine Updates ohne Safety-Net.
|
||||
log::info!(
|
||||
"Container-Update: Backup vor Recreate ({} Services updates)",
|
||||
check_res.updates.len()
|
||||
);
|
||||
let backup = backup::create()
|
||||
.await
|
||||
.map_err(|e| format!("Pre-Backup fehlgeschlagen, Update abgebrochen: {e}"))?;
|
||||
|
||||
let log = compose_up().await?;
|
||||
|
||||
let services_list: Vec<String> =
|
||||
check_res.updates.iter().map(|u| u.service.clone()).collect();
|
||||
crate::events::info(format!(
|
||||
"Container-Update angewendet auf {} (Backup: {})",
|
||||
services_list.join(", "),
|
||||
backup.filename
|
||||
))
|
||||
.await;
|
||||
|
||||
Ok(ApplyResult {
|
||||
applied_at_iso: Local::now().to_rfc3339(),
|
||||
updated_services: services_list,
|
||||
backup_filename: Some(backup.filename),
|
||||
recreate_log: log,
|
||||
})
|
||||
}
|
||||
|
||||
fn config_values() -> (bool, u64) {
|
||||
let cfg = crate::config::load().unwrap_or_default();
|
||||
let enabled = cfg
|
||||
.get("CONTAINER_AUTOUPDATE_ENABLED")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(false);
|
||||
let interval = cfg
|
||||
.get("CONTAINER_AUTOUPDATE_INTERVAL_HOURS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_INTERVAL_HOURS);
|
||||
(enabled, interval)
|
||||
}
|
||||
|
||||
pub async fn scheduler_loop() {
|
||||
let mut tick = tokio::time::interval(SCHED_TICK);
|
||||
tick.tick().await; // initial tick verwerfen (siehe backup::scheduler_loop)
|
||||
let mut last_check: Option<chrono::DateTime<Local>> = None;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let (enabled, interval) = config_values();
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
let now = Local::now();
|
||||
let due = match last_check {
|
||||
None => true,
|
||||
Some(t) => now.signed_duration_since(t).num_hours() >= interval as i64,
|
||||
};
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
last_check = Some(now);
|
||||
match apply().await {
|
||||
Ok(res) if res.updated_services.is_empty() => {
|
||||
log::info!("Auto-Update: keine neuen Images");
|
||||
}
|
||||
Ok(res) => {
|
||||
log::info!(
|
||||
"Auto-Update angewendet auf: {} (Backup: {:?})",
|
||||
res.updated_services.join(", "),
|
||||
res.backup_filename
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!("Auto-Update fehlgeschlagen: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Disk-Usage-Sammler fuer's Backup-Panel und (spaeter) Warn-Banner.
|
||||
//!
|
||||
//! Vier Metriken:
|
||||
//! - Postgres logische DB-Groesse (`pg_database_size('postgres')`)
|
||||
//! - Backup-Verzeichnis-Groesse (Summe aller .sql-Files)
|
||||
//! - Docker-Volumes-Total (alle Volumes des Daemons, via `docker system df`)
|
||||
//! - Freier Disk-Space im Backup-Verzeichnis (via `df -k`)
|
||||
|
||||
use crate::{paths, services};
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiskUsage {
|
||||
/// Logische Groesse der `postgres`-DB (None wenn db nicht erreichbar).
|
||||
pub postgres_db_bytes: Option<u64>,
|
||||
/// Summe aller `rapport-*.sql`-Backups.
|
||||
pub backups_total_bytes: u64,
|
||||
pub backup_count: u32,
|
||||
/// Freier Platz auf dem Disk wo die Backups liegen.
|
||||
pub host_free_bytes: u64,
|
||||
/// Gesamt-Platz auf demselben Disk.
|
||||
pub host_total_bytes: u64,
|
||||
/// Totaler Docker-Volumes-Verbrauch (alle Compose-Stacks, nicht nur unsere).
|
||||
pub docker_volumes_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn collect() -> DiskUsage {
|
||||
let (host_free, host_total) = host_disk(&paths::backups_dir());
|
||||
DiskUsage {
|
||||
postgres_db_bytes: pg_db_size().await.ok(),
|
||||
backups_total_bytes: backups_size(),
|
||||
backup_count: backup_count(),
|
||||
host_free_bytes: host_free,
|
||||
host_total_bytes: host_total,
|
||||
docker_volumes_bytes: docker_volumes_size().await.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pg_db_size() -> Result<u64, String> {
|
||||
// Postgres-Passwort fuer psql via -e PGPASSWORD durchreichen — sonst
|
||||
// promptet psql interaktiv und wir kriegen einen Auth-Error.
|
||||
let cfg = crate::config::load().unwrap_or_default();
|
||||
let pw = cfg
|
||||
.get("POSTGRES_PASSWORD")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let out = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args([
|
||||
"compose", "exec",
|
||||
"-e", &format!("PGPASSWORD={pw}"),
|
||||
"-T", "db",
|
||||
"psql", "-U", "supabase_admin", "-d", "postgres",
|
||||
"-tAc", "SELECT pg_database_size('postgres');",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn backups_size() -> u64 {
|
||||
let dir = paths::backups_dir();
|
||||
std::fs::read_dir(&dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter(|m| m.is_file())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn backup_count() -> u32 {
|
||||
crate::backup::list().len() as u32
|
||||
}
|
||||
|
||||
fn host_disk(path: &std::path::Path) -> (u64, u64) {
|
||||
// `df -k <path>` → 2. Zeile: FS 1k-blocks Used Avail Capacity ... Mounted-on
|
||||
let out = std::process::Command::new("df")
|
||||
.arg("-k")
|
||||
.arg(path)
|
||||
.output();
|
||||
let Ok(o) = out else { return (0, 0) };
|
||||
if !o.status.success() {
|
||||
return (0, 0);
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let mut lines = stdout.lines();
|
||||
let _header = lines.next();
|
||||
let Some(data_line) = lines.next() else { return (0, 0) };
|
||||
let cols: Vec<&str> = data_line.split_whitespace().collect();
|
||||
if cols.len() < 4 {
|
||||
return (0, 0);
|
||||
}
|
||||
let total = cols[1].parse::<u64>().unwrap_or(0).saturating_mul(1024);
|
||||
let avail = cols[3].parse::<u64>().unwrap_or(0).saturating_mul(1024);
|
||||
(avail, total)
|
||||
}
|
||||
|
||||
async fn docker_volumes_size() -> Result<u64, String> {
|
||||
let out = Command::new("docker")
|
||||
.args(["system", "df", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err("docker system df fehlgeschlagen".into());
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
// Docker nennt's "Local Volumes" (Mac/Linux); aelter manchmal "Volumes".
|
||||
let t = v.get("Type").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if t == "Local Volumes" || t == "Volumes" {
|
||||
let size_str = v.get("Size").and_then(|x| x.as_str()).unwrap_or("");
|
||||
return Ok(parse_human_size(size_str));
|
||||
}
|
||||
}
|
||||
Err("Volumes-Row nicht gefunden".into())
|
||||
}
|
||||
|
||||
/// "1.5GB" / "850MB" / "0B" → Bytes
|
||||
fn parse_human_size(s: &str) -> u64 {
|
||||
let s = s.trim();
|
||||
let split = s
|
||||
.find(|c: char| c.is_alphabetic())
|
||||
.unwrap_or(s.len());
|
||||
let (num, unit) = s.split_at(split);
|
||||
let n: f64 = num.parse().unwrap_or(0.0);
|
||||
let mul = match unit.trim().to_uppercase().as_str() {
|
||||
"B" => 1.0,
|
||||
"KB" | "K" => 1000.0,
|
||||
"MB" | "M" => 1000.0 * 1000.0,
|
||||
"GB" | "G" => 1000.0 * 1000.0 * 1000.0,
|
||||
"TB" | "T" => 1000.0_f64.powi(4),
|
||||
"KIB" => 1024.0,
|
||||
"MIB" => 1024.0 * 1024.0,
|
||||
"GIB" => 1024.0 * 1024.0 * 1024.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
(n * mul) as u64
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Globaler App-Event-Log fuer das Status-Dashboard.
|
||||
//!
|
||||
//! Ringbuffer (max 100 Eintraege) mit Timestamp + Severity + Message.
|
||||
//! Wird von verschiedenen Stellen geschrieben (Supervisor, Backup-Scheduler,
|
||||
//! Container-Update, Pre-Pull, App-Startup) und vom Frontend gepollt.
|
||||
|
||||
use chrono::Local;
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const CAPACITY: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Event {
|
||||
pub ts_iso: String,
|
||||
pub kind: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
static EVENTS: OnceLock<Mutex<VecDeque<Event>>> = OnceLock::new();
|
||||
|
||||
fn store() -> &'static Mutex<VecDeque<Event>> {
|
||||
EVENTS.get_or_init(|| Mutex::new(VecDeque::with_capacity(CAPACITY)))
|
||||
}
|
||||
|
||||
pub async fn append(kind: &'static str, message: impl Into<String>) {
|
||||
let msg = message.into();
|
||||
log::info!("[event] {}", msg);
|
||||
let mut q = store().lock().await;
|
||||
if q.len() >= CAPACITY {
|
||||
q.pop_front();
|
||||
}
|
||||
q.push_back(Event {
|
||||
ts_iso: Local::now().to_rfc3339(),
|
||||
kind,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn info(msg: impl Into<String>) {
|
||||
append("info", msg).await
|
||||
}
|
||||
|
||||
pub async fn warn(msg: impl Into<String>) {
|
||||
append("warn", msg).await
|
||||
}
|
||||
|
||||
pub async fn error(msg: impl Into<String>) {
|
||||
append("error", msg).await
|
||||
}
|
||||
|
||||
pub async fn list() -> Vec<Event> {
|
||||
let q = store().lock().await;
|
||||
q.iter().rev().cloned().collect()
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Erste-Hilfe-Aktionen fuer den Notfall.
|
||||
//!
|
||||
//! Drei destruktive Aktionen mit unterschiedlichem Risiko:
|
||||
//! - `recreate_containers` (mild): compose down + up --force-recreate.
|
||||
//! Container neu, Volumes bleiben → Daten safe.
|
||||
//! - `reset_pgdata` (NUKE): compose down -v + up. Volumes WEG → Postgres
|
||||
//! wird von Grund auf neu initialisiert. Erfordert Pre-Backup.
|
||||
//! - `diagnose_bundle`: kein Schaden — sammelt Logs, ps, version, config
|
||||
//! (redacted) in eine Text-Datei unter backups/.
|
||||
|
||||
use crate::{backup::BackupInfo, paths, services};
|
||||
use chrono::Local;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RecreateResult {
|
||||
pub log: String,
|
||||
pub finished_at_iso: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ResetResult {
|
||||
pub safety_backup: BackupInfo,
|
||||
pub log: String,
|
||||
pub finished_at_iso: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiagnoseResult {
|
||||
pub filename: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
async fn compose(args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.arg("compose")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn: {e}"))?;
|
||||
let combined = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
if !out.status.success() {
|
||||
return Err(format!("compose {args:?} failed: {combined}"));
|
||||
}
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
/// Container neu erstellen — Volumes bleiben.
|
||||
pub async fn recreate_containers() -> Result<RecreateResult, String> {
|
||||
crate::events::warn("Erste Hilfe: Container neu erstellen ...").await;
|
||||
let _ = compose(&["down"]).await;
|
||||
let log = compose(&["up", "-d", "--force-recreate"]).await?;
|
||||
crate::events::info("Erste Hilfe: Container neu erstellt").await;
|
||||
Ok(RecreateResult {
|
||||
log,
|
||||
finished_at_iso: Local::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
/// VOLL DESTRUKTIV: Volumes platt + frisches Init.
|
||||
pub async fn reset_pgdata() -> Result<ResetResult, String> {
|
||||
crate::events::warn("Erste Hilfe: PGDATA-RESET — erst Safety-Backup").await;
|
||||
let safety = crate::backup::create()
|
||||
.await
|
||||
.map_err(|e| format!("Safety-Backup vor PGDATA-Reset fehlgeschlagen — Reset abgebrochen: {e}"))?;
|
||||
crate::events::warn(format!(
|
||||
"Erste Hilfe: stoppe Stack inkl. Volumes (Safety: {})",
|
||||
safety.filename
|
||||
))
|
||||
.await;
|
||||
let _ = compose(&["down", "-v"]).await?;
|
||||
let log = compose(&["up", "-d"]).await?;
|
||||
crate::events::error(format!(
|
||||
"Erste Hilfe: PGDATA platt gemacht und neu initialisiert (Safety: {})",
|
||||
safety.filename
|
||||
))
|
||||
.await;
|
||||
Ok(ResetResult {
|
||||
safety_backup: safety,
|
||||
log,
|
||||
finished_at_iso: Local::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sammelt Diagnose-Informationen in eine Text-Datei unter backups/.
|
||||
pub async fn diagnose_bundle() -> Result<DiagnoseResult, String> {
|
||||
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
|
||||
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let filename = format!("diagnose-{stamp}.txt");
|
||||
let path = paths::backups_dir().join(&filename);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("# RAPPORT Server-App Diagnose ({})\n\n", Local::now().to_rfc3339()));
|
||||
|
||||
out.push_str("## docker version\n```\n");
|
||||
out.push_str(&run_capture(&["docker", "version"]).await);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("## docker compose ps -a\n```\n");
|
||||
out.push_str(&run_capture(&["docker", "compose", "ps", "-a"]).await);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("## docker system df\n```\n");
|
||||
out.push_str(&run_capture(&["docker", "system", "df"]).await);
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("## config.env (redacted)\n```\n");
|
||||
out.push_str(&redacted_config());
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("## App-Events (letzte 100)\n```\n");
|
||||
for e in crate::events::list().await {
|
||||
out.push_str(&format!("[{}] {}: {}\n", e.ts_iso, e.kind, e.message));
|
||||
}
|
||||
out.push_str("\n```\n\n");
|
||||
|
||||
out.push_str("## Container-Logs (letzte 50 Zeilen pro Service)\n");
|
||||
for svc in crate::services::default_services() {
|
||||
out.push_str(&format!("\n### {}\n```\n", svc.id));
|
||||
out.push_str(&run_capture(&[
|
||||
"docker", "compose", "logs", "--no-color", "--tail", "50", &svc.id,
|
||||
]).await);
|
||||
out.push_str("\n```\n");
|
||||
}
|
||||
|
||||
std::fs::write(&path, out.as_bytes()).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
crate::events::info(format!("Diagnose-Bundle: {filename} ({} KB)", bytes / 1024)).await;
|
||||
|
||||
Ok(DiagnoseResult { filename, bytes })
|
||||
}
|
||||
|
||||
async fn run_capture(argv: &[&str]) -> String {
|
||||
let mut cmd = Command::new(argv[0]);
|
||||
cmd.current_dir(services::compose_dir());
|
||||
for a in &argv[1..] {
|
||||
cmd.arg(a);
|
||||
}
|
||||
match cmd.output().await {
|
||||
Ok(o) => format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&o.stdout),
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
),
|
||||
Err(e) => format!("(spawn fehlgeschlagen: {e})"),
|
||||
}
|
||||
}
|
||||
|
||||
const REDACT_PREFIXES: &[&str] = &[
|
||||
"POSTGRES_PASSWORD",
|
||||
"JWT_SECRET",
|
||||
"ADMIN_UI_PASSWORD",
|
||||
"ANON_KEY",
|
||||
"SERVICE_ROLE_KEY",
|
||||
"SMTP_PASS",
|
||||
];
|
||||
|
||||
fn redacted_config() -> String {
|
||||
let map = crate::config::load().unwrap_or_default();
|
||||
let mut out = String::new();
|
||||
for (k, v) in &map {
|
||||
let redacted = REDACT_PREFIXES.iter().any(|p| k.contains(p));
|
||||
if redacted {
|
||||
out.push_str(&format!("{k}=<REDACTED {} chars>\n", v.len()));
|
||||
} else {
|
||||
out.push_str(&format!("{k}={v}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! HTTP/HTTPS-Server fuer den Admin-WebUI-Zugang.
|
||||
//!
|
||||
//! Spiegelt die Tauri-Commands als REST-Endpoints + serviert das React-`dist/`.
|
||||
//! Konfiguration aus `config.env`:
|
||||
//! - `ADMIN_UI_BIND` (Default `127.0.0.1`; LAN-Freigabe → `0.0.0.0`)
|
||||
//! - `ADMIN_UI_PORT` (Default `9090`)
|
||||
//! - `ADMIN_UI_PASSWORD` (Auto-generiert, ~32 Bytes random)
|
||||
//! - `ADMIN_UI_TLS` (Default `true` — self-signed Cert wird automatisch
|
||||
//! in `<data_dir>/admin-ui-cert.pem` erzeugt)
|
||||
//!
|
||||
//! Auth: Basic-Auth mit User `admin`. Fehlversuche werden pro IP gezaehlt;
|
||||
//! nach `MAX_FAILS` Fehlern in `FAIL_WINDOW` Sekunden ist die IP fuer
|
||||
//! `LOCKOUT` Sekunden geblockt.
|
||||
|
||||
use crate::supervisor::Supervisor;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{header, Request, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use base64::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
const MAX_FAILS: u32 = 5;
|
||||
const FAIL_WINDOW: Duration = Duration::from_secs(60);
|
||||
const LOCKOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Default)]
|
||||
struct AuthFailure {
|
||||
count: u32,
|
||||
first_seen: Option<Instant>,
|
||||
locked_until: Option<Instant>,
|
||||
}
|
||||
|
||||
type FailMap = Arc<Mutex<HashMap<IpAddr, AuthFailure>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HttpState {
|
||||
pub supervisor: Arc<Mutex<Supervisor>>,
|
||||
pub password: String,
|
||||
fails: FailMap,
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
bind: String,
|
||||
port: u16,
|
||||
password: String,
|
||||
tls: bool,
|
||||
supervisor: Arc<Mutex<Supervisor>>,
|
||||
static_dir: std::path::PathBuf,
|
||||
) -> Result<(), String> {
|
||||
let state = HttpState {
|
||||
supervisor,
|
||||
password,
|
||||
fails: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
|
||||
let api = Router::new()
|
||||
.route("/services", get(list_services))
|
||||
.route("/services/start-all", post(start_all))
|
||||
.route("/services/stop-all", post(stop_all))
|
||||
.route("/services/:id/start", post(start_service))
|
||||
.route("/services/:id/stop", post(stop_service))
|
||||
.route("/services/:id/restart", post(restart_service_h))
|
||||
.route("/services/restart-all", post(restart_all_h))
|
||||
.route("/services/:id/logs", get(service_logs))
|
||||
.route("/activity", get(current_activity))
|
||||
.route("/backups", get(list_backups_h))
|
||||
.route("/backups/now", post(backup_now_h))
|
||||
.route("/backups/:filename/restore", post(restore_backup_h))
|
||||
.route("/container-updates/check", post(check_updates_h))
|
||||
.route("/container-updates/apply", post(apply_updates_h))
|
||||
.route("/events", get(list_events_h))
|
||||
.route("/stats", get(list_stats_h))
|
||||
.route("/disk", get(disk_usage_h))
|
||||
.route("/firstaid/recreate", post(firstaid_recreate_h))
|
||||
.route("/firstaid/reset-pgdata", post(firstaid_reset_pgdata_h))
|
||||
.route("/firstaid/diagnose", post(firstaid_diagnose_h))
|
||||
.route("/setup/status", get(setup_status_h))
|
||||
.route("/setup/install", post(setup_install_h));
|
||||
|
||||
let static_service = ServeDir::new(&static_dir).append_index_html_on_directories(true);
|
||||
|
||||
// Middleware-Reihenfolge (innen → aussen):
|
||||
// csrf_check — state-changing Requests brauchen den Custom-Header
|
||||
// basic_auth — IP-Lockout + Credential-Check
|
||||
// security_headers — auf allen Antworten (auch 401 etc.)
|
||||
let app = Router::new()
|
||||
.nest("/api", api)
|
||||
.fallback_service(static_service)
|
||||
.layer(middleware::from_fn(csrf_check))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), basic_auth))
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = format!("{bind}:{port}")
|
||||
.parse()
|
||||
.map_err(|e| format!("bad bind addr {bind}:{port}: {e}"))?;
|
||||
|
||||
if tls {
|
||||
// rustls 0.23 verlangt expliziten Crypto-Provider. Egal welcher zuerst —
|
||||
// ignore_err falls schon installiert.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let (cert_path, key_path) = ensure_tls_cert()?;
|
||||
let config = RustlsConfig::from_pem_file(cert_path, key_path)
|
||||
.await
|
||||
.map_err(|e| format!("rustls config: {e}"))?;
|
||||
log::info!("Admin WebUI listening on https://{addr} (user: admin)");
|
||||
axum_server::bind_rustls(addr, config)
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await
|
||||
.map_err(|e| format!("axum_server: {e}"))?;
|
||||
} else {
|
||||
log::info!("Admin WebUI listening on http://{addr} (user: admin) — TLS off!");
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| format!("listen {addr}: {e}"))?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("axum serve: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS cert provisioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ensure_tls_cert() -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
|
||||
let cert = crate::paths::admin_ui_cert_path();
|
||||
let key = crate::paths::admin_ui_key_path();
|
||||
if cert.exists() && key.exists() {
|
||||
return Ok((cert, key));
|
||||
}
|
||||
log::info!("Generating self-signed TLS cert for Admin WebUI ...");
|
||||
crate::paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
|
||||
let hostname = hostname::get()
|
||||
.ok()
|
||||
.and_then(|n| n.into_string().ok())
|
||||
.unwrap_or_else(|| "localhost".into());
|
||||
let san = vec![
|
||||
"localhost".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
hostname.clone(),
|
||||
format!("{hostname}.local"),
|
||||
];
|
||||
let cert_kp = rcgen::generate_simple_self_signed(san)
|
||||
.map_err(|e| format!("rcgen: {e}"))?;
|
||||
std::fs::write(&cert, cert_kp.cert.pem()).map_err(|e| format!("write cert: {e}"))?;
|
||||
std::fs::write(&key, cert_kp.key_pair.serialize_pem())
|
||||
.map_err(|e| format!("write key: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok((cert, key))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic-Auth middleware + per-IP rate limit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn basic_auth(
|
||||
State(state): State<HttpState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let ip = peer.ip();
|
||||
|
||||
// Lockout-Check.
|
||||
{
|
||||
let mut fails = state.fails.lock().await;
|
||||
if let Some(rec) = fails.get_mut(&ip) {
|
||||
if let Some(until) = rec.locked_until {
|
||||
if Instant::now() < until {
|
||||
return Ok(too_many("Too many failed attempts. Locked out."));
|
||||
}
|
||||
// Lockout abgelaufen — Reset.
|
||||
*rec = AuthFailure::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ok = check_auth(req.headers().get(header::AUTHORIZATION), &state.password);
|
||||
|
||||
if ok {
|
||||
let mut fails = state.fails.lock().await;
|
||||
fails.remove(&ip);
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
|
||||
// Failure registrieren.
|
||||
let mut fails = state.fails.lock().await;
|
||||
let rec = fails.entry(ip).or_default();
|
||||
let now = Instant::now();
|
||||
match rec.first_seen {
|
||||
Some(first) if now.duration_since(first) < FAIL_WINDOW => {
|
||||
rec.count += 1;
|
||||
}
|
||||
_ => {
|
||||
rec.count = 1;
|
||||
rec.first_seen = Some(now);
|
||||
}
|
||||
}
|
||||
if rec.count >= MAX_FAILS {
|
||||
rec.locked_until = Some(now + LOCKOUT);
|
||||
log::warn!(
|
||||
"Auth lockout: {} after {} fails — blocked for {}s",
|
||||
ip,
|
||||
rec.count,
|
||||
LOCKOUT.as_secs()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(unauthorized())
|
||||
}
|
||||
|
||||
fn check_auth(header: Option<&axum::http::HeaderValue>, password: &str) -> bool {
|
||||
let s = match header.and_then(|h| h.to_str().ok()) {
|
||||
Some(s) => s,
|
||||
None => return false,
|
||||
};
|
||||
let Some(b64) = s.strip_prefix("Basic ") else { return false };
|
||||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64) else { return false };
|
||||
let Ok(s) = std::str::from_utf8(&decoded) else { return false };
|
||||
let Some((user, pass)) = s.split_once(':') else { return false };
|
||||
user == "admin" && pass == password
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
let mut resp = Response::new(Body::from("Authentication required"));
|
||||
*resp.status_mut() = StatusCode::UNAUTHORIZED;
|
||||
resp.headers_mut().insert(
|
||||
header::WWW_AUTHENTICATE,
|
||||
"Basic realm=\"RAPPORT Server Admin\"".parse().unwrap(),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
fn too_many(msg: &'static str) -> Response {
|
||||
let mut resp = Response::new(Body::from(msg));
|
||||
*resp.status_mut() = StatusCode::TOO_MANY_REQUESTS;
|
||||
resp
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSRF — Custom-Header-Pflicht fuer state-changing Methoden
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Browser senden Authorization-Header automatisch wenn sie Credentials gecacht
|
||||
// haben. Damit eine boesartige Seite nicht via auto-submittendem `<form>` einen
|
||||
// `start-all` triggern kann, verlangen wir auf POST/PUT/DELETE/PATCH den
|
||||
// Custom-Header `X-Rapport-Csrf: 1`. Cross-origin `<form>` kann diesen nicht
|
||||
// setzen; cross-origin `fetch()` triggert dafuer CORS-Preflight, das wir nicht
|
||||
// allowen — also auch geblockt.
|
||||
|
||||
async fn csrf_check(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
|
||||
let method = req.method();
|
||||
let is_writing = matches!(
|
||||
method.as_str(),
|
||||
"POST" | "PUT" | "DELETE" | "PATCH"
|
||||
);
|
||||
if is_writing && req.headers().get("x-rapport-csrf").is_none() {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security-Header — Defense-in-Depth fuer den WebUI-Browser-Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn security_headers(req: Request<Body>, next: Next) -> Response {
|
||||
let mut resp = next.run(req).await;
|
||||
let h = resp.headers_mut();
|
||||
// Clickjacking
|
||||
h.insert("x-frame-options", "DENY".parse().unwrap());
|
||||
// MIME-Sniffing
|
||||
h.insert("x-content-type-options", "nosniff".parse().unwrap());
|
||||
// Kein Referer rauslassen (privates LAN)
|
||||
h.insert("referrer-policy", "no-referrer".parse().unwrap());
|
||||
// Strict-Transport-Security — Browser merken sich, dass nur HTTPS gilt.
|
||||
// 1 Woche reicht; spaeter koennen wir hochdrehen wenn das setup stabil ist.
|
||||
h.insert(
|
||||
"strict-transport-security",
|
||||
"max-age=604800".parse().unwrap(),
|
||||
);
|
||||
// CSP: kein externes JS, kein eval, keine inline-Scripts.
|
||||
// Inline-Styles sind erlaubt weil React StyleSheets manchmal so generiert.
|
||||
h.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; \
|
||||
script-src 'self'; \
|
||||
style-src 'self' 'unsafe-inline'; \
|
||||
img-src 'self' data:; \
|
||||
connect-src 'self'; \
|
||||
font-src 'self'; \
|
||||
object-src 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self'; \
|
||||
frame-ancestors 'none'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn list_services(State(s): State<HttpState>) -> impl IntoResponse {
|
||||
Json(crate::supervisor::list_with_timeout(&s.supervisor, 300).await)
|
||||
}
|
||||
|
||||
async fn start_all(State(s): State<HttpState>) -> impl IntoResponse {
|
||||
map_result(crate::supervisor::Supervisor::start_all_managed(s.supervisor.clone()).await)
|
||||
}
|
||||
|
||||
async fn stop_all(State(s): State<HttpState>) -> impl IntoResponse {
|
||||
map_result(crate::supervisor::Supervisor::stop_all_managed(s.supervisor.clone()).await)
|
||||
}
|
||||
|
||||
async fn start_service(
|
||||
State(s): State<HttpState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let mut sv = s.supervisor.lock().await;
|
||||
map_result(sv.start(&id).await)
|
||||
}
|
||||
|
||||
async fn stop_service(
|
||||
State(s): State<HttpState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let mut sv = s.supervisor.lock().await;
|
||||
map_result(sv.stop(&id).await)
|
||||
}
|
||||
|
||||
async fn restart_service_h(
|
||||
State(s): State<HttpState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let mut sv = s.supervisor.lock().await;
|
||||
map_result(sv.restart(&id).await)
|
||||
}
|
||||
|
||||
async fn restart_all_h(State(s): State<HttpState>) -> impl IntoResponse {
|
||||
map_result(crate::supervisor::Supervisor::restart_all_managed(s.supervisor.clone()).await)
|
||||
}
|
||||
|
||||
async fn service_logs(
|
||||
State(s): State<HttpState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let sv = s.supervisor.lock().await;
|
||||
Json(sv.logs(&id).await)
|
||||
}
|
||||
|
||||
async fn current_activity(State(s): State<HttpState>) -> impl IntoResponse {
|
||||
let sv = s.supervisor.lock().await;
|
||||
Json(sv.current_activity().await)
|
||||
}
|
||||
|
||||
async fn list_backups_h() -> impl IntoResponse {
|
||||
Json(crate::backup::list())
|
||||
}
|
||||
|
||||
async fn backup_now_h() -> Response {
|
||||
match crate::backup::create().await {
|
||||
Ok(info) => Json(info).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_backup_h(Path(filename): Path<String>) -> Response {
|
||||
match crate::backup::restore(&filename).await {
|
||||
Ok(res) => Json(res).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_updates_h() -> Response {
|
||||
match crate::container_update::check().await {
|
||||
Ok(res) => Json(res).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_updates_h() -> Response {
|
||||
match crate::container_update::apply().await {
|
||||
Ok(res) => Json(res).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_events_h() -> impl IntoResponse {
|
||||
Json(crate::events::list().await)
|
||||
}
|
||||
|
||||
async fn list_stats_h() -> impl IntoResponse {
|
||||
Json(crate::stats::collect().await)
|
||||
}
|
||||
|
||||
async fn disk_usage_h() -> impl IntoResponse {
|
||||
Json(crate::disk::collect().await)
|
||||
}
|
||||
|
||||
async fn firstaid_recreate_h() -> Response {
|
||||
match crate::firstaid::recreate_containers().await {
|
||||
Ok(r) => Json(r).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn firstaid_reset_pgdata_h() -> Response {
|
||||
match crate::firstaid::reset_pgdata().await {
|
||||
Ok(r) => Json(r).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn firstaid_diagnose_h() -> Response {
|
||||
match crate::firstaid::diagnose_bundle().await {
|
||||
Ok(r) => Json(r).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_status_h() -> impl IntoResponse {
|
||||
Json(crate::setup::status().await)
|
||||
}
|
||||
|
||||
async fn setup_install_h(State(s): State<HttpState>) -> Response {
|
||||
let _ = s;
|
||||
match crate::setup::install_and_start().await {
|
||||
Ok(r) => Json(r).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_result(r: Result<(), String>) -> Response {
|
||||
match r {
|
||||
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! RAPPORT Server-App — Tauri-Entry-Point.
|
||||
//!
|
||||
//! Hier wird der Process-Supervisor initialisiert, in den App-State gehängt,
|
||||
//! und alle `#[tauri::command]`-Handler registriert.
|
||||
|
||||
mod backup;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod container_update;
|
||||
mod disk;
|
||||
mod events;
|
||||
mod firstaid;
|
||||
mod health;
|
||||
mod http_server;
|
||||
mod paths;
|
||||
mod services;
|
||||
mod setup;
|
||||
mod stats;
|
||||
mod supervisor;
|
||||
|
||||
use std::sync::Arc;
|
||||
use supervisor::Supervisor;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Geteilter Supervisor-State über `tauri::State`.
|
||||
pub struct AppState {
|
||||
pub supervisor: Arc<Mutex<Supervisor>>,
|
||||
}
|
||||
|
||||
/// Aggregat-Status fuer die Tray-Icon-Farbe.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TrayStatus {
|
||||
/// keine Services laufen (alle stopped) oder Liste leer
|
||||
Off,
|
||||
/// alle laufen, niemand error/starting
|
||||
Ok,
|
||||
/// gemischt: einige laufen, andere stopped/starting (oder noch nicht alle ready)
|
||||
Warn,
|
||||
/// mind. ein service in error
|
||||
Error,
|
||||
}
|
||||
|
||||
fn aggregate_status(statuses: &[supervisor::ServiceStatus]) -> TrayStatus {
|
||||
use supervisor::ServiceState as S;
|
||||
if statuses.is_empty() {
|
||||
return TrayStatus::Off;
|
||||
}
|
||||
let mut has_error = false;
|
||||
let mut has_running = false;
|
||||
let mut has_transitioning = false;
|
||||
let mut has_stopped = false;
|
||||
for s in statuses {
|
||||
match s.state {
|
||||
S::Error => has_error = true,
|
||||
S::Running => has_running = true,
|
||||
S::Starting | S::Stopping => has_transitioning = true,
|
||||
S::Stopped => has_stopped = true,
|
||||
}
|
||||
}
|
||||
if has_error {
|
||||
TrayStatus::Error
|
||||
} else if has_running && !has_transitioning && !has_stopped {
|
||||
TrayStatus::Ok
|
||||
} else if !has_running && !has_transitioning && has_stopped {
|
||||
TrayStatus::Off
|
||||
} else {
|
||||
TrayStatus::Warn
|
||||
}
|
||||
}
|
||||
|
||||
/// 44x44 Farb-PNG-Bytes (Pillow-generiert, in `icons/` checked-in).
|
||||
const TRAY_OFF_PNG: &[u8] = include_bytes!("../icons/tray-off@2x.png");
|
||||
const TRAY_OK_PNG: &[u8] = include_bytes!("../icons/tray-ok@2x.png");
|
||||
const TRAY_WARN_PNG: &[u8] = include_bytes!("../icons/tray-warn@2x.png");
|
||||
const TRAY_ERROR_PNG: &[u8] = include_bytes!("../icons/tray-error@2x.png");
|
||||
|
||||
fn set_tray_icon(app: &tauri::AppHandle, status: TrayStatus) {
|
||||
let bytes = match status {
|
||||
TrayStatus::Off => TRAY_OFF_PNG,
|
||||
TrayStatus::Ok => TRAY_OK_PNG,
|
||||
TrayStatus::Warn => TRAY_WARN_PNG,
|
||||
TrayStatus::Error => TRAY_ERROR_PNG,
|
||||
};
|
||||
let tooltip = match status {
|
||||
TrayStatus::Off => "RAPPORT Server — gestoppt",
|
||||
TrayStatus::Ok => "RAPPORT Server — alle Services laufen",
|
||||
TrayStatus::Warn => "RAPPORT Server — teilweise gestartet / im Uebergang",
|
||||
TrayStatus::Error => "RAPPORT Server — Fehler in mindestens einem Service",
|
||||
};
|
||||
let Some(tray) = app.tray_by_id("main-tray") else { return };
|
||||
if let Ok(image) = tauri::image::Image::from_bytes(bytes) {
|
||||
let _ = tray.set_icon(Some(image));
|
||||
let _ = tray.set_icon_as_template(false);
|
||||
}
|
||||
let _ = tray.set_tooltip(Some(tooltip));
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
env_logger::Builder::from_default_env()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.init();
|
||||
|
||||
log::info!("RAPPORT Server-App starting ...");
|
||||
|
||||
// PATH IMMER erweitern (auch wenn ~/.rapport/bin/ noch nicht existiert) —
|
||||
// sonst klappt der Auto-Start nach dem Setup-Wizard nicht weil unser
|
||||
// Prozess den frisch installierten docker/colima nicht findet.
|
||||
let rapport_bin = setup::bin_dir();
|
||||
let cur = std::env::var("PATH").unwrap_or_default();
|
||||
let new_path = format!("{}:{cur}", rapport_bin.display());
|
||||
std::env::set_var("PATH", new_path);
|
||||
log::info!("PATH erweitert um {}", rapport_bin.display());
|
||||
|
||||
let supervisor = Arc::new(Mutex::new(Supervisor::new()));
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
// Autostart: macOS legt einen LaunchAgent unter
|
||||
// ~/Library/LaunchAgents/com.rapport.server-app.plist an.
|
||||
// Beim Login-Launch passen wir `--hidden` mit, damit das Fenster
|
||||
// direkt im Tray landet (kein Popup im Login-Flow).
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--hidden"]),
|
||||
))
|
||||
.manage(AppState {
|
||||
supervisor: supervisor.clone(),
|
||||
})
|
||||
.setup(move |app| {
|
||||
// App-Data-Verzeichnisse (PGDATA-Volume-Mount, logs, backups) anlegen.
|
||||
if let Err(e) = paths::ensure_dirs() {
|
||||
log::error!("ensure_dirs: {e}");
|
||||
}
|
||||
|
||||
// `--hidden` aus dem Login-Launch: Fenster sofort verstecken,
|
||||
// App lebt nur im Tray weiter.
|
||||
let launched_hidden = std::env::args().any(|a| a == "--hidden");
|
||||
if launched_hidden {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Compose-Verzeichnis aufloesen (kommt aus config.env oder
|
||||
// Auto-Detect-Reihe). Ohne das gibt's nichts zu supervisen.
|
||||
let compose_override = config::load()
|
||||
.ok()
|
||||
.and_then(|c| c.get("COMPOSE_DIR").cloned());
|
||||
match services::init_compose_dir(compose_override) {
|
||||
Ok(p) => log::info!("Compose-Dir: {}", p.display()),
|
||||
Err(e) => log::error!("Compose-Dir nicht gefunden: {e}"),
|
||||
}
|
||||
|
||||
// config.env vorhanden? Sonst Defaults generieren und persistieren.
|
||||
// Sonst landen Template-Platzhalter ({POSTGRES_PASSWORD}) literal
|
||||
// in den Container-Envs.
|
||||
match config::load() {
|
||||
Ok(mut map) => {
|
||||
let before = map.len();
|
||||
config::ensure_defaults(&mut map);
|
||||
if map.len() != before {
|
||||
if let Err(e) = config::save(&map) {
|
||||
log::error!("config save: {e}");
|
||||
} else {
|
||||
log::info!("Initialized config.env with defaults");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("config load: {e}"),
|
||||
}
|
||||
|
||||
// Auto-register alle Services beim Start (noch ohne sie zu starten).
|
||||
let supervisor_clone = supervisor.clone();
|
||||
let auto_start_containers = config::load()
|
||||
.ok()
|
||||
.and_then(|c| c.get("AUTO_START_CONTAINERS_ON_LAUNCH").cloned())
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut sv = supervisor_clone.lock().await;
|
||||
let n = services::default_services().len();
|
||||
for service_def in services::default_services() {
|
||||
sv.register(service_def);
|
||||
}
|
||||
let how = if launched_hidden { " (hidden launch)" } else { "" };
|
||||
events::info(format!(
|
||||
"RAPPORT Server-App gestartet{how} — {n} Services registriert"
|
||||
)).await;
|
||||
drop(sv);
|
||||
if auto_start_containers {
|
||||
// Nur autostart wenn Daemon erreichbar ist — sonst landen
|
||||
// alle Services in Error, Auto-Recovery spinnt nutzlos und
|
||||
// der Setup-Wizard kommt nie zum Zug.
|
||||
let daemon_ok = tokio::process::Command::new("docker")
|
||||
.arg("info")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if daemon_ok {
|
||||
events::info("Auto-Start: Container werden hochgefahren ...").await;
|
||||
if let Err(e) = supervisor::Supervisor::start_all_managed(supervisor_clone.clone()).await {
|
||||
events::warn(format!("Auto-Start fehlgeschlagen: {e}")).await;
|
||||
}
|
||||
} else {
|
||||
events::info("Auto-Start uebersprungen — Docker-Daemon nicht erreichbar (Setup noetig?)").await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-pull aller Compose-Images im Hintergrund. Erste Klicks
|
||||
// sollen nicht hinter einem 150-MB-Pull haengen bleiben.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
events::info("Pre-pull Docker-Images gestartet").await;
|
||||
let out = tokio::process::Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args(["compose", "pull"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => events::info("Pre-pull fertig").await,
|
||||
Ok(o) => {
|
||||
events::warn(format!(
|
||||
"Pre-pull mit Fehlern: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim().chars().take(200).collect::<String>()
|
||||
)).await;
|
||||
}
|
||||
Err(e) => events::warn(format!("Pre-pull konnte nicht starten: {e}")).await,
|
||||
}
|
||||
});
|
||||
|
||||
// Backup-Scheduler — pg_dumpall alle BACKUP_INTERVAL_HOURS Stunden.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
backup::scheduler_loop().await;
|
||||
});
|
||||
|
||||
// Container-Update-Scheduler — alle CONTAINER_AUTOUPDATE_INTERVAL_HOURS:
|
||||
// docker compose pull + (falls Updates) Backup + Compose-Up.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
container_update::scheduler_loop().await;
|
||||
});
|
||||
|
||||
// Health-Tick-Loop: alle 2s State aus compose ps, daraus:
|
||||
// - Tray-Icon-Farbe (gruen/gelb/rot)
|
||||
// - macOS-Notification fuer NEU in Error gerutschte Services
|
||||
let supervisor_for_health = supervisor.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
|
||||
let mut last_state: Option<TrayStatus> = None;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut sv = supervisor_for_health.lock().await;
|
||||
let newly_errored = sv.tick_health().await;
|
||||
let agg = aggregate_status(&sv.list());
|
||||
// Display-Names holen bevor wir den Lock abgeben.
|
||||
let error_titles: Vec<(String, String)> = newly_errored
|
||||
.iter()
|
||||
.map(|id| (id.clone(), sv.display_name(id).unwrap_or_else(|| id.clone())))
|
||||
.collect();
|
||||
drop(sv);
|
||||
|
||||
if Some(agg) != last_state {
|
||||
set_tray_icon(&app_handle, agg);
|
||||
last_state = Some(agg);
|
||||
}
|
||||
for (id, name) in error_titles {
|
||||
events::error(format!("Service {name} ist in den Fehler-State gewechselt")).await;
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("RAPPORT Server — Fehler")
|
||||
.body(format!("{name} ({id}) ist auf 'unhealthy'/'restarting'."))
|
||||
.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-Recovery-Loop: alle 15s pruefen ob Services in Error sind die
|
||||
// jetzt einen Restart-Versuch bekommen sollen (mit exponential backoff).
|
||||
let supervisor_for_recovery = supervisor.clone();
|
||||
let app_handle_for_recovery = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||
interval.tick().await; // initial tick verwerfen
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let cfg = config::load().unwrap_or_default();
|
||||
let enabled = cfg
|
||||
.get("AUTO_RECOVERY_ENABLED")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true);
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
let base_delay = std::time::Duration::from_secs(
|
||||
cfg.get("AUTO_RECOVERY_BASE_DELAY_SECONDS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60),
|
||||
);
|
||||
let max_attempts: u32 = cfg
|
||||
.get("AUTO_RECOVERY_MAX_ATTEMPTS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
// Wenn der Daemon gerade aus ist (Wizard noch nicht durch),
|
||||
// gar nicht erst versuchen — sonst pumpen wir die Versuchs-
|
||||
// zaehler hoch und geben spaeter auf wenn's eigentlich
|
||||
// ginge.
|
||||
let daemon_ok = tokio::process::Command::new("docker")
|
||||
.arg("info")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !daemon_ok {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut sv = supervisor_for_recovery.lock().await;
|
||||
let report = sv.recovery_candidates(max_attempts, base_delay);
|
||||
drop(sv);
|
||||
|
||||
for id in &report.to_restart {
|
||||
events::warn(format!("Auto-Recovery: Restart-Versuch fuer {id}")).await;
|
||||
let mut sv = supervisor_for_recovery.lock().await;
|
||||
let display = sv.display_name(id).unwrap_or_else(|| id.clone());
|
||||
if let Err(e) = sv.restart(id).await {
|
||||
events::warn(format!(
|
||||
"Auto-Recovery {display}: {e}"
|
||||
)).await;
|
||||
}
|
||||
}
|
||||
for id in &report.maxed_out {
|
||||
events::error(format!("Auto-Recovery aufgegeben fuer {id} — manueller Eingriff noetig")).await;
|
||||
let sv = supervisor_for_recovery.lock().await;
|
||||
let display = sv.display_name(id).unwrap_or_else(|| id.clone());
|
||||
drop(sv);
|
||||
let _ = app_handle_for_recovery
|
||||
.notification()
|
||||
.builder()
|
||||
.title("RAPPORT Server — Auto-Recovery aufgegeben")
|
||||
.body(format!(
|
||||
"{display} ({id}) crasht wiederholt. Bitte manuell pruefen."
|
||||
))
|
||||
.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// HTTP-Admin-WebUI (LAN-Zugriff fuer headless Mac Mini).
|
||||
let supervisor_for_http = supervisor.clone();
|
||||
let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("dist");
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let cfg = config::load().unwrap_or_default();
|
||||
let bind = cfg
|
||||
.get("ADMIN_UI_BIND")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "127.0.0.1".into());
|
||||
let port: u16 = cfg
|
||||
.get("ADMIN_UI_PORT")
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(9090);
|
||||
let password = cfg
|
||||
.get("ADMIN_UI_PASSWORD")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let tls = cfg
|
||||
.get("ADMIN_UI_TLS")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true);
|
||||
if password.is_empty() {
|
||||
log::warn!("ADMIN_UI_PASSWORD leer — WebUI nicht gestartet");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = http_server::serve(
|
||||
bind, port, password, tls, supervisor_for_http, static_dir,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("http_server: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// Tray-Icon mit Show/Quit-Menue. Fenster schliessen reduziert in
|
||||
// den Tray (Container laufen weiter). Quit aus dem Tray beendet
|
||||
// die App tatsaechlich.
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
use tauri::menu::{Menu, MenuItem};
|
||||
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
|
||||
|
||||
let show_item = MenuItem::with_id(app, "show", "Show Dashboard", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit RAPPORT Server", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||
|
||||
let _ = TrayIconBuilder::with_id("main-tray")
|
||||
.tooltip("RAPPORT Server")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event {
|
||||
if let Some(window) = tray.app_handle().get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Fenster schliessen → in den Tray verstecken statt App killen.
|
||||
// So laufen die Docker-Container weiter, der HTTP-WebUI-Server
|
||||
// bleibt erreichbar, und der Health-Tick-Loop tickt weiter.
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::list_services,
|
||||
commands::start_service,
|
||||
commands::stop_service,
|
||||
commands::restart_service,
|
||||
commands::start_all,
|
||||
commands::stop_all,
|
||||
commands::restart_all,
|
||||
commands::service_logs,
|
||||
commands::service_status,
|
||||
commands::backup_now,
|
||||
commands::list_backups,
|
||||
commands::restore_backup,
|
||||
commands::check_container_updates,
|
||||
commands::apply_container_updates,
|
||||
commands::list_events,
|
||||
commands::list_stats,
|
||||
commands::disk_usage,
|
||||
commands::firstaid_recreate,
|
||||
commands::firstaid_reset_pgdata,
|
||||
commands::firstaid_diagnose,
|
||||
commands::setup_status,
|
||||
commands::setup_install,
|
||||
commands::get_config,
|
||||
commands::set_config_value,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Service-Inventar.
|
||||
//!
|
||||
//! Die App ist eine duenne UI ueber dem Compose-Stack im
|
||||
//! `RAPPORT/SERVER-CONTAINER`-Repo. `id` ist hier 1:1 der Compose-Service-Name —
|
||||
//! der Supervisor bildet daraus `docker compose <id>`-Aufrufe. Image-, Env- und
|
||||
//! Mount-Konfiguration kommt komplett aus `SERVER-CONTAINER/docker-compose.yml`
|
||||
//! plus dortiger `.env`.
|
||||
|
||||
use crate::health::HealthProbe;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Wird beim App-Start aus Konfig / Auto-Detect gesetzt — danach read-only.
|
||||
static COMPOSE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Compose-Projektverzeichnis. Liefert den vorher gesetzten Pfad, oder
|
||||
/// panickt wenn `init_compose_dir()` noch nicht gelaufen ist.
|
||||
pub fn compose_dir() -> &'static Path {
|
||||
COMPOSE_DIR
|
||||
.get()
|
||||
.map(|p| p.as_path())
|
||||
.expect("compose_dir not initialized — call init_compose_dir() first")
|
||||
}
|
||||
|
||||
/// Initialisiert den Compose-Pfad. Reihenfolge:
|
||||
/// 1. expliziter `override` (z.B. aus `config.env` `COMPOSE_DIR`)
|
||||
/// 2. Env-Variable `RAPPORT_COMPOSE_DIR`
|
||||
/// 3. `~/RAPPORT/SERVER-CONTAINER/`
|
||||
/// 4. `<exe-parent>/../SERVER-CONTAINER/`
|
||||
/// 5. `<crate-dir>/../../SERVER-CONTAINER/` (Dev-Layout)
|
||||
///
|
||||
/// Jeder Kandidat muss eine `docker-compose.yml` enthalten.
|
||||
pub fn init_compose_dir(override_path: Option<String>) -> Result<&'static Path, String> {
|
||||
if let Some(p) = COMPOSE_DIR.get() {
|
||||
return Ok(p.as_path());
|
||||
}
|
||||
let candidates = collect_candidates(override_path);
|
||||
for c in &candidates {
|
||||
if c.join("docker-compose.yml").exists() {
|
||||
let _ = COMPOSE_DIR.set(c.clone());
|
||||
return Ok(COMPOSE_DIR.get().unwrap().as_path());
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"no docker-compose.yml found — tried: {}",
|
||||
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
fn collect_candidates(override_path: Option<String>) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = override_path {
|
||||
if !p.is_empty() {
|
||||
out.push(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
if let Ok(p) = std::env::var("RAPPORT_COMPOSE_DIR") {
|
||||
out.push(PathBuf::from(p));
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
// Eigener Dev-Klon hat Vorrang (Source-Edits sollen sichtbar bleiben).
|
||||
out.push(PathBuf::from(&home).join("RAPPORT/SERVER-CONTAINER"));
|
||||
// Setup-Wizard-Default: vom Gitea-Tarball nach ~/.rapport/compose/ extrahiert.
|
||||
out.push(PathBuf::from(&home).join(".rapport/compose"));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
out.push(parent.join("../SERVER-CONTAINER"));
|
||||
}
|
||||
}
|
||||
out.push(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("SERVER-CONTAINER"),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServiceDef {
|
||||
/// Compose-Service-Name. Wird so als Argument an `docker compose` gegeben.
|
||||
pub id: String,
|
||||
/// Anzeige-Name fuer die UI.
|
||||
pub display_name: String,
|
||||
/// Host-Port fuer die UI-Anzeige + Health-Probe.
|
||||
pub port: u16,
|
||||
/// Andere Services die laut Compose vorher laufen sollten — nur
|
||||
/// informativ fuer Sortierung in der UI. Echte Dep-Order erzwingt Compose.
|
||||
pub depends_on: Vec<String>,
|
||||
/// Health-Probe (HTTP/TCP) fuer "echt up" jenseits von `docker ps`.
|
||||
pub health: HealthProbe,
|
||||
}
|
||||
|
||||
pub fn default_services() -> Vec<ServiceDef> {
|
||||
vec![
|
||||
ServiceDef {
|
||||
id: "db".into(),
|
||||
display_name: "Postgres".into(),
|
||||
port: 15432, // Host-Port aus SERVER-CONTAINER/.env (DB_PORT)
|
||||
depends_on: vec![],
|
||||
health: HealthProbe::TcpAndQuery {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 5432,
|
||||
db: "postgres".into(),
|
||||
},
|
||||
},
|
||||
ServiceDef {
|
||||
id: "auth".into(),
|
||||
display_name: "GoTrue (Auth)".into(),
|
||||
port: 9999,
|
||||
depends_on: vec!["db".into()],
|
||||
health: HealthProbe::Http {
|
||||
url: "http://127.0.0.1:9999/health".into(),
|
||||
},
|
||||
},
|
||||
ServiceDef {
|
||||
id: "rest".into(),
|
||||
display_name: "PostgREST".into(),
|
||||
port: 3000,
|
||||
depends_on: vec!["db".into()],
|
||||
health: HealthProbe::Http {
|
||||
url: "http://127.0.0.1:3000/".into(),
|
||||
},
|
||||
},
|
||||
ServiceDef {
|
||||
id: "realtime".into(),
|
||||
display_name: "Realtime".into(),
|
||||
port: 4000,
|
||||
depends_on: vec!["db".into()],
|
||||
health: HealthProbe::Http {
|
||||
url: "http://127.0.0.1:4000/api/health".into(),
|
||||
},
|
||||
},
|
||||
ServiceDef {
|
||||
id: "storage".into(),
|
||||
display_name: "Storage".into(),
|
||||
port: 5000,
|
||||
depends_on: vec!["db".into(), "rest".into()],
|
||||
health: HealthProbe::Http {
|
||||
url: "http://127.0.0.1:5000/status".into(),
|
||||
},
|
||||
},
|
||||
ServiceDef {
|
||||
id: "kong".into(),
|
||||
display_name: "Kong (API-Gateway)".into(),
|
||||
port: 18000, // KONG_HTTP_PORT
|
||||
depends_on: vec!["auth".into(), "rest".into(), "realtime".into(), "storage".into()],
|
||||
health: HealthProbe::Http {
|
||||
url: "http://127.0.0.1:8000/".into(),
|
||||
},
|
||||
},
|
||||
ServiceDef {
|
||||
id: "app".into(),
|
||||
display_name: "Frontend (rapport-app)".into(),
|
||||
port: 18080, // APP_PORT
|
||||
depends_on: vec!["kong".into()],
|
||||
health: HealthProbe::Http {
|
||||
url: "http://127.0.0.1:8080/".into(),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! Setup-Wizard: Detection + Direct-Download-Installation von Docker-CLI,
|
||||
//! Colima und Lima. Kein Brew, keine externen Package-Manager.
|
||||
//!
|
||||
//! Layout:
|
||||
//! ~/.rapport/
|
||||
//! ├── bin/
|
||||
//! │ ├── docker
|
||||
//! │ ├── docker-compose
|
||||
//! │ ├── colima
|
||||
//! │ └── limactl
|
||||
//! ├── lima-share/ (von Lima-Tarball, share/lima/)
|
||||
//! └── home/ (HOME-Override fuer Colima/Lima Konfig)
|
||||
//!
|
||||
//! `lib.rs::run()` prependet `~/.rapport/bin` an PATH und setzt `LIMA_HOME`
|
||||
//! etc. damit die Tools die mitgelieferten Files finden.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::Command;
|
||||
|
||||
// ---- Pinned Versionen ----------------------------------------------------
|
||||
const DOCKER_VERSION: &str = "29.5.2";
|
||||
const COLIMA_VERSION: &str = "v0.10.1";
|
||||
const LIMA_VERSION: &str = "v2.1.1";
|
||||
// Quelle fuer den Compose-Stack — wird beim Setup-Wizard automatisch nach
|
||||
// ~/.rapport/compose/ heruntergeladen wenn lokal noch nichts gefunden wird.
|
||||
const COMPOSE_TARBALL_URL: &str =
|
||||
"https://git.kgva.ch/karim/RAPPORT-SERVER/archive/main.tar.gz";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SetupStatus {
|
||||
pub docker_cli: bool,
|
||||
pub colima_installed: bool,
|
||||
pub limactl_installed: bool,
|
||||
pub daemon_running: bool,
|
||||
pub ready: bool,
|
||||
pub recommended_action: RecommendedAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RecommendedAction {
|
||||
/// Alles ok — UI rendert das Dashboard.
|
||||
NoneReady,
|
||||
/// Tools alle installiert, nur Daemon ist aus → `colima start`.
|
||||
StartColima,
|
||||
/// Tools fehlen → Direct-Download.
|
||||
InstallAll,
|
||||
}
|
||||
|
||||
pub fn install_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".rapport")
|
||||
}
|
||||
|
||||
pub fn bin_dir() -> PathBuf {
|
||||
install_dir().join("bin")
|
||||
}
|
||||
|
||||
pub fn compose_dir() -> PathBuf {
|
||||
install_dir().join("compose")
|
||||
}
|
||||
|
||||
/// Lima erwartet die Templates relativ zur Binary unter `<bin>/../share/lima/`.
|
||||
/// Wir halten uns an dieses Layout in `~/.rapport/share/`.
|
||||
fn lima_share_dir() -> PathBuf {
|
||||
install_dir().join("share")
|
||||
}
|
||||
|
||||
pub async fn status() -> SetupStatus {
|
||||
let docker_cli = bin_exists("docker") || which("docker").await;
|
||||
let colima_installed = bin_exists("colima") || which("colima").await;
|
||||
let limactl_installed = bin_exists("limactl") || which("limactl").await;
|
||||
let daemon_running = if docker_cli { docker_info_ok().await } else { false };
|
||||
let ready = daemon_running;
|
||||
let action = if ready {
|
||||
RecommendedAction::NoneReady
|
||||
} else if docker_cli && colima_installed && limactl_installed {
|
||||
RecommendedAction::StartColima
|
||||
} else {
|
||||
RecommendedAction::InstallAll
|
||||
};
|
||||
SetupStatus {
|
||||
docker_cli,
|
||||
colima_installed,
|
||||
limactl_installed,
|
||||
daemon_running,
|
||||
ready,
|
||||
recommended_action: action,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct InstallResult {
|
||||
pub finished_at_iso: String,
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
pub async fn install_and_start() -> Result<InstallResult, String> {
|
||||
let s = status().await;
|
||||
crate::events::info(format!("Setup: Aktion = {:?}", s.recommended_action)).await;
|
||||
let mut log = String::new();
|
||||
|
||||
if matches!(s.recommended_action, RecommendedAction::InstallAll) {
|
||||
download_all(&mut log).await?;
|
||||
}
|
||||
|
||||
// Compose-Stack-Bootstrap: nur wenn noch nichts gefunden wird (z.B.
|
||||
// Erst-Install). Wer eine eigene SERVER-CONTAINER-Repo hat (lokaler
|
||||
// Klon unter ~/RAPPORT/SERVER-CONTAINER/) bleibt unberuehrt.
|
||||
if let Err(_) = crate::services::init_compose_dir(None) {
|
||||
bootstrap_compose_stack(&mut log).await?;
|
||||
}
|
||||
|
||||
crate::events::info("Setup: colima start ...").await;
|
||||
log.push_str("\n=== colima start ===\n");
|
||||
let colima = pick_path("colima");
|
||||
let limactl = pick_path("limactl");
|
||||
let extra_path = format!(
|
||||
"{}:{}",
|
||||
bin_dir().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let out = Command::new(&colima)
|
||||
.args(["start", "--cpu", "2", "--memory", "4", "--disk", "30"])
|
||||
.env("PATH", &extra_path)
|
||||
.env("LIMA_HOME", install_dir().join("lima-home"))
|
||||
.env("HOME", std::env::var("HOME").unwrap_or_default())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn colima ({}): {e}", colima.display()))?;
|
||||
log.push_str(&format!(
|
||||
"stdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
));
|
||||
if !out.status.success() {
|
||||
return Err(format!("colima start failed:\n{log}\n\nlimactl: {}", limactl.display()));
|
||||
}
|
||||
|
||||
let after = status().await;
|
||||
if !after.daemon_running {
|
||||
return Err(format!(
|
||||
"Daemon nach Setup nicht erreichbar. Log:\n{log}"
|
||||
));
|
||||
}
|
||||
crate::events::info("Setup fertig: Daemon laeuft").await;
|
||||
Ok(InstallResult {
|
||||
finished_at_iso: chrono::Local::now().to_rfc3339(),
|
||||
log,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direct-Download-Sektion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn download_all(log: &mut String) -> Result<(), String> {
|
||||
let arch = detect_arch()?;
|
||||
std::fs::create_dir_all(bin_dir()).map_err(|e| format!("mkdir bin: {e}"))?;
|
||||
std::fs::create_dir_all(lima_share_dir()).map_err(|e| format!("mkdir lima-share: {e}"))?;
|
||||
|
||||
// Lima zuerst (Colima braucht es).
|
||||
if !file_exists(&bin_dir().join("limactl")) {
|
||||
install_lima(&arch, log).await?;
|
||||
}
|
||||
if !file_exists(&bin_dir().join("colima")) {
|
||||
install_colima(&arch, log).await?;
|
||||
}
|
||||
if !file_exists(&bin_dir().join("docker")) {
|
||||
install_docker(&arch, log).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Arch {
|
||||
/// `arm64` oder `amd64`
|
||||
docker_label: &'static str,
|
||||
/// `Darwin-arm64` oder `Darwin-x86_64`
|
||||
colima_label: &'static str,
|
||||
/// `Darwin-arm64` oder `Darwin-x86_64`
|
||||
lima_label: &'static str,
|
||||
/// `aarch64` oder `x86_64` (subdir auf download.docker.com)
|
||||
docker_subdir: &'static str,
|
||||
}
|
||||
|
||||
fn detect_arch() -> Result<Arch, String> {
|
||||
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
Ok(Arch {
|
||||
docker_label: "arm64",
|
||||
colima_label: "Darwin-arm64",
|
||||
lima_label: "Darwin-arm64",
|
||||
docker_subdir: "aarch64",
|
||||
})
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
|
||||
Ok(Arch {
|
||||
docker_label: "amd64",
|
||||
colima_label: "Darwin-x86_64",
|
||||
lima_label: "Darwin-x86_64",
|
||||
docker_subdir: "x86_64",
|
||||
})
|
||||
} else {
|
||||
Err("Setup-Wizard unterstuetzt aktuell nur macOS (Mac Intel + Apple Silicon)".into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_lima(arch: &Arch, log: &mut String) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"https://github.com/lima-vm/lima/releases/download/{LIMA_VERSION}/lima-{ver}-{lbl}.tar.gz",
|
||||
ver = LIMA_VERSION.trim_start_matches('v'),
|
||||
lbl = arch.lima_label
|
||||
);
|
||||
crate::events::info(format!("Setup: lade Lima {LIMA_VERSION} ...")).await;
|
||||
log.push_str(&format!("=== lima ===\n{url}\n"));
|
||||
let tmp = tempdir_path("lima.tar.gz");
|
||||
download_to(&url, &tmp).await?;
|
||||
let extract_to = install_dir();
|
||||
extract_tar_gz(&tmp, &extract_to).await?;
|
||||
// Tarball legt `bin/` und `share/` ab — moven nach unserer Struktur.
|
||||
let _ = std::fs::rename(extract_to.join("bin/limactl"), bin_dir().join("limactl"));
|
||||
let _ = std::fs::rename(extract_to.join("share"), lima_share_dir());
|
||||
let _ = std::fs::remove_dir(extract_to.join("bin"));
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
chmod_x(&bin_dir().join("limactl"))?;
|
||||
unquarantine(&bin_dir().join("limactl"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_colima(arch: &Arch, log: &mut String) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"https://github.com/abiosoft/colima/releases/download/{COLIMA_VERSION}/colima-{lbl}",
|
||||
lbl = arch.colima_label
|
||||
);
|
||||
crate::events::info(format!("Setup: lade Colima {COLIMA_VERSION} ...")).await;
|
||||
log.push_str(&format!("\n=== colima ===\n{url}\n"));
|
||||
let dest = bin_dir().join("colima");
|
||||
download_to(&url, &dest).await?;
|
||||
chmod_x(&dest)?;
|
||||
unquarantine(&dest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_docker(arch: &Arch, log: &mut String) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"https://download.docker.com/mac/static/stable/{sub}/docker-{ver}.tgz",
|
||||
sub = arch.docker_subdir,
|
||||
ver = DOCKER_VERSION
|
||||
);
|
||||
crate::events::info(format!("Setup: lade Docker-CLI {DOCKER_VERSION} ...")).await;
|
||||
log.push_str(&format!("\n=== docker ===\n{url}\n"));
|
||||
let tmp = tempdir_path("docker.tgz");
|
||||
download_to(&url, &tmp).await?;
|
||||
let extract_to = tempdir_path("docker-extract");
|
||||
std::fs::create_dir_all(&extract_to).map_err(|e| format!("mkdir extract: {e}"))?;
|
||||
extract_tar_gz(&tmp, &extract_to).await?;
|
||||
// Tarball-Layout: docker/{docker,docker-compose,docker-buildx,...}
|
||||
let src = extract_to.join("docker");
|
||||
for bin in &["docker", "docker-compose", "docker-buildx"] {
|
||||
let from = src.join(bin);
|
||||
let to = bin_dir().join(bin);
|
||||
if from.exists() {
|
||||
let _ = std::fs::rename(&from, &to);
|
||||
chmod_x(&to)?;
|
||||
unquarantine(&to);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&extract_to);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compose-Stack-Bootstrap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn bootstrap_compose_stack(log: &mut String) -> Result<(), String> {
|
||||
crate::events::info("Setup: lade Compose-Stack von Gitea ...").await;
|
||||
log.push_str("\n=== compose-stack download ===\n");
|
||||
|
||||
let dest = compose_dir();
|
||||
std::fs::create_dir_all(&dest).map_err(|e| format!("mkdir compose dir: {e}"))?;
|
||||
|
||||
let tmp = tempdir_path("compose.tar.gz");
|
||||
download_to(COMPOSE_TARBALL_URL, &tmp).await?;
|
||||
// Gitea-Archiv legt einen Top-Level-Ordner an — strip-components=1
|
||||
let status = Command::new("tar")
|
||||
.args(["-xzf"])
|
||||
.arg(&tmp)
|
||||
.args(["-C"])
|
||||
.arg(&dest)
|
||||
.arg("--strip-components=1")
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("spawn tar: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("tar -xzf {} failed", tmp.display()));
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
// .env aus .env.example mit unseren Secrets generieren — sonst startet
|
||||
// compose nicht.
|
||||
let env_path = dest.join(".env");
|
||||
if !env_path.exists() {
|
||||
let example_path = dest.join(".env.example");
|
||||
let template = if example_path.exists() {
|
||||
std::fs::read_to_string(&example_path)
|
||||
.map_err(|e| format!("read .env.example: {e}"))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let env_content = render_compose_env(&template)?;
|
||||
std::fs::write(&env_path, env_content).map_err(|e| format!("write .env: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
crate::events::info(format!(".env generiert: {}", env_path.display())).await;
|
||||
}
|
||||
|
||||
crate::events::info(format!("Compose-Stack bereit unter {}", dest.display())).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generiert eine `.env` aus dem Template — bekannte Keys mit Werten aus
|
||||
/// unserer `config.env` oder mit sinnvollen Defaults, unbekannte werden 1:1
|
||||
/// uebernommen falls schon ein Wert dort steht, sonst leer gelassen.
|
||||
fn render_compose_env(template: &str) -> Result<String, String> {
|
||||
let app_cfg = crate::config::load().unwrap_or_default();
|
||||
let pg_pw = app_cfg
|
||||
.get("POSTGRES_PASSWORD")
|
||||
.cloned()
|
||||
.ok_or_else(|| "POSTGRES_PASSWORD fehlt in config.env".to_string())?;
|
||||
let jwt = app_cfg
|
||||
.get("JWT_SECRET")
|
||||
.cloned()
|
||||
.ok_or_else(|| "JWT_SECRET fehlt in config.env".to_string())?;
|
||||
|
||||
let (anon, service) = generate_supabase_keys(&jwt)?;
|
||||
|
||||
let defaults: std::collections::HashMap<&str, String> = [
|
||||
("POSTGRES_PASSWORD", pg_pw),
|
||||
("JWT_SECRET", jwt),
|
||||
("ANON_KEY", anon),
|
||||
("SERVICE_ROLE_KEY", service),
|
||||
("SITE_URL", "http://localhost:18080".into()),
|
||||
("API_EXTERNAL_URL", "http://localhost:18000".into()),
|
||||
("APP_PORT", "18080".into()),
|
||||
("KONG_HTTP_PORT", "18000".into()),
|
||||
("KONG_HTTPS_PORT", "18443".into()),
|
||||
("DB_PORT", "15432".into()),
|
||||
("RAPPORT_APP_TAG", "main".into()),
|
||||
("SMTP_HOST", String::new()),
|
||||
("SMTP_PORT", "587".into()),
|
||||
("SMTP_USER", String::new()),
|
||||
("SMTP_PASS", String::new()),
|
||||
("SMTP_SENDER_NAME", "RAPPORT".into()),
|
||||
("SMTP_ADMIN_EMAIL", String::new()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut out = String::from("# Auto-generiert vom RAPPORT Server-App Setup-Wizard.\n");
|
||||
out.push_str("# Werte koennen hier oder im Settings-Tab der App geaendert werden.\n\n");
|
||||
for line in template.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
if let Some(eq_pos) = trimmed.find('=') {
|
||||
let key = trimmed[..eq_pos].trim();
|
||||
if let Some(val) = defaults.get(key) {
|
||||
out.push_str(&format!("{key}={val}\n"));
|
||||
} else {
|
||||
// Unbekannter Key — 1:1 uebernehmen
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generiert die zwei Standard-Supabase-JWTs ('anon' + 'service_role') die
|
||||
/// signiert mit `jwt_secret` sind. Beide haben sehr lange Laufzeit (10 Jahre).
|
||||
fn generate_supabase_keys(jwt_secret: &str) -> Result<(String, String), String> {
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Claims {
|
||||
role: &'static str,
|
||||
iss: &'static str,
|
||||
iat: u64,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| format!("time: {e}"))?
|
||||
.as_secs();
|
||||
let exp = now + 60 * 60 * 24 * 365 * 10; // 10 Jahre
|
||||
|
||||
let key = EncodingKey::from_secret(jwt_secret.as_bytes());
|
||||
let make = |role: &'static str| -> Result<String, String> {
|
||||
encode(
|
||||
&Header::default(),
|
||||
&Claims { role, iss: "supabase", iat: now, exp },
|
||||
&key,
|
||||
)
|
||||
.map_err(|e| format!("encode JWT: {e}"))
|
||||
};
|
||||
Ok((make("anon")?, make("service_role")?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn bin_exists(name: &str) -> bool {
|
||||
file_exists(&bin_dir().join(name))
|
||||
}
|
||||
|
||||
fn file_exists(p: &std::path::Path) -> bool {
|
||||
p.is_file()
|
||||
}
|
||||
|
||||
fn pick_path(name: &str) -> PathBuf {
|
||||
let local = bin_dir().join(name);
|
||||
if local.is_file() {
|
||||
local
|
||||
} else {
|
||||
PathBuf::from(name) // PATH-Lookup
|
||||
}
|
||||
}
|
||||
|
||||
async fn which(cmd: &str) -> bool {
|
||||
match Command::new("which").arg(cmd).output().await {
|
||||
Ok(o) => o.status.success() && !o.stdout.is_empty(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn docker_info_ok() -> bool {
|
||||
let docker = pick_path("docker");
|
||||
let extra_path = format!(
|
||||
"{}:{}",
|
||||
bin_dir().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
match Command::new(&docker)
|
||||
.arg("info")
|
||||
.env("PATH", &extra_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
{
|
||||
Ok(s) => s.success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn tempdir_path(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("rapport-setup-{name}"))
|
||||
}
|
||||
|
||||
async fn download_to(url: &str, dest: &std::path::Path) -> Result<(), String> {
|
||||
let resp = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| format!("reqwest build: {e}"))?
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("http get {url}: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {} fuer {url}", resp.status()));
|
||||
}
|
||||
let mut out = tokio::fs::File::create(dest)
|
||||
.await
|
||||
.map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("read body: {e}"))?;
|
||||
out.write_all(&bytes)
|
||||
.await
|
||||
.map_err(|e| format!("write {}: {e}", dest.display()))?;
|
||||
out.flush().await.map_err(|e| format!("flush: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn extract_tar_gz(tar_path: &std::path::Path, out_dir: &std::path::Path) -> Result<(), String> {
|
||||
let status = Command::new("tar")
|
||||
.args(["-xzf"])
|
||||
.arg(tar_path)
|
||||
.arg("-C")
|
||||
.arg(out_dir)
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("spawn tar: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("tar -xzf {} failed", tar_path.display()));
|
||||
}
|
||||
let _ = AsyncReadExt::read(&mut tokio::io::empty(), &mut [0u8; 0]).await; // silence unused
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chmod_x(p: &std::path::Path) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o755);
|
||||
std::fs::set_permissions(p, perms).map_err(|e| format!("chmod {}: {e}", p.display()))?;
|
||||
}
|
||||
let _ = p;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unquarantine(p: &std::path::Path) {
|
||||
// Macht aus dem GitHub-Download eine "vertrauenswuerdige" Datei, sonst
|
||||
// blockt macOS Gatekeeper das Ausfuehren beim ersten Mal.
|
||||
let _ = std::process::Command::new("xattr")
|
||||
.args(["-d", "com.apple.quarantine"])
|
||||
.arg(p)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Container-Live-Stats via `docker stats --no-stream`.
|
||||
//!
|
||||
//! Wird auf Demand abgerufen (separater Tauri-Command + HTTP-Endpoint),
|
||||
//! NICHT in den 2s-Health-Tick eingehaengt — `docker stats` braucht selbst
|
||||
//! ~500ms-1s und wuerde den UI-Poll ausbremsen.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContainerStats {
|
||||
/// Compose-Service-ID (z.B. `db`, `auth`) — abgeleitet vom Container-Namen.
|
||||
pub service_id: String,
|
||||
pub cpu_percent: f32,
|
||||
pub mem_bytes: u64,
|
||||
pub mem_percent: f32,
|
||||
}
|
||||
|
||||
pub async fn collect() -> Vec<ContainerStats> {
|
||||
let out = Command::new("docker")
|
||||
.args(["stats", "--no-stream", "--format", "{{json .}}"])
|
||||
.output()
|
||||
.await;
|
||||
let stdout = match out {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
let mut stats = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
let name = v.get("Name").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let Some(service_id) = name.strip_prefix("rapport-").map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
// Sonderfall: das Frontend-Image heisst `rapport-server-app` in compose
|
||||
// (container_name), aber Compose-Service ist `app`. Manuelles Mapping.
|
||||
let service_id = if service_id == "server-app" {
|
||||
"app".into()
|
||||
} else {
|
||||
service_id
|
||||
};
|
||||
let cpu = parse_percent(v.get("CPUPerc").and_then(|x| x.as_str()).unwrap_or(""));
|
||||
let mem_pct = parse_percent(v.get("MemPerc").and_then(|x| x.as_str()).unwrap_or(""));
|
||||
let mem_bytes = parse_mem_usage(v.get("MemUsage").and_then(|x| x.as_str()).unwrap_or(""));
|
||||
stats.push(ContainerStats {
|
||||
service_id,
|
||||
cpu_percent: cpu,
|
||||
mem_bytes,
|
||||
mem_percent: mem_pct,
|
||||
});
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
fn parse_percent(s: &str) -> f32 {
|
||||
s.trim_end_matches('%').trim().parse().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// "55.32MiB / 7.756GiB" → 55.32 MiB in Bytes
|
||||
fn parse_mem_usage(s: &str) -> u64 {
|
||||
let part = s.split('/').next().unwrap_or("").trim();
|
||||
parse_size(part)
|
||||
}
|
||||
|
||||
fn parse_size(s: &str) -> u64 {
|
||||
let split_idx = s.find(|c: char| c.is_alphabetic()).unwrap_or(s.len());
|
||||
let (num_str, unit) = s.split_at(split_idx);
|
||||
let n: f64 = num_str.parse().unwrap_or(0.0);
|
||||
let mul = match unit.trim().to_lowercase().as_str() {
|
||||
"b" => 1.0,
|
||||
"kib" | "kb" | "k" => 1024.0,
|
||||
"mib" | "mb" | "m" => 1024.0 * 1024.0,
|
||||
"gib" | "gb" | "g" => 1024.0 * 1024.0 * 1024.0,
|
||||
"tib" | "tb" | "t" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
(n * mul) as u64
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Process-Supervisor — duenner Wrapper um `docker compose`.
|
||||
//!
|
||||
//! Statt selbst Container-Argumente zu bauen, delegieren wir Start/Stop/Logs
|
||||
//! an die Compose-Datei in `services::compose_dir()`. Das hat den Vorteil dass
|
||||
//! Image-/Env-/Volume-Konfiguration nur einmal existiert (im SERVER-CONTAINER-
|
||||
//! Repo) und nicht hier und dort gepflegt werden muss.
|
||||
|
||||
use crate::services::{self, ServiceDef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const LOG_RING_CAPACITY: usize = 1000;
|
||||
|
||||
/// Globaler Status-Cache fuer schnelles `list_services` ohne den Supervisor-Mutex
|
||||
/// blockieren zu muessen. Wird von list_with_timeout() gefuellt; bei lang
|
||||
/// laufenden compose-Calls (z.B. Erst-Start mit Image-Pull) liefert der Cache
|
||||
/// die letzte bekannte Liste so dass die UI nicht hangs zeigt.
|
||||
static STATUS_CACHE: std::sync::OnceLock<Arc<Mutex<Vec<ServiceStatus>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn status_cache() -> &'static Arc<Mutex<Vec<ServiceStatus>>> {
|
||||
STATUS_CACHE.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
|
||||
}
|
||||
|
||||
/// Versucht den Supervisor-Mutex in `timeout_ms` zu kriegen — wenn nicht,
|
||||
/// gibt den Cache zurueck. Aktualisiert Cache bei jedem Erfolg.
|
||||
pub async fn list_with_timeout(
|
||||
supervisor: &Arc<Mutex<Supervisor>>,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<ServiceStatus> {
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||
match tokio::time::timeout(timeout, supervisor.lock()).await {
|
||||
Ok(sv) => {
|
||||
let list = sv.list();
|
||||
*status_cache().lock().await = list.clone();
|
||||
list
|
||||
}
|
||||
Err(_) => status_cache().lock().await.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceState {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Stopping,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ServiceStatus {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub state: ServiceState,
|
||||
pub pid: Option<u32>,
|
||||
pub port: u16,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
struct ServiceEntry {
|
||||
def: ServiceDef,
|
||||
state: ServiceState,
|
||||
log_pump: Option<Child>,
|
||||
pid: Option<u32>,
|
||||
last_error: Option<String>,
|
||||
logs: Arc<Mutex<VecDeque<String>>>,
|
||||
/// Wann zum erstmals in Error gerutscht — gesetzt bei Transition INTO Error,
|
||||
/// gecleart wenn raus.
|
||||
errored_since: Option<Instant>,
|
||||
recovery_attempts: u32,
|
||||
/// Wann der naechste Auto-Restart-Versuch faellig ist.
|
||||
next_recovery_at: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
pub struct RecoveryReport {
|
||||
pub to_restart: Vec<String>,
|
||||
pub maxed_out: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Supervisor {
|
||||
services: HashMap<String, ServiceEntry>,
|
||||
pub activity: Arc<Mutex<String>>,
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
services: HashMap::new(),
|
||||
activity: Arc::new(Mutex::new(String::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activity_handle(&self) -> Arc<Mutex<String>> {
|
||||
self.activity.clone()
|
||||
}
|
||||
|
||||
pub async fn current_activity(&self) -> String {
|
||||
self.activity.lock().await.clone()
|
||||
}
|
||||
|
||||
pub fn register(&mut self, def: ServiceDef) {
|
||||
let id = def.id.clone();
|
||||
self.services.insert(
|
||||
id,
|
||||
ServiceEntry {
|
||||
def,
|
||||
state: ServiceState::Stopped,
|
||||
log_pump: None,
|
||||
pid: None,
|
||||
last_error: None,
|
||||
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY))),
|
||||
errored_since: None,
|
||||
recovery_attempts: 0,
|
||||
next_recovery_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<ServiceStatus> {
|
||||
let mut out: Vec<_> = self
|
||||
.services
|
||||
.values()
|
||||
.map(|e| ServiceStatus {
|
||||
id: e.def.id.clone(),
|
||||
display_name: e.def.display_name.clone(),
|
||||
state: e.state,
|
||||
pid: e.pid,
|
||||
port: e.def.port,
|
||||
last_error: e.last_error.clone(),
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.port.cmp(&b.port));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn status(&self, id: &str) -> Option<ServiceStatus> {
|
||||
let e = self.services.get(id)?;
|
||||
Some(ServiceStatus {
|
||||
id: e.def.id.clone(),
|
||||
display_name: e.def.display_name.clone(),
|
||||
state: e.state,
|
||||
pid: e.pid,
|
||||
port: e.def.port,
|
||||
last_error: e.last_error.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn logs(&self, id: &str) -> Vec<String> {
|
||||
let Some(e) = self.services.get(id) else { return vec![] };
|
||||
e.logs.lock().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn start(&mut self, id: &str) -> Result<(), String> {
|
||||
log::info!("start({id})");
|
||||
let entry = self
|
||||
.services
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("unknown service: {id}"))?;
|
||||
if matches!(entry.state, ServiceState::Running | ServiceState::Starting) {
|
||||
return Ok(());
|
||||
}
|
||||
entry.state = ServiceState::Starting;
|
||||
entry.last_error = None;
|
||||
|
||||
let logs_ref = entry.logs.clone();
|
||||
let result = compose_up(&entry.def.id, logs_ref).await;
|
||||
let entry = self.services.get_mut(id).unwrap();
|
||||
match result {
|
||||
Ok(log_pump) => {
|
||||
entry.pid = log_pump.id();
|
||||
entry.log_pump = Some(log_pump);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
entry.state = ServiceState::Error;
|
||||
entry.last_error = Some(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-in replacement fuer `start_all` als freie Funktion — haelt den
|
||||
/// Supervisor-Mutex NUR fuer kurze State-Updates, NICHT waehrend `docker
|
||||
/// compose up -d`. So bleibt die UI responsive auch beim Erst-Pull.
|
||||
pub async fn start_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
|
||||
log::info!("start_all_managed (docker compose up -d, lock-frei waehrend compose)");
|
||||
crate::events::info("Container werden hochgefahren (kann beim Erst-Start bis zu 1 Min dauern) ...").await;
|
||||
// 1) Quick lock: mark all Starting + prime Cache
|
||||
{
|
||||
let mut sv = arc.lock().await;
|
||||
sv.mark_all_starting();
|
||||
*status_cache().lock().await = sv.list();
|
||||
}
|
||||
// 2) Slow compose call — KEIN Lock gehalten
|
||||
let compose_result = compose(&["up", "-d"]).await;
|
||||
// 3) Quick lock: finalize
|
||||
let mut sv = arc.lock().await;
|
||||
match compose_result {
|
||||
Err(e) => {
|
||||
sv.mark_all_error(&e);
|
||||
*status_cache().lock().await = sv.list();
|
||||
crate::events::error(format!("Start fehlgeschlagen: {e}")).await;
|
||||
Err(e)
|
||||
}
|
||||
Ok(_) => {
|
||||
sv.ensure_log_pumps().await;
|
||||
*status_cache().lock().await = sv.list();
|
||||
drop(sv);
|
||||
crate::events::info("Alle Services gestartet").await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
|
||||
log::info!("stop_all_managed (docker compose down, lock-frei waehrend compose)");
|
||||
crate::events::info("Alle Services stoppen ...").await;
|
||||
{
|
||||
let mut sv = arc.lock().await;
|
||||
sv.mark_all_stopping().await;
|
||||
*status_cache().lock().await = sv.list();
|
||||
}
|
||||
let res = compose(&["down"]).await;
|
||||
let mut sv = arc.lock().await;
|
||||
sv.mark_all_stopped();
|
||||
*status_cache().lock().await = sv.list();
|
||||
res.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn restart_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
|
||||
log::info!("restart_all_managed (docker compose restart, lock-frei waehrend compose)");
|
||||
crate::events::info("Alle Services neu starten ...").await;
|
||||
{
|
||||
let mut sv = arc.lock().await;
|
||||
for entry in sv.services.values_mut() {
|
||||
entry.state = ServiceState::Starting;
|
||||
entry.last_error = None;
|
||||
if let Some(mut lp) = entry.log_pump.take() {
|
||||
let _ = lp.kill().await;
|
||||
}
|
||||
}
|
||||
*status_cache().lock().await = sv.list();
|
||||
}
|
||||
let res = compose(&["restart"]).await;
|
||||
let mut sv = arc.lock().await;
|
||||
match res {
|
||||
Err(e) => {
|
||||
sv.mark_all_error(&e);
|
||||
*status_cache().lock().await = sv.list();
|
||||
crate::events::error(format!("Restart-All fehlgeschlagen: {e}")).await;
|
||||
Err(e)
|
||||
}
|
||||
Ok(_) => {
|
||||
sv.ensure_log_pumps().await;
|
||||
*status_cache().lock().await = sv.list();
|
||||
drop(sv);
|
||||
crate::events::info("Alle Services neu gestartet").await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restart(&mut self, id: &str) -> Result<(), String> {
|
||||
log::info!("restart({id})");
|
||||
crate::events::info(format!("Service {id} neu starten ...")).await;
|
||||
let entry = self
|
||||
.services
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("unknown service: {id}"))?;
|
||||
entry.state = ServiceState::Starting;
|
||||
entry.last_error = None;
|
||||
if let Some(mut lp) = entry.log_pump.take() {
|
||||
let _ = lp.kill().await;
|
||||
}
|
||||
let logs = entry.logs.clone();
|
||||
let id_clone = entry.def.id.clone();
|
||||
|
||||
let res = compose(&["restart", &id_clone]).await;
|
||||
let entry = self.services.get_mut(id).unwrap();
|
||||
if let Err(ref e) = res {
|
||||
entry.state = ServiceState::Error;
|
||||
entry.last_error = Some(e.clone());
|
||||
return Err(e.clone());
|
||||
}
|
||||
// Log-Pump neu starten — der alte Stream ist tot.
|
||||
match spawn_log_pump(&id_clone, logs).await {
|
||||
Ok(lp) => {
|
||||
entry.pid = lp.id();
|
||||
entry.log_pump = Some(lp);
|
||||
}
|
||||
Err(e) => log::warn!("log pump restart {id_clone}: {e}"),
|
||||
}
|
||||
crate::events::info(format!("Service {id} neu gestartet")).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&mut self, id: &str) -> Result<(), String> {
|
||||
log::info!("stop({id})");
|
||||
let entry = self
|
||||
.services
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("unknown service: {id}"))?;
|
||||
if matches!(entry.state, ServiceState::Stopped) {
|
||||
return Ok(());
|
||||
}
|
||||
entry.state = ServiceState::Stopping;
|
||||
if let Some(mut lp) = entry.log_pump.take() {
|
||||
let _ = lp.kill().await;
|
||||
}
|
||||
let id_clone = entry.def.id.clone();
|
||||
let _ = compose(&["stop", &id_clone]).await;
|
||||
entry.pid = None;
|
||||
entry.state = ServiceState::Stopped;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Quick: alle Services auf `Starting` setzen + Cache update.
|
||||
/// Wird vor dem langen compose-Call aufgerufen damit die UI nicht hangs sieht.
|
||||
fn mark_all_starting(&mut self) {
|
||||
for entry in self.services.values_mut() {
|
||||
entry.state = ServiceState::Starting;
|
||||
entry.last_error = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick: alle auf `Stopping` setzen + Log-Pumps killen.
|
||||
async fn mark_all_stopping(&mut self) {
|
||||
for entry in self.services.values_mut() {
|
||||
if let Some(mut lp) = entry.log_pump.take() {
|
||||
let _ = lp.kill().await;
|
||||
}
|
||||
entry.state = ServiceState::Stopping;
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick: alle als Stopped markieren (Endzustand nach compose down).
|
||||
fn mark_all_stopped(&mut self) {
|
||||
for entry in self.services.values_mut() {
|
||||
entry.state = ServiceState::Stopped;
|
||||
entry.pid = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick: alle als Error markieren (Endzustand wenn compose-Call schiefging).
|
||||
fn mark_all_error(&mut self, msg: &str) {
|
||||
for entry in self.services.values_mut() {
|
||||
entry.state = ServiceState::Error;
|
||||
entry.last_error = Some(msg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick: Log-Pumps fuer alle Services nachstarten falls noch keiner laeuft.
|
||||
async fn ensure_log_pumps(&mut self) {
|
||||
let ids: Vec<String> = self.services.keys().cloned().collect();
|
||||
for id in ids {
|
||||
let Some(entry) = self.services.get_mut(&id) else { continue };
|
||||
if entry.log_pump.is_none() {
|
||||
if let Ok(lp) = spawn_log_pump(&entry.def.id, entry.logs.clone()).await {
|
||||
entry.pid = lp.id();
|
||||
entry.log_pump = Some(lp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fragt `docker compose ps` nach dem aktuellen Stand und uebernimmt
|
||||
/// die State-Machine daraus. Liefert die Liste der Services die in
|
||||
/// diesem Tick NEU in den Error-State gewechselt sind — der Caller
|
||||
/// dispatched daraus z.B. eine macOS-Notification.
|
||||
pub async fn tick_health(&mut self) -> Vec<String> {
|
||||
let mut newly_errored = Vec::new();
|
||||
let statuses = match query_compose_status().await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::warn!("compose ps failed: {e}");
|
||||
return newly_errored;
|
||||
}
|
||||
};
|
||||
let ids: Vec<String> = self.services.keys().cloned().collect();
|
||||
for id in ids {
|
||||
let Some(e) = self.services.get_mut(&id) else { continue };
|
||||
let cs = statuses.get(&id);
|
||||
let new_state = match cs {
|
||||
// Container nicht in compose ps:
|
||||
// - wenn wir gerade auf Starting sind (z.B. compose up haengt
|
||||
// noch im Pull) → bleibt Starting (sonst flackert die UI)
|
||||
// - sonst → Stopped
|
||||
None => {
|
||||
if e.state == ServiceState::Starting {
|
||||
ServiceState::Starting
|
||||
} else {
|
||||
ServiceState::Stopped
|
||||
}
|
||||
}
|
||||
Some(s) if s.health == Some("unhealthy".to_string()) => ServiceState::Error,
|
||||
Some(s) if s.state == "restarting" || s.state == "dead" => ServiceState::Error,
|
||||
Some(s) if s.state == "exited" || s.state == "stopped" || s.state == "removing" => {
|
||||
ServiceState::Stopped
|
||||
}
|
||||
Some(s) if s.state == "created" || s.state == "paused" => ServiceState::Starting,
|
||||
Some(s) if s.state == "running" => match s.health.as_deref() {
|
||||
Some("starting") => ServiceState::Starting,
|
||||
_ => ServiceState::Running,
|
||||
},
|
||||
Some(_) => ServiceState::Starting,
|
||||
};
|
||||
if e.state != new_state {
|
||||
let was_error = matches!(e.state, ServiceState::Error);
|
||||
if new_state == ServiceState::Error {
|
||||
if let Some(s) = cs {
|
||||
e.last_error = Some(format!("{}: {}", s.state, s.health.clone().unwrap_or_default()));
|
||||
}
|
||||
if !was_error {
|
||||
// Transition INTO Error: Recovery-Counter starten.
|
||||
e.errored_since = Some(Instant::now());
|
||||
e.recovery_attempts = 0;
|
||||
// Erste Recovery erst nach base-delay (lib.rs setzt das spaeter neu).
|
||||
e.next_recovery_at = Some(Instant::now() + Duration::from_secs(60));
|
||||
newly_errored.push(id.clone());
|
||||
}
|
||||
} else {
|
||||
// Out of Error: Recovery-State resetten.
|
||||
e.errored_since = None;
|
||||
e.recovery_attempts = 0;
|
||||
e.next_recovery_at = None;
|
||||
if matches!(new_state, ServiceState::Running | ServiceState::Stopped) {
|
||||
e.last_error = None;
|
||||
}
|
||||
}
|
||||
e.state = new_state;
|
||||
}
|
||||
}
|
||||
newly_errored
|
||||
}
|
||||
|
||||
/// Display-Name fuer eine Service-ID (fuer Notifications, UI etc.).
|
||||
pub fn display_name(&self, id: &str) -> Option<String> {
|
||||
self.services.get(id).map(|e| e.def.display_name.clone())
|
||||
}
|
||||
|
||||
/// Bewertet welche Services jetzt einen Recovery-Restart bekommen sollen.
|
||||
/// Inkrementiert dabei den Versuchszaehler atomisch und plant den naechsten
|
||||
/// Versuch via exponential backoff. Wenn `max_attempts` erreicht ist,
|
||||
/// landet die Service-ID in `maxed_out` und es wird nicht weiter versucht.
|
||||
pub fn recovery_candidates(
|
||||
&mut self,
|
||||
max_attempts: u32,
|
||||
base_delay: Duration,
|
||||
) -> RecoveryReport {
|
||||
let now = Instant::now();
|
||||
let mut report = RecoveryReport::default();
|
||||
for entry in self.services.values_mut() {
|
||||
if !matches!(entry.state, ServiceState::Error) {
|
||||
continue;
|
||||
}
|
||||
if entry.recovery_attempts >= max_attempts {
|
||||
continue; // bereits aufgegeben — keine Doppel-Notification
|
||||
}
|
||||
let due = entry.next_recovery_at.map(|t| now >= t).unwrap_or(true);
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
entry.recovery_attempts += 1;
|
||||
if entry.recovery_attempts >= max_attempts {
|
||||
entry.next_recovery_at = None;
|
||||
report.maxed_out.push(entry.def.id.clone());
|
||||
} else {
|
||||
// exponential backoff: 1×, 2×, 4×, 8×, ... base_delay (capped 8x)
|
||||
let exp = (entry.recovery_attempts.saturating_sub(1)).min(8);
|
||||
let backoff = base_delay.saturating_mul(1u32 << exp);
|
||||
entry.next_recovery_at = Some(now + backoff);
|
||||
}
|
||||
report.to_restart.push(entry.def.id.clone());
|
||||
}
|
||||
report
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compose helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ComposeStatusRaw {
|
||||
#[serde(rename = "Service")]
|
||||
service: String,
|
||||
#[serde(rename = "State")]
|
||||
state: String,
|
||||
#[serde(rename = "Health", default)]
|
||||
health: String,
|
||||
}
|
||||
|
||||
struct ComposeStatus {
|
||||
state: String,
|
||||
health: Option<String>,
|
||||
}
|
||||
|
||||
async fn query_compose_status() -> Result<HashMap<String, ComposeStatus>, String> {
|
||||
let output = Command::new("docker")
|
||||
.current_dir(services::compose_dir())
|
||||
.args(["compose", "ps", "-a", "--format", "json"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
// Compose gibt JSON-Lines aus (ein Objekt pro Zeile), nicht ein Array.
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<ComposeStatusRaw>(line) {
|
||||
Ok(raw) => {
|
||||
out.insert(
|
||||
raw.service,
|
||||
ComposeStatus {
|
||||
state: raw.state,
|
||||
health: if raw.health.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(raw.health)
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!("compose ps json parse: {e} | line={line}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn compose(args: &[&str]) -> Result<std::process::Output, String> {
|
||||
let mut cmd = Command::new("docker");
|
||||
cmd.current_dir(services::compose_dir()).arg("compose");
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn docker compose: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"docker compose {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn compose_up(service: &str, logs: Arc<Mutex<VecDeque<String>>>) -> Result<Child, String> {
|
||||
compose(&["up", "-d", service]).await?;
|
||||
spawn_log_pump(service, logs).await
|
||||
}
|
||||
|
||||
async fn spawn_log_pump(
|
||||
service: &str,
|
||||
logs: Arc<Mutex<VecDeque<String>>>,
|
||||
) -> Result<Child, String> {
|
||||
let mut cmd = Command::new("docker");
|
||||
cmd.current_dir(services::compose_dir())
|
||||
.args(["compose", "logs", "-f", "--no-log-prefix", service])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("spawn docker compose logs: {e}"))?;
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
tokio::spawn(pump_lines(stdout, logs.clone()));
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
tokio::spawn(pump_lines(stderr, logs));
|
||||
}
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
async fn pump_lines<R: tokio::io::AsyncRead + Unpin + Send + 'static>(
|
||||
reader: R,
|
||||
logs: Arc<Mutex<VecDeque<String>>>,
|
||||
) {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let mut buf = logs.lock().await;
|
||||
if buf.len() >= LOG_RING_CAPACITY {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(line);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||