feat: Sync/Clone-Modus, Rotation, --setup Deployment, Scheduler-Fix, Dashboard-Updates, README

This commit is contained in:
Björn Nehlsen
2026-08-11 22:45:58 +02:00
parent 2eee55384e
commit a495570b20
14 changed files with 3851 additions and 681 deletions
+5
View File
@@ -1,3 +1,7 @@
# Customs
data/config.db
data/secret.key
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@@ -174,3 +178,4 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
*.bak
+152 -92
View File
@@ -1,101 +1,161 @@
# StFV Backup # StFV Backup
## Modulares Python-Startsystem Flask-basierte Backup-Verwaltung für SMB, LOCAL, FTP und SFTP – mit Web-UI, Zeitplan-Steuerung und Sync/Clone-Modus.
Das Projekt ist jetzt in moderne, klar getrennte Module aufgeteilt: ---
- app/app_runner.py: Orchestrierung des kompletten Startablaufs ## Schnellstart
- app/venv_manager.py: .venv-Pruefung, Erstellung und Re-Exec
- app/dependencies.py: requirements-Parsing und Installation fehlender Pakete
- app/config_store.py: SQLite-Schema, Laden und Speichern der Konfiguration
- app/setup_wizard.py: Interaktive Erfassung von Erstkonfiguration
- app/security.py: Passwort-Hashing und Verifikation
- app/ui.py: Frische Konsolen-Ausgabe mit klaren Statusfarben
- app/paths.py: Zentrale Pfadverwaltung
### Startlogik
- Start ausserhalb von .venv: automatische Erstellung und Neustart in .venv
- requirements.txt wird geprueft, fehlende Pakete werden installiert
- SQLite unter data/config.db wird geprueft/erstellt
- Falls Konfiguration fehlt oder unvollstaendig ist, startet das Setup fuer:
- IP
- Port
- Debug (true/false)
- Adminuser
- Adminpassword
### Bedeutung der Felder
- IP, Port, Debug: Laufzeitkonfiguration fuer den Flask-Webserver.
- Adminuser, Adminpassword: Zugangsdaten fuer den Admin-Login im Browser.
Sicherheitsaspekt:
- Das Passwort wird verdeckt eingegeben und ausschliesslich als PBKDF2-SHA256-Hash gespeichert.
- Eine Verifikationsfunktion fuer Login-Checks ist vorbereitet.
### Browserzugriff
- Nach dem Start laeuft Flask auf der konfigurierten Adresse, z. B. http://127.0.0.1:5000
- Der Root-Pfad leitet auf /login um.
- Erfolgreiche Anmeldung fuehrt auf /dashboard.
### Admin-Menue
Nach dem Login stehen folgende Menuepunkte bereit:
- Dashboard: Liste der eingerichteten Backups mit letztem Lauf und Groesse
- Neues Backup: SMB-Quelle/Ziel, Unterpfade, Target-Muster, Kompression und Verschluesselung
- Server Settings: IP, Port und Debug fuer den Flask-Webserver
- Admin Konto: Eigenen Admin-Benutzer und Passwort aendern
- Benutzer: Neue Benutzer anlegen (optional mit Adminrechten)
### Backup-Konfiguration (aktuell)
- Source/Destination starten mit einem Typ-Dropdown: SMB, LOCAL, FTP, SFTP
- Je nach Typ werden nur die passenden Felder eingeblendet
- Source: Verbindungs-Testbutton pro Konfiguration
- Destination: Verbindungs-Testbutton pro Konfiguration
- SMB Browser: Verzeichnisse fuer Source und Destination direkt durchklickbar
- Target: Frei definierbares Muster, z. B. backup_{date}_{time}
- Kompression: zip, tar.gz, tar.bz2, tar.xz, 7z
- Archiv-Passwort: wird verschluesselt gespeichert
- SMB-Passwoerter: werden verschluesselt gespeichert
### Geplante Backup-Module
Das Dashboard ist auf den Ausbau fuer mehrere Zielsysteme vorbereitet:
- Nextcloud
- Unraid
- MS SQL (z. B. auf Win11 VM)
- Transport-Protokolle wie FTP, SFTP, SMB, NFS
### Service fuer direkten Browser-Test
Damit der Server dauerhaft laeuft und du direkt im Browser testen kannst:
```bash
./scripts/service.sh install
./scripts/service.sh start
```
Nutzliche Befehle:
```bash
./scripts/service.sh status
./scripts/service.sh restart
./scripts/service.sh stop
./scripts/service.sh logs
./scripts/service.sh enable
```
Service-Datei im Projekt:
- deploy/systemd/nextcloud-backup.service
### Start
```bash ```bash
# Einmalig: Venv anlegen, Abhängigkeiten installieren, Ersteinrichtung starten
python3 main.py python3 main.py
# Deployment auf einem Linux-Server (systemd)
python3 main.py --setup
systemctl --user enable nextcloud-backup
systemctl --user start nextcloud-backup
``` ```
Nach dem Start ist die Oberfläche erreichbar unter:
`http://<IP>:<Port>/` (Standard: `http://127.0.0.1:5001/`)
---
## Deployment auf Oracle Linux / RHEL
### Voraussetzungen
```bash
sudo dnf install python3 python3-pip git -y
# Service soll auch ohne aktive Login-Session laufen
sudo loginctl enable-linger $USER
```
### Projekt klonen und einrichten
```bash
git clone https://gitea.example.com/user/stfv-backup.git ~/stfv-backup
cd ~/stfv-backup
python3 main.py --setup
```
`--setup` erledigt automatisch:
1. Virtuelle Umgebung unter `.venv/` anlegen
2. Abhängigkeiten installieren
3. systemd-User-Service unter `~/.config/systemd/user/nextcloud-backup.service` schreiben (mit echten Pfaden)
4. `systemctl --user daemon-reload` ausführen
### Service aktivieren
```bash
systemctl --user enable nextcloud-backup
systemctl --user start nextcloud-backup
systemctl --user status nextcloud-backup
journalctl --user -u nextcloud-backup -f
```
### Beim ersten Start
Beim allerersten Aufruf ohne Konfiguration fragt das System interaktiv ab:
- IP und Port des Flask-Servers
- Admin-Benutzername und Passwort (wird als PBKDF2-SHA256-Hash gespeichert)
---
## Funktionsübersicht
### Backup-Modi
| Modus | Verhalten |
|---|---|
| **Vollständig** | Alle Dateien aus der Quelle werden jedes Mal übertragen |
| **Sync** | Nur neue oder geänderte Dateien (Vergleich per Größe + mtime) |
| **Clone** | Wie Sync, zusätzlich werden Dateien im Ziel gelöscht die in der Quelle nicht mehr existieren |
### Unterstützte Protokolle
- **SMB** – mit integriertem Verzeichnis-Browser und Ordner-Erstellung
- **LOCAL** – lokales Dateisystem mit Browser und Ordner-Erstellung
- **FTP** – mit konfigurierbarem Port (Standard: 21)
- **SFTP** – SSH-basiert (Standard-Port: 22)
### Zeitplan
- CRON-Ausdruck frei definierbar (z. B. `0 2 * * *` für täglich um 02:00 Uhr)
- CRON-Vorschau im Wizard zeigt den nächsten geplanten Zeitpunkt
- Leer lassen = nur manuelles Backup möglich
- „Nächster Lauf" im Dashboard wird nach jedem Backup automatisch aktualisiert
### Rotation
- Anzahl aufzubewahrender Backups konfigurierbar
- `0` = unbegrenzt (keine automatische Löschung)
- Nur bei Modus **Vollständig** verfügbar (Sync/Clone verwalten den Zustand selbst)
### Kompression & Verschlüsselung
| Methode | Unterstützt |
|---|---|
| Keine (1:1 Kopie) | ✅ |
| zip | ✅ |
| tar.gz / tar.bz2 / tar.xz | ✅ |
| 7z | ✅ (py7zr erforderlich) |
| AES-256 Archiv-Passwort | ✅ |
### Sicherheit
- Passwörter werden nie im Klartext gespeichert
- Benutzerpasswörter: PBKDF2-SHA256
- Verbindungspasswörter: AES-verschlüsselt in der Datenbank
- Session-basierter Login mit Rollen (Admin / Benutzer)
---
## Projektstruktur
```
main.py # Einstiegspunkt; --setup für Deployment
app/
app_runner.py # Bootstrap, Setup-Logik, systemd-Installation
web_server.py # Flask-Routen, Backup-Ausführung, Scheduler
config_store.py # SQLite-Schema, Migrationen, CRUD
config_model.py # Dataclasses
remote_targets.py # Verbindungstests, Browser-Helfer
setup_wizard.py # Interaktive Erstkonfiguration (CLI)
venv_manager.py # Venv-Prüfung und Re-Exec
dependencies.py # Paketinstallation
security.py # Passwort-Hashing
paths.py # Zentrale Pfadverwaltung
ui.py # Konsolenausgabe
templates/ # Jinja2-Templates (Dashboard, Wizard, ...)
data/
config.db # SQLite-Datenbank (wird automatisch angelegt)
deploy/
systemd/
nextcloud-backup.service # Vorlage; --setup schreibt echte Pfade
scripts/
service.sh # Manuelle Service-Verwaltung (Alternative zu --setup)
requirements.txt
```
---
## Service manuell verwalten
```bash
./scripts/service.sh install # Service-Datei installieren + daemon-reload
./scripts/service.sh start
./scripts/service.sh stop
./scripts/service.sh restart
./scripts/service.sh status
./scripts/service.sh logs
./scripts/service.sh enable # Autostart aktivieren
./scripts/service.sh disable
```
---
## Anforderungen
- Python 3.10+
- Abhängigkeiten: siehe `requirements.txt` (werden beim Start automatisch installiert)
+62 -3
View File
@@ -11,9 +11,8 @@ class AppRunner:
self.paths = AppPaths(base_dir=base_dir) self.paths = AppPaths(base_dir=base_dir)
self.entry_script = entry_script self.entry_script = entry_script
def run(self) -> None: def _bootstrap(self) -> None:
ConsoleUI.headline("StFV Backup Bootstrap") """Ensure venv and dependencies are ready (re-execs into venv if needed)."""
venv = VirtualEnvManager(self.paths.venv_dir, self.entry_script) venv = VirtualEnvManager(self.paths.venv_dir, self.entry_script)
if not venv.in_virtualenv(): if not venv.in_virtualenv():
ConsoleUI.info("Virtuelle Umgebung wird vorbereitet...") ConsoleUI.info("Virtuelle Umgebung wird vorbereitet...")
@@ -26,6 +25,66 @@ class AppRunner:
else: else:
ConsoleUI.info("Alle Abhaengigkeiten sind bereits installiert.") ConsoleUI.info("Alle Abhaengigkeiten sind bereits installiert.")
def setup(self) -> None:
ConsoleUI.headline("StFV Backup Setup")
self._bootstrap()
ConsoleUI.success("System ist bereit.")
self._install_systemd_service()
def _install_systemd_service(self) -> None:
import shutil
import subprocess
if not shutil.which("systemctl"):
ConsoleUI.warn("systemctl nicht gefunden – kein systemd-System erkannt.")
ConsoleUI.info("Manuelle Installation: bash scripts/service.sh install")
return
service_name = "nextcloud-backup.service"
user_systemd_dir = Path.home() / ".config" / "systemd" / "user"
user_systemd_dir.mkdir(parents=True, exist_ok=True)
if self.paths.venv_dir.name == "nt":
venv_python = self.paths.venv_dir / "Scripts" / "python.exe"
else:
venv_python = self.paths.venv_dir / "bin" / "python"
service_content = f"""[Unit]
Description=StFV Backup Flask Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory={self.paths.base_dir}
ExecStart={venv_python} {self.entry_script.resolve()}
Restart=on-failure
RestartSec=3
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=default.target
"""
target = user_systemd_dir / service_name
target.write_text(service_content)
ConsoleUI.success(f"Service-Datei geschrieben: {target}")
try:
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
ConsoleUI.success("systemd daemon neu geladen.")
except subprocess.CalledProcessError as exc:
ConsoleUI.warn(f"daemon-reload fehlgeschlagen: {exc}")
ConsoleUI.info(f"Service aktivieren: systemctl --user enable {service_name}")
ConsoleUI.info(f"Service starten: systemctl --user start {service_name}")
ConsoleUI.info(f"Status pruefen: systemctl --user status {service_name}")
ConsoleUI.info(f"Logs verfolgen: journalctl --user -u {service_name} -f")
def run(self) -> None:
ConsoleUI.headline("StFV Backup Bootstrap")
self._bootstrap()
from app.config_store import ConfigStore from app.config_store import ConfigStore
from app.setup_wizard import SetupWizard from app.setup_wizard import SetupWizard
from app.web_server import FlaskServer from app.web_server import FlaskServer
+10 -5
View File
@@ -53,11 +53,9 @@ class BackupJob:
encryption_mode: str encryption_mode: str
archive_password: str archive_password: str
schedule_mode: str schedule_mode: str
schedule_time: str
schedule_weekday: str
schedule_day_of_month: str
schedule_interval: int | None
schedule_cron: str schedule_cron: str
backup_mode: str
rotation_keep: int
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -78,6 +76,12 @@ class BackupExecutionJob:
destination_subpath: str destination_subpath: str
destination_username: str destination_username: str
destination_password: str destination_password: str
source_entry_type: str
target_kind: str
target_pattern: str
compression_method: str
backup_mode: str
rotation_keep: int
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -88,6 +92,7 @@ class BackupSummary:
destination_label: str destination_label: str
target_pattern: str target_pattern: str
compression_method: str compression_method: str
schedule_mode: str schedule_cron: str
next_run_at: str | None
last_run_at: str | None last_run_at: str | None
last_size_bytes: int | None last_size_bytes: int | None
+283 -72
View File
@@ -69,12 +69,10 @@ class ConfigStore:
compression_method TEXT NOT NULL, compression_method TEXT NOT NULL,
encryption_mode TEXT NOT NULL DEFAULT 'none', encryption_mode TEXT NOT NULL DEFAULT 'none',
archive_password_enc TEXT NOT NULL, archive_password_enc TEXT NOT NULL,
schedule_mode TEXT NOT NULL DEFAULT 'daily', schedule_mode TEXT NOT NULL DEFAULT 'custom',
schedule_time TEXT NOT NULL DEFAULT '02:00',
schedule_weekday TEXT NOT NULL DEFAULT '1',
schedule_day_of_month TEXT NOT NULL DEFAULT '1',
schedule_interval INTEGER,
schedule_cron TEXT NOT NULL DEFAULT '', schedule_cron TEXT NOT NULL DEFAULT '',
backup_mode TEXT NOT NULL DEFAULT 'full',
rotation_keep INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT, last_run_at TEXT,
last_size_bytes INTEGER, last_size_bytes INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -87,6 +85,12 @@ class ConfigStore:
row["name"].lower() row["name"].lower()
for row in conn.execute("PRAGMA table_info(backups)").fetchall() for row in conn.execute("PRAGMA table_info(backups)").fetchall()
} }
if {"schedule_time", "schedule_weekday", "schedule_day_of_month", "schedule_interval"} & backup_columns:
self._migrate_legacy_schedule_columns(conn)
backup_columns = {
row["name"].lower()
for row in conn.execute("PRAGMA table_info(backups)").fetchall()
}
if "source_port" not in backup_columns: if "source_port" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN source_port INTEGER") conn.execute("ALTER TABLE backups ADD COLUMN source_port INTEGER")
if "destination_port" not in backup_columns: if "destination_port" not in backup_columns:
@@ -98,17 +102,13 @@ class ConfigStore:
if "encryption_mode" not in backup_columns: if "encryption_mode" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN encryption_mode TEXT NOT NULL DEFAULT 'none'") conn.execute("ALTER TABLE backups ADD COLUMN encryption_mode TEXT NOT NULL DEFAULT 'none'")
if "schedule_mode" not in backup_columns: if "schedule_mode" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'daily'") conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'custom'")
if "schedule_time" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_time TEXT NOT NULL DEFAULT '02:00'")
if "schedule_weekday" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_weekday TEXT NOT NULL DEFAULT '1'")
if "schedule_day_of_month" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_day_of_month TEXT NOT NULL DEFAULT '1'")
if "schedule_interval" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_interval INTEGER")
if "schedule_cron" not in backup_columns: if "schedule_cron" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN schedule_cron TEXT NOT NULL DEFAULT ''") conn.execute("ALTER TABLE backups ADD COLUMN schedule_cron TEXT NOT NULL DEFAULT ''")
if "backup_mode" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN backup_mode TEXT NOT NULL DEFAULT 'full'")
if "rotation_keep" not in backup_columns:
conn.execute("ALTER TABLE backups ADD COLUMN rotation_keep INTEGER NOT NULL DEFAULT 0")
columns = { columns = {
row["name"].lower() row["name"].lower()
@@ -126,6 +126,109 @@ class ConfigStore:
self._migrate_legacy_admin_if_needed() self._migrate_legacy_admin_if_needed()
def _migrate_legacy_schedule_columns(self, conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE backups RENAME TO backups_legacy")
conn.execute(
"""
CREATE TABLE backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
source_protocol TEXT NOT NULL,
source_host TEXT NOT NULL,
source_port INTEGER,
source_share TEXT NOT NULL,
source_subpath TEXT NOT NULL,
source_username TEXT NOT NULL,
source_password_enc TEXT NOT NULL,
destination_protocol TEXT NOT NULL,
destination_host TEXT NOT NULL,
destination_port INTEGER,
destination_share TEXT NOT NULL,
destination_subpath TEXT NOT NULL,
destination_username TEXT NOT NULL,
destination_password_enc TEXT NOT NULL,
source_entry_type TEXT NOT NULL DEFAULT 'directory',
target_kind TEXT NOT NULL DEFAULT 'folder',
target_pattern TEXT NOT NULL,
compression_method TEXT NOT NULL,
encryption_mode TEXT NOT NULL DEFAULT 'none',
archive_password_enc TEXT NOT NULL,
schedule_mode TEXT NOT NULL DEFAULT 'custom',
schedule_cron TEXT NOT NULL DEFAULT '',
last_run_at TEXT,
last_size_bytes INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
INSERT INTO backups (
id,
name,
source_protocol,
source_host,
source_port,
source_share,
source_subpath,
source_username,
source_password_enc,
destination_protocol,
destination_host,
destination_port,
destination_share,
destination_subpath,
destination_username,
destination_password_enc,
source_entry_type,
target_kind,
target_pattern,
compression_method,
encryption_mode,
archive_password_enc,
schedule_mode,
schedule_cron,
last_run_at,
last_size_bytes,
created_at,
updated_at
)
SELECT
id,
name,
source_protocol,
source_host,
source_port,
source_share,
source_subpath,
source_username,
source_password_enc,
destination_protocol,
destination_host,
destination_port,
destination_share,
destination_subpath,
destination_username,
destination_password_enc,
COALESCE(source_entry_type, 'directory'),
COALESCE(target_kind, 'folder'),
target_pattern,
compression_method,
COALESCE(encryption_mode, 'none'),
archive_password_enc,
'custom',
COALESCE(schedule_cron, ''),
last_run_at,
last_size_bytes,
created_at,
updated_at
FROM backups_legacy
"""
)
conn.execute("DROP TABLE backups_legacy")
conn.commit()
def _migrate_legacy_admin_if_needed(self) -> None: def _migrate_legacy_admin_if_needed(self) -> None:
with self._connect() as conn: with self._connect() as conn:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
@@ -302,14 +405,12 @@ class ConfigStore:
encryption_mode, encryption_mode,
archive_password_enc, archive_password_enc,
schedule_mode, schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron, schedule_cron,
backup_mode,
rotation_keep,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", """,
( (
backup.name, backup.name,
@@ -334,11 +435,9 @@ class ConfigStore:
backup.encryption_mode, backup.encryption_mode,
self.crypto.encrypt_text(backup.archive_password), self.crypto.encrypt_text(backup.archive_password),
backup.schedule_mode, backup.schedule_mode,
backup.schedule_time,
backup.schedule_weekday,
backup.schedule_day_of_month,
backup.schedule_interval,
backup.schedule_cron, backup.schedule_cron,
backup.backup_mode,
backup.rotation_keep,
), ),
) )
conn.commit() conn.commit()
@@ -346,6 +445,19 @@ class ConfigStore:
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
return False return False
def _compute_next_run(self, cron_expr: str) -> str | None:
if not cron_expr:
return None
try:
from datetime import datetime
from croniter import croniter
next_dt = croniter(cron_expr, datetime.now()).get_next(datetime)
return next_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return None
def list_backups(self) -> list[BackupSummary]: def list_backups(self) -> list[BackupSummary]:
with self._connect() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
@@ -365,7 +477,7 @@ class ConfigStore:
destination_subpath, destination_subpath,
target_pattern, target_pattern,
compression_method, compression_method,
schedule_mode, schedule_cron,
last_run_at, last_run_at,
last_size_bytes last_size_bytes
FROM backups FROM backups
@@ -403,7 +515,8 @@ class ConfigStore:
destination_label=destination_label, destination_label=destination_label,
target_pattern=row["target_pattern"], target_pattern=row["target_pattern"],
compression_method=row["compression_method"], compression_method=row["compression_method"],
schedule_mode=row["schedule_mode"] if row["schedule_mode"] else "daily", schedule_cron=row["schedule_cron"],
next_run_at=self._compute_next_run(row["schedule_cron"]),
last_run_at=row["last_run_at"], last_run_at=row["last_run_at"],
last_size_bytes=row["last_size_bytes"], last_size_bytes=row["last_size_bytes"],
) )
@@ -411,6 +524,118 @@ class ConfigStore:
return items return items
def get_full_backup_for_edit(self, backup_id: int) -> dict | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT id, name,
source_protocol, source_host, source_port, source_share, source_subpath,
source_username, source_password_enc,
destination_protocol, destination_host, destination_port, destination_share,
destination_subpath, destination_username, destination_password_enc,
source_entry_type, target_kind, target_pattern,
compression_method, encryption_mode, archive_password_enc,
schedule_mode, schedule_cron, backup_mode, rotation_keep
FROM backups WHERE id = ?
""",
(backup_id,),
).fetchone()
if row is None:
return None
is_src_local = row["source_protocol"] == "LOCAL"
is_dst_local = row["destination_protocol"] == "LOCAL"
return {
"id": row["id"],
"name": row["name"],
"source_protocol": row["source_protocol"],
"source_host": "" if is_src_local else (row["source_host"] or ""),
"source_port": row["source_port"] or "",
"source_share": row["source_share"] or "",
"source_subpath": row["source_subpath"] or "/",
"source_username": row["source_username"] or "",
"source_password": "***" if row["source_password_enc"] else "",
"source_local_path": row["source_subpath"] if is_src_local else "",
"destination_protocol": row["destination_protocol"],
"destination_host": "" if is_dst_local else (row["destination_host"] or ""),
"destination_port": row["destination_port"] or "",
"destination_share": row["destination_share"] or "",
"destination_subpath": row["destination_subpath"] or "/",
"destination_username": row["destination_username"] or "",
"destination_password": "***" if row["destination_password_enc"] else "",
"destination_local_path": row["destination_subpath"] if is_dst_local else "",
"source_entry_type": row["source_entry_type"],
"target_kind": row["target_kind"],
"target_pattern": row["target_pattern"],
"compression_method": row["compression_method"],
"encryption_mode": row["encryption_mode"],
"archive_password": "***" if row["archive_password_enc"] else "",
"schedule_mode": row["schedule_mode"],
"schedule_cron": row["schedule_cron"] or "",
"backup_mode": row["backup_mode"] or "full",
"rotation_keep": row["rotation_keep"] or 0,
}
def update_full_backup(
self,
backup_id: int,
name: str,
source_protocol: str, source_host: str, source_port: int | None,
source_share: str, source_subpath: str, source_username: str,
source_password: str | None,
destination_protocol: str, destination_host: str, destination_port: int | None,
destination_share: str, destination_subpath: str, destination_username: str,
destination_password: str | None,
source_entry_type: str, target_kind: str, target_pattern: str,
compression_method: str, encryption_mode: str,
archive_password: str | None,
schedule_mode: str, schedule_cron: str,
backup_mode: str = "full",
rotation_keep: int = 0,
) -> bool:
with self._connect() as conn:
try:
row = conn.execute(
"SELECT source_password_enc, destination_password_enc, archive_password_enc FROM backups WHERE id = ?",
(backup_id,),
).fetchone()
if row is None:
return False
src_enc = self.crypto.encrypt_text(source_password) if source_password is not None else row["source_password_enc"]
dst_enc = self.crypto.encrypt_text(destination_password) if destination_password is not None else row["destination_password_enc"]
arc_enc = self.crypto.encrypt_text(archive_password) if archive_password is not None else row["archive_password_enc"]
result = conn.execute(
"""
UPDATE backups SET
name=?, source_protocol=?, source_host=?, source_port=?,
source_share=?, source_subpath=?, source_username=?, source_password_enc=?,
destination_protocol=?, destination_host=?, destination_port=?,
destination_share=?, destination_subpath=?, destination_username=?, destination_password_enc=?,
source_entry_type=?, target_kind=?, target_pattern=?,
compression_method=?, encryption_mode=?, archive_password_enc=?,
schedule_mode=?, schedule_cron=?, backup_mode=?, rotation_keep=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
""",
(
name, source_protocol, source_host, source_port,
source_share, source_subpath, source_username, src_enc,
destination_protocol, destination_host, destination_port,
destination_share, destination_subpath, destination_username, dst_enc,
source_entry_type, target_kind, target_pattern,
compression_method, encryption_mode, arc_enc,
schedule_mode, schedule_cron, backup_mode, rotation_keep,
backup_id,
),
)
conn.commit()
return result.rowcount == 1
except sqlite3.IntegrityError:
return False
def get_backup_edit_data(self, backup_id: int) -> dict | None: def get_backup_edit_data(self, backup_id: int) -> dict | None:
with self._connect() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
@@ -422,10 +647,6 @@ class ConfigStore:
compression_method, compression_method,
encryption_mode, encryption_mode,
schedule_mode, schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron schedule_cron
FROM backups FROM backups
WHERE id = ? WHERE id = ?
@@ -443,10 +664,6 @@ class ConfigStore:
"compression_method": row["compression_method"], "compression_method": row["compression_method"],
"encryption_mode": row["encryption_mode"], "encryption_mode": row["encryption_mode"],
"schedule_mode": row["schedule_mode"], "schedule_mode": row["schedule_mode"],
"schedule_time": row["schedule_time"],
"schedule_weekday": row["schedule_weekday"],
"schedule_day_of_month": row["schedule_day_of_month"],
"schedule_interval": row["schedule_interval"],
"schedule_cron": row["schedule_cron"], "schedule_cron": row["schedule_cron"],
} }
@@ -455,22 +672,13 @@ class ConfigStore:
row = conn.execute( row = conn.execute(
""" """
SELECT SELECT
id, id, name,
name, source_protocol, source_host, source_port, source_share, source_subpath,
source_protocol, source_username, source_password_enc,
source_host, destination_protocol, destination_host, destination_port, destination_share,
source_port, destination_subpath, destination_username, destination_password_enc,
source_share, source_entry_type, target_kind, target_pattern,
source_subpath, compression_method, backup_mode, rotation_keep
source_username,
source_password_enc,
destination_protocol,
destination_host,
destination_port,
destination_share,
destination_subpath,
destination_username,
destination_password_enc
FROM backups FROM backups
WHERE id = ? WHERE id = ?
""", """,
@@ -497,6 +705,12 @@ class ConfigStore:
destination_subpath=row["destination_subpath"], destination_subpath=row["destination_subpath"],
destination_username=row["destination_username"], destination_username=row["destination_username"],
destination_password=self.crypto.decrypt_text(row["destination_password_enc"]), destination_password=self.crypto.decrypt_text(row["destination_password_enc"]),
source_entry_type=row["source_entry_type"],
target_kind=row["target_kind"],
target_pattern=row["target_pattern"],
compression_method=row["compression_method"],
backup_mode=row["backup_mode"] or "full",
rotation_keep=row["rotation_keep"] or 0,
) )
def update_backup_edit_data( def update_backup_edit_data(
@@ -508,10 +722,6 @@ class ConfigStore:
encryption_mode: str, encryption_mode: str,
archive_password: str, archive_password: str,
schedule_mode: str, schedule_mode: str,
schedule_time: str,
schedule_weekday: str,
schedule_day_of_month: str,
schedule_interval: int | None,
schedule_cron: str, schedule_cron: str,
) -> bool: ) -> bool:
with self._connect() as conn: with self._connect() as conn:
@@ -527,10 +737,6 @@ class ConfigStore:
encryption_mode = ?, encryption_mode = ?,
archive_password_enc = ?, archive_password_enc = ?,
schedule_mode = ?, schedule_mode = ?,
schedule_time = ?,
schedule_weekday = ?,
schedule_day_of_month = ?,
schedule_interval = ?,
schedule_cron = ?, schedule_cron = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
@@ -542,10 +748,6 @@ class ConfigStore:
encryption_mode, encryption_mode,
self.crypto.encrypt_text(archive_password), self.crypto.encrypt_text(archive_password),
schedule_mode, schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron, schedule_cron,
backup_id, backup_id,
), ),
@@ -560,10 +762,6 @@ class ConfigStore:
compression_method = ?, compression_method = ?,
encryption_mode = ?, encryption_mode = ?,
schedule_mode = ?, schedule_mode = ?,
schedule_time = ?,
schedule_weekday = ?,
schedule_day_of_month = ?,
schedule_interval = ?,
schedule_cron = ?, schedule_cron = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
@@ -574,10 +772,6 @@ class ConfigStore:
compression_method, compression_method,
encryption_mode, encryption_mode,
schedule_mode, schedule_mode,
schedule_time,
schedule_weekday,
schedule_day_of_month,
schedule_interval,
schedule_cron, schedule_cron,
backup_id, backup_id,
), ),
@@ -587,22 +781,39 @@ class ConfigStore:
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
return False return False
def mark_backup_run(self, backup_id: int) -> bool: def mark_backup_run(self, backup_id: int, size_bytes: int) -> bool:
safe_size = max(0, int(size_bytes))
with self._connect() as conn: with self._connect() as conn:
result = conn.execute( result = conn.execute(
""" """
UPDATE backups UPDATE backups
SET SET
last_run_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', 'localtime'), last_run_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'now', 'localtime'),
last_size_bytes = COALESCE(last_size_bytes, 0), last_size_bytes = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
""", """,
(backup_id,), (safe_size, backup_id),
) )
conn.commit() conn.commit()
return result.rowcount == 1 return result.rowcount == 1
def get_backup_runtime_meta(self, backup_id: int) -> dict | None:
with self._connect() as conn:
row = conn.execute(
"SELECT last_run_at, last_size_bytes, schedule_cron FROM backups WHERE id = ?",
(backup_id,),
).fetchone()
if row is None:
return None
return {
"last_run_at": row["last_run_at"],
"last_size_bytes": row["last_size_bytes"],
"next_run_at": self._compute_next_run(row["schedule_cron"] or ""),
}
def delete_backup(self, backup_id: int) -> bool: def delete_backup(self, backup_id: int) -> bool:
with self._connect() as conn: with self._connect() as conn:
result = conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,)) result = conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,))
+94 -4
View File
@@ -251,9 +251,9 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]:
if protocol == "LOCAL": if protocol == "LOCAL":
check_path = Path(target.subpath) check_path = Path(target.subpath)
if check_path.exists() and check_path.is_dir(): if check_path.exists() and (check_path.is_dir() or check_path.is_file()):
return True, "Lokaler Pfad erreichbar." return True, "Lokaler Pfad erreichbar."
return False, "Lokaler Pfad ist nicht erreichbar oder kein Verzeichnis." return False, "Lokaler Pfad nicht gefunden oder nicht zugänglich."
if protocol == "SMB": if protocol == "SMB":
if not target.share: if not target.share:
@@ -271,7 +271,13 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]:
try: try:
ftp.connect(target.host, target.port or 21, timeout=8) ftp.connect(target.host, target.port or 21, timeout=8)
ftp.login(target.username, target.password) ftp.login(target.username, target.password)
ftp.cwd(target.subpath or "/") subpath = target.subpath or "/"
try:
ftp.cwd(subpath)
except Exception: # noqa: BLE001
# subpath may be a file — try parent directory
parent = "/".join(subpath.rstrip("/").split("/")[:-1]) or "/"
ftp.cwd(parent)
return True, "FTP-Verbindung und Pfad sind erreichbar." return True, "FTP-Verbindung und Pfad sind erreichbar."
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return False, f"FTP-Test fehlgeschlagen: {exc}" return False, f"FTP-Test fehlgeschlagen: {exc}"
@@ -288,7 +294,13 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]:
transport = paramiko.Transport((target.host, target.port or 22)) transport = paramiko.Transport((target.host, target.port or 22))
transport.connect(username=target.username, password=target.password) transport.connect(username=target.username, password=target.password)
sftp = paramiko.SFTPClient.from_transport(transport) sftp = paramiko.SFTPClient.from_transport(transport)
sftp.listdir(target.subpath or "/") subpath = target.subpath or "/"
try:
sftp.listdir(subpath)
except Exception: # noqa: BLE001
# subpath may be a file — try parent directory
parent = "/".join(subpath.rstrip("/").split("/")[:-1]) or "/"
sftp.listdir(parent)
return True, "SFTP-Verbindung und Pfad sind erreichbar." return True, "SFTP-Verbindung und Pfad sind erreichbar."
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return False, f"SFTP-Test fehlgeschlagen: {exc}" return False, f"SFTP-Test fehlgeschlagen: {exc}"
@@ -301,6 +313,84 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]:
return False, "Unbekanntes Protokoll." return False, "Unbekanntes Protokoll."
def browse_ftp_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]:
ftp = FTP()
try:
ftp.connect(target.host, target.port or 21, timeout=8)
ftp.login(target.username, target.password)
current_path = normalize_subpath(target.subpath or "/")
try:
ftp.cwd(current_path)
except Exception:
current_path = "/"
ftp.cwd("/")
entries: list[dict[str, str]] = []
if current_path != "/":
parent = "/".join(current_path.rstrip("/").split("/")[:-1]) or "/"
entries.append({"name": "..", "path": parent, "entry_type": "up"})
raw_lines: list[str] = []
try:
ftp.retrlines("LIST", raw_lines.append)
except Exception:
pass
items: list[dict[str, str]] = []
for line in raw_lines:
parts = line.split(None, 8)
if len(parts) < 9:
continue
name = parts[8].strip()
if name in (".", ".."):
continue
is_dir = parts[0].startswith("d")
sub_path = _build_child_path(current_path, name)
items.append({"name": name, "path": sub_path, "entry_type": "directory" if is_dir else "file"})
entries.extend(_sort_entries(items))
return True, current_path, entries
except Exception as exc: # noqa: BLE001
return False, f"FTP-Fehler: {exc}", []
finally:
try:
ftp.quit()
except Exception: # noqa: BLE001
pass
def browse_sftp_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]:
import stat as stat_module
transport = None
sftp_client = None
try:
transport = paramiko.Transport((target.host, target.port or 22))
transport.connect(username=target.username, password=target.password)
sftp_client = paramiko.SFTPClient.from_transport(transport)
current_path = normalize_subpath(target.subpath or "/")
entries: list[dict[str, str]] = []
if current_path != "/":
parent = "/".join(current_path.rstrip("/").split("/")[:-1]) or "/"
entries.append({"name": "..", "path": parent, "entry_type": "up"})
items: list[dict[str, str]] = []
for attr in sftp_client.listdir_attr(current_path):
is_dir = stat_module.S_ISDIR(attr.st_mode or 0)
sub_path = _build_child_path(current_path, attr.filename)
items.append({"name": attr.filename, "path": sub_path, "entry_type": "directory" if is_dir else "file"})
entries.extend(_sort_entries(items))
return True, current_path, entries
except Exception as exc: # noqa: BLE001
return False, f"SFTP-Fehler: {exc}", []
finally:
if sftp_client is not None:
sftp_client.close()
if transport is not None:
transport.close()
def browse_smb_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]: def browse_smb_directories(target: TargetConnection) -> tuple[bool, str, list[dict[str, str]]]:
if target.protocol.upper() != "SMB": if target.protocol.upper() != "SMB":
return False, "SMB-Browser ist nur fuer SMB verfuegbar.", [] return False, "SMB-Browser ist nur fuer SMB verfuegbar.", []
+648 -73
View File
@@ -3,107 +3,682 @@
{% block title %}StFV Backup - Backup bearbeiten{% endblock %} {% block title %}StFV Backup - Backup bearbeiten{% endblock %}
{% block content %} {% block content %}
<h2>Backup bearbeiten</h2> <h2 style="margin-bottom:4px;">Backup bearbeiten</h2>
<p>Hier kannst du Name, Ziel, Verschlüsselung und Zeitplan anpassen.</p>
<form method="post" action="{{ url_for('backup_edit', backup_id=backup.id) }}"> <style>
<div class="card"> .wizard-step { display: none; }
<label for="name">Name</label> .wizard-step.active { display: block; animation: fade-slide 240ms ease; }
.wizard-nav { margin-top: 0; margin-bottom: 8px; display: flex; gap: 8px; flex-wrap: wrap; }
.wizard-pill { border: 1px solid rgba(30,42,59,0.2); padding: 6px 10px; border-radius: 999px; font-size: 12px; color: #5a6e87; background: #fff; cursor: pointer; }
.wizard-pill.active { border-color: rgba(10,127,104,0.35); background: #e5fff7; color: #0a7f68; font-weight: 700; }
.step-actions { margin-top: 12px; display: flex; gap: 10px; align-items: center; }
.btn-secondary { border: 1px solid rgba(30,42,59,0.2); background: #fff; color: #1e2a3b; }
.tree { list-style: none; margin: 8px 0 0; padding: 0 0 0 8px; border-left: 1px solid rgba(30,42,59,0.15); }
.tree li { margin: 4px 0; padding-left: 10px; position: relative; }
.tree li::before { content: ""; position: absolute; left: -8px; top: 12px; width: 8px; border-top: 1px solid rgba(30,42,59,0.15); }
.tree-node { border: 0; background: transparent; color: #1e2a3b; font-weight: 600; cursor: pointer; padding: 3px 4px; border-radius: 6px; }
.tree-node:hover { background: #eef6ff; }
.tree-node.active { background: #e5fff7; color: #0a7f68; }
.tree-meta { font-size: 13px; color: #5a6e87; margin-top: 6px; }
.tree-empty { color: #5a6e87; font-size: 13px; }
.browser-overlay { position: fixed; inset: 0; background: rgba(10,20,34,0.52); display: none; align-items: center; justify-content: center; z-index: 1100; padding: 20px; }
.browser-dialog { background: #fff; border-radius: 16px; width: min(92vw,560px); max-height: 74vh; display: flex; flex-direction: column; box-shadow: 0 20px 48px rgba(30,42,59,0.24); overflow: hidden; position: relative; }
.browser-dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid rgba(30,42,59,0.12); }
.browser-dialog-body { padding: 12px 16px 16px; overflow: auto; }
.browser-dialog-actions { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 16px 14px; border-top: 1px solid rgba(30,42,59,0.12); }
.browser-dialog-actions .btn-secondary { background: #fff; }
.browser-dialog-right { display: flex; gap: 8px; }
.create-folder-overlay { position: absolute; inset: 0; background: rgba(255,255,255,0.97); backdrop-filter: blur(4px); z-index: 20; display: flex; align-items: center; justify-content: center; border-radius: 16px; }
.create-folder-box { padding: 20px; width: 100%; max-width: 320px; display: grid; gap: 10px; }
.create-folder-box strong { font-size: 15px; }
.create-folder-actions { display: flex; gap: 8px; justify-content: flex-end; }
.step-header { margin: -16px -16px 14px; padding: 12px 16px; border-bottom: 1px solid rgba(30,42,59,0.08); background: rgba(30,42,59,0.025); border-radius: 14px 14px 0 0; }
.step-header h3 { margin: 0; }
.token-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.token-chip { display: inline-flex; align-items: center; padding: 4px 11px; border-radius: 999px; background: #e5fff7; border: 1px solid rgba(10,127,104,0.35); color: #0a7f68; font-size: 12px; font-weight: 700; cursor: grab; user-select: none; }
.token-chip:hover { background: #c8f5e8; }
.token-menu { position: fixed; z-index: 3000; background: #fff; border: 1px solid rgba(30,42,59,0.15); border-radius: 12px; box-shadow: 0 8px 24px rgba(30,42,59,0.18); padding: 6px; min-width: 190px; }
.token-menu-item { padding: 7px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; }
.token-menu-item:hover { background: #f0f7ff; }
.token-menu-sub { font-size: 11px; color: #5a6e87; font-weight: 400; margin-top: 1px; }
.row-3 { grid-template-columns: minmax(90px,0.8fr) 2fr 1.4fr; }
@media (max-width: 700px) { .row-3 { grid-template-columns: 1fr; } }
.path-input-group { display: flex; gap: 6px; margin-bottom: 12px; }
.path-input-group input { flex: 1; margin-bottom: 0; }
.btn-browse { padding: 8px 11px; border-radius: 12px; border: 1px solid rgba(30,42,59,0.2); background: #fff; cursor: pointer; font-size: 15px; line-height: 1; color: #1e2a3b; flex-shrink: 0; }
.btn-browse:hover { background: #f0f7ff; }
.hidden-toggle { font-size: 12px; color: #5a6e87; display: flex; align-items: center; gap: 5px; cursor: pointer; font-weight: 600; }
.hidden-toggle input[type="checkbox"] { width: auto; margin: 0; cursor: pointer; }
@keyframes fade-slide { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
</style>
<form method="post" action="{{ url_for('backup_edit', backup_id=backup.id) }}" id="backupForm">
<input type="hidden" id="source_entry_type" name="source_entry_type" value="{{ backup.source_entry_type }}">
<input type="hidden" id="target_kind" name="target_kind" value="{{ backup.target_kind }}">
<div class="wizard-nav">
<button type="button" class="wizard-pill active" data-step-pill="0" onclick="showStep(0)">Allgemein</button>
<button type="button" class="wizard-pill" data-step-pill="1" onclick="showStep(1)">Source</button>
<button type="button" class="wizard-pill" data-step-pill="2" onclick="showStep(2)">Destination</button>
<button type="button" class="wizard-pill" data-step-pill="3" onclick="showStep(3)">Target</button>
<button type="button" class="wizard-pill" data-step-pill="4" onclick="showStep(4)">Zeitplan</button>
</div>
<section class="card wizard-step active" data-step="0">
<div class="step-header"><h3>Allgemein</h3></div>
<label for="name">Backup Name</label>
<input id="name" name="name" type="text" value="{{ backup.name }}" required> <input id="name" name="name" type="text" value="{{ backup.name }}" required>
<div class="step-actions">
<button type="button" class="btn" onclick="nextStep(0)">Weiter</button>
</div>
</section>
<label for="target_pattern">Zielordner / Zieldatei</label> <section class="card wizard-step" data-step="1">
<div class="step-header"><h3>Source</h3></div>
<div class="row row-3">
<div>
<label for="source_protocol">Typ</label>
<select id="source_protocol" name="source_protocol" required>
{% for p in target_protocols %}<option value="{{ p }}" {% if p == backup.source_protocol %}selected{% endif %}>{{ p }}</option>{% endfor %}
</select>
</div>
<div id="source_host_col" style="display:none;">
<label for="source_host">Host/IP</label>
<div style="display:flex; gap:6px;">
<input id="source_host" name="source_host" type="text" value="{{ backup.source_host }}" style="flex:1; margin-bottom:0;">
<input id="source_port_ui" type="number" min="1" max="65535" placeholder="Port"
title="Port" value="{{ backup.source_port or '' }}" style="width:72px; margin-bottom:0; display:none;">
</div>
</div>
<div id="source_share_row" style="display:none;">
<label for="source_share">Freigabe</label>
<input id="source_share" name="source_share" type="text" value="{{ backup.source_share }}">
</div>
<div id="source_local_path_col" style="display:none; grid-column: 2 / -1;">
<label for="source_local_path">Lokaler Pfad</label>
<div class="path-input-group">
<input id="source_local_path" type="text" value="{{ backup.source_local_path }}">
<button type="button" class="btn-browse" onclick="openLocalBrowserOverlay('source')" title="Browser öffnen">🔍</button>
</div>
</div>
</div>
<div id="source_local_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
<div class="browser-dialog-header"><strong>Lokaler Browser</strong><label class="hidden-toggle"><input type="checkbox" id="source_local_show_hidden" onchange="refreshLocalBrowserLevel('source')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('source')">×</button></div>
<div class="browser-dialog-body"><div id="source_local_browser_meta" class="tree-meta"></div><ul id="source_local_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div></div>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('source')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptLocalBrowserSelection('source')">Auswählen</button>
</div>
</div>
</div>
</div>
<div id="source_remote_fields" style="display:none;">
<div class="row">
<div><label for="source_username">Benutzer</label><input id="source_username" name="source_username" type="text" value="{{ backup.source_username }}"></div>
<div><label for="source_password">Passwort</label><input id="source_password" name="source_password" type="password" value="{{ backup.source_password }}"></div>
</div>
<label for="source_subpath">Pfad</label>
<div class="path-input-group">
<input id="source_subpath" name="source_subpath" type="text" value="{{ backup.source_subpath }}">
<button type="button" class="btn-browse" onclick="browseSmb('source')" title="Browser öffnen">🔍</button>
</div>
<p id="source_test_message"></p>
<div id="source_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Browser">
<div class="browser-dialog-header"><strong>Browser</strong><label class="hidden-toggle"><input type="checkbox" id="source_smb_show_hidden" onchange="refreshBrowserLevel('source')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('source')">×</button></div>
<div class="browser-dialog-body"><div id="source_browser_meta" class="tree-meta"></div><ul id="source_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div></div>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('source')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptBrowserSelection('source')">Auswählen</button>
</div>
</div>
</div>
</div>
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(1)">Zurück</button>
<button type="button" class="btn" onclick="nextStep(1)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="2">
<div class="step-header"><h3>Destination</h3></div>
<div class="row row-3">
<div>
<label for="destination_protocol">Typ</label>
<select id="destination_protocol" name="destination_protocol" required>
{% for p in target_protocols %}<option value="{{ p }}" {% if p == backup.destination_protocol %}selected{% endif %}>{{ p }}</option>{% endfor %}
</select>
</div>
<div id="destination_host_col" style="display:none;">
<label for="destination_host">Host/IP</label>
<div style="display:flex; gap:6px;">
<input id="destination_host" name="destination_host" type="text" value="{{ backup.destination_host }}" style="flex:1; margin-bottom:0;">
<input id="destination_port_ui" type="number" min="1" max="65535" placeholder="Port"
title="Port" value="{{ backup.destination_port or '' }}" style="width:72px; margin-bottom:0; display:none;">
</div>
</div>
<div id="destination_share_row" style="display:none;">
<label for="destination_share">Freigabe</label>
<input id="destination_share" name="destination_share" type="text" value="{{ backup.destination_share }}">
</div>
<div id="destination_local_path_col" style="display:none; grid-column: 2 / -1;">
<label for="destination_local_path">Lokaler Pfad</label>
<div class="path-input-group">
<input id="destination_local_path" type="text" value="{{ backup.destination_local_path }}">
<button type="button" class="btn-browse" onclick="openLocalBrowserOverlay('destination')" title="Browser öffnen">🔍</button>
</div>
</div>
</div>
<div id="destination_local_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
<div class="browser-dialog-header"><strong>Lokaler Browser</strong><label class="hidden-toggle"><input type="checkbox" id="destination_local_show_hidden" onchange="refreshLocalBrowserLevel('destination')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('destination')">×</button></div>
<div class="browser-dialog-body"><div id="destination_local_browser_meta" class="tree-meta"></div><ul id="destination_local_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div id="destination_local_new_folder_bar" style="display:none; flex:1; align-items:center; gap:6px;">
<input id="destination_local_new_folder" type="text" placeholder="Ordnername" autocomplete="off" style="flex:1; min-width:0; margin:0;">
<button class="btn" type="button" onclick="createLocalDestinationFolder()">Erstellen</button>
<button class="btn btn-secondary" type="button" onclick="closeLocalCreateFolderOverlay()">&#x2715;</button>
</div>
<button id="destination_local_new_folder_btn" type="button" class="btn btn-secondary" onclick="openLocalCreateFolderOverlay()">Ordner erstellen</button>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('destination')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptLocalBrowserSelection('destination')">Auswählen</button>
</div>
</div>
</div>
</div>
<div id="destination_remote_fields" style="display:none;">
<div class="row">
<div><label for="destination_username">Benutzer</label><input id="destination_username" name="destination_username" type="text" value="{{ backup.destination_username }}"></div>
<div><label for="destination_password">Passwort</label><input id="destination_password" name="destination_password" type="password" value="{{ backup.destination_password }}"></div>
</div>
<label for="destination_subpath">Pfad</label>
<div class="path-input-group">
<input id="destination_subpath" name="destination_subpath" type="text" value="{{ backup.destination_subpath }}">
<button type="button" class="btn-browse" onclick="browseSmb('destination')" title="Browser öffnen">🔍</button>
</div>
<p id="destination_test_message"></p>
<div id="destination_browser_overlay" class="browser-overlay">
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Browser">
<div class="browser-dialog-header"><strong>Browser</strong><label class="hidden-toggle"><input type="checkbox" id="destination_smb_show_hidden" onchange="refreshBrowserLevel('destination')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('destination')">×</button></div>
<div class="browser-dialog-body"><div id="destination_browser_meta" class="tree-meta"></div><ul id="destination_browser_tree" class="tree"></ul></div>
<div class="browser-dialog-actions">
<div id="destination_new_folder_bar" style="display:none; flex:1; align-items:center; gap:6px;">
<input id="destination_new_folder" type="text" placeholder="Ordnername" autocomplete="off" style="flex:1; min-width:0; margin:0;">
<button class="btn" type="button" onclick="createDestinationFolder()">Erstellen</button>
<button class="btn btn-secondary" type="button" onclick="closeCreateFolderOverlay()">&#x2715;</button>
</div>
<button id="destination_new_folder_btn" type="button" class="btn btn-secondary" onclick="openCreateFolderOverlay()">Ordner erstellen</button>
<div class="browser-dialog-right">
<button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('destination')">Abbrechen</button>
<button type="button" class="btn" onclick="acceptBrowserSelection('destination')">Auswählen</button>
</div>
</div>
</div>
</div>
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(2)">Zurück</button>
<button type="button" class="btn" onclick="nextStep(2)">Weiter</button>
</div>
</section>
<section class="card wizard-step" data-step="3">
<div class="step-header"><h3>Target</h3></div>
<div id="target_pattern_box">
<label for="target_pattern">Ziel</label>
<div class="token-bar" id="token_bar"></div>
<input id="target_pattern" name="target_pattern" type="text" value="{{ backup.target_pattern }}" required> <input id="target_pattern" name="target_pattern" type="text" value="{{ backup.target_pattern }}" required>
<div id="token_context_menu" class="token-menu" style="display:none;"></div>
</div>
<div class="row" style="grid-template-columns: 1fr 120px 1fr;">
<div>
<label for="backup_mode">Backup-Modus</label>
<select id="backup_mode" name="backup_mode" onchange="syncBackupMode()">
{% for mode in backup_modes %}
<option value="{{ mode }}" {% if mode == backup.backup_mode %}selected{% endif %}>{% if mode == 'full' %}Vollständig{% elif mode == 'sync' %}Sync{% else %}Clone{% endif %}</option>
{% endfor %}
</select>
</div>
<div id="rotation_col">
<label for="rotation_keep">Rotation (0=∞)</label>
<input id="rotation_keep" name="rotation_keep" type="number" min="0" value="{{ backup.rotation_keep }}" style="width:100%;">
</div>
<div id="compression_col">
<label for="compression_method">Kompression</label> <label for="compression_method">Kompression</label>
<select id="compression_method" name="compression_method" required> <select id="compression_method" name="compression_method" required>
{% for method in compression_methods %} {% for m in compression_methods %}<option value="{{ m }}" {% if m == backup.compression_method %}selected{% endif %}>{% if m == 'none' %}Keine (1:1){% else %}{{ m }}{% endif %}</option>{% endfor %}
<option value="{{ method }}" {% if method == backup.compression_method %}selected{% endif %}>{{ method }}</option>
{% endfor %}
</select> </select>
</div>
</div>
<div id="encryption_row" class="row" style="display:none; grid-template-columns: 1fr 1fr 1fr;">
<div>
<label for="encryption_mode">Verschlüsselung</label> <label for="encryption_mode">Verschlüsselung</label>
<select id="encryption_mode" name="encryption_mode" required> <select id="encryption_mode" name="encryption_mode" required>
{% for mode in encryption_modes %} {% for m in encryption_modes %}<option value="{{ m }}" {% if m == backup.encryption_mode %}selected{% endif %}>{{ m }}</option>{% endfor %}
<option value="{{ mode }}" {% if mode == backup.encryption_mode %}selected{% endif %}>{{ mode }}</option>
{% endfor %}
</select>
<div id="password_box">
<label for="archive_password">Neues Passwort (optional)</label>
<input id="archive_password" name="archive_password" type="password" placeholder="Leer lassen = bestehendes Passwort behalten">
<label for="archive_password_confirm">Passwort bestätigen</label>
<input id="archive_password_confirm" name="archive_password_confirm" type="password">
</div>
</div>
<div class="card">
<h3>Zeitplan</h3>
<label for="schedule_mode">Ausführung</label>
<select id="schedule_mode" name="schedule_mode" required>
{% for mode in schedule_modes %}
<option value="{{ mode }}" {% if mode == backup.schedule_mode %}selected{% endif %}>{{ mode }}</option>
{% endfor %}
</select>
<div id="schedule_time_box">
<label for="schedule_time">Uhrzeit</label>
<input id="schedule_time" name="schedule_time" type="time" value="{{ backup.schedule_time or '02:00' }}">
</div>
<div id="schedule_weekday_box" style="display:none;">
<label for="schedule_weekday">Wochentag</label>
<select id="schedule_weekday" name="schedule_weekday">
<option value="1" {% if backup.schedule_weekday == '1' %}selected{% endif %}>Montag</option>
<option value="2" {% if backup.schedule_weekday == '2' %}selected{% endif %}>Dienstag</option>
<option value="3" {% if backup.schedule_weekday == '3' %}selected{% endif %}>Mittwoch</option>
<option value="4" {% if backup.schedule_weekday == '4' %}selected{% endif %}>Donnerstag</option>
<option value="5" {% if backup.schedule_weekday == '5' %}selected{% endif %}>Freitag</option>
<option value="6" {% if backup.schedule_weekday == '6' %}selected{% endif %}>Samstag</option>
<option value="0" {% if backup.schedule_weekday == '0' %}selected{% endif %}>Sonntag</option>
</select> </select>
</div> </div>
<div id="archive_pwd_col" style="display:none;">
<div id="schedule_dom_box" style="display:none;"> <label for="archive_password">Passwort</label>
<label for="schedule_day_of_month">Tag im Monat</label> <input id="archive_password" name="archive_password" type="password" value="{{ backup.archive_password }}">
<input id="schedule_day_of_month" name="schedule_day_of_month" type="number" min="1" max="31" value="{{ backup.schedule_day_of_month or '1' }}">
</div> </div>
<div id="archive_pwd2_col" style="display:none;">
<div id="schedule_interval_box" style="display:none;"> <label for="archive_password_confirm">Bestätigen</label>
<label for="schedule_interval">Intervall (X)</label> <input id="archive_password_confirm" name="archive_password_confirm" type="password" value="{{ backup.archive_password }}">
<input id="schedule_interval" name="schedule_interval" type="number" min="1" value="{{ backup.schedule_interval or 1 }}">
</div> </div>
</div>
<div class="step-actions">
<button type="button" class="btn btn-secondary" onclick="prevStep(3)">Zurück</button>
<button type="button" class="btn" onclick="nextStep(3)">Weiter</button>
</div>
</section>
<div id="schedule_cron_box" style="display:none;"> <section class="card wizard-step" data-step="4">
<div class="step-header"><h3>Zeitplan</h3></div>
<label for="schedule_cron">CRON Ausdruck</label> <label for="schedule_cron">CRON Ausdruck</label>
<input id="schedule_cron" name="schedule_cron" type="text" value="{{ backup.schedule_cron or '' }}"> <input id="schedule_cron" name="schedule_cron" type="text" value="{{ backup.schedule_cron }}">
<div id="cron_preview" style="display:none;margin-top:6px;font-size:13px;color:#1e2a3b;">
<div id="cron_description" style="font-weight:600;"></div>
<div id="cron_next_run" style="color:#5a6e87;"></div>
</div> </div>
</div> <div id="cron_error" style="display:none;margin-top:6px;font-size:13px;color:#b9382f;"></div>
<div class="step-actions"> <div class="step-actions">
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}" style="text-decoration:none;">Abbrechen</a> <a class="btn btn-secondary" href="{{ url_for('dashboard') }}" style="text-decoration:none;">Abbrechen</a>
<button type="button" class="btn btn-secondary" onclick="prevStep(4)">Zurück</button>
<button class="btn" type="submit">Speichern</button> <button class="btn" type="submit">Speichern</button>
</div> </div>
</section>
</form> </form>
<script> <script>
function syncEncryptionFields() { let currentStep = 0;
const mode = document.getElementById('encryption_mode').value; function htmlEscape(v) { return String(v).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;'); }
const box = document.getElementById('password_box');
box.style.display = mode === 'password-aes256' ? 'block' : 'none'; function showStep(i) {
document.querySelectorAll('.wizard-step').forEach((el, idx) => el.classList.toggle('active', idx === i));
document.querySelectorAll('[data-step-pill]').forEach((el, idx) => el.classList.toggle('active', idx === i));
currentStep = i;
}
function requireValue(id, msg) { const el = document.getElementById(id); if (!el || !el.value.trim()) { alert(msg); el?.focus(); return false; } return true; }
function validateStep(i) {
if (i === 0) return requireValue('name', 'Bitte Backup Name setzen.');
if (i === 1) {
const p = document.getElementById('source_protocol').value;
if (p === 'LOCAL') return requireValue('source_local_path', 'Bitte lokalen Source-Pfad angeben.');
if (!requireValue('source_host','Bitte Host/IP setzen.')) return false;
if (!requireValue('source_username','Bitte Benutzer setzen.')) return false;
if (!requireValue('source_password','Bitte Passwort setzen.')) return false;
if (p === 'SMB' && !requireValue('source_share','Bitte Share setzen.')) return false;
return requireValue('source_subpath','Bitte Pfad setzen.');
}
if (i === 2) {
const p = document.getElementById('destination_protocol').value;
if (p === 'LOCAL') return requireValue('destination_local_path', 'Bitte lokalen Destination-Pfad angeben.');
if (!requireValue('destination_host','Bitte Host/IP setzen.')) return false;
if (!requireValue('destination_username','Bitte Benutzer setzen.')) return false;
if (!requireValue('destination_password','Bitte Passwort setzen.')) return false;
if (p === 'SMB' && !requireValue('destination_share','Bitte Share setzen.')) return false;
return requireValue('destination_subpath','Bitte Pfad setzen.');
}
if (i === 3) {
if (!requireValue('target_pattern','Bitte Ziel setzen.')) return false;
if (document.getElementById('encryption_mode').value === 'password-aes256') {
if (!requireValue('archive_password','Bitte Passwort setzen.')) return false;
if (!requireValue('archive_password_confirm','Bitte Passwort bestätigen.')) return false;
}
return true;
}
return true;
}
function nextStep(i) { if (!validateStep(i)) return; showStep(Math.min(i+1,4)); }
function prevStep(i) { showStep(Math.max(i-1,0)); }
function setRequired(el, on) { if (!el) return; on ? el.setAttribute('required','required') : el.removeAttribute('required'); }
function syncProtocolFields(prefix) {
const protocol = document.getElementById(prefix+'_protocol').value;
const remoteFields = document.getElementById(prefix+'_remote_fields');
const localPathCol = document.getElementById(prefix+'_local_path_col');
const hostCol = document.getElementById(prefix+'_host_col');
const shareRow = document.getElementById(prefix+'_share_row');
const host = document.getElementById(prefix+'_host');
const share = document.getElementById(prefix+'_share');
const subpath = document.getElementById(prefix+'_subpath');
const user = document.getElementById(prefix+'_username');
const pass = document.getElementById(prefix+'_password');
const localPath = document.getElementById(prefix+'_local_path');
if (protocol === 'LOCAL') {
remoteFields.style.display='none'; localPathCol.style.display='block';
hostCol.style.display='none'; shareRow.style.display='none';
const portUiL = document.getElementById(prefix+'_port_ui');
if (portUiL) { portUiL.style.display='none'; portUiL.value=''; }
setRequired(host,false); setRequired(share,false); setRequired(user,false); setRequired(pass,false); setRequired(localPath,true);
subpath.value = localPath.value||''; share.value=''; user.value=''; pass.value=''; host.value='local';
if (prefix==='source') setSourceEntryType('directory');
return;
}
remoteFields.style.display='block'; localPathCol.style.display='none';
hostCol.style.display='block'; shareRow.style.display = protocol==='SMB'?'block':'none';
setRequired(host,true); setRequired(share,protocol==='SMB'); setRequired(user,true); setRequired(pass,true); setRequired(localPath,false);
const portUi = document.getElementById(prefix+'_port_ui');
if (portUi) {
if (protocol==='FTP') { portUi.style.display='block'; if(!portUi.value) portUi.value='21'; }
else if (protocol==='SFTP') { portUi.style.display='block'; if(!portUi.value) portUi.value='22'; }
else { portUi.style.display='none'; portUi.value=''; }
}
if (protocol==='FTP'||protocol==='SFTP') { if(!share.value) share.value='/'; if(!subpath.value) subpath.value='/'; }
} }
function syncScheduleFields() { function collectPayload(prefix) {
const mode = document.getElementById('schedule_mode').value; const protocol = document.getElementById(prefix+'_protocol').value;
document.getElementById('schedule_weekday_box').style.display = mode === 'weekly' || mode === 'every_n_weeks' ? 'block' : 'none'; const payload = { prefix, [prefix+'_protocol']: protocol,
document.getElementById('schedule_dom_box').style.display = mode === 'monthly' || mode === 'yearly' ? 'block' : 'none'; [prefix+'_host']: document.getElementById(prefix+'_host').value,
document.getElementById('schedule_interval_box').style.display = mode === 'every_n_days' || mode === 'every_n_weeks' ? 'block' : 'none'; [prefix+'_share']: document.getElementById(prefix+'_share').value,
document.getElementById('schedule_cron_box').style.display = mode === 'custom' ? 'block' : 'none'; [prefix+'_subpath']: document.getElementById(prefix+'_subpath').value,
[prefix+'_username']: document.getElementById(prefix+'_username').value,
[prefix+'_password']: document.getElementById(prefix+'_password').value };
if (protocol==='LOCAL') {
payload[prefix+'_host']='local'; payload[prefix+'_share']=''; payload[prefix+'_username']=''; payload[prefix+'_password']='';
payload[prefix+'_subpath'] = document.getElementById(prefix+'_local_path').value;
document.getElementById(prefix+'_subpath').value = payload[prefix+'_subpath'];
}
if (protocol==='FTP'||protocol==='SFTP') {
const pUi=document.getElementById(prefix+'_port_ui');
const pVal=pUi?.value||(protocol==='FTP'?'21':'22');
payload[prefix+'_host']=String(payload[prefix+'_host']||'').split(':')[0]+':'+pVal;
}
return payload;
} }
document.getElementById('encryption_mode').addEventListener('change', syncEncryptionFields); function setSourceEntryType(t) {
document.getElementById('schedule_mode').addEventListener('change', syncScheduleFields); const type = t==='file'?'file':'directory';
syncEncryptionFields(); document.getElementById('source_entry_type').value = type;
syncScheduleFields(); document.getElementById('target_kind').value = type==='file'?'file':'folder';
}
async function loadLocalBrowserLevel(prefix, path) {
const list = document.getElementById(prefix+'_local_browser_tree');
const meta = document.getElementById(prefix+'_local_browser_meta');
list.innerHTML='<li class="tree-empty">L\u00e4dt\u2026</li>';
let data;
try { const r=await fetch('{{ url_for("api_local_browse") }}?path='+encodeURIComponent(path)); data=await r.json(); }
catch(e) { list.innerHTML='<li class="tree-empty">Verbindungsfehler</li>'; return; }
if (!data.ok) { meta.textContent=''; list.innerHTML='<li class="tree-empty">'+htmlEscape(data.message)+'</li>'; return; }
meta.textContent='Pfad: '+data.path;
document.getElementById(prefix+'_local_path').value=data.path;
document.getElementById(prefix+'_subpath').value=data.path;
list.innerHTML='';
const allEntries=data.entries||[];
const entries=filterEntries(allEntries, prefix+'_local_show_hidden');
if (!entries.length) { const e=document.createElement('li'); e.className='tree-empty'; e.textContent='Keine Eintr\u00e4ge'; list.appendChild(e); return; }
entries.forEach(entry=>{
const li=document.createElement('li'); const btn=document.createElement('button');
btn.type='button'; btn.className='tree-node';
btn.textContent=(entry.entry_type==='up'?'\u21a9':entry.entry_type==='file'?'\ud83d\udcc4':'\ud83d\udcc1')+' '+entry.name;
btn.addEventListener('click',()=>{
if (entry.entry_type==='file') {
document.getElementById(prefix+'_local_path').value=entry.path;
document.getElementById(prefix+'_subpath').value=entry.path;
if(prefix==='source') setSourceEntryType('file');
list.querySelectorAll('.tree-node').forEach(n=>n.classList.remove('active')); btn.classList.add('active');
meta.textContent='Pfad: '+entry.path;
} else {
document.getElementById(prefix+'_local_path').value=entry.path;
document.getElementById(prefix+'_subpath').value=entry.path;
if(prefix==='source') setSourceEntryType('directory');
loadLocalBrowserLevel(prefix,entry.path);
}
});
li.appendChild(btn); list.appendChild(li);
});
}
function openLocalBrowserOverlay(prefix) {
document.getElementById(prefix+'_local_browser_overlay').style.display='flex';
loadLocalBrowserLevel(prefix, document.getElementById(prefix+'_local_path').value||'/');
}
function closeLocalBrowserOverlay(prefix) { document.getElementById(prefix+'_local_browser_overlay').style.display='none'; }
function acceptLocalBrowserSelection(prefix) { closeLocalBrowserOverlay(prefix); }
function refreshLocalBrowserLevel(prefix) {
loadLocalBrowserLevel(prefix, document.getElementById(prefix+'_local_path').value||'/');
}
function openLocalCreateFolderOverlay() {
document.getElementById('destination_local_new_folder_btn').style.display='none';
const bar=document.getElementById('destination_local_new_folder_bar');
bar.style.display='flex';
const i=document.getElementById('destination_local_new_folder'); i.value=''; i.focus();
}
function closeLocalCreateFolderOverlay() {
document.getElementById('destination_local_new_folder_bar').style.display='none';
document.getElementById('destination_local_new_folder_btn').style.display='';
}
async function createLocalDestinationFolder() {
const inp=document.getElementById('destination_local_new_folder'); const name=inp.value.trim();
if (!name) return;
const currentPath=document.getElementById('destination_local_path').value||'/';
const r=await fetch('{{ url_for("api_local_mkdir") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:currentPath,folder_name:name})});
const data=await r.json();
if (!data.ok) { const m=document.getElementById('destination_local_browser_meta'); m.textContent=data.message||'Fehler.'; m.style.color='#b9382f'; return; }
inp.value=''; document.getElementById('destination_local_path').value=data.path; document.getElementById('destination_subpath').value=data.path;
closeLocalCreateFolderOverlay(); loadLocalBrowserLevel('destination',data.path);
}
async function loadBrowserLevel(prefix, path) {
const list = document.getElementById(prefix+'_browser_tree');
const meta = document.getElementById(prefix+'_browser_meta');
list.innerHTML = '<li class="tree-empty">Lädt…</li>';
const payload = collectPayload(prefix); payload[prefix+'_subpath'] = path;
let data;
try { const r = await fetch('{{ url_for("api_target_browse") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); data=await r.json(); }
catch(e) { list.innerHTML='<li class="tree-empty">Verbindungsfehler</li>'; return; }
const msg = document.getElementById(prefix+'_test_message');
if (!data.ok) { msg.textContent=data.message||'Fehler.'; msg.style.color='#b9382f'; list.innerHTML=''; return; }
msg.textContent=''; document.getElementById(prefix+'_subpath').value=data.path; meta.textContent='Pfad: '+data.path;
list.innerHTML='';
const allEntries2=data.entries||[];
const entries=filterEntries(allEntries2, prefix+'_local_show_hidden');
if (!entries.length) { const e=document.createElement('li'); e.className='tree-empty'; e.textContent='Keine Einträge'; list.appendChild(e); return; }
entries.forEach(entry=>{
const li=document.createElement('li'); const btn=document.createElement('button');
btn.type='button'; btn.className='tree-node';
btn.textContent=(entry.entry_type==='up'?'↩':entry.entry_type==='file'?'📄':'📁')+' '+entry.name;
btn.addEventListener('click',()=>{
if (entry.entry_type==='file') {
document.getElementById(prefix+'_subpath').value=entry.path;
if (prefix==='source') setSourceEntryType('file');
list.querySelectorAll('.tree-node').forEach(n=>n.classList.remove('active')); btn.classList.add('active');
document.getElementById(prefix+'_test_message').textContent='Datei ausgewählt: '+entry.path;
document.getElementById(prefix+'_test_message').style.color='#0a7f68';
} else { if(prefix==='source') setSourceEntryType('directory'); document.getElementById(prefix+'_subpath').value=entry.path; loadBrowserLevel(prefix,entry.path); }
});
li.appendChild(btn); list.appendChild(li);
});
}
function openBrowserOverlay(prefix) {
const p=document.getElementById(prefix+'_protocol').value;
if (!['SMB','FTP','SFTP'].includes(p)) return;
document.getElementById(prefix+'_browser_overlay').style.display='flex';
loadBrowserLevel(prefix,document.getElementById(prefix+'_subpath').value||'/');
}
function closeBrowserOverlay(prefix) { document.getElementById(prefix+'_browser_overlay').style.display='none'; }
function acceptBrowserSelection(prefix) { closeBrowserOverlay(prefix); }
async function browseSmb(prefix) {
const p=document.getElementById(prefix+'_protocol').value;
if (!['SMB','FTP','SFTP'].includes(p)) {
const msg=document.getElementById(prefix+'_test_message');
msg.textContent='Browser ist nur bei SMB, FTP und SFTP verfügbar.';
msg.style.color='#b9382f'; return;
}
openBrowserOverlay(prefix);
}
function refreshBrowserLevel(prefix) { loadBrowserLevel(prefix, document.getElementById(prefix+'_subpath').value||'/'); }
function filterEntries(entries, cbId) {
if (document.getElementById(cbId)?.checked) return entries;
return entries.filter(e => e.entry_type==='up' || !e.name.startsWith('.'));
}
function openCreateFolderOverlay() {
document.getElementById('destination_new_folder_btn').style.display='none';
const bar=document.getElementById('destination_new_folder_bar');
bar.style.display='flex';
const i=document.getElementById('destination_new_folder'); i.value=''; i.focus();
}
function closeCreateFolderOverlay() {
document.getElementById('destination_new_folder_bar').style.display='none';
document.getElementById('destination_new_folder_btn').style.display='';
}
async function createDestinationFolder() {
const inp=document.getElementById('destination_new_folder'); const name=inp.value.trim();
const msg=document.getElementById('destination_test_message');
if (!name) { msg.textContent='Bitte Ordnernamen eingeben.'; msg.style.color='#b9382f'; return; }
const payload=collectPayload('destination'); payload.folder_name=name;
const r=await fetch('{{ url_for("api_target_smb_mkdir") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const data=await r.json();
if (!data.ok) { msg.textContent=data.message||'Fehler.'; msg.style.color='#b9382f'; return; }
inp.value=''; document.getElementById('destination_subpath').value=data.path;
msg.textContent='Ordner erstellt: '+data.path; msg.style.color='#0a7f68';
closeCreateFolderOverlay(); loadBrowserLevel('destination',data.path);
}
function syncCompressionFields() {
const c=document.getElementById('compression_method').value;
const encRow=document.getElementById('encryption_row');
const encSel=document.getElementById('encryption_mode');
const pwdCol=document.getElementById('archive_pwd_col');
const pwd2Col=document.getElementById('archive_pwd2_col');
const pwd=document.getElementById('archive_password'); const pwd2=document.getElementById('archive_password_confirm');
if (c==='none') { encRow.style.display='none'; encSel.value='none'; setRequired(pwd,false); setRequired(pwd2,false); pwd.value=''; pwd2.value=''; return; }
encRow.style.display='';
const showPwd=encSel.value==='password-aes256';
pwdCol.style.display=showPwd?'':'none'; pwd2Col.style.display=showPwd?'':'none';
setRequired(pwd,showPwd); setRequired(pwd2,showPwd);
if (!showPwd) { pwd.value=''; pwd2.value=''; }
}
function syncBackupMode() {
const mode=document.getElementById('backup_mode').value;
const compCol=document.getElementById('compression_col');
const compSel=document.getElementById('compression_method');
const rotCol=document.getElementById('rotation_col');
const targetBox=document.getElementById('target_pattern_box');
const targetInput=document.getElementById('target_pattern');
if (mode==='sync'||mode==='clone') {
compSel.value='none'; compSel.disabled=true;
compCol.style.opacity='0.45';
compCol.title='Sync/Clone arbeitet immer ohne Kompression';
rotCol.style.display='none';
targetBox.style.display='none';
targetInput.removeAttribute('required');
} else {
compSel.disabled=false; compCol.style.opacity=''; compCol.title='';
rotCol.style.display='';
targetBox.style.display='';
targetInput.setAttribute('required', '');
}
syncCompressionFields();
}
const TOKEN_GROUPS = [
{label:'Datum',defaultToken:'{date}',formats:[{token:'{date}',label:'YYYY-MM-DD',sub:'2026-07-30'},{token:'{date_de}',label:'TT.MM.JJJJ',sub:'30.07.2026'},{token:'{date_compact}',label:'JJJJMMTT',sub:'20260730'}]},
{label:'Uhrzeit',defaultToken:'{time}',formats:[{token:'{time}',label:'HH-MM-SS',sub:'14-30-00'},{token:'{time_short}',label:'HHMM',sub:'1430'}]},
{label:'Datum+Zeit',defaultToken:'{datetime}',formats:[{token:'{datetime}',label:'JJJJ-MM-TT_HH-MM-SS',sub:'2026-07-30_14-30-00'}]},
];
const activeTokens = TOKEN_GROUPS.map(g=>g.defaultToken);
function buildTokenBar() {
const bar=document.getElementById('token_bar'); bar.innerHTML='';
TOKEN_GROUPS.forEach((group,idx)=>{
const chip=document.createElement('span'); chip.className='token-chip'; chip.draggable=true;
chip.textContent=group.label; chip.title=activeTokens[idx]+' · Rechtsklick für Formate';
chip.addEventListener('click',()=>insertToken(activeTokens[idx]));
chip.addEventListener('dragstart',e=>{e.dataTransfer.setData('text/plain',activeTokens[idx]);e.dataTransfer.effectAllowed='copy';});
chip.addEventListener('contextmenu',e=>{e.preventDefault();showTokenMenu(e,group,idx);});
bar.appendChild(chip);
});
}
function insertToken(token) {
const inp=document.getElementById('target_pattern'); const s=inp.selectionStart??inp.value.length; const e=inp.selectionEnd??inp.value.length;
inp.value=inp.value.slice(0,s)+token+inp.value.slice(e); inp.setSelectionRange(s+token.length,s+token.length); inp.focus();
}
function showTokenMenu(ev,group,idx) {
const menu=document.getElementById('token_context_menu'); menu.innerHTML='';
group.formats.forEach(fmt=>{
const item=document.createElement('div'); item.className='token-menu-item';
item.innerHTML='<div>'+htmlEscape(fmt.label)+'</div><div class="token-menu-sub">'+htmlEscape(fmt.token)+' &nbsp;·&nbsp; '+htmlEscape(fmt.sub)+'</div>';
item.addEventListener('click',()=>{activeTokens[idx]=fmt.token;buildTokenBar();closeTokenMenu();insertToken(fmt.token);});
menu.appendChild(item);
});
menu.style.left=ev.clientX+'px'; menu.style.top=ev.clientY+'px'; menu.style.display='block';
requestAnimationFrame(()=>{ const r=menu.getBoundingClientRect(); if(r.right>window.innerWidth) menu.style.left=(ev.clientX-r.width)+'px'; if(r.bottom>window.innerHeight) menu.style.top=(ev.clientY-r.height)+'px'; });
}
function closeTokenMenu() { document.getElementById('token_context_menu').style.display='none'; }
document.addEventListener('click',e=>{ if(!e.target.closest('.token-chip')&&!e.target.closest('.token-menu')) closeTokenMenu(); });
const _tp=document.getElementById('target_pattern');
_tp.addEventListener('dragover',e=>e.preventDefault());
_tp.addEventListener('drop',e=>{ e.preventDefault(); const t=e.dataTransfer.getData('text/plain'); if(!t) return; const s=_tp.selectionStart??_tp.value.length; const en=_tp.selectionEnd??_tp.value.length; _tp.value=_tp.value.slice(0,s)+t+_tp.value.slice(en); _tp.setSelectionRange(s+t.length,s+t.length); _tp.focus(); });
document.getElementById('destination_new_folder').addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();createDestinationFolder();} if(e.key==='Escape') closeCreateFolderOverlay(); });
document.getElementById('name').addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();nextStep(0);} });
let _cronTimer=null;
async function updateCronPreview() {
const val=document.getElementById('schedule_cron').value.trim();
const preview=document.getElementById('cron_preview'); const errEl=document.getElementById('cron_error');
if (!val) { preview.style.display='none'; errEl.style.display='none'; return; }
try {
const r=await fetch('{{ url_for("api_cron_preview") }}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cron:val})});
const data=await r.json();
if (!data.ok) { preview.style.display='none'; errEl.textContent=data.message||'Ungültiger Ausdruck.'; errEl.style.display='block'; return; }
document.getElementById('cron_description').textContent=data.description;
document.getElementById('cron_next_run').textContent='Nächstes Backup: '+data.next_run;
preview.style.display='block'; errEl.style.display='none';
} catch(_) { preview.style.display='none'; }
}
document.getElementById('source_protocol').addEventListener('change',()=>syncProtocolFields('source'));
document.getElementById('destination_protocol').addEventListener('change',()=>syncProtocolFields('destination'));
document.getElementById('source_local_path').addEventListener('input',ev=>{ document.getElementById('source_subpath').value=ev.target.value; });
document.getElementById('destination_local_path').addEventListener('input',ev=>{ document.getElementById('destination_subpath').value=ev.target.value; });
document.getElementById('destination_local_new_folder').addEventListener('keydown',(e)=>{ if(e.key==='Enter'){e.preventDefault();createLocalDestinationFolder();} if(e.key==='Escape') closeLocalCreateFolderOverlay(); });
document.getElementById('compression_method').addEventListener('change',syncCompressionFields);
document.getElementById('encryption_mode').addEventListener('change',syncCompressionFields);
document.getElementById('schedule_cron').addEventListener('input',()=>{ clearTimeout(_cronTimer); _cronTimer=setTimeout(updateCronPreview,500); });
// Preserve pre-filled passwords: syncProtocolFields may clear pass for LOCAL,
// but for remote protocols the pre-filled "***" value stays untouched.
syncProtocolFields('source');
syncProtocolFields('destination');
// syncBackupMode calls syncCompressionFields internally
syncBackupMode();
buildTokenBar();
if (document.getElementById('schedule_cron').value.trim()) updateCronPreview();
showStep(0);
// Merge port into host field before form submission (FTP/SFTP)
document.getElementById('backupForm').addEventListener('submit', () => {
for (const prefix of ['source', 'destination']) {
const protocol = document.getElementById(prefix + '_protocol').value;
if (protocol === 'FTP' || protocol === 'SFTP') {
const hostEl = document.getElementById(prefix + '_host');
const portUi = document.getElementById(prefix + '_port_ui');
if (portUi?.value && hostEl) {
hostEl.value = hostEl.value.split(':')[0] + ':' + portUi.value;
}
}
}
});
</script> </script>
{% endblock %} {% endblock %}
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -101,7 +101,21 @@
cursor: pointer; cursor: pointer;
} }
.messages { display: grid; gap: 10px; } .flash-overlay {
position: fixed;
top: 18px;
right: 18px;
z-index: 1200;
display: grid;
gap: 10px;
max-width: min(92vw, 360px);
pointer-events: none;
}
.flash-overlay.hidden {
opacity: 0;
transition: opacity 220ms ease;
}
.msg { .msg {
margin: 0; margin: 0;
@@ -109,6 +123,8 @@
border-radius: 12px; border-radius: 12px;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
box-shadow: 0 10px 24px rgba(30, 42, 59, 0.16);
pointer-events: auto;
} }
.msg.success { .msg.success {
@@ -240,11 +256,18 @@
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<section class="panel content messages"> <div id="flash-overlay" class="flash-overlay" role="status" aria-live="polite">
{% for category, message in messages %} {% for category, message in messages %}
<p class="msg {{ category }}">{{ message }}</p> <p class="msg {{ category }}">{{ message }}</p>
{% endfor %} {% endfor %}
</section> </div>
<script>
window.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('flash-overlay');
if (!overlay) return;
window.setTimeout(() => overlay.classList.add('hidden'), 3000);
});
</script>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
+269 -6
View File
@@ -8,6 +8,12 @@
white-space: nowrap; white-space: nowrap;
} }
.run-action {
position: relative;
display: inline-flex;
margin-right: 5px;
}
.icon-btn { .icon-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -34,6 +40,72 @@
color: #b9382f; color: #b9382f;
border-color: rgba(185, 56, 47, 0.3); border-color: rgba(185, 56, 47, 0.3);
} }
.icon-btn-stop {
color: #b9382f;
border-color: rgba(185, 56, 47, 0.38);
background: #fff2f1;
}
.run-tooltip {
position: absolute;
right: 0;
bottom: 100%;
/* extra bottom-padding bridges the gap so CSS :hover stays active while moving mouse to tooltip */
padding: 9px 10px 18px;
transform: translateY(4px);
width: 280px;
border: 1px solid rgba(30, 42, 59, 0.2);
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 28px rgba(30, 42, 59, 0.2);
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease, transform 150ms ease;
pointer-events: auto;
user-select: text;
cursor: text;
z-index: 30;
}
.run-action:hover .run-tooltip,
.run-action:focus-within .run-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.run-tooltip-title {
margin: 0 0 6px;
font-size: 12px;
font-weight: 700;
color: #1e2a3b;
}
.run-progress {
width: 100%;
height: 7px;
border-radius: 999px;
background: #ecf2fa;
overflow: hidden;
margin-bottom: 6px;
}
.run-progress > span {
display: block;
width: 0;
height: 100%;
background: linear-gradient(90deg, #0a7f68, #02a883);
transition: width 220ms ease;
}
.run-tooltip-text {
margin: 0;
font-size: 12px;
color: #5a6e87;
line-height: 1.4;
word-break: break-word;
}
</style> </style>
<h2>Eingerichtete Backups</h2> <h2>Eingerichtete Backups</h2>
@@ -49,6 +121,7 @@
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Zeitplan</th> <th>Zeitplan</th>
<th>Nächster Lauf</th>
<th>Letzter Lauf</th> <th>Letzter Lauf</th>
<th>Größe</th> <th>Größe</th>
<th>Aktionen</th> <th>Aktionen</th>
@@ -58,13 +131,28 @@
{% for backup in backups %} {% for backup in backups %}
<tr> <tr>
<td><strong>{{ backup.name }}</strong></td> <td><strong>{{ backup.name }}</strong></td>
<td>{{ backup.schedule_mode }}</td> <td>{{ backup.schedule_cron or '–' }}</td>
<td>{{ backup.last_run_at | fmt_dt }}</td> <td data-next-run>{% if backup.schedule_cron %}{{ backup.next_run_at | fmt_dt }}{% else %}Kein automatisches Backup{% endif %}</td>
<td>{{ backup.last_size_bytes | fmt_bytes }}</td> <td data-last-run>{{ backup.last_run_at | fmt_dt }}</td>
<td data-last-size>{{ backup.last_size_bytes | fmt_bytes }}</td>
<td class="actions"> <td class="actions">
<form method="post" action="{{ url_for('backup_run', backup_id=backup.backup_id) }}" style="display:inline;"> <span class="run-action" data-run-action data-backup-id="{{ backup.backup_id }}">
<button class="icon-btn" type="submit" title="Manuell ausführen" aria-label="Manuell ausführen">&#9654;</button> <button
</form> class="icon-btn"
type="button"
title="Manuell ausführen"
aria-label="Manuell ausführen"
data-run-toggle
data-run-url="{{ url_for('backup_run_api', backup_id=backup.backup_id) }}"
data-stop-url="{{ url_for('backup_stop_api', backup_id=backup.backup_id) }}"
data-status-url="{{ url_for('backup_status_api', backup_id=backup.backup_id) }}"
>&#9654;</button>
<div class="run-tooltip" role="status" aria-live="polite">
<p class="run-tooltip-title" data-run-title>Bereit</p>
<div class="run-progress"><span data-run-bar></span></div>
<p class="run-tooltip-text" data-run-text>Kein Lauf aktiv</p>
</div>
</span>
<a class="icon-btn" href="{{ url_for('backup_edit', backup_id=backup.backup_id) }}" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</a> <a class="icon-btn" href="{{ url_for('backup_edit', backup_id=backup.backup_id) }}" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</a>
<form method="post" action="{{ url_for('backup_delete', backup_id=backup.backup_id) }}" style="display:inline;" onsubmit="return confirm('Backup wirklich löschen?');"> <form method="post" action="{{ url_for('backup_delete', backup_id=backup.backup_id) }}" style="display:inline;" onsubmit="return confirm('Backup wirklich löschen?');">
<button class="icon-btn icon-btn-danger" type="submit" title="Löschen" aria-label="Löschen">&#128465;</button> <button class="icon-btn icon-btn-danger" type="submit" title="Löschen" aria-label="Löschen">&#128465;</button>
@@ -79,4 +167,179 @@
<p>Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.</p> <p>Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.</p>
</div> </div>
{% endif %} {% endif %}
<script>
(() => {
const fastPollMs = 1200;
const idlePollMs = 10000;
const rows = Array.from(document.querySelectorAll("[data-run-action]"));
if (!rows.length) {
return;
}
let pollTimer = null;
let pollInFlight = false;
const clampPercent = (value) => {
const num = Number(value);
if (!Number.isFinite(num)) return 0;
return Math.max(0, Math.min(100, Math.round(num)));
};
const formatBytes = (value) => {
if (value === null || value === undefined || value === "") {
return "Noch kein Lauf";
}
const num = Number(value);
if (!Number.isFinite(num)) {
return "Unbekannt";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = num;
let unitIdx = 0;
while (size >= 1024 && unitIdx < units.length - 1) {
size /= 1024;
unitIdx += 1;
}
return `${size.toFixed(2)} ${units[unitIdx]}`;
};
const updateRuntimeCells = (container, runtime) => {
if (!runtime) {
return;
}
const row = container.closest("tr");
if (!row) {
return;
}
const lastRunCell = row.querySelector("[data-last-run]");
const lastSizeCell = row.querySelector("[data-last-size]");
const nextRunCell = row.querySelector("[data-next-run]");
if (lastRunCell) {
lastRunCell.textContent = runtime.last_run_at ? String(runtime.last_run_at) : "Noch kein Lauf";
}
if (lastSizeCell) {
lastSizeCell.textContent = formatBytes(runtime.last_size_bytes);
}
if (nextRunCell && runtime.next_run_at !== undefined) {
nextRunCell.textContent = runtime.next_run_at || "Kein automatisches Backup";
}
};
const renderStatus = (container, status) => {
const button = container.querySelector("[data-run-toggle]");
const title = container.querySelector("[data-run-title]");
const text = container.querySelector("[data-run-text]");
const bar = container.querySelector("[data-run-bar]");
const isRunning = !!status?.is_running;
const percent = clampPercent(status?.progress_percent ?? 0);
const progressText = String(status?.progress_text || (isRunning ? "In Progress" : "Bereit"));
const statusMessage = String(status?.status_message || "");
button.dataset.isRunning = isRunning ? "1" : "0";
button.innerHTML = isRunning ? "&#10006;" : "&#9654;";
button.classList.toggle("icon-btn-stop", isRunning);
button.title = isRunning ? "Abbrechen" : "Manuell ausführen";
button.setAttribute("aria-label", isRunning ? "Backup-Lauf abbrechen" : "Manuell ausführen");
title.textContent = `${progressText} (${percent}%)`;
text.textContent = statusMessage || (isRunning ? "Backup läuft..." : "Kein Lauf aktiv");
bar.style.width = `${percent}%`;
};
const fetchStatus = async (container) => {
const button = container.querySelector("[data-run-toggle]");
try {
const response = await fetch(button.dataset.statusUrl, {
method: "GET",
headers: { "Accept": "application/json" },
credentials: "same-origin"
});
if (!response.ok) {
return button.dataset.isRunning === "1";
}
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("application/json")) {
return button.dataset.isRunning === "1";
}
const data = await response.json().catch(() => null);
if (!data || !data.status) {
return button.dataset.isRunning === "1";
}
renderStatus(container, data.status || {});
updateRuntimeCells(container, data.runtime || null);
return !!data.status?.is_running;
} catch (error) {
console.warn("Status-Polling fehlgeschlagen", error);
return button.dataset.isRunning === "1";
}
};
const schedulePoll = (delayMs) => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
pollTimer = window.setTimeout(() => {
void pollStatuses();
}, delayMs);
};
const pollStatuses = async () => {
if (pollInFlight) {
schedulePoll(fastPollMs);
return;
}
pollInFlight = true;
try {
const runningStates = await Promise.all(rows.map((container) => fetchStatus(container)));
const anyRunning = runningStates.some((isRunning) => isRunning === true);
schedulePoll(anyRunning ? fastPollMs : idlePollMs);
} finally {
pollInFlight = false;
}
};
const toggleRun = async (container) => {
const button = container.querySelector("[data-run-toggle]");
const isRunning = button.dataset.isRunning === "1";
const endpoint = isRunning ? button.dataset.stopUrl : button.dataset.runUrl;
button.disabled = true;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"X-Requested-With": "fetch"
},
credentials: "same-origin"
});
const data = await response.json().catch(() => ({}));
if (data.status) {
renderStatus(container, data.status);
}
updateRuntimeCells(container, data.runtime || null);
schedulePoll(fastPollMs);
if (!response.ok && data.message) {
console.warn(data.message);
}
} catch (error) {
console.warn("Run-Aktion fehlgeschlagen", error);
} finally {
button.disabled = false;
}
};
rows.forEach((container) => {
const button = container.querySelector("[data-run-toggle]");
button.addEventListener("click", () => {
void toggleRun(container);
});
void fetchStatus(container);
});
void pollStatuses();
})();
</script>
{% endblock %} {% endblock %}
+1395 -107
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+6 -1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import sys
from pathlib import Path from pathlib import Path
from app.app_runner import AppRunner from app.app_runner import AppRunner
@@ -6,7 +7,11 @@ from app.app_runner import AppRunner
def main() -> None: def main() -> None:
base_dir = Path(__file__).resolve().parent base_dir = Path(__file__).resolve().parent
AppRunner(base_dir=base_dir, entry_script=Path(__file__)).run() runner = AppRunner(base_dir=base_dir, entry_script=Path(__file__))
if "--setup" in sys.argv:
runner.setup()
else:
runner.run()
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -4,3 +4,5 @@ cryptography>=43.0,<44.0
pysmb>=1.2.10,<2.0 pysmb>=1.2.10,<2.0
paramiko>=3.4,<4.0 paramiko>=3.4,<4.0
smbprotocol>=1.13.0,<2.0 smbprotocol>=1.13.0,<2.0
py7zr>=0.22,<1.0
croniter>=1.0,<2.0