diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py index e19ecee..79e5d7e 100644 --- a/src/pobsync/commands/run_scheduled.py +++ b/src/pobsync/commands/run_scheduled.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, Callable from ..config.source import ConfigSource, FileConfigSource from ..errors import ConfigError @@ -21,6 +21,15 @@ from ..snapshot_meta import read_snapshot_meta from ..util import ensure_dir, realpath_startswith, sanitize_host, write_yaml_atomic +DEFAULT_DRY_RUN_TIMEOUT_SECONDS = 900 + + +def dry_run_log_path(host: str, run_id: int | None = None) -> Path: + host = sanitize_host(host) + run_dir = f"run-{run_id}" if run_id is not None else "adhoc" + return Path(f"/tmp/pobsync-dryrun/{host}/{run_dir}/rsync.log") + + def _read_log_tail(log_path: Path, *, max_lines: int = 40) -> list[str]: try: lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() @@ -96,6 +105,8 @@ def run_scheduled( prune_max_delete: int | None = None, prune_protect_bases: bool = False, config_source: ConfigSource | None = None, + run_id: int | None = None, + cancel_check: Callable[[], bool] | None = None, ) -> dict[str, Any]: host = sanitize_host(host) @@ -149,9 +160,10 @@ def run_scheduled( # DRY RUN # ------------------------------------------------------------ if dry_run: - dest = f"/tmp/pobsync-dryrun/{host}/" - dryrun_log = Path(f"/tmp/pobsync-dryrun/{host}/rsync.log") + dryrun_log = dry_run_log_path(host, run_id=run_id) + dest = str(dryrun_log.parent) + "/" dryrun_log.unlink(missing_ok=True) + effective_timeout_seconds = timeout_seconds or DEFAULT_DRY_RUN_TIMEOUT_SECONDS cmd = build_rsync_command( rsync_binary=str(rsync_binary), @@ -161,13 +173,18 @@ def run_scheduled( dest=dest, link_dest=link_dest, dry_run=True, - timeout_seconds=timeout_seconds, + timeout_seconds=effective_timeout_seconds, bwlimit_kbps=bwlimit_kbps, extra_excludes=list(excludes), extra_includes=list(includes), ) - result = run_rsync(cmd, log_path=dryrun_log, timeout_seconds=timeout_seconds) + result = run_rsync( + cmd, + log_path=dryrun_log, + timeout_seconds=effective_timeout_seconds, + cancel_check=cancel_check, + ) log_tail = _read_log_tail(dryrun_log) return { @@ -176,6 +193,8 @@ def run_scheduled( "host": host, "base": str(base_dir) if base_dir else None, "log": str(dryrun_log), + "cancelled": result.cancelled, + "timeout_seconds": effective_timeout_seconds, "ssh_credential": cfg.get("ssh_credential"), "rsync": { "exit_code": result.exit_code, @@ -242,13 +261,13 @@ def run_scheduled( log_path.touch(exist_ok=True) write_yaml_atomic(meta_path, meta) - result = run_rsync(cmd, log_path=log_path, timeout_seconds=timeout_seconds) + result = run_rsync(cmd, log_path=log_path, timeout_seconds=timeout_seconds, cancel_check=cancel_check) end_ts = utc_now() meta["ended_at"] = format_iso_z(end_ts) meta["duration_seconds"] = int((end_ts - ts).total_seconds()) meta["rsync"]["exit_code"] = result.exit_code - meta["status"] = "success" if result.exit_code == 0 else "failed" + meta["status"] = "cancelled" if result.cancelled else ("success" if result.exit_code == 0 else "failed") write_yaml_atomic(meta_path, meta) if not log_path.exists(): @@ -270,6 +289,7 @@ def run_scheduled( "host": host, "snapshot": str(incomplete_dir), "status": meta["status"], + "cancelled": result.cancelled, "log": str(log_path), "ssh_credential": cfg.get("ssh_credential"), "rsync": { diff --git a/src/pobsync/rsync.py b/src/pobsync/rsync.py index 8182c57..a6d0967 100644 --- a/src/pobsync/rsync.py +++ b/src/pobsync/rsync.py @@ -1,16 +1,20 @@ from __future__ import annotations +import os +import signal import shlex import subprocess +import time from dataclasses import dataclass from pathlib import Path -from typing import Sequence +from typing import Callable, Sequence @dataclass(frozen=True) class RsyncResult: exit_code: int command: list[str] + cancelled: bool = False def build_ssh_command(ssh_cfg: dict) -> list[str]: @@ -66,7 +70,12 @@ def build_rsync_command( -def run_rsync(command: list[str], log_path: Path, timeout_seconds: int) -> RsyncResult: +def run_rsync( + command: list[str], + log_path: Path, + timeout_seconds: int, + cancel_check: Callable[[], bool] | None = None, +) -> RsyncResult: """ Run rsync and always write stdout/stderr to log_path. @@ -77,17 +86,33 @@ def run_rsync(command: list[str], log_path: Path, timeout_seconds: int) -> Rsync # Ensure the file exists early. log_path.touch(exist_ok=True) + with log_path.open("ab") as f: + process = subprocess.Popen(command, stdout=f, stderr=subprocess.STDOUT, start_new_session=True) + started = time.monotonic() + while True: + exit_code = process.poll() + if exit_code is not None: + return RsyncResult(exit_code=exit_code, command=command) + + if cancel_check is not None and cancel_check(): + _terminate_process_group(process) + f.write(b"\n[pobsync] rsync cancelled\n") + return RsyncResult(exit_code=130, command=command, cancelled=True) + + if timeout_seconds > 0 and time.monotonic() - started >= timeout_seconds: + _terminate_process_group(process) + f.write(b"\n[pobsync] rsync timed out\n") + return RsyncResult(exit_code=124, command=command) + + time.sleep(1) + + +def _terminate_process_group(process: subprocess.Popen) -> None: try: - with log_path.open("ab") as f: - p = subprocess.run( - command, - stdout=f, - stderr=subprocess.STDOUT, - timeout=timeout_seconds if timeout_seconds > 0 else None, - ) - return RsyncResult(exit_code=p.returncode, command=command) - except subprocess.TimeoutExpired as e: - # Log timeout info and return a non-zero exit code. - with log_path.open("ab") as f: - f.write(b"\n[pobsync] rsync timed out\n") - return RsyncResult(exit_code=124, command=command) + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=10) + except ProcessLookupError: + return + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=10) diff --git a/src/pobsync_backend/backup_runner.py b/src/pobsync_backend/backup_runner.py index 0ed54a4..f62e10e 100644 --- a/src/pobsync_backend/backup_runner.py +++ b/src/pobsync_backend/backup_runner.py @@ -5,7 +5,7 @@ from pathlib import Path from django.db import transaction from django.utils import timezone -from pobsync.commands.run_scheduled import run_scheduled +from pobsync.commands.run_scheduled import dry_run_log_path, run_scheduled from pobsync_backend.config_source import DjangoConfigSource from pobsync_backend.models import BackupRun, HostConfig from pobsync_backend.retention import run_sql_retention_apply @@ -47,7 +47,8 @@ def execute_backup_run( ) -> BackupRun: run.status = BackupRun.Status.RUNNING run.started_at = run.started_at or timezone.now() - run.save(update_fields=["status", "started_at"]) + run.result = _running_result(run=run, dry_run=bool(dry_run)) + run.save(update_fields=["status", "started_at", "result"]) try: result = run_scheduled( @@ -56,15 +57,27 @@ def execute_backup_run( dry_run=bool(dry_run), prune=False, config_source=DjangoConfigSource(), + run_id=run.id, + cancel_check=lambda: _run_cancel_requested(run.id), ) except Exception as exc: - run.status = BackupRun.Status.FAILED + run.refresh_from_db() + run.status = BackupRun.Status.CANCELLED if run.status == BackupRun.Status.CANCELLED else BackupRun.Status.FAILED run.ended_at = timezone.now() - run.result = {"ok": False, "error": str(exc), "type": type(exc).__name__} + run.result = { + **(run.result if isinstance(run.result, dict) else {}), + "ok": False, + "error": str(exc), + "type": type(exc).__name__, + } run.save(update_fields=["status", "ended_at", "result"]) raise - run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED + run.refresh_from_db() + if result.get("cancelled") or run.status == BackupRun.Status.CANCELLED: + run.status = BackupRun.Status.CANCELLED + else: + run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED run.ended_at = timezone.now() run.snapshot_path = str(result.get("snapshot") or "") run.base_path = str(result.get("base") or "") @@ -146,3 +159,18 @@ def requested_options(run: BackupRun) -> dict[str, object]: if not isinstance(requested, dict): return {} return requested + + +def _running_result(*, run: BackupRun, dry_run: bool) -> dict[str, object]: + result = dict(run.result) if isinstance(run.result, dict) else {} + execution = { + "started_at": (run.started_at or timezone.now()).isoformat(), + } + if dry_run: + execution["log"] = str(dry_run_log_path(run.host.host, run_id=run.id)) + result["execution"] = execution + return result + + +def _run_cancel_requested(run_id: int) -> bool: + return BackupRun.objects.filter(id=run_id, status=BackupRun.Status.CANCELLED).exists() diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index ae585a4..309f178 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -86,7 +86,6 @@ Started Ended Snapshot - Rsync @@ -97,10 +96,9 @@ {{ run.started_at|default:"" }} {{ run.ended_at|default:"" }} {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %} - {{ run.rsync_exit_code|default:"" }} {% empty %} - No backup runs recorded yet. + No backup runs recorded 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 8a25f97..f812772 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -175,7 +175,6 @@ Ended Snapshot Base - Rsync @@ -186,10 +185,9 @@ {{ run.ended_at|default:"" }} {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %} {{ run.base_path|default:"" }} - {{ run.rsync_exit_code|default:"" }} {% empty %} - No backup runs recorded for this host. + No backup runs recorded for this host. {% endfor %} diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html index 1583fdc..c8bc9d3 100644 --- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html @@ -7,6 +7,12 @@
Back to host + {% if can_cancel %} +
+ {% csrf_token %} + +
+ {% endif %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html b/src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html index 514d7b6..abeb3d3 100644 --- a/src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/snapshot_detail.html @@ -47,7 +47,6 @@ Status Started Ended - Rsync @@ -57,10 +56,9 @@ {{ run.status }} {{ run.started_at|default:"" }} {{ run.ended_at|default:"" }} - {{ run.rsync_exit_code|default:"" }} {% empty %} - No backup runs linked to this snapshot. + No backup runs linked to this snapshot. {% endfor %} diff --git a/src/pobsync_backend/tests/test_backup_worker.py b/src/pobsync_backend/tests/test_backup_worker.py index eef53a8..26739bb 100644 --- a/src/pobsync_backend/tests/test_backup_worker.py +++ b/src/pobsync_backend/tests/test_backup_worker.py @@ -48,22 +48,87 @@ class BackupWorkerTests(TestCase): run = queue_backup_run(host=host) with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled: - run_scheduled.return_value = { - "ok": True, - "dry_run": False, - "host": host.host, - "snapshot": str(snapshot_dir), - "base": None, - "rsync": {"exit_code": 0}, - } + def fake_run_scheduled(**kwargs): + run.refresh_from_db() + self.assertIn("execution", run.result) + return { + "ok": True, + "dry_run": False, + "host": host.host, + "snapshot": str(snapshot_dir), + "base": None, + "rsync": {"exit_code": 0}, + } + + run_scheduled.side_effect = fake_run_scheduled count = Command()._run_once(prefix=Path(tmp) / "home") + run_scheduled.assert_called_once() self.assertEqual(count, 1) + self.assertEqual(run_scheduled.call_args.kwargs["run_id"], run.id) run.refresh_from_db() self.assertEqual(run.status, BackupRun.Status.SUCCESS) self.assertEqual(SnapshotRecord.objects.count(), 1) self.assertEqual(run.snapshot, SnapshotRecord.objects.get()) + def test_worker_records_dry_run_log_path_while_running(self) -> None: + with TemporaryDirectory() as tmp: + GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups")) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = queue_backup_run(host=host, dry_run=True) + + with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled: + def fake_run_scheduled(**kwargs): + run.refresh_from_db() + self.assertEqual( + run.result["execution"]["log"], + f"/tmp/pobsync-dryrun/{host.host}/run-{run.id}/rsync.log", + ) + return { + "ok": True, + "dry_run": True, + "host": host.host, + "base": None, + "log": run.result["execution"]["log"], + "rsync": {"exit_code": 0}, + } + + run_scheduled.side_effect = fake_run_scheduled + count = Command()._run_once(prefix=Path(tmp) / "home") + + self.assertEqual(count, 1) + run.refresh_from_db() + self.assertEqual(run.status, BackupRun.Status.SUCCESS) + self.assertEqual(run.result["log"], f"/tmp/pobsync-dryrun/{host.host}/run-{run.id}/rsync.log") + + def test_worker_preserves_cancelled_status_from_running_run(self) -> None: + with TemporaryDirectory() as tmp: + GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups")) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = queue_backup_run(host=host, dry_run=True) + + with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled: + def fake_run_scheduled(**kwargs): + BackupRun.objects.filter(id=run.id).update(status=BackupRun.Status.CANCELLED) + self.assertTrue(kwargs["cancel_check"]()) + return { + "ok": False, + "dry_run": True, + "cancelled": True, + "host": host.host, + "base": None, + "log": f"/tmp/pobsync-dryrun/{host.host}/run-{run.id}/rsync.log", + "rsync": {"exit_code": 130}, + } + + run_scheduled.side_effect = fake_run_scheduled + count = Command()._run_once(prefix=Path(tmp) / "home") + + self.assertEqual(count, 1) + run.refresh_from_db() + self.assertEqual(run.status, BackupRun.Status.CANCELLED) + self.assertEqual(run.rsync_exit_code, 130) + def test_worker_returns_zero_without_queued_runs(self) -> None: count = Command()._run_once(prefix=Path("/opt/pobsync")) diff --git a/src/pobsync_backend/tests/test_run_scheduled_config_source.py b/src/pobsync_backend/tests/test_run_scheduled_config_source.py index 6e838dd..5c5573c 100644 --- a/src/pobsync_backend/tests/test_run_scheduled_config_source.py +++ b/src/pobsync_backend/tests/test_run_scheduled_config_source.py @@ -50,7 +50,7 @@ class RunScheduledConfigSourceTests(SimpleTestCase): run_rsync.assert_called_once() def test_failed_dry_run_includes_log_tail(self) -> None: - def fake_run_rsync(command, log_path, timeout_seconds): + def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text("Permission denied (publickey).\nrsync error\n", encoding="utf-8") return RsyncResult(exit_code=12, command=command) @@ -68,13 +68,13 @@ class RunScheduledConfigSourceTests(SimpleTestCase): self.assertEqual(result["rsync"]["log_tail"], ["Permission denied (publickey).", "rsync error"]) def test_dry_run_clears_previous_log_before_running(self) -> None: - def fake_run_rsync(command, log_path, timeout_seconds): + def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): self.assertFalse(log_path.exists()) log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text("current run only\n", encoding="utf-8") return RsyncResult(exit_code=0, command=command) - old_log = Path("/tmp/pobsync-dryrun/web-01/rsync.log") + old_log = Path("/tmp/pobsync-dryrun/web-01/adhoc/rsync.log") old_log.parent.mkdir(parents=True, exist_ok=True) old_log.write_text("old failure\n", encoding="utf-8") @@ -89,6 +89,47 @@ class RunScheduledConfigSourceTests(SimpleTestCase): self.assertTrue(result["ok"]) self.assertEqual(result["rsync"]["log_tail"], ["current run only"]) + def test_dry_run_uses_run_specific_log_path_and_default_timeout(self) -> None: + def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): + self.assertEqual(log_path, Path("/tmp/pobsync-dryrun/web-01/run-42/rsync.log")) + self.assertEqual(timeout_seconds, 900) + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text("run 42\n", encoding="utf-8") + return RsyncResult(exit_code=0, command=command) + + with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync): + result = run_scheduled( + prefix=Path("/missing-prefix"), + host="web-01", + dry_run=True, + config_source=FakeConfigSource(), + run_id=42, + ) + + self.assertTrue(result["ok"]) + self.assertEqual(result["log"], "/tmp/pobsync-dryrun/web-01/run-42/rsync.log") + self.assertEqual(result["timeout_seconds"], 900) + + def test_dry_run_reports_cancelled_rsync(self) -> None: + def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): + self.assertTrue(cancel_check()) + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text("[pobsync] rsync cancelled\n", encoding="utf-8") + return RsyncResult(exit_code=130, command=command, cancelled=True) + + with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync): + result = run_scheduled( + prefix=Path("/missing-prefix"), + host="web-01", + dry_run=True, + config_source=FakeConfigSource(), + cancel_check=lambda: True, + ) + + self.assertFalse(result["ok"]) + self.assertTrue(result["cancelled"]) + self.assertEqual(result["rsync"]["exit_code"], 130) + def test_successful_real_run_applies_prune_when_requested(self) -> None: with TemporaryDirectory() as tmp: prefix = Path(tmp) / "home" diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index e1d92a7..d5bdb28 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -764,6 +764,44 @@ class ViewTests(TestCase): self.assertContains(response, ""ok": true") self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) + def test_run_detail_offers_cancel_for_running_run(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING) + + response = self.client.get(reverse("run_detail", args=[run.id])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Cancel run") + self.assertContains(response, reverse("cancel_run", args=[run.id])) + + def test_cancel_run_marks_queued_run_cancelled(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.QUEUED) + + response = self.client.post(reverse("cancel_run", args=[run.id]), follow=True) + + self.assertRedirects(response, reverse("run_detail", args=[run.id])) + self.assertContains(response, "Cancellation requested") + run.refresh_from_db() + self.assertEqual(run.status, BackupRun.Status.CANCELLED) + self.assertIsNotNone(run.ended_at) + self.assertEqual(run.result["cancellation"]["previous_status"], BackupRun.Status.QUEUED) + + def test_cancel_run_requests_running_run_cancellation(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + run = BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING) + + response = self.client.post(reverse("cancel_run", args=[run.id]), follow=True) + + self.assertRedirects(response, reverse("run_detail", args=[run.id])) + run.refresh_from_db() + self.assertEqual(run.status, BackupRun.Status.CANCELLED) + self.assertIsNone(run.ended_at) + self.assertEqual(run.result["cancellation"]["previous_status"], BackupRun.Status.RUNNING) + def test_snapshot_detail_renders_metadata_runs_and_children(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 3ea753b..78cc6b9 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -10,6 +10,7 @@ from django.contrib.admin.views.decorators import staff_member_required from django.conf import settings from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone from django.views.decorators.http import require_POST from pobsync.errors import PobsyncError @@ -348,12 +349,37 @@ def run_detail(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) context = { "run": run, + "can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}, "requested": run.result.get("requested") if isinstance(run.result, dict) else {}, "result_json": _pretty_json(run.result), } return render(request, "pobsync_backend/run_detail.html", context) +@staff_member_required +@require_POST +def cancel_run(request, run_id: int): + run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id) + if run.status not in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}: + messages.warning(request, f"Run {run.id} is already {run.status}.") + return redirect("run_detail", run_id=run.id) + + result = dict(run.result) if isinstance(run.result, dict) else {} + result["cancellation"] = { + "requested_at": timezone.now().isoformat(), + "previous_status": run.status, + } + update_fields = ["status", "result"] + run.status = BackupRun.Status.CANCELLED + run.result = result + if result["cancellation"]["previous_status"] == BackupRun.Status.QUEUED: + run.ended_at = timezone.now() + update_fields.append("ended_at") + run.save(update_fields=update_fields) + messages.success(request, f"Cancellation requested for run {run.id}.") + return redirect("run_detail", run_id=run.id) + + @staff_member_required def snapshot_detail(request, snapshot_id: int): snapshot = get_object_or_404( diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 9b91de3..cf13fc1 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ path("hosts//retention-plan/", views.host_retention_plan, name="host_retention_plan"), path("hosts//schedule/", views.edit_host_schedule, name="edit_host_schedule"), path("runs//", views.run_detail, name="run_detail"), + path("runs//cancel/", views.cancel_run, name="cancel_run"), path("snapshots//", views.snapshot_detail, name="snapshot_detail"), path("api/", api.api_index), path("api/status/", api.status),