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