feat: Sync/Clone-Modus, Rotation, --setup Deployment, Scheduler-Fix, Dashboard-Updates, README
This commit is contained in:
+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,))
|
||||
|
||||
Reference in New Issue
Block a user