(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.
This commit is contained in:
2026-05-19 22:57:58 +02:00
parent 9624fb469f
commit 86eee0f916
7 changed files with 55 additions and 6 deletions

View File

@@ -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):

View File

@@ -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,

View File

@@ -61,6 +61,7 @@
<th>Snapshots</th>
<th>Latest Snapshot</th>
<th>Latest Run</th>
<th>Next Run</th>
<th>New Data</th>
<th>Runs</th>
<th>Retention</th>
@@ -84,17 +85,18 @@
<td>
{% if host.stats_summary.latest_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_run.id %}">Run {{ host.stats_summary.latest_run.id }}</a>
<div class="muted">{{ host.stats_summary.latest_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_run.duration_seconds is not None %}s{% endif %}</div>
<div class="muted">{{ 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 %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td>{% if host.next_run_at %}{{ host.next_run_at }}{% else %}<span class="muted">none</span>{% endif %}</td>
<td>{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</td>
<td>{{ host.run_count }}</td>
<td>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</td>
</tr>
{% empty %}
<tr><td colspan="9" class="muted">No hosts configured yet.</td></tr>
<tr><td colspan="10" class="muted">No hosts configured yet.</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -52,6 +52,7 @@
<div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div>
<div class="muted">Evaluated by the pobsync scheduler service.</div>
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
<div><strong>Next run:</strong> {{ next_run_at|default:"" }}</div>
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
@@ -93,6 +94,7 @@
<thead>
<tr>
<th>Run</th>
<th>Type</th>
<th>Started</th>
<th>Duration</th>
<th>Files</th>
@@ -106,6 +108,7 @@
{% for run in stats_summary.runs %}
<tr>
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
<td>{{ run.run_type }}</td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.duration_seconds|default:"" }}{% if run.duration_seconds is not None %}s{% endif %}</td>
<td>{{ run.rsync.files_total|default:"" }}</td>

View File

@@ -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:

View File

@@ -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")

View File

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