feat: Sync/Clone-Modus, Rotation, --setup Deployment, Scheduler-Fix, Dashboard-Updates, README
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.", []
|
||||||
|
|||||||
+656
-81
@@ -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">
|
||||||
<label for="target_pattern">Zielordner / Zieldatei</label>
|
<button type="button" class="btn" onclick="nextStep(0)">Weiter</button>
|
||||||
<input id="target_pattern" name="target_pattern" type="text" value="{{ backup.target_pattern }}" required>
|
|
||||||
|
|
||||||
<label for="compression_method">Kompression</label>
|
|
||||||
<select id="compression_method" name="compression_method" required>
|
|
||||||
{% for method in compression_methods %}
|
|
||||||
<option value="{{ method }}" {% if method == backup.compression_method %}selected{% endif %}>{{ method }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="encryption_mode">Verschlüsselung</label>
|
|
||||||
<select id="encryption_mode" name="encryption_mode" required>
|
|
||||||
{% for mode in encryption_modes %}
|
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
<div class="card">
|
<section class="card wizard-step" data-step="1">
|
||||||
<h3>Zeitplan</h3>
|
<div class="step-header"><h3>Source</h3></div>
|
||||||
<label for="schedule_mode">Ausführung</label>
|
<div class="row row-3">
|
||||||
<select id="schedule_mode" name="schedule_mode" required>
|
<div>
|
||||||
{% for mode in schedule_modes %}
|
<label for="source_protocol">Typ</label>
|
||||||
<option value="{{ mode }}" {% if mode == backup.schedule_mode %}selected{% endif %}>{{ mode }}</option>
|
<select id="source_protocol" name="source_protocol" required>
|
||||||
{% endfor %}
|
{% for p in target_protocols %}<option value="{{ p }}" {% if p == backup.source_protocol %}selected{% endif %}>{{ p }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
<div id="schedule_time_box">
|
<div id="source_host_col" style="display:none;">
|
||||||
<label for="schedule_time">Uhrzeit</label>
|
<label for="source_host">Host/IP</label>
|
||||||
<input id="schedule_time" name="schedule_time" type="time" value="{{ backup.schedule_time or '02:00' }}">
|
<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>
|
||||||
|
|
||||||
<div id="schedule_weekday_box" style="display:none;">
|
<div id="source_local_browser_overlay" class="browser-overlay">
|
||||||
<label for="schedule_weekday">Wochentag</label>
|
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
|
||||||
<select id="schedule_weekday" name="schedule_weekday">
|
<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>
|
||||||
<option value="1" {% if backup.schedule_weekday == '1' %}selected{% endif %}>Montag</option>
|
<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>
|
||||||
<option value="2" {% if backup.schedule_weekday == '2' %}selected{% endif %}>Dienstag</option>
|
<div class="browser-dialog-actions">
|
||||||
<option value="3" {% if backup.schedule_weekday == '3' %}selected{% endif %}>Mittwoch</option>
|
<div></div>
|
||||||
<option value="4" {% if backup.schedule_weekday == '4' %}selected{% endif %}>Donnerstag</option>
|
<div class="browser-dialog-right">
|
||||||
<option value="5" {% if backup.schedule_weekday == '5' %}selected{% endif %}>Freitag</option>
|
<button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('source')">Abbrechen</button>
|
||||||
<option value="6" {% if backup.schedule_weekday == '6' %}selected{% endif %}>Samstag</option>
|
<button type="button" class="btn" onclick="acceptLocalBrowserSelection('source')">Auswählen</button>
|
||||||
<option value="0" {% if backup.schedule_weekday == '0' %}selected{% endif %}>Sonntag</option>
|
</div>
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div id="schedule_dom_box" style="display:none;">
|
<div id="destination_local_browser_overlay" class="browser-overlay">
|
||||||
<label for="schedule_day_of_month">Tag im Monat</label>
|
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
|
||||||
<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 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()">✕</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()">✕</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>
|
||||||
|
<div id="token_context_menu" class="token-menu" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="schedule_interval_box" style="display:none;">
|
<div class="row" style="grid-template-columns: 1fr 120px 1fr;">
|
||||||
<label for="schedule_interval">Intervall (X)</label>
|
<div>
|
||||||
<input id="schedule_interval" name="schedule_interval" type="number" min="1" value="{{ backup.schedule_interval or 1 }}">
|
<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>
|
||||||
|
<select id="compression_method" name="compression_method" required>
|
||||||
|
{% 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 %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="schedule_cron_box" style="display:none;">
|
<div id="encryption_row" class="row" style="display:none; grid-template-columns: 1fr 1fr 1fr;">
|
||||||
<label for="schedule_cron">CRON Ausdruck</label>
|
<div>
|
||||||
<input id="schedule_cron" name="schedule_cron" type="text" value="{{ backup.schedule_cron or '' }}">
|
<label for="encryption_mode">Verschlüsselung</label>
|
||||||
|
<select id="encryption_mode" name="encryption_mode" required>
|
||||||
|
{% for m in encryption_modes %}<option value="{{ m }}" {% if m == backup.encryption_mode %}selected{% endif %}>{{ m }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="archive_pwd_col" style="display:none;">
|
||||||
|
<label for="archive_password">Passwort</label>
|
||||||
|
<input id="archive_password" name="archive_password" type="password" value="{{ backup.archive_password }}">
|
||||||
|
</div>
|
||||||
|
<div id="archive_pwd2_col" style="display:none;">
|
||||||
|
<label for="archive_password_confirm">Bestätigen</label>
|
||||||
|
<input id="archive_password_confirm" name="archive_password_confirm" type="password" value="{{ backup.archive_password }}">
|
||||||
|
</div>
|
||||||
</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 class="step-actions">
|
<section class="card wizard-step" data-step="4">
|
||||||
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}" style="text-decoration:none;">Abbrechen</a>
|
<div class="step-header"><h3>Zeitplan</h3></div>
|
||||||
<button class="btn" type="submit">Speichern</button>
|
<label for="schedule_cron">CRON Ausdruck</label>
|
||||||
</div>
|
<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 id="cron_error" style="display:none;margin-top:6px;font-size:13px;color:#b9382f;"></div>
|
||||||
|
<div class="step-actions">
|
||||||
|
<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>
|
||||||
|
</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('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",'''); }
|
||||||
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)+' · '+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 %}
|
||||||
|
|||||||
+849
-265
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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">▶</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) }}"
|
||||||
|
>▶</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">✎</a>
|
<a class="icon-btn" href="{{ url_for('backup_edit', backup_id=backup.backup_id) }}" title="Bearbeiten" aria-label="Bearbeiten">✎</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">🗑</button>
|
<button class="icon-btn icon-btn-danger" type="submit" title="Löschen" aria-label="Löschen">🗑</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 ? "✖" : "▶";
|
||||||
|
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 %}
|
||||||
|
|||||||
+1437
-149
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user