(feature) Add staff-only service status API

Add /api/status/ for quick inspection of database backend, object counts, latest
backup run, and latest scheduler activity. Link it from the API index and reuse
schedule serialization between host summaries and status output.

Cover the endpoint with a focused API test and document the new status URL.
This commit is contained in:
2026-05-19 11:46:22 +02:00
parent ccd89119da
commit 2778a589ea
4 changed files with 88 additions and 9 deletions

View File

@@ -38,6 +38,7 @@ The admin is available at:
Staff-only JSON endpoints are available at: Staff-only JSON endpoints are available at:
- http://127.0.0.1:8000/api/ - http://127.0.0.1:8000/api/
- http://127.0.0.1:8000/api/status/
## SQL-First Setup ## SQL-First Setup
@@ -117,6 +118,7 @@ This starts Django on:
- http://127.0.0.1:8010/admin/ - http://127.0.0.1:8010/admin/
- http://127.0.0.1:8010/api/ - http://127.0.0.1:8010/api/
- http://127.0.0.1:8010/api/status/
Run the scheduler alongside the web admin: 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. base record when it is known.
The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem. 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. 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: The remaining internal engine code still contains reusable backup primitives:

View File

@@ -3,8 +3,10 @@ from __future__ import annotations
from typing import Any from typing import Any
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.db import connection
from django.db.models import Count from django.db.models import Count
from django.http import JsonResponse from django.http import JsonResponse
from django.utils import timezone
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
@@ -15,6 +17,7 @@ def api_index(request) -> JsonResponse:
{ {
"ok": True, "ok": True,
"endpoints": { "endpoints": {
"status": request.build_absolute_uri("/api/status/"),
"hosts": request.build_absolute_uri("/api/hosts/"), "hosts": request.build_absolute_uri("/api/hosts/"),
"snapshots": request.build_absolute_uri("/api/snapshots/"), "snapshots": request.build_absolute_uri("/api/snapshots/"),
"runs": request.build_absolute_uri("/api/runs/"), "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 @staff_member_required
def hosts(request) -> JsonResponse: def hosts(request) -> JsonResponse:
host_qs = ( host_qs = (
@@ -78,14 +110,7 @@ def _host_payload(host: HostConfig) -> dict[str, Any]:
}, },
"schedule": None "schedule": None
if schedule is None if schedule is None
else { else _schedule_payload(schedule),
"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),
},
} }
@@ -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: def _limit_from_request(request, *, default: int = 100, maximum: int = 500) -> int:
value = request.GET.get("limit", str(default)) value = request.GET.get("limit", str(default))
try: try:

View File

@@ -4,6 +4,7 @@ from datetime import datetime, timezone
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.utils import timezone as django_timezone
from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
@@ -80,10 +81,47 @@ class ApiTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
endpoints = response.json()["endpoints"] endpoints = response.json()["endpoints"]
self.assertEqual(endpoints["status"], "http://testserver/api/status/")
self.assertEqual(endpoints["hosts"], "http://testserver/api/hosts/") self.assertEqual(endpoints["hosts"], "http://testserver/api/hosts/")
self.assertEqual(endpoints["snapshots"], "http://testserver/api/snapshots/") self.assertEqual(endpoints["snapshots"], "http://testserver/api/snapshots/")
self.assertEqual(endpoints["runs"], "http://testserver/api/runs/") 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( def _snapshot(
self, self,
host: HostConfig, host: HostConfig,

View File

@@ -8,6 +8,7 @@ from pobsync_backend import api
urlpatterns = [ urlpatterns = [
path("api/", api.api_index), path("api/", api.api_index),
path("api/status/", api.status),
path("api/hosts/", api.hosts), path("api/hosts/", api.hosts),
path("api/snapshots/", api.snapshots), path("api/snapshots/", api.snapshots),
path("api/runs/", api.runs), path("api/runs/", api.runs),