diff --git a/README.md b/README.md index 08d865b..9b77ac3 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The admin is available at: Staff-only JSON endpoints are available at: - http://127.0.0.1:8000/api/ +- http://127.0.0.1:8000/api/status/ ## SQL-First Setup @@ -117,6 +118,7 @@ This starts Django on: - http://127.0.0.1:8010/admin/ - http://127.0.0.1:8010/api/ +- http://127.0.0.1:8010/api/status/ Run the scheduler alongside the web admin: @@ -147,7 +149,7 @@ Discovered snapshots are stored in `SnapshotRecord`, including the base snapshot base record when it is known. The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem. Post-backup pruning from Django also uses the SQL retention service after the completed snapshot is recorded. -Staff-only JSON endpoints expose hosts, snapshots, and backup runs for lightweight inspection. +Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection. The remaining internal engine code still contains reusable backup primitives: diff --git a/src/pobsync_backend/api.py b/src/pobsync_backend/api.py index c9112af..471342f 100644 --- a/src/pobsync_backend/api.py +++ b/src/pobsync_backend/api.py @@ -3,8 +3,10 @@ from __future__ import annotations from typing import Any from django.contrib.admin.views.decorators import staff_member_required +from django.db import connection from django.db.models import Count from django.http import JsonResponse +from django.utils import timezone from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord @@ -15,6 +17,7 @@ def api_index(request) -> JsonResponse: { "ok": True, "endpoints": { + "status": request.build_absolute_uri("/api/status/"), "hosts": request.build_absolute_uri("/api/hosts/"), "snapshots": request.build_absolute_uri("/api/snapshots/"), "runs": request.build_absolute_uri("/api/runs/"), @@ -23,6 +26,35 @@ def api_index(request) -> JsonResponse: ) +@staff_member_required +def status(request) -> JsonResponse: + latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first() + latest_schedule = ScheduleConfig.objects.select_related("host").order_by("-last_started_at", "-updated_at").first() + + return JsonResponse( + { + "ok": True, + "generated_at": timezone.now().isoformat(), + "database": { + "vendor": connection.vendor, + "engine": connection.settings_dict["ENGINE"], + }, + "counts": { + "hosts": HostConfig.objects.count(), + "enabled_hosts": HostConfig.objects.filter(enabled=True).count(), + "schedules": ScheduleConfig.objects.count(), + "enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(), + "snapshots": SnapshotRecord.objects.count(), + "runs": BackupRun.objects.count(), + "running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(), + "failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(), + }, + "latest_run": None if latest_run is None else _run_payload(latest_run), + "latest_schedule": None if latest_schedule is None else _schedule_payload(latest_schedule), + } + ) + + @staff_member_required def hosts(request) -> JsonResponse: host_qs = ( @@ -78,14 +110,7 @@ def _host_payload(host: HostConfig) -> dict[str, Any]: }, "schedule": None if schedule is None - else { - "cron_expr": schedule.cron_expr, - "enabled": schedule.enabled, - "prune": schedule.prune, - "last_status": schedule.last_status, - "last_started_at": _iso(schedule.last_started_at), - "last_finished_at": _iso(schedule.last_finished_at), - }, + else _schedule_payload(schedule), } @@ -143,6 +168,19 @@ def _run_payload(run: BackupRun) -> dict[str, Any]: } +def _schedule_payload(schedule: ScheduleConfig) -> dict[str, Any]: + return { + "host": schedule.host.host, + "cron_expr": schedule.cron_expr, + "enabled": schedule.enabled, + "prune": schedule.prune, + "last_due_key": schedule.last_due_key, + "last_status": schedule.last_status, + "last_started_at": _iso(schedule.last_started_at), + "last_finished_at": _iso(schedule.last_finished_at), + } + + def _limit_from_request(request, *, default: int = 100, maximum: int = 500) -> int: value = request.GET.get("limit", str(default)) try: diff --git a/src/pobsync_backend/tests/test_api.py b/src/pobsync_backend/tests/test_api.py index f037bba..b7bcd83 100644 --- a/src/pobsync_backend/tests/test_api.py +++ b/src/pobsync_backend/tests/test_api.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from django.contrib.auth import get_user_model from django.test import TestCase +from django.utils import timezone as django_timezone from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord @@ -80,10 +81,47 @@ class ApiTests(TestCase): self.assertEqual(response.status_code, 200) endpoints = response.json()["endpoints"] + self.assertEqual(endpoints["status"], "http://testserver/api/status/") self.assertEqual(endpoints["hosts"], "http://testserver/api/hosts/") self.assertEqual(endpoints["snapshots"], "http://testserver/api/snapshots/") self.assertEqual(endpoints["runs"], "http://testserver/api/runs/") + def test_status_endpoint_returns_counts_and_latest_activity(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + schedule = ScheduleConfig.objects.create( + host=host, + cron_expr="15 2 * * *", + enabled=True, + prune=True, + last_due_key="202605190215", + last_status="success", + last_started_at=django_timezone.now(), + ) + snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH") + BackupRun.objects.create( + host=host, + status=BackupRun.Status.SUCCESS, + snapshot=snapshot, + started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc), + ) + + response = self.client.get("/api/status/") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["database"]["vendor"], "sqlite") + self.assertEqual(payload["counts"]["hosts"], 1) + self.assertEqual(payload["counts"]["enabled_hosts"], 1) + self.assertEqual(payload["counts"]["enabled_schedules"], 1) + self.assertEqual(payload["counts"]["snapshots"], 1) + self.assertEqual(payload["counts"]["runs"], 1) + self.assertEqual(payload["latest_run"]["host"], host.host) + self.assertEqual(payload["latest_run"]["snapshot"]["dirname"], snapshot.dirname) + self.assertEqual(payload["latest_schedule"]["host"], host.host) + self.assertEqual(payload["latest_schedule"]["last_due_key"], schedule.last_due_key) + def _snapshot( self, host: HostConfig, diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 47b3722..508dde0 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -8,6 +8,7 @@ from pobsync_backend import api urlpatterns = [ path("api/", api.api_index), + path("api/status/", api.status), path("api/hosts/", api.hosts), path("api/snapshots/", api.snapshots), path("api/runs/", api.runs),