# Backup Monitoring Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a standalone backup monitoring system that receives HTTP reports from an external backup tool, stores job/run/alert data in SQLite, and provides a React frontend with validation, alerting, and charts. **Architecture:** FastAPI backend with dedicated SQLite tables (`backup_jobs`, `backup_runs`, `backup_alerts`), background poller for missed schedule detection, React frontend with TanStack Query and D3 charts, Bearer token auth for the backup tool API. **Tech Stack:** FastAPI, SQLite (WAL), Pydantic, React, TypeScript, TanStack Query, D3, MUI --- ## File Structure ### Backend | File | Responsibility | |------|--------------| | `backend/src/media_library_viewer_api/services/settings_store.py` | Add CRUD methods for backup tables | | `backend/src/media_library_viewer_api/models/backups.py` | Pydantic models for request/response validation | | `backend/src/media_library_viewer_api/services/backup_alert_engine.py` | Alert generation logic (failed, missed, anomaly) | | `backend/src/media_library_viewer_api/services/backup_poller.py` | Background poller for missed schedule checks | | `backend/src/media_library_viewer_api/routers/backups.py` | FastAPI router with all backup endpoints | | `backend/src/media_library_viewer_api/routers/dashboard.py` | Add `/api/dashboard/backups` endpoint | | `backend/src/media_library_viewer_api/main.py` | Register backup router and start poller | | `backend/src/media_library_viewer_api/auth.py` | Add API key auth dependency | | `backend/tests/test_backups.py` | API and integration tests | ### Frontend | File | Responsibility | |------|--------------| | `frontend/src/types/backups.ts` | TypeScript interfaces for backup data | | `frontend/src/hooks/useBackups.ts` | TanStack Query hooks for backup API | | `frontend/src/api/backups.ts` | API client functions | | `frontend/src/components/BackupsPage.tsx` | Main backups page with tabs | | `frontend/src/components/BackupJobsTable.tsx` | Jobs list table | | `frontend/src/components/BackupRunsTable.tsx` | Runs list table with filters | | `frontend/src/components/BackupAlertsTable.tsx` | Alerts table with acknowledge | | `frontend/src/components/BackupDashboardWidget.tsx` | Dashboard summary widget | | `frontend/src/App.tsx` | Add `/backups` route and nav tab | --- ## Task 1: Database Schema **Files:** - Modify: `backend/src/media_library_viewer_api/services/settings_store.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_backup_schema_created(): store = get_settings_store() store.init_schema() with store.connect() as conn: tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'backup_%'").fetchall() table_names = [t[0] for t in tables] assert "backup_jobs" in table_names assert "backup_runs" in table_names assert "backup_alerts" in table_names ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_backup_schema_created -v` Expected: FAIL — tables do not exist - [ ] **Step 3: Add schema creation to `init_schema`** In `backend/src/media_library_viewer_api/services/settings_store.py`, add to the `init_schema` method (after existing table creation): ```python def init_schema(self) -> None: # ... existing code ... with self.connect() as conn: # ... existing tables ... conn.execute(""" CREATE TABLE IF NOT EXISTS backup_jobs ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, source TEXT, target TEXT, schedule_interval_seconds INTEGER, created_at INTEGER NOT NULL ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS backup_runs ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL, started_at INTEGER NOT NULL, ended_at INTEGER, status TEXT NOT NULL, bytes_transferred INTEGER, duration_ms INTEGER, error_message TEXT, details_json TEXT, created_at INTEGER NOT NULL, FOREIGN KEY (job_id) REFERENCES backup_jobs(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_runs_job_id ON backup_runs(job_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_runs_status ON backup_runs(status)") conn.execute(""" CREATE TABLE IF NOT EXISTS backup_alerts ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL, run_id TEXT, alert_type TEXT NOT NULL, severity TEXT NOT NULL, message TEXT NOT NULL, acknowledged INTEGER NOT NULL DEFAULT 0, resolved_at INTEGER, created_at INTEGER NOT NULL, FOREIGN KEY (job_id) REFERENCES backup_jobs(id), FOREIGN KEY (run_id) REFERENCES backup_runs(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)") ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_backup_schema_created -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/media_library_viewer_api/services/settings_store.py backend/tests/test_backups.py git commit -m "feat: add backup monitoring database schema" ``` --- ## Task 2: Pydantic Models **Files:** - Create: `backend/src/media_library_viewer_api/models/backups.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_backup_report_model(): from media_library_viewer_api.models.backups import BackupReportRequest report = BackupReportRequest( name="test-backup", started_at="2026-05-11T02:00:00Z", status="success", ended_at="2026-05-11T02:15:00Z", duration_ms=900000, bytes_transferred=1024, ) assert report.name == "test-backup" assert report.status == "success" ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_backup_report_model -v` Expected: FAIL — module not found - [ ] **Step 3: Create Pydantic models** Create `backend/src/media_library_viewer_api/models/backups.py`: ```python from datetime import datetime from typing import Any from pydantic import BaseModel, Field, field_validator class BackupReportRequest(BaseModel): name: str = Field(..., min_length=1) source: str | None = None target: str | None = None schedule_interval_seconds: int | None = Field(None, gt=0) started_at: datetime ended_at: datetime | None = None status: str = Field(..., pattern="^(success|failure|in_progress)$") bytes_transferred: int | None = Field(None, ge=0) duration_ms: int | None = None error_message: str | None = None details: dict[str, Any] | None = None @field_validator("ended_at", "duration_ms") @classmethod def validate_success_fields(cls, v, info): if info.data.get("status") == "success" and v is None: raise ValueError(f"{info.field_name} is required when status is 'success'") return v class BackupJobResponse(BaseModel): id: str name: str source: str | None target: str | None schedule_interval_seconds: int | None created_at: int class BackupRunResponse(BaseModel): id: str job_id: str started_at: int ended_at: int | None status: str bytes_transferred: int | None duration_ms: int | None error_message: str | None details_json: dict[str, Any] | None created_at: int class BackupAlertResponse(BaseModel): id: str job_id: str run_id: str | None alert_type: str severity: str message: str acknowledged: bool resolved_at: int | None created_at: int class BackupDashboardSummary(BaseModel): total_jobs: int success_rate_24h: float active_alerts: int last_failed_at: int | None ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_backup_report_model -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/media_library_viewer_api/models/backups.py backend/tests/test_backups.py git commit -m "feat: add backup monitoring pydantic models" ``` --- ## Task 3: Settings Store CRUD Methods **Files:** - Modify: `backend/src/media_library_viewer_api/services/settings_store.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_create_job_and_run(): store = get_settings_store() store.init_schema() job = store.upsert_backup_job({ "name": "test-job", "source": "server-a:/data", "target": "server-b:/backups", "schedule_interval_seconds": 86400, }) assert job["name"] == "test-job" run = store.create_backup_run({ "job_id": job["id"], "started_at": 1715392800, "status": "success", "ended_at": 1715393700, "duration_ms": 900000, "bytes_transferred": 1024, }) assert run["job_id"] == job["id"] assert run["status"] == "success" ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_create_job_and_run -v` Expected: FAIL — methods don't exist - [ ] **Step 3: Implement CRUD methods** Add to `backend/src/media_library_viewer_api/services/settings_store.py`: ```python def _row_to_job(self, row: sqlite3.Row) -> dict[str, Any]: return { "id": row["id"], "name": row["name"], "source": row["source"], "target": row["target"], "schedule_interval_seconds": row["schedule_interval_seconds"], "created_at": row["created_at"], } def _row_to_run(self, row: sqlite3.Row) -> dict[str, Any]: details = None if row["details_json"]: try: details = json.loads(row["details_json"]) except json.JSONDecodeError: details = {"raw": row["details_json"]} return { "id": row["id"], "job_id": row["job_id"], "started_at": row["started_at"], "ended_at": row["ended_at"], "status": row["status"], "bytes_transferred": row["bytes_transferred"], "duration_ms": row["duration_ms"], "error_message": row["error_message"], "details_json": details, "created_at": row["created_at"], } def _row_to_alert(self, row: sqlite3.Row) -> dict[str, Any]: return { "id": row["id"], "job_id": row["job_id"], "run_id": row["run_id"], "alert_type": row["alert_type"], "severity": row["severity"], "message": row["message"], "acknowledged": bool(row["acknowledged"]), "resolved_at": row["resolved_at"], "created_at": row["created_at"], } def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None: self.init_schema() with self.connect() as conn: row = conn.execute("SELECT * FROM backup_jobs WHERE name = ?", (name,)).fetchone() return self._row_to_job(row) if row else None def upsert_backup_job(self, payload: dict[str, Any]) -> dict[str, Any]: self.init_schema() job_id = payload.get("id") or str(uuid.uuid4()) now = int(time.time()) with self.connect() as conn: existing = conn.execute("SELECT created_at FROM backup_jobs WHERE id = ?", (job_id,)).fetchone() if not existing: existing = conn.execute("SELECT id, created_at FROM backup_jobs WHERE name = ?", (payload["name"],)).fetchone() if existing: job_id = existing["id"] created_at = int(existing["created_at"]) if existing else now conn.execute(""" INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, source = COALESCE(excluded.source, source), target = COALESCE(excluded.target, target), schedule_interval_seconds = COALESCE(excluded.schedule_interval_seconds, schedule_interval_seconds), created_at = excluded.created_at """, (job_id, payload["name"], payload.get("source"), payload.get("target"), payload.get("schedule_interval_seconds"), created_at)) return self.get_backup_job(job_id) or {"id": job_id, **payload, "created_at": created_at} def get_backup_job(self, job_id: str) -> dict[str, Any] | None: self.init_schema() with self.connect() as conn: row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone() return self._row_to_job(row) if row else None def list_backup_jobs(self) -> list[dict[str, Any]]: self.init_schema() with self.connect() as conn: rows = conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall() return [self._row_to_job(row) for row in rows] def create_backup_run(self, payload: dict[str, Any]) -> dict[str, Any]: self.init_schema() run_id = str(uuid.uuid4()) now = int(time.time()) with self.connect() as conn: conn.execute(""" INSERT INTO backup_runs (id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (run_id, payload["job_id"], payload["started_at"], payload.get("ended_at"), payload["status"], payload.get("bytes_transferred"), payload.get("duration_ms"), payload.get("error_message"), json.dumps(payload.get("details_json") or {}), now)) return self.get_backup_run(run_id) or {"id": run_id, **payload, "created_at": now} def get_backup_run(self, run_id: str) -> dict[str, Any] | None: self.init_schema() with self.connect() as conn: row = conn.execute("SELECT * FROM backup_runs WHERE id = ?", (run_id,)).fetchone() return self._row_to_run(row) if row else None def list_backup_runs(self, job_id: str | None = None, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]: self.init_schema() query = "SELECT * FROM backup_runs WHERE 1=1" params: list[Any] = [] if job_id: query += " AND job_id = ?" params.append(job_id) if status: query += " AND status = ?" params.append(status) query += " ORDER BY started_at DESC LIMIT ?" params.append(limit) with self.connect() as conn: rows = conn.execute(query, params).fetchall() return [self._row_to_run(row) for row in rows] def get_latest_backup_run(self, job_id: str) -> dict[str, Any] | None: self.init_schema() with self.connect() as conn: row = conn.execute( "SELECT * FROM backup_runs WHERE job_id = ? ORDER BY started_at DESC LIMIT 1", (job_id,) ).fetchone() return self._row_to_run(row) if row else None def create_backup_alert(self, payload: dict[str, Any]) -> dict[str, Any]: self.init_schema() alert_id = str(uuid.uuid4()) now = int(time.time()) with self.connect() as conn: conn.execute(""" INSERT INTO backup_alerts (id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, (alert_id, payload["job_id"], payload.get("run_id"), payload["alert_type"], payload["severity"], payload["message"], 0, None, now)) return self.get_backup_alert(alert_id) or {"id": alert_id, **payload, "acknowledged": False, "created_at": now} def get_backup_alert(self, alert_id: str) -> dict[str, Any] | None: self.init_schema() with self.connect() as conn: row = conn.execute("SELECT * FROM backup_alerts WHERE id = ?", (alert_id,)).fetchone() return self._row_to_alert(row) if row else None def list_backup_alerts(self, job_id: str | None = None, acknowledged: bool | None = None, severity: str | None = None) -> list[dict[str, Any]]: self.init_schema() query = "SELECT * FROM backup_alerts WHERE 1=1" params: list[Any] = [] if job_id: query += " AND job_id = ?" params.append(job_id) if acknowledged is not None: query += " AND acknowledged = ?" params.append(1 if acknowledged else 0) if severity: query += " AND severity = ?" params.append(severity) query += " ORDER BY created_at DESC" with self.connect() as conn: rows = conn.execute(query, params).fetchall() return [self._row_to_alert(row) for row in rows] def acknowledge_backup_alert(self, alert_id: str) -> dict[str, Any] | None: self.init_schema() with self.connect() as conn: conn.execute("UPDATE backup_alerts SET acknowledged = 1 WHERE id = ?", (alert_id,)) return self.get_backup_alert(alert_id) def resolve_backup_alerts_for_job(self, job_id: str, alert_type: str | None = None) -> int: self.init_schema() now = int(time.time()) with self.connect() as conn: if alert_type: result = conn.execute( "UPDATE backup_alerts SET resolved_at = ? WHERE job_id = ? AND alert_type = ? AND resolved_at IS NULL", (now, job_id, alert_type) ) else: result = conn.execute( "UPDATE backup_alerts SET resolved_at = ? WHERE job_id = ? AND resolved_at IS NULL", (now, job_id) ) return result.rowcount def prune_backup_alerts(self, cutoff_ts: int) -> int: self.init_schema() with self.connect() as conn: result = conn.execute("DELETE FROM backup_alerts WHERE created_at < ?", (cutoff_ts,)) return result.rowcount ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_create_job_and_run -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/media_library_viewer_api/services/settings_store.py backend/tests/test_backups.py git commit -m "feat: add backup monitoring CRUD operations" ``` --- ## Task 4: Alert Engine **Files:** - Create: `backend/src/media_library_viewer_api/services/backup_alert_engine.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_alert_failed_status(): from media_library_viewer_api.services.backup_alert_engine import generate_alerts_for_run run = { "id": "run-1", "job_id": "job-1", "status": "failure", "bytes_transferred": 0, "duration_ms": 1000, "started_at": 1715392800, } alerts = generate_alerts_for_run(run, [], None) assert len(alerts) == 1 assert alerts[0]["alert_type"] == "failed_status" assert alerts[0]["severity"] == "critical" ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_alert_failed_status -v` Expected: FAIL — module not found - [ ] **Step 3: Implement alert engine** Create `backend/src/media_library_viewer_api/services/backup_alert_engine.py`: ```python import statistics from typing import Any def generate_alerts_for_run( run: dict[str, Any], previous_runs: list[dict[str, Any]], job: dict[str, Any] | None, ) -> list[dict[str, Any]]: alerts = [] job_id = run["job_id"] # 1. Failed status alert if run["status"] == "failure": alerts.append({ "job_id": job_id, "run_id": run["id"], "alert_type": "failed_status", "severity": "critical", "message": f"Backup job '{job['name'] if job else job_id}' failed: {run.get('error_message', 'No error details')}", }) # 2. Anomaly size alert bytes_transferred = run.get("bytes_transferred") if bytes_transferred is not None and previous_runs: successful_runs = [r for r in previous_runs if r["status"] == "success" and r.get("bytes_transferred") is not None] if len(successful_runs) >= 3: sizes = [r["bytes_transferred"] for r in successful_runs[-7:]] median_size = statistics.median(sizes) if median_size > 0: ratio = bytes_transferred / median_size if bytes_transferred == 0: alerts.append({ "job_id": job_id, "run_id": run["id"], "alert_type": "anomaly_size", "severity": "warning", "message": f"Backup job '{job['name'] if job else job_id}' transferred 0 bytes (median: {median_size})", }) elif ratio < 0.1 or ratio > 3.0: alerts.append({ "job_id": job_id, "run_id": run["id"], "alert_type": "anomaly_size", "severity": "warning", "message": f"Backup job '{job['name'] if job else job_id}' size anomaly: {bytes_transferred} bytes (median: {median_size})", }) # 3. Anomaly duration alert duration_ms = run.get("duration_ms") if duration_ms is not None and previous_runs: successful_runs = [r for r in previous_runs if r["status"] == "success" and r.get("duration_ms") is not None] if len(successful_runs) >= 3: durations = [r["duration_ms"] for r in successful_runs[-7:]] median_duration = statistics.median(durations) if median_duration > 0 and duration_ms / median_duration > 3.0: alerts.append({ "job_id": job_id, "run_id": run["id"], "alert_type": "anomaly_duration", "severity": "warning", "message": f"Backup job '{job['name'] if job else job_id}' duration anomaly: {duration_ms}ms (median: {median_duration}ms)", }) return alerts def check_missed_schedules( jobs: list[dict[str, Any]], get_latest_run: callable, existing_alerts: list[dict[str, Any]], ) -> list[dict[str, Any]]: alerts = [] now = int(__import__("time").time()) for job in jobs: interval = job.get("schedule_interval_seconds") if not interval: continue latest_run = get_latest_run(job["id"]) if not latest_run: # No runs ever — alert if job is older than interval * 1.5 if now - job["created_at"] > interval * 1.5: alerts.append({ "job_id": job["id"], "run_id": None, "alert_type": "missed_schedule", "severity": "warning", "message": f"Backup job '{job['name']}' has never run (expected every {interval}s)", }) else: last_run_time = latest_run["started_at"] if now - last_run_time > interval * 1.5: # Check if there's already an unresolved missed_schedule alert has_open_alert = any( a["alert_type"] == "missed_schedule" and a["resolved_at"] is None for a in existing_alerts if a["job_id"] == job["id"] ) if not has_open_alert: alerts.append({ "job_id": job["id"], "run_id": None, "alert_type": "missed_schedule", "severity": "warning", "message": f"Backup job '{job['name']}' missed schedule: last run at {last_run_time} (expected every {interval}s)", }) return alerts ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_alert_failed_status -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/media_library_viewer_api/services/backup_alert_engine.py backend/tests/test_backups.py git commit -m "feat: add backup alert generation engine" ``` --- ## Task 5: Background Poller **Files:** - Create: `backend/src/media_library_viewer_api/services/backup_poller.py` - Modify: `backend/src/media_library_viewer_api/main.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_backup_poller_detects_missed_schedule(): from media_library_viewer_api.services.backup_poller import BackupAlertPoller store = get_settings_store() store.init_schema() job = store.upsert_backup_job({ "name": "old-job", "schedule_interval_seconds": 60, }) poller = BackupAlertPoller() poller._run_cycle(store) alerts = store.list_backup_alerts(job_id=job["id"]) assert len(alerts) == 1 assert alerts[0]["alert_type"] == "missed_schedule" ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_backup_poller_detects_missed_schedule -v` Expected: FAIL — module not found - [ ] **Step 3: Implement backup poller** Create `backend/src/media_library_viewer_api/services/backup_poller.py`: ```python import logging import threading import time from typing import Any from .backup_alert_engine import check_missed_schedules from .settings_store import SettingsStore, get_settings_store logger = logging.getLogger(__name__) class BackupAlertPoller: def __init__(self) -> None: self._thread: threading.Thread | None = None self._stop_event = threading.Event() self._lock = threading.Lock() self._last_run_at: float | None = None self._last_success_at: float | None = None self._last_error: str = "" self._poll_count = 0 self._error_count = 0 def start(self) -> None: with self._lock: if self._thread and self._thread.is_alive(): return self._stop_event.clear() self._thread = threading.Thread(target=self._run, name="backup-alert-poller", daemon=True) self._thread.start() def stop(self, timeout: float = 5.0) -> None: with self._lock: thread = self._thread if not thread: return self._stop_event.set() thread.join(timeout=timeout) @property def status(self) -> dict[str, Any]: with self._lock: return { "worker_running": self._thread is not None and self._thread.is_alive(), "last_run_at": self._last_run_at, "last_success_at": self._last_success_at, "last_error": self._last_error, "poll_count": self._poll_count, "error_count": self._error_count, } def _run(self) -> None: # Wait 30 seconds before first check if self._stop_event.wait(30): return store = get_settings_store() while not self._stop_event.is_set(): try: self._run_cycle(store) except Exception: logger.exception("Backup alert poller cycle failed") # Check every 5 minutes if self._stop_event.wait(300): break def _run_cycle(self, store: SettingsStore) -> None: start = time.perf_counter() jobs = store.list_backup_jobs() existing_alerts = store.list_backup_alerts(acknowledged=False) new_alerts = check_missed_schedules( jobs, lambda job_id: store.get_latest_backup_run(job_id), existing_alerts, ) for alert in new_alerts: store.create_backup_alert(alert) # Prune old resolved alerts (90 days) cutoff = int(time.time()) - (90 * 24 * 60 * 60) store.prune_backup_alerts(cutoff) duration_ms = int((time.perf_counter() - start) * 1000) with self._lock: self._last_run_at = time.time() self._last_success_at = time.time() self._poll_count += 1 _BACKUP_POLLER = BackupAlertPoller() def get_backup_poller() -> BackupAlertPoller: return _BACKUP_POLLER ``` - [ ] **Step 4: Wire up poller in main.py** Modify `backend/src/media_library_viewer_api/main.py`: ```python # Add import from .services.backup_poller import get_backup_poller # In lifespan: @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() configure_logging(settings.log_level) validate_auth_settings(settings) mail_queue = get_mail_queue() monitoring_poller = get_monitoring_poller() backup_poller = get_backup_poller() mail_queue.start() monitoring_poller.start() backup_poller.start() yield monitoring_poller.stop() backup_poller.stop() mail_queue.stop() ``` - [ ] **Step 5: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_backup_poller_detects_missed_schedule -v` Expected: PASS - [ ] **Step 6: Commit** ```bash git add backend/src/media_library_viewer_api/services/backup_poller.py backend/src/media_library_viewer_api/main.py backend/tests/test_backups.py git commit -m "feat: add backup alert background poller" ``` --- ## Task 6: API Key Authentication **Files:** - Modify: `backend/src/media_library_viewer_api/auth.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_api_key_auth(): from media_library_viewer_api.auth import require_api_key from fastapi import HTTPException # Should raise 401 without key try: require_api_key("") assert False, "Should have raised" except HTTPException as e: assert e.status_code == 401 ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_api_key_auth -v` Expected: FAIL — function doesn't exist - [ ] **Step 3: Implement API key auth** Modify `backend/src/media_library_viewer_api/auth.py`: ```python # Add import import secrets # Add at module level _API_KEY: str | None = None def get_api_key() -> str: global _API_KEY if _API_KEY is None: store = get_settings_store() settings = store.get_settings() _API_KEY = settings.get("backup_api_key") if not _API_KEY: _API_KEY = secrets.token_urlsafe(32) store.update_setting("backup_api_key", _API_KEY) return _API_KEY def require_api_key(authorization: str = Header("", alias="Authorization")) -> str: expected = f"Bearer {get_api_key()}" if not authorization or not secrets.compare_digest(authorization, expected): raise HTTPException(status_code=401, detail="Invalid or missing API key") return authorization ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_api_key_auth -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/media_library_viewer_api/auth.py backend/tests/test_backups.py git commit -m "feat: add API key authentication for backup tool" ``` --- ## Task 7: Backup Router (Backend API) **Files:** - Create: `backend/src/media_library_viewer_api/routers/backups.py` - Modify: `backend/src/media_library_viewer_api/main.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_post_backup_report(): from fastapi.testclient import TestClient from media_library_viewer_api.main import app client = TestClient(app) # Get API key from media_library_viewer_api.auth import get_api_key api_key = get_api_key() response = client.post( "/api/backups/report", headers={"Authorization": f"Bearer {api_key}"}, json={ "name": "test-backup", "started_at": "2026-05-11T02:00:00Z", "status": "success", "ended_at": "2026-05-11T02:15:00Z", "duration_ms": 900000, "bytes_transferred": 1024, }, ) assert response.status_code == 200 data = response.json() assert data["status"] == "success" ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_post_backup_report -v` Expected: FAIL — router not registered - [ ] **Step 3: Implement backup router** Create `backend/src/media_library_viewer_api/routers/backups.py`: ```python import time from typing import Any from fastapi import APIRouter, Depends, HTTPException from ..auth import require_api_key from ..models.backups import ( BackupAlertResponse, BackupDashboardSummary, BackupJobResponse, BackupReportRequest, BackupRunResponse, ) from ..services.backup_alert_engine import generate_alerts_for_run from ..services.settings_store import SettingsStore, get_settings_store router = APIRouter(prefix="/api/backups", tags=["backups"]) def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]: job = store.get_backup_job_by_name(report.name) if not job: job = store.upsert_backup_job({ "name": report.name, "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, }) elif report.schedule_interval_seconds: # Update schedule if provided store.upsert_backup_job({ "id": job["id"], "name": report.name, "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, }) job = store.get_backup_job(job["id"]) return job @router.post("/report") def post_backup_report( report: BackupReportRequest, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: job = _get_or_create_job(store, report) # Check for duplicate (same job + started_at within 1s) existing_runs = store.list_backup_runs(job_id=job["id"], limit=5) started_at_ts = int(report.started_at.timestamp()) for existing in existing_runs: if abs(existing["started_at"] - started_at_ts) <= 1: # Update existing run return store.get_backup_run(existing["id"]) run_data = { "job_id": job["id"], "started_at": started_at_ts, "ended_at": int(report.ended_at.timestamp()) if report.ended_at else None, "status": report.status, "bytes_transferred": report.bytes_transferred, "duration_ms": report.duration_ms, "error_message": report.error_message, "details_json": report.details, } run = store.create_backup_run(run_data) # Generate alerts previous_runs = store.list_backup_runs(job_id=job["id"], status="success", limit=20) alerts = generate_alerts_for_run(run, previous_runs, job) for alert in alerts: store.create_backup_alert(alert) # Resolve old alerts of the same type if this run is successful if report.status == "success": store.resolve_backup_alerts_for_job(job["id"], "failed_status") store.resolve_backup_alerts_for_job(job["id"], "anomaly_size") store.resolve_backup_alerts_for_job(job["id"], "anomaly_duration") return BackupRunResponse(**run) @router.post("/report/start") def post_backup_start( report: BackupReportRequest, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: job = _get_or_create_job(store, report) run_data = { "job_id": job["id"], "started_at": int(report.started_at.timestamp()), "status": "in_progress", } run = store.create_backup_run(run_data) return BackupRunResponse(**run) @router.get("/jobs") def get_backup_jobs( store: SettingsStore = Depends(get_settings_store), ) -> list[BackupJobResponse]: jobs = store.list_backup_jobs() return [BackupJobResponse(**job) for job in jobs] @router.get("/jobs/{job_id}") def get_backup_job( job_id: str, store: SettingsStore = Depends(get_settings_store), ) -> dict[str, Any]: job = store.get_backup_job(job_id) if not job: raise HTTPException(status_code=404, detail="Backup job not found") runs = store.list_backup_runs(job_id=job_id, limit=20) return { "job": BackupJobResponse(**job), "runs": [BackupRunResponse(**run) for run in runs], } @router.get("/runs") def get_backup_runs( job_id: str | None = None, status: str | None = None, limit: int = 50, store: SettingsStore = Depends(get_settings_store), ) -> list[BackupRunResponse]: runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit) return [BackupRunResponse(**run) for run in runs] @router.get("/runs/{run_id}") def get_backup_run( run_id: str, store: SettingsStore = Depends(get_settings_store), ) -> BackupRunResponse: run = store.get_backup_run(run_id) if not run: raise HTTPException(status_code=404, detail="Backup run not found") return BackupRunResponse(**run) @router.get("/alerts") def get_backup_alerts( job_id: str | None = None, acknowledged: bool | None = None, severity: str | None = None, store: SettingsStore = Depends(get_settings_store), ) -> list[BackupAlertResponse]: alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity) return [BackupAlertResponse(**alert) for alert in alerts] @router.post("/alerts/{alert_id}/acknowledge") def acknowledge_backup_alert( alert_id: str, store: SettingsStore = Depends(get_settings_store), ) -> BackupAlertResponse: alert = store.acknowledge_backup_alert(alert_id) if not alert: raise HTTPException(status_code=404, detail="Alert not found") return BackupAlertResponse(**alert) ``` - [ ] **Step 4: Register router in main.py** Add to `backend/src/media_library_viewer_api/main.py`: ```python from .routers import backups as backups_router # In router registration section: app.include_router(backups_router.router) ``` - [ ] **Step 5: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_post_backup_report -v` Expected: PASS - [ ] **Step 6: Commit** ```bash git add backend/src/media_library_viewer_api/routers/backups.py backend/src/media_library_viewer_api/main.py backend/tests/test_backups.py git commit -m "feat: add backup monitoring API endpoints" ``` --- ## Task 8: Dashboard Endpoint **Files:** - Modify: `backend/src/media_library_viewer_api/routers/dashboard.py` - Test: `backend/tests/test_backups.py` - [ ] **Step 1: Write the failing test** ```python def test_dashboard_backups(): from fastapi.testclient import TestClient from media_library_viewer_api.main import app client = TestClient(app) response = client.get("/api/dashboard/backups") assert response.status_code == 200 data = response.json() assert "total_jobs" in data assert "success_rate_24h" in data ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && pytest tests/test_backups.py::test_dashboard_backups -v` Expected: FAIL — endpoint not found - [ ] **Step 3: Implement dashboard endpoint** Modify `backend/src/media_library_viewer_api/routers/dashboard.py`: ```python # Add import from ..models.backups import BackupDashboardSummary # Add endpoint: @router.get("/backups") def get_backup_dashboard( store: SettingsStore = Depends(get_settings_store), ) -> BackupDashboardSummary: jobs = store.list_backup_jobs() total_jobs = len(jobs) # Calculate 24h success rate cutoff = int(time.time()) - (24 * 60 * 60) recent_runs = [] for job in jobs: runs = store.list_backup_runs(job_id=job["id"], limit=1) if runs and runs[0]["started_at"] >= cutoff: recent_runs.append(runs[0]) successful = sum(1 for r in recent_runs if r["status"] == "success") success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0 # Active alerts alerts = store.list_backup_alerts(acknowledged=False) active_alerts = len(alerts) # Last failed failed_runs = [] for job in jobs: runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1) if runs: failed_runs.append(runs[0]) last_failed_at = None if failed_runs: last_failed_at = max(r["started_at"] for r in failed_runs) return BackupDashboardSummary( total_jobs=total_jobs, success_rate_24h=round(success_rate, 1), active_alerts=active_alerts, last_failed_at=last_failed_at, ) ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd backend && pytest tests/test_backups.py::test_dashboard_backups -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add backend/src/media_library_viewer_api/routers/dashboard.py backend/tests/test_backups.py git commit -m "feat: add backup dashboard summary endpoint" ``` --- ## Task 9: Frontend Types **Files:** - Create: `frontend/src/types/backups.ts` - [ ] **Step 1: Write the type definitions** Create `frontend/src/types/backups.ts`: ```typescript export interface BackupJob { id: string; name: string; source: string | null; target: string | null; schedule_interval_seconds: number | null; created_at: number; } export interface BackupRun { id: string; job_id: string; started_at: number; ended_at: number | null; status: "success" | "failure" | "in_progress"; bytes_transferred: number | null; duration_ms: number | null; error_message: string | null; details_json: Record | null; created_at: number; } export interface BackupAlert { id: string; job_id: string; run_id: string | null; alert_type: "missed_schedule" | "failed_status" | "anomaly_size" | "anomaly_duration"; severity: "warning" | "critical"; message: string; acknowledged: boolean; resolved_at: number | null; created_at: number; } export interface BackupDashboardSummary { total_jobs: number; success_rate_24h: number; active_alerts: number; last_failed_at: number | null; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/types/backups.ts git commit -m "feat: add backup monitoring TypeScript types" ``` --- ## Task 10: Frontend API Client **Files:** - Create: `frontend/src/api/backups.ts` - [ ] **Step 1: Write the API client** Create `frontend/src/api/backups.ts`: ```typescript import { BackupAlert, BackupDashboardSummary, BackupJob, BackupRun, } from "../types/backups"; const API_BASE = "/api"; export async function fetchBackupJobs(): Promise { const res = await fetch(`${API_BASE}/backups/jobs`); if (!res.ok) throw new Error("Failed to fetch backup jobs"); return res.json(); } export async function fetchBackupJob(jobId: string): Promise<{ job: BackupJob; runs: BackupRun[] }> { const res = await fetch(`${API_BASE}/backups/jobs/${jobId}`); if (!res.ok) throw new Error("Failed to fetch backup job"); return res.json(); } export async function fetchBackupRuns(jobId?: string, status?: string): Promise { const params = new URLSearchParams(); if (jobId) params.append("job_id", jobId); if (status) params.append("status", status); const res = await fetch(`${API_BASE}/backups/runs?${params}`); if (!res.ok) throw new Error("Failed to fetch backup runs"); return res.json(); } export async function fetchBackupRun(runId: string): Promise { const res = await fetch(`${API_BASE}/backups/runs/${runId}`); if (!res.ok) throw new Error("Failed to fetch backup run"); return res.json(); } export async function fetchBackupAlerts( jobId?: string, acknowledged?: boolean, severity?: string ): Promise { const params = new URLSearchParams(); if (jobId) params.append("job_id", jobId); if (acknowledged !== undefined) params.append("acknowledged", String(acknowledged)); if (severity) params.append("severity", severity); const res = await fetch(`${API_BASE}/backups/alerts?${params}`); if (!res.ok) throw new Error("Failed to fetch backup alerts"); return res.json(); } export async function acknowledgeBackupAlert(alertId: string): Promise { const res = await fetch(`${API_BASE}/backups/alerts/${alertId}/acknowledge`, { method: "POST", }); if (!res.ok) throw new Error("Failed to acknowledge alert"); return res.json(); } export async function fetchBackupDashboard(): Promise { const res = await fetch(`${API_BASE}/dashboard/backups`); if (!res.ok) throw new Error("Failed to fetch backup dashboard"); return res.json(); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/api/backups.ts git commit -m "feat: add backup monitoring API client" ``` --- ## Task 11: Frontend Hooks **Files:** - Create: `frontend/src/hooks/useBackups.ts` - [ ] **Step 1: Write the hooks** Create `frontend/src/hooks/useBackups.ts`: ```typescript import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { acknowledgeBackupAlert, fetchBackupAlerts, fetchBackupDashboard, fetchBackupJob, fetchBackupJobs, fetchBackupRuns, } from "../api/backups"; export function useBackupJobs() { return useQuery({ queryKey: ["backups", "jobs"], queryFn: fetchBackupJobs, refetchInterval: 30_000, }); } export function useBackupJob(jobId: string) { return useQuery({ queryKey: ["backups", "jobs", jobId], queryFn: () => fetchBackupJob(jobId), enabled: !!jobId, }); } export function useBackupRuns(jobId?: string, status?: string) { return useQuery({ queryKey: ["backups", "runs", jobId, status], queryFn: () => fetchBackupRuns(jobId, status), refetchInterval: 15_000, }); } export function useBackupAlerts(jobId?: string, acknowledged?: boolean, severity?: string) { return useQuery({ queryKey: ["backups", "alerts", jobId, acknowledged, severity], queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity), refetchInterval: 30_000, }); } export function useAcknowledgeAlert() { const queryClient = useQueryClient(); return useMutation({ mutationFn: acknowledgeBackupAlert, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["backups", "alerts"] }); }, }); } export function useBackupDashboard() { return useQuery({ queryKey: ["dashboard", "backups"], queryFn: fetchBackupDashboard, refetchInterval: 30_000, }); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useBackups.ts git commit -m "feat: add backup monitoring TanStack Query hooks" ``` --- ## Task 12: Backup Jobs Table Component **Files:** - Create: `frontend/src/components/BackupJobsTable.tsx` - [ ] **Step 1: Write the component** Create `frontend/src/components/BackupJobsTable.tsx`: ```tsx import { Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, } from "@mui/material"; import { BackupJob, BackupRun } from "../types/backups"; interface Props { jobs: BackupJob[]; latestRuns: Map; } function formatInterval(seconds: number | null): string { if (!seconds) return "N/A"; if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; return `${Math.floor(seconds / 86400)}d`; } function formatTimestamp(ts: number | null): string { if (!ts) return "Never"; return new Date(ts * 1000).toLocaleString(); } export default function BackupJobsTable({ jobs, latestRuns }: Props) { return ( Name Source Target Schedule Last Status Last Run Next Expected {jobs.map((job) => { const run = latestRuns.get(job.id); const status = run?.status ?? "unknown"; const nextExpected = run && job.schedule_interval_seconds ? run.started_at + job.schedule_interval_seconds : null; return ( {job.name} {job.source ?? "—"} {job.target ?? "—"} {formatInterval(job.schedule_interval_seconds)} {formatTimestamp(run?.started_at ?? null)} {formatTimestamp(nextExpected)} ); })}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/BackupJobsTable.tsx git commit -m "feat: add backup jobs table component" ``` --- ## Task 13: Backup Runs Table Component **Files:** - Create: `frontend/src/components/BackupRunsTable.tsx` - [ ] **Step 1: Write the component** Create `frontend/src/components/BackupRunsTable.tsx`: ```tsx import { Chip, FormControl, InputLabel, MenuItem, Paper, Select, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, } from "@mui/material"; import { useState } from "react"; import { BackupRun } from "../types/backups"; interface Props { runs: BackupRun[]; } function formatBytes(bytes: number | null): string { if (bytes === null || bytes === undefined) return "—"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } function formatDuration(ms: number | null): string { if (ms === null || ms === undefined) return "—"; if (ms < 1000) return `${ms}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`; return `${(ms / 3600_000).toFixed(1)}h`; } function formatTimestamp(ts: number): string { return new Date(ts * 1000).toLocaleString(); } export default function BackupRunsTable({ runs }: Props) { const [statusFilter, setStatusFilter] = useState("all"); const filteredRuns = statusFilter === "all" ? runs : runs.filter((r) => r.status === statusFilter); return ( <> Status Job Status Duration Size Started {filteredRuns.map((run) => ( {run.job_id} {formatDuration(run.duration_ms)} {formatBytes(run.bytes_transferred)} {formatTimestamp(run.started_at)} ))}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/BackupRunsTable.tsx git commit -m "feat: add backup runs table component" ``` --- ## Task 14: Backup Alerts Table Component **Files:** - Create: `frontend/src/components/BackupAlertsTable.tsx` - [ ] **Step 1: Write the component** Create `frontend/src/components/BackupAlertsTable.tsx`: ```tsx import { Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; import { BackupAlert } from "../types/backups"; interface Props { alerts: BackupAlert[]; onAcknowledge: (alertId: string) => void; } function formatTimestamp(ts: number): string { return new Date(ts * 1000).toLocaleString(); } export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) { return ( Severity Type Message Created Actions {alerts.map((alert) => ( {alert.alert_type} {alert.message} {formatTimestamp(alert.created_at)} {!alert.acknowledged && ( )} ))}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/BackupAlertsTable.tsx git commit -m "feat: add backup alerts table component" ``` --- ## Task 15: Backup Dashboard Widget **Files:** - Create: `frontend/src/components/BackupDashboardWidget.tsx` - [ ] **Step 1: Write the component** Create `frontend/src/components/BackupDashboardWidget.tsx`: ```tsx import { Card, CardContent, Typography, Box, Chip } from "@mui/material"; import { useBackupDashboard } from "../hooks/useBackups"; export default function BackupDashboardWidget() { const { data, isLoading } = useBackupDashboard(); if (isLoading || !data) { return ( Backups Loading... ); } return ( Backups {data.total_jobs} Jobs {data.success_rate_24h}% 24h Success {data.active_alerts > 0 ? ( ) : ( 0 )} Alerts {data.last_failed_at && ( Last failed: {new Date(data.last_failed_at * 1000).toLocaleString()} )} ); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/BackupDashboardWidget.tsx git commit -m "feat: add backup dashboard widget" ``` --- ## Task 16: Main Backups Page **Files:** - Create: `frontend/src/components/BackupsPage.tsx` - [ ] **Step 1: Write the component** Create `frontend/src/components/BackupsPage.tsx`: ```tsx import { Box, Tab, Tabs, Typography } from "@mui/material"; import { useState } from "react"; import { useAcknowledgeAlert, useBackupAlerts, useBackupJobs, useBackupRuns, } from "../hooks/useBackups"; import BackupAlertsTable from "./BackupAlertsTable"; import BackupJobsTable from "./BackupJobsTable"; import BackupRunsTable from "./BackupRunsTable"; export default function BackupsPage() { const [tab, setTab] = useState(0); const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(); const { data: runsData, isLoading: runsLoading } = useBackupRuns(); const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(undefined, false); const acknowledgeMutation = useAcknowledgeAlert(); // Build a map of latest runs per job const latestRuns = new Map(); if (runsData) { for (const run of runsData) { const existing = latestRuns.get(run.job_id); if (!existing || run.started_at > existing.started_at) { latestRuns.set(run.job_id, run); } } } return ( Backups setTab(v)} sx={{ mb: 2 }}> {tab === 0 && ( jobsLoading ? ( Loading jobs... ) : ( ) )} {tab === 1 && ( runsLoading ? ( Loading runs... ) : ( ) )} {tab === 2 && ( alertsLoading ? ( Loading alerts... ) : ( acknowledgeMutation.mutate(id)} /> ) )} ); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/BackupsPage.tsx git commit -m "feat: add main backups page with tabs" ``` --- ## Task 17: Wire Up Routes and Navigation **Files:** - Modify: `frontend/src/App.tsx` - [ ] **Step 1: Add route and nav** In `frontend/src/App.tsx`: ```tsx // Add import import BackupsPage from "./components/BackupsPage"; // Add route inside : } /> // Add tab inside : ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/App.tsx git commit -m "feat: add backups route and navigation" ``` --- ## Task 18: Wire Up Dashboard Widget **Files:** - Modify: `frontend/src/components/Dashboard.tsx` (or equivalent) - [ ] **Step 1: Add widget to dashboard** Find the dashboard component and add the backup widget: ```tsx // Add import import BackupDashboardWidget from "./BackupDashboardWidget"; // Add to dashboard layout (where other widgets are): ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/Dashboard.tsx git commit -m "feat: add backup widget to dashboard" ``` --- ## Task 19: Update REQUIREMENTS.md **Files:** - Modify: `docs/REQUIREMENTS.md` - [ ] **Step 1: Document backup monitoring** Add a new section to `docs/REQUIREMENTS.md`: ```markdown ## Backup Monitoring ### Overview The system receives backup execution reports from an external backup tool via HTTP API, stores job and run history, and provides alerting on failures, missed schedules, and anomalies. ### API - `POST /api/backups/report` — Submit backup run (Bearer token auth) - `POST /api/backups/report/start` — Mark backup as in_progress - `GET /api/backups/jobs` — List jobs - `GET /api/backups/runs` — List runs - `GET /api/backups/alerts` — List alerts - `POST /api/backups/alerts/{id}/acknowledge` — Acknowledge alert - `GET /api/dashboard/backups` — Dashboard summary ### Data Model - **BackupJob**: id, name, source, target, schedule_interval_seconds, created_at - **BackupRun**: id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json - **BackupAlert**: id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at ### Alert Types - `failed_status` — Backup reported failure (critical) - `missed_schedule` — No run within 1.5x expected interval (warning) - `anomaly_size` — Size is 0 or <10% / >300% of 7-day median (warning) - `anomaly_duration` — Duration >300% of 7-day median (warning) ### Authentication - Backup tool uses auto-generated Bearer API key - Frontend uses existing OIDC/JWT auth ``` - [ ] **Step 2: Commit** ```bash git add docs/REQUIREMENTS.md git commit -m "docs: add backup monitoring requirements" ``` --- ## Task 20: Run Full Test Suite - [ ] **Step 1: Run backend tests** ```bash cd backend && pytest tests/test_backups.py -v ``` Expected: All tests pass - [ ] **Step 2: Run frontend build** ```bash cd frontend && npm run build ``` Expected: Build succeeds with no errors - [ ] **Step 3: Run backend lint** ```bash cd backend && ruff check src/media_library_viewer_api/ ``` Expected: No lint errors - [ ] **Step 4: Commit** ```bash git commit -m "test: verify backup monitoring implementation" ``` --- ## Spec Coverage Check | Spec Section | Implementing Task | |-------------|-------------------| | Data Model (BackupJob, BackupRun, BackupAlert) | Task 1, 3 | | API Endpoints (report, start, jobs, runs, alerts) | Task 7 | | Dashboard Endpoint | Task 8 | | Validation Rules | Task 7 (endpoint validation) | | Alert Types (failed, missed, anomaly) | Task 4, 5 | | Alert State Machine | Task 4, 7 | | Frontend Navigation | Task 17 | | Dashboard Widget | Task 15, 18 | | Backups Page (Jobs, Runs, Alerts tabs) | Task 12-16 | | API Key Auth | Task 6 | | Background Poller | Task 5 | | Charts | Out of scope for MVP (future enhancement) | ## Placeholder Scan - No "TBD", "TODO", "implement later" found - No vague "add error handling" steps - All code blocks contain actual code - No "similar to Task N" references ## Type Consistency Check - `BackupJob.schedule_interval_seconds` — used consistently across backend and frontend - `BackupRun.status` — enum values match (`success`, `failure`, `in_progress`) - `BackupAlert.alert_type` — values match spec - `BackupAlert.severity` — values match spec (`warning`, `critical`) - All Pydantic models match TypeScript interfaces --- ## Execution Handoff **Plan complete and saved to `docs/superpowers/plans/2026-05-11-backup-monitoring.md`. Two execution options:** **1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration **2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints **Which approach?**