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

This commit is contained in:
Björn Nehlsen
2026-08-11 22:45:58 +02:00
parent 2eee55384e
commit a495570b20
14 changed files with 3851 additions and 681 deletions
+283 -72
View File
@@ -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,))