feat: Sync/Clone-Modus, Rotation, --setup Deployment, Scheduler-Fix, Dashboard-Updates, README
This commit is contained in:
+62
-3
@@ -11,9 +11,8 @@ class AppRunner:
|
||||
self.paths = AppPaths(base_dir=base_dir)
|
||||
self.entry_script = entry_script
|
||||
|
||||
def run(self) -> None:
|
||||
ConsoleUI.headline("StFV Backup Bootstrap")
|
||||
|
||||
def _bootstrap(self) -> None:
|
||||
"""Ensure venv and dependencies are ready (re-execs into venv if needed)."""
|
||||
venv = VirtualEnvManager(self.paths.venv_dir, self.entry_script)
|
||||
if not venv.in_virtualenv():
|
||||
ConsoleUI.info("Virtuelle Umgebung wird vorbereitet...")
|
||||
@@ -26,6 +25,66 @@ class AppRunner:
|
||||
else:
|
||||
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.setup_wizard import SetupWizard
|
||||
from app.web_server import FlaskServer
|
||||
|
||||
+10
-5
@@ -53,11 +53,9 @@ class BackupJob:
|
||||
encryption_mode: str
|
||||
archive_password: str
|
||||
schedule_mode: str
|
||||
schedule_time: str
|
||||
schedule_weekday: str
|
||||
schedule_day_of_month: str
|
||||
schedule_interval: int | None
|
||||
schedule_cron: str
|
||||
backup_mode: str
|
||||
rotation_keep: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -78,6 +76,12 @@ class BackupExecutionJob:
|
||||
destination_subpath: str
|
||||
destination_username: 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)
|
||||
@@ -88,6 +92,7 @@ class BackupSummary:
|
||||
destination_label: str
|
||||
target_pattern: str
|
||||
compression_method: str
|
||||
schedule_mode: str
|
||||
schedule_cron: str
|
||||
next_run_at: str | None
|
||||
last_run_at: str | None
|
||||
last_size_bytes: int | None
|
||||
|
||||
+283
-72
@@ -69,12 +69,10 @@ class ConfigStore:
|
||||
compression_method TEXT NOT NULL,
|
||||
encryption_mode TEXT NOT NULL DEFAULT 'none',
|
||||
archive_password_enc TEXT NOT NULL,
|
||||
schedule_mode TEXT NOT NULL DEFAULT 'daily',
|
||||
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_mode TEXT NOT NULL DEFAULT 'custom',
|
||||
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_size_bytes INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -87,6 +85,12 @@ class ConfigStore:
|
||||
row["name"].lower()
|
||||
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:
|
||||
conn.execute("ALTER TABLE backups ADD COLUMN source_port INTEGER")
|
||||
if "destination_port" not in backup_columns:
|
||||
@@ -98,17 +102,13 @@ class ConfigStore:
|
||||
if "encryption_mode" not in backup_columns:
|
||||
conn.execute("ALTER TABLE backups ADD COLUMN encryption_mode TEXT NOT NULL DEFAULT 'none'")
|
||||
if "schedule_mode" not in backup_columns:
|
||||
conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'daily'")
|
||||
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")
|
||||
conn.execute("ALTER TABLE backups ADD COLUMN schedule_mode TEXT NOT NULL DEFAULT 'custom'")
|
||||
if "schedule_cron" not in backup_columns:
|
||||
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 = {
|
||||
row["name"].lower()
|
||||
@@ -126,6 +126,109 @@ class ConfigStore:
|
||||
|
||||
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:
|
||||
with self._connect() as conn:
|
||||
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
@@ -302,14 +405,12 @@ class ConfigStore:
|
||||
encryption_mode,
|
||||
archive_password_enc,
|
||||
schedule_mode,
|
||||
schedule_time,
|
||||
schedule_weekday,
|
||||
schedule_day_of_month,
|
||||
schedule_interval,
|
||||
schedule_cron,
|
||||
backup_mode,
|
||||
rotation_keep,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
backup.name,
|
||||
@@ -334,11 +435,9 @@ class ConfigStore:
|
||||
backup.encryption_mode,
|
||||
self.crypto.encrypt_text(backup.archive_password),
|
||||
backup.schedule_mode,
|
||||
backup.schedule_time,
|
||||
backup.schedule_weekday,
|
||||
backup.schedule_day_of_month,
|
||||
backup.schedule_interval,
|
||||
backup.schedule_cron,
|
||||
backup.backup_mode,
|
||||
backup.rotation_keep,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -346,6 +445,19 @@ class ConfigStore:
|
||||
except sqlite3.IntegrityError:
|
||||
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]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -365,7 +477,7 @@ class ConfigStore:
|
||||
destination_subpath,
|
||||
target_pattern,
|
||||
compression_method,
|
||||
schedule_mode,
|
||||
schedule_cron,
|
||||
last_run_at,
|
||||
last_size_bytes
|
||||
FROM backups
|
||||
@@ -403,7 +515,8 @@ class ConfigStore:
|
||||
destination_label=destination_label,
|
||||
target_pattern=row["target_pattern"],
|
||||
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_size_bytes=row["last_size_bytes"],
|
||||
)
|
||||
@@ -411,6 +524,118 @@ class ConfigStore:
|
||||
|
||||
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:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
@@ -422,10 +647,6 @@ class ConfigStore:
|
||||
compression_method,
|
||||
encryption_mode,
|
||||
schedule_mode,
|
||||
schedule_time,
|
||||
schedule_weekday,
|
||||
schedule_day_of_month,
|
||||
schedule_interval,
|
||||
schedule_cron
|
||||
FROM backups
|
||||
WHERE id = ?
|
||||
@@ -443,10 +664,6 @@ class ConfigStore:
|
||||
"compression_method": row["compression_method"],
|
||||
"encryption_mode": row["encryption_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"],
|
||||
}
|
||||
|
||||
@@ -455,22 +672,13 @@ class ConfigStore:
|
||||
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
|
||||
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, backup_mode, rotation_keep
|
||||
FROM backups
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -497,6 +705,12 @@ class ConfigStore:
|
||||
destination_subpath=row["destination_subpath"],
|
||||
destination_username=row["destination_username"],
|
||||
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(
|
||||
@@ -508,10 +722,6 @@ class ConfigStore:
|
||||
encryption_mode: str,
|
||||
archive_password: str,
|
||||
schedule_mode: str,
|
||||
schedule_time: str,
|
||||
schedule_weekday: str,
|
||||
schedule_day_of_month: str,
|
||||
schedule_interval: int | None,
|
||||
schedule_cron: str,
|
||||
) -> bool:
|
||||
with self._connect() as conn:
|
||||
@@ -527,10 +737,6 @@ class ConfigStore:
|
||||
encryption_mode = ?,
|
||||
archive_password_enc = ?,
|
||||
schedule_mode = ?,
|
||||
schedule_time = ?,
|
||||
schedule_weekday = ?,
|
||||
schedule_day_of_month = ?,
|
||||
schedule_interval = ?,
|
||||
schedule_cron = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
@@ -542,10 +748,6 @@ class ConfigStore:
|
||||
encryption_mode,
|
||||
self.crypto.encrypt_text(archive_password),
|
||||
schedule_mode,
|
||||
schedule_time,
|
||||
schedule_weekday,
|
||||
schedule_day_of_month,
|
||||
schedule_interval,
|
||||
schedule_cron,
|
||||
backup_id,
|
||||
),
|
||||
@@ -560,10 +762,6 @@ class ConfigStore:
|
||||
compression_method = ?,
|
||||
encryption_mode = ?,
|
||||
schedule_mode = ?,
|
||||
schedule_time = ?,
|
||||
schedule_weekday = ?,
|
||||
schedule_day_of_month = ?,
|
||||
schedule_interval = ?,
|
||||
schedule_cron = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
@@ -574,10 +772,6 @@ class ConfigStore:
|
||||
compression_method,
|
||||
encryption_mode,
|
||||
schedule_mode,
|
||||
schedule_time,
|
||||
schedule_weekday,
|
||||
schedule_day_of_month,
|
||||
schedule_interval,
|
||||
schedule_cron,
|
||||
backup_id,
|
||||
),
|
||||
@@ -587,22 +781,39 @@ class ConfigStore:
|
||||
except sqlite3.IntegrityError:
|
||||
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:
|
||||
result = conn.execute(
|
||||
"""
|
||||
UPDATE backups
|
||||
SET
|
||||
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
|
||||
WHERE id = ?
|
||||
""",
|
||||
(backup_id,),
|
||||
(safe_size, backup_id),
|
||||
)
|
||||
conn.commit()
|
||||
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:
|
||||
with self._connect() as conn:
|
||||
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":
|
||||
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 False, "Lokaler Pfad ist nicht erreichbar oder kein Verzeichnis."
|
||||
return False, "Lokaler Pfad nicht gefunden oder nicht zugänglich."
|
||||
|
||||
if protocol == "SMB":
|
||||
if not target.share:
|
||||
@@ -271,7 +271,13 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]:
|
||||
try:
|
||||
ftp.connect(target.host, target.port or 21, timeout=8)
|
||||
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."
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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.connect(username=target.username, password=target.password)
|
||||
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."
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, f"SFTP-Test fehlgeschlagen: {exc}"
|
||||
@@ -301,6 +313,84 @@ def test_connection(target: TargetConnection) -> tuple[bool, str]:
|
||||
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]]]:
|
||||
if target.protocol.upper() != "SMB":
|
||||
return False, "SMB-Browser ist nur fuer SMB verfuegbar.", []
|
||||
|
||||
+656
-81
@@ -3,107 +3,682 @@
|
||||
{% block title %}StFV Backup - Backup bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Backup bearbeiten</h2>
|
||||
<p>Hier kannst du Name, Ziel, Verschlüsselung und Zeitplan anpassen.</p>
|
||||
<h2 style="margin-bottom:4px;">Backup bearbeiten</h2>
|
||||
|
||||
<form method="post" action="{{ url_for('backup_edit', backup_id=backup.id) }}">
|
||||
<div class="card">
|
||||
<label for="name">Name</label>
|
||||
<style>
|
||||
.wizard-step { display: none; }
|
||||
.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>
|
||||
|
||||
<label for="target_pattern">Zielordner / Zieldatei</label>
|
||||
<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 class="step-actions">
|
||||
<button type="button" class="btn" onclick="nextStep(0)">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<h3>Zeitplan</h3>
|
||||
<label for="schedule_mode">Ausführung</label>
|
||||
<select id="schedule_mode" name="schedule_mode" required>
|
||||
{% for mode in schedule_modes %}
|
||||
<option value="{{ mode }}" {% if mode == backup.schedule_mode %}selected{% endif %}>{{ mode }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div id="schedule_time_box">
|
||||
<label for="schedule_time">Uhrzeit</label>
|
||||
<input id="schedule_time" name="schedule_time" type="time" value="{{ backup.schedule_time or '02:00' }}">
|
||||
<section class="card wizard-step" data-step="1">
|
||||
<div class="step-header"><h3>Source</h3></div>
|
||||
<div class="row row-3">
|
||||
<div>
|
||||
<label for="source_protocol">Typ</label>
|
||||
<select id="source_protocol" name="source_protocol" required>
|
||||
{% for p in target_protocols %}<option value="{{ p }}" {% if p == backup.source_protocol %}selected{% endif %}>{{ p }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="source_host_col" style="display:none;">
|
||||
<label for="source_host">Host/IP</label>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<input id="source_host" name="source_host" type="text" value="{{ backup.source_host }}" style="flex:1; margin-bottom:0;">
|
||||
<input id="source_port_ui" type="number" min="1" max="65535" placeholder="Port"
|
||||
title="Port" value="{{ backup.source_port or '' }}" style="width:72px; margin-bottom:0; display:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="source_share_row" style="display:none;">
|
||||
<label for="source_share">Freigabe</label>
|
||||
<input id="source_share" name="source_share" type="text" value="{{ backup.source_share }}">
|
||||
</div>
|
||||
<div id="source_local_path_col" style="display:none; grid-column: 2 / -1;">
|
||||
<label for="source_local_path">Lokaler Pfad</label>
|
||||
<div class="path-input-group">
|
||||
<input id="source_local_path" type="text" value="{{ backup.source_local_path }}">
|
||||
<button type="button" class="btn-browse" onclick="openLocalBrowserOverlay('source')" title="Browser öffnen">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="schedule_weekday_box" style="display:none;">
|
||||
<label for="schedule_weekday">Wochentag</label>
|
||||
<select id="schedule_weekday" name="schedule_weekday">
|
||||
<option value="1" {% if backup.schedule_weekday == '1' %}selected{% endif %}>Montag</option>
|
||||
<option value="2" {% if backup.schedule_weekday == '2' %}selected{% endif %}>Dienstag</option>
|
||||
<option value="3" {% if backup.schedule_weekday == '3' %}selected{% endif %}>Mittwoch</option>
|
||||
<option value="4" {% if backup.schedule_weekday == '4' %}selected{% endif %}>Donnerstag</option>
|
||||
<option value="5" {% if backup.schedule_weekday == '5' %}selected{% endif %}>Freitag</option>
|
||||
<option value="6" {% if backup.schedule_weekday == '6' %}selected{% endif %}>Samstag</option>
|
||||
<option value="0" {% if backup.schedule_weekday == '0' %}selected{% endif %}>Sonntag</option>
|
||||
</select>
|
||||
<div id="source_local_browser_overlay" class="browser-overlay">
|
||||
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
|
||||
<div class="browser-dialog-header"><strong>Lokaler Browser</strong><label class="hidden-toggle"><input type="checkbox" id="source_local_show_hidden" onchange="refreshLocalBrowserLevel('source')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('source')">×</button></div>
|
||||
<div class="browser-dialog-body"><div id="source_local_browser_meta" class="tree-meta"></div><ul id="source_local_browser_tree" class="tree"></ul></div>
|
||||
<div class="browser-dialog-actions">
|
||||
<div></div>
|
||||
<div class="browser-dialog-right">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('source')">Abbrechen</button>
|
||||
<button type="button" class="btn" onclick="acceptLocalBrowserSelection('source')">Auswählen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="source_remote_fields" style="display:none;">
|
||||
<div class="row">
|
||||
<div><label for="source_username">Benutzer</label><input id="source_username" name="source_username" type="text" value="{{ backup.source_username }}"></div>
|
||||
<div><label for="source_password">Passwort</label><input id="source_password" name="source_password" type="password" value="{{ backup.source_password }}"></div>
|
||||
</div>
|
||||
<label for="source_subpath">Pfad</label>
|
||||
<div class="path-input-group">
|
||||
<input id="source_subpath" name="source_subpath" type="text" value="{{ backup.source_subpath }}">
|
||||
<button type="button" class="btn-browse" onclick="browseSmb('source')" title="Browser öffnen">🔍</button>
|
||||
</div>
|
||||
<p id="source_test_message"></p>
|
||||
<div id="source_browser_overlay" class="browser-overlay">
|
||||
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Browser">
|
||||
<div class="browser-dialog-header"><strong>Browser</strong><label class="hidden-toggle"><input type="checkbox" id="source_smb_show_hidden" onchange="refreshBrowserLevel('source')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('source')">×</button></div>
|
||||
<div class="browser-dialog-body"><div id="source_browser_meta" class="tree-meta"></div><ul id="source_browser_tree" class="tree"></ul></div>
|
||||
<div class="browser-dialog-actions">
|
||||
<div></div>
|
||||
<div class="browser-dialog-right">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeBrowserOverlay('source')">Abbrechen</button>
|
||||
<button type="button" class="btn" onclick="acceptBrowserSelection('source')">Auswählen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="prevStep(1)">Zurück</button>
|
||||
<button type="button" class="btn" onclick="nextStep(1)">Weiter</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card wizard-step" data-step="2">
|
||||
<div class="step-header"><h3>Destination</h3></div>
|
||||
<div class="row row-3">
|
||||
<div>
|
||||
<label for="destination_protocol">Typ</label>
|
||||
<select id="destination_protocol" name="destination_protocol" required>
|
||||
{% for p in target_protocols %}<option value="{{ p }}" {% if p == backup.destination_protocol %}selected{% endif %}>{{ p }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="destination_host_col" style="display:none;">
|
||||
<label for="destination_host">Host/IP</label>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<input id="destination_host" name="destination_host" type="text" value="{{ backup.destination_host }}" style="flex:1; margin-bottom:0;">
|
||||
<input id="destination_port_ui" type="number" min="1" max="65535" placeholder="Port"
|
||||
title="Port" value="{{ backup.destination_port or '' }}" style="width:72px; margin-bottom:0; display:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="destination_share_row" style="display:none;">
|
||||
<label for="destination_share">Freigabe</label>
|
||||
<input id="destination_share" name="destination_share" type="text" value="{{ backup.destination_share }}">
|
||||
</div>
|
||||
<div id="destination_local_path_col" style="display:none; grid-column: 2 / -1;">
|
||||
<label for="destination_local_path">Lokaler Pfad</label>
|
||||
<div class="path-input-group">
|
||||
<input id="destination_local_path" type="text" value="{{ backup.destination_local_path }}">
|
||||
<button type="button" class="btn-browse" onclick="openLocalBrowserOverlay('destination')" title="Browser öffnen">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="schedule_dom_box" style="display:none;">
|
||||
<label for="schedule_day_of_month">Tag im Monat</label>
|
||||
<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 id="destination_local_browser_overlay" class="browser-overlay">
|
||||
<div class="browser-dialog" role="dialog" aria-modal="true" aria-label="Lokaler Browser">
|
||||
<div class="browser-dialog-header"><strong>Lokaler Browser</strong><label class="hidden-toggle"><input type="checkbox" id="destination_local_show_hidden" onchange="refreshLocalBrowserLevel('destination')"> Versteckte</label><button type="button" class="btn btn-secondary" onclick="closeLocalBrowserOverlay('destination')">×</button></div>
|
||||
<div class="browser-dialog-body"><div id="destination_local_browser_meta" class="tree-meta"></div><ul id="destination_local_browser_tree" class="tree"></ul></div>
|
||||
<div class="browser-dialog-actions">
|
||||
<div id="destination_local_new_folder_bar" style="display:none; flex:1; align-items:center; gap:6px;">
|
||||
<input id="destination_local_new_folder" type="text" placeholder="Ordnername" autocomplete="off" style="flex:1; min-width:0; margin:0;">
|
||||
<button class="btn" type="button" onclick="createLocalDestinationFolder()">Erstellen</button>
|
||||
<button class="btn btn-secondary" type="button" onclick="closeLocalCreateFolderOverlay()">✕</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 id="schedule_interval_box" style="display:none;">
|
||||
<label for="schedule_interval">Intervall (X)</label>
|
||||
<input id="schedule_interval" name="schedule_interval" type="number" min="1" value="{{ backup.schedule_interval or 1 }}">
|
||||
<div class="row" style="grid-template-columns: 1fr 120px 1fr;">
|
||||
<div>
|
||||
<label for="backup_mode">Backup-Modus</label>
|
||||
<select id="backup_mode" name="backup_mode" onchange="syncBackupMode()">
|
||||
{% for mode in backup_modes %}
|
||||
<option value="{{ mode }}" {% if mode == backup.backup_mode %}selected{% endif %}>{% if mode == 'full' %}Vollständig{% elif mode == 'sync' %}Sync{% else %}Clone{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="rotation_col">
|
||||
<label for="rotation_keep">Rotation (0=∞)</label>
|
||||
<input id="rotation_keep" name="rotation_keep" type="number" min="0" value="{{ backup.rotation_keep }}" style="width:100%;">
|
||||
</div>
|
||||
<div id="compression_col">
|
||||
<label for="compression_method">Kompression</label>
|
||||
<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 id="schedule_cron_box" style="display:none;">
|
||||
<label for="schedule_cron">CRON Ausdruck</label>
|
||||
<input id="schedule_cron" name="schedule_cron" type="text" value="{{ backup.schedule_cron or '' }}">
|
||||
<div id="encryption_row" class="row" style="display:none; grid-template-columns: 1fr 1fr 1fr;">
|
||||
<div>
|
||||
<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 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">
|
||||
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}" style="text-decoration:none;">Abbrechen</a>
|
||||
<button class="btn" type="submit">Speichern</button>
|
||||
</div>
|
||||
<section class="card wizard-step" data-step="4">
|
||||
<div class="step-header"><h3>Zeitplan</h3></div>
|
||||
<label for="schedule_cron">CRON Ausdruck</label>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
function syncEncryptionFields() {
|
||||
const mode = document.getElementById('encryption_mode').value;
|
||||
const box = document.getElementById('password_box');
|
||||
box.style.display = mode === 'password-aes256' ? 'block' : 'none';
|
||||
let currentStep = 0;
|
||||
function htmlEscape(v) { return String(v).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",'''); }
|
||||
|
||||
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() {
|
||||
const mode = document.getElementById('schedule_mode').value;
|
||||
document.getElementById('schedule_weekday_box').style.display = mode === 'weekly' || mode === 'every_n_weeks' ? 'block' : 'none';
|
||||
document.getElementById('schedule_dom_box').style.display = mode === 'monthly' || mode === 'yearly' ? 'block' : 'none';
|
||||
document.getElementById('schedule_interval_box').style.display = mode === 'every_n_days' || mode === 'every_n_weeks' ? 'block' : 'none';
|
||||
document.getElementById('schedule_cron_box').style.display = mode === 'custom' ? 'block' : 'none';
|
||||
function collectPayload(prefix) {
|
||||
const protocol = document.getElementById(prefix+'_protocol').value;
|
||||
const payload = { prefix, [prefix+'_protocol']: protocol,
|
||||
[prefix+'_host']: document.getElementById(prefix+'_host').value,
|
||||
[prefix+'_share']: document.getElementById(prefix+'_share').value,
|
||||
[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);
|
||||
document.getElementById('schedule_mode').addEventListener('change', syncScheduleFields);
|
||||
syncEncryptionFields();
|
||||
syncScheduleFields();
|
||||
function setSourceEntryType(t) {
|
||||
const type = t==='file'?'file':'directory';
|
||||
document.getElementById('source_entry_type').value = type;
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
+849
-265
File diff suppressed because it is too large
Load Diff
@@ -101,7 +101,21 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
@@ -109,6 +123,8 @@
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 10px 24px rgba(30, 42, 59, 0.16);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
@@ -240,11 +256,18 @@
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<section class="panel content messages">
|
||||
<div id="flash-overlay" class="flash-overlay" role="status" aria-live="polite">
|
||||
{% for category, message in messages %}
|
||||
<p class="msg {{ category }}">{{ message }}</p>
|
||||
{% 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 %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-action {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -34,6 +40,72 @@
|
||||
color: #b9382f;
|
||||
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>
|
||||
|
||||
<h2>Eingerichtete Backups</h2>
|
||||
@@ -49,6 +121,7 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Zeitplan</th>
|
||||
<th>Nächster Lauf</th>
|
||||
<th>Letzter Lauf</th>
|
||||
<th>Größe</th>
|
||||
<th>Aktionen</th>
|
||||
@@ -58,13 +131,28 @@
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td><strong>{{ backup.name }}</strong></td>
|
||||
<td>{{ backup.schedule_mode }}</td>
|
||||
<td>{{ backup.last_run_at | fmt_dt }}</td>
|
||||
<td>{{ backup.last_size_bytes | fmt_bytes }}</td>
|
||||
<td>{{ backup.schedule_cron or '–' }}</td>
|
||||
<td data-next-run>{% if backup.schedule_cron %}{{ backup.next_run_at | fmt_dt }}{% else %}Kein automatisches Backup{% endif %}</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">
|
||||
<form method="post" action="{{ url_for('backup_run', backup_id=backup.backup_id) }}" style="display:inline;">
|
||||
<button class="icon-btn" type="submit" title="Manuell ausführen" aria-label="Manuell ausführen">▶</button>
|
||||
</form>
|
||||
<span class="run-action" data-run-action data-backup-id="{{ backup.backup_id }}">
|
||||
<button
|
||||
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>
|
||||
<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>
|
||||
@@ -79,4 +167,179 @@
|
||||
<p>Noch keine Backups eingerichtet. Lege jetzt den ersten Job an.</p>
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
+1437
-149
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user