From 86eee0f9161682a129cfda84652c4ed87c81978f Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 22:57:58 +0200 Subject: [PATCH] (feature) Show next scheduled run and backup run type in the UI Add a scheduler helper that calculates the next due time for a cron-style schedule expression and surface that value on the dashboard and host detail pages. Show the latest run type in host summaries and backup trend tables so manual and scheduled backups are distinguishable in the Django UI. Keep the calculation derived from existing ScheduleConfig data without adding a migration. --- src/pobsync_backend/scheduler.py | 13 ++++++++++- src/pobsync_backend/stats_summary.py | 1 + .../templates/pobsync_backend/dashboard.html | 6 +++-- .../pobsync_backend/host_detail.html | 3 +++ src/pobsync_backend/tests/test_scheduler.py | 8 ++++++- src/pobsync_backend/tests/test_views.py | 8 +++++++ src/pobsync_backend/views.py | 22 +++++++++++++++++-- 7 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/pobsync_backend/scheduler.py b/src/pobsync_backend/scheduler.py index 95a4f40..e5cac06 100644 --- a/src/pobsync_backend/scheduler.py +++ b/src/pobsync_backend/scheduler.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta @dataclass(frozen=True) @@ -36,6 +36,17 @@ def is_due(expr: str, moment: datetime) -> bool: ) +def next_due_after(expr: str, moment: datetime, *, max_days: int = 366) -> datetime | None: + parse_cron_expr(expr) + candidate = moment.replace(second=0, microsecond=0) + timedelta(minutes=1) + deadline = candidate + timedelta(days=max_days) + while candidate <= deadline: + if is_due(expr, candidate): + return candidate + candidate += timedelta(minutes=1) + return None + + def _field_matches(field: str, value: int, min_value: int, max_value: int, sunday_alias: bool = False) -> bool: for part in field.split(","): if _part_matches(part.strip(), value, min_value, max_value, sunday_alias=sunday_alias): diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index 15ffc61..b6802f7 100644 --- a/src/pobsync_backend/stats_summary.py +++ b/src/pobsync_backend/stats_summary.py @@ -82,6 +82,7 @@ def _run_summary(run: BackupRun) -> dict[str, Any]: return { "id": run.id, "host": run.host.host, + "run_type": run.run_type, "started_at": run.started_at, "ended_at": run.ended_at, "snapshot": run.snapshot, diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index a7119fd..e12fcfe 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -61,6 +61,7 @@ Snapshots Latest Snapshot Latest Run + Next Run New Data Runs Retention @@ -84,17 +85,18 @@ {% if host.stats_summary.latest_run.id %} Run {{ host.stats_summary.latest_run.id }} -
{{ host.stats_summary.latest_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_run.duration_seconds is not None %}s{% endif %}
+
{{ host.stats_summary.latest_run.run_type }} {{ host.stats_summary.latest_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_run.duration_seconds is not None %}s{% endif %}
{% else %} none {% endif %} + {% if host.next_run_at %}{{ host.next_run_at }}{% else %}none{% endif %} {{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }} {{ host.run_count }} d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }} {% empty %} - No hosts configured yet. + No hosts configured yet. {% endfor %} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index d87ca88..b176998 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -52,6 +52,7 @@
Schedule expression: {{ schedule.cron_expr }}
Evaluated by the pobsync scheduler service.
Enabled: {{ schedule.enabled|yesno:"yes,no" }}
+
Next run: {{ next_run_at|default:"" }}
Prune: {{ schedule.prune|yesno:"yes,no" }}
Last status: {{ schedule.last_status|default:"" }}
Last started: {{ schedule.last_started_at|default:"" }}
@@ -93,6 +94,7 @@ Run + Type Started Duration Files @@ -106,6 +108,7 @@ {% for run in stats_summary.runs %} Run {{ run.id }} + {{ run.run_type }} {{ run.started_at|default:"" }} {{ run.duration_seconds|default:"" }}{% if run.duration_seconds is not None %}s{% endif %} {{ run.rsync.files_total|default:"" }} diff --git a/src/pobsync_backend/tests/test_scheduler.py b/src/pobsync_backend/tests/test_scheduler.py index f5f2815..8cfd105 100644 --- a/src/pobsync_backend/tests/test_scheduler.py +++ b/src/pobsync_backend/tests/test_scheduler.py @@ -9,7 +9,7 @@ from django.test import SimpleTestCase, TestCase from pobsync_backend.management.commands.run_pobsync_scheduler import Command from pobsync_backend.models import HostConfig, ScheduleConfig -from pobsync_backend.scheduler import due_key, is_due +from pobsync_backend.scheduler import due_key, is_due, next_due_after class SchedulerTests(SimpleTestCase): @@ -36,6 +36,12 @@ class SchedulerTests(SimpleTestCase): self.assertEqual(due_key(moment), "202605190215") + def test_next_due_after_returns_next_matching_minute(self) -> None: + moment = datetime(2026, 5, 19, 2, 15, 45, tzinfo=ZoneInfo("UTC")) + + self.assertEqual(next_due_after("30 2 * * *", moment), datetime(2026, 5, 19, 2, 30, tzinfo=ZoneInfo("UTC"))) + self.assertEqual(next_due_after("15 2 * * *", moment), datetime(2026, 5, 20, 2, 15, tzinfo=ZoneInfo("UTC"))) + class SchedulerCommandTests(TestCase): def test_run_due_executes_schedule_once_per_minute(self) -> None: diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 4ecae04..e5bc391 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -37,6 +37,7 @@ class ViewTests(TestCase): snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH") run = BackupRun.objects.create( host=host, + run_type=BackupRun.RunType.MANUAL, status=BackupRun.Status.SUCCESS, snapshot=snapshot, started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc), @@ -57,6 +58,7 @@ class ViewTests(TestCase): snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH") run = BackupRun.objects.create( host=host, + run_type=BackupRun.RunType.MANUAL, status=BackupRun.Status.SUCCESS, snapshot=snapshot, started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc), @@ -79,6 +81,7 @@ class ViewTests(TestCase): }, }, ) + ScheduleConfig.objects.create(host=host, cron_expr="* * * * *", enabled=True) response = self.client.get(reverse("dashboard")) @@ -87,8 +90,10 @@ class ViewTests(TestCase): self.assertContains(response, "Runs Until Full") self.assertContains(response, "Avg Daily New") self.assertContains(response, "Days Until Full") + self.assertContains(response, "Next Run") self.assertContains(response, "10") self.assertContains(response, f"Run {run.id}") + self.assertContains(response, "manual") self.assertContains(response, "1000") def test_dashboard_links_latest_snapshot_for_each_host(self) -> None: @@ -548,6 +553,7 @@ class ViewTests(TestCase): self.assertContains(response, "15 2 * * *") self.assertContains(response, "Schedule expression") self.assertContains(response, "Evaluated by the pobsync scheduler service.") + self.assertContains(response, "Next run:") self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "Discover snapshots") self.assertContains(response, "Edit schedule") @@ -570,6 +576,7 @@ class ViewTests(TestCase): snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH") BackupRun.objects.create( host=host, + run_type=BackupRun.RunType.MANUAL, status=BackupRun.Status.SUCCESS, snapshot=snapshot, started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc), @@ -611,6 +618,7 @@ class ViewTests(TestCase): self.assertContains(response, "Backup Trends") self.assertContains(response, "Avg New Data") self.assertContains(response, "Avg Daily New") + self.assertContains(response, "manual") self.assertContains(response, "45s") self.assertContains(response, "250") self.assertContains(response, "2.0") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index ef1dcdb..aae4720 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -32,6 +32,7 @@ from .host_ops import collect_host_checks, ensure_host_directories from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential from .retention import run_sql_retention_apply, run_sql_retention_plan from .self_check import collect_self_checks, summarize_self_checks +from .scheduler import next_due_after from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key, merge_known_hosts, scan_known_host from .stats_summary import collect_dashboard_stats, collect_host_stats @@ -41,7 +42,8 @@ from .stats_summary import collect_dashboard_stats, collect_host_stats def dashboard(request): global_config = GlobalConfig.objects.filter(name="default").first() hosts = list( - HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True)) + HostConfig.objects.select_related("schedule") + .annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True)) .order_by("host") ) for host_config in hosts: @@ -50,6 +52,7 @@ def dashboard(request): .order_by("-started_at", "-discovered_at", "-id") .first() ) + host_config.next_run_at = _next_run_for_host(host_config) stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config) context = { "hosts": hosts, @@ -255,6 +258,7 @@ def create_host_config(request): @staff_member_required def host_detail(request, host: str): host_config = get_object_or_404(HostConfig, host=host) + schedule = _schedule_for_host(host_config) queued_runs = host_config.runs.filter(status=BackupRun.Status.QUEUED) running_runs = host_config.runs.filter(status=BackupRun.Status.RUNNING) active_run = host_config.runs.filter( @@ -265,7 +269,8 @@ def host_detail(request, host: str): stats_summary = collect_host_stats(host=host_config, limit=10) context = { "host": host_config, - "schedule": _schedule_for_host(host_config), + "schedule": schedule, + "next_run_at": _next_run_for_schedule(schedule, host_config), "discovery": inspect_snapshot_discovery(host=host_config), "host_checks": host_checks, "host_check_summary": summarize_self_checks(host_checks), @@ -548,6 +553,19 @@ def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None: return None +def _next_run_for_host(host_config: HostConfig): + return _next_run_for_schedule(_schedule_for_host(host_config), host_config) + + +def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostConfig): + if schedule is None or not schedule.enabled or not host_config.enabled: + return None + try: + return next_due_after(schedule.cron_expr, timezone.localtime(timezone.now())) + except ValueError: + return None + + def _default_schedule_initial() -> dict[str, object]: return { "cron_expr": "15 2 * * *",