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 * * *",