- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table) - Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens - Active sessions management: list/revoke via /api/settings/sessions with device info - Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project) - Automatic daily backups: backup_db(), prune, scheduler + admin API - Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects() - GitHubAdapter implements ForgeAdapter contract, transport injection for mocking - Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs - Linting config: ruff (Python) + eslint (JS) - Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky) - Bumped version to 5.9.1 Co-authored-by: Bruno <[email protected]>
112 lines
3.2 KiB
Python
112 lines
3.2 KiB
Python
"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import shutil
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _backup_dir() -> Path:
|
|
d = Path(settings.backup_dir)
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def _db_path() -> Path:
|
|
return settings.db_path
|
|
|
|
|
|
def backup_db(now: datetime | None = None) -> str | None:
|
|
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
|
|
|
|
Returns the backup filename, or None when backup is disabled or the
|
|
database file does not exist.
|
|
"""
|
|
if not settings.backup_enabled:
|
|
return None
|
|
db_path = _db_path()
|
|
if str(db_path) == ":memory:" or not Path(db_path).is_file():
|
|
return None
|
|
|
|
now = now or datetime.now()
|
|
# Checkpoint the WAL so the backup is consistent.
|
|
try:
|
|
import sqlite3
|
|
with sqlite3.connect(str(db_path)) as conn:
|
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
except Exception:
|
|
pass
|
|
|
|
dest_dir = _backup_dir()
|
|
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
|
|
dest = dest_dir / filename
|
|
shutil.copy2(db_path, dest)
|
|
|
|
# Prune old backups, keeping ``backup_keep`` most recent files.
|
|
prune_old_backups()
|
|
logger.info("Backup created: %s", dest)
|
|
return filename
|
|
|
|
|
|
def list_backups() -> list[dict]:
|
|
"""List existing backup files (name, size bytes, mtime)."""
|
|
files = []
|
|
for p in _backup_dir().glob("flowdeck-*.db"):
|
|
stat = p.stat()
|
|
files.append({
|
|
"filename": p.name,
|
|
"size": stat.st_size,
|
|
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
})
|
|
files.sort(key=lambda f: f["filename"], reverse=True)
|
|
return files
|
|
|
|
|
|
def prune_old_backups(keep: int | None = None) -> int:
|
|
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
|
|
keep = keep if keep is not None else settings.backup_keep
|
|
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
|
|
removed = 0
|
|
for p in files[keep:]:
|
|
try:
|
|
p.unlink()
|
|
removed += 1
|
|
except OSError:
|
|
logger.warning("Could not prune backup %s", p)
|
|
return removed
|
|
|
|
|
|
def last_backup_age_hours() -> float | None:
|
|
"""Hours since the most recent backup (None if none exists)."""
|
|
files = list(_backup_dir().glob("flowdeck-*.db"))
|
|
if not files:
|
|
return None
|
|
newest = max(files, key=lambda p: p.stat().st_mtime)
|
|
age = time.time() - newest.stat().st_mtime
|
|
return age / 3600
|
|
|
|
|
|
def backup_due() -> bool:
|
|
"""True when a backup should run now (interval elapsed since last one)."""
|
|
age = last_backup_age_hours()
|
|
if age is None:
|
|
return True
|
|
return age >= settings.backup_interval_hours
|
|
|
|
|
|
async def backup_scheduler():
|
|
"""Background loop: run a backup once per interval (default daily)."""
|
|
while True:
|
|
try:
|
|
if backup_due():
|
|
backup_db()
|
|
except Exception as exc: # never let the loop die
|
|
logger.warning("backup_scheduler error: %s", exc)
|
|
await __import__("asyncio").sleep(3600) # re-check hourly
|