a43d6a6206
Users router removed: - Delete routers/users.py + users_impl.py (Jellyfin-backed user directory, Jellyfin-email message compose, Jellyseerr enrichment). - Drop orphaned get_jellyseerr_client dep from dependencies.py (get_user_id stays; used by dashboard/media/media_index_worker). - clients/jellyseerr.py stays (still imported by widgets/sources.py). - test_api.py TestUsers block + mock_jellyseerr fixture removed. Backups service attribution: - backup_jobs gains a nullable service_id column (PRAGMA migration). - _resolve_backup_service_id helper: explicit service_id wins, else first-wins an enabled backups instance, else empty (backward-compat). - Both report endpoints accept ?service_id= and persist it on the job. - Dashboard summary + poller aggregate across all jobs unchanged. Named dashboards backend: - named_dashboards table (id, label, slug UNIQUE, sort_order, payload_json, timestamps) with full CRUD methods + _slugify/_unique_slug helpers. - models/dashboards.py (NamedDashboardInput/NamedDashboard). - routers/dashboards.py: GET/POST/PUT/DELETE /api/dashboards. - Router registered in main.py. Tests: test_dashboards.py (CRUD, slug collision, explicit slug, 404); test_api.py trimmed. 271 backend tests pass (was 268; +6 dashboards -3 users); ruff clean. Refs openspec/changes/services-as-hub-ia/ (spec R5/R6.1, tasks slice 3).
195 lines
6.5 KiB
Python
195 lines
6.5 KiB
Python
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from ..auth import require_api_key
|
|
from ..models.backups import (
|
|
BackupAlertResponse,
|
|
BackupReportRequest,
|
|
BackupRunResponse,
|
|
)
|
|
from ..observability import record_backup_run
|
|
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 _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
|
|
"""Return the service_id for backup attribution.
|
|
|
|
First-wins: if no explicit service_id is given, pick the first enabled
|
|
``backups`` service instance (spec R6.1). Returns an empty string when
|
|
none is configured (backward-compatible with pre-service reports).
|
|
"""
|
|
if explicit:
|
|
return explicit
|
|
candidates = store.list_services("backups")
|
|
for svc in candidates:
|
|
if svc.get("enabled"):
|
|
return svc["id"]
|
|
return ""
|
|
|
|
|
|
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> 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,
|
|
"service_id": service_id,
|
|
}
|
|
)
|
|
elif report.schedule_interval_seconds:
|
|
store.upsert_backup_job(
|
|
{
|
|
"id": job["id"],
|
|
"name": report.name,
|
|
"source": report.source,
|
|
"target": report.target,
|
|
"schedule_interval_seconds": report.schedule_interval_seconds,
|
|
"service_id": service_id,
|
|
}
|
|
)
|
|
job = store.get_backup_job(job["id"])
|
|
return job
|
|
|
|
|
|
@router.post("/report")
|
|
def post_backup_report(
|
|
report: BackupReportRequest,
|
|
service_id: str | None = None,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
_auth: str = Depends(require_api_key),
|
|
) -> BackupRunResponse:
|
|
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
|
job = _get_or_create_job(store, report, resolved_service_id)
|
|
|
|
# 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:
|
|
return BackupRunResponse(**existing)
|
|
|
|
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": report.details,
|
|
}
|
|
run = store.create_backup_run(run_data)
|
|
record_backup_run(job_name=report.name, status=report.status, success=report.status == "success")
|
|
|
|
# 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")
|
|
|
|
# Map details -> details_json for response model
|
|
run["details_json"] = run.pop("details", None)
|
|
return BackupRunResponse(**run)
|
|
|
|
|
|
@router.post("/report/start")
|
|
def post_backup_start(
|
|
report: BackupReportRequest,
|
|
service_id: str | None = None,
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
_auth: str = Depends(require_api_key),
|
|
) -> BackupRunResponse:
|
|
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
|
job = _get_or_create_job(store, report, resolved_service_id)
|
|
|
|
run_data = {
|
|
"job_id": job["id"],
|
|
"started_at": int(report.started_at.timestamp()),
|
|
"status": "in_progress",
|
|
}
|
|
run = store.create_backup_run(run_data)
|
|
record_backup_run(job_name=report.name, status="in_progress")
|
|
# Map details -> details_json for response model
|
|
run["details_json"] = run.pop("details", None)
|
|
return BackupRunResponse(**run)
|
|
|
|
|
|
@router.get("/jobs")
|
|
def get_backup_jobs(
|
|
store: SettingsStore = Depends(get_settings_store),
|
|
) -> list[dict[str, Any]]:
|
|
jobs = store.list_backup_jobs()
|
|
return 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": job,
|
|
"runs": 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)
|