diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..cf20ba5
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,37 @@
+# Changelog
+
+## 1.0.0 - 2026-05-21
+
+Initial stable release of the Django-first pobsync control panel.
+
+### Added
+
+- Django control panel for hosts, global settings, schedules, SSH credentials, snapshots, runs, self-checks, and logs.
+- Native systemd installer and updater for production backup servers.
+- SQLite by default, with optional MariaDB support.
+- Scheduler and worker services for queued manual backups and scheduled backups.
+- Manual backup, dry-run, cancellation, verbose rsync logging, and run detail views.
+- Snapshot discovery for existing backup directories and SQL-backed snapshot records.
+- SQL retention planning and apply flow with base snapshot protection and incomplete snapshot visibility.
+- Explicit cleanup flow for incomplete snapshots, separate from normal retention pruning.
+- Purged snapshot audit overview with reason, action source, operator, host, kind, path, and timestamp.
+- Dashboard and host pages with backup health, latest run/snapshot, next run, and storage/stat summaries.
+- Review resolution for failed/warning runs and incomplete snapshot tasks so operational warnings can be acknowledged.
+- Worker heartbeat metadata and stale running-run reconciliation for queued backup workers.
+- SSH key generation, upload, edit, guarded delete, known_hosts management, and per-host key selection.
+- In-app changelog page sourced from this changelog.
+- Restore guidance on snapshot detail pages.
+
+### Changed
+
+- Django and the database are now the source of truth for configuration.
+- Docker Compose is documented as development and disposable test tooling rather than the primary production path.
+- The `pobsync` console entrypoint is now a maintainer layer around Django management commands.
+- Scheduled pruning is evaluated by the pobsync scheduler service and recorded through Django, not host cron.
+- Retention and incomplete cleanup now preserve audit history even after source snapshot records are removed.
+
+### Removed
+
+- Legacy YAML config import/export workflow.
+- Public short aliases for configuration commands.
+- Obsolete global config storage fields.
diff --git a/Dockerfile b/Dockerfile
index 5bcf687..70ce399 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,7 +10,7 @@ RUN apt-get update \
WORKDIR /app
-COPY pyproject.toml README.md ./
+COPY pyproject.toml README.md CHANGELOG.md ./
COPY src ./src
COPY manage.py ./
COPY scripts/docker-entrypoint ./scripts/docker-entrypoint
diff --git a/pyproject.toml b/pyproject.toml
index 10efdfe..a7b13da 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pobsync"
-version = "0.1.0"
+version = "1.0.0"
description = "Pull-based rsync backup tool with hardlinked snapshots"
requires-python = ">=3.11"
dependencies = [
diff --git a/src/pobsync/__init__.py b/src/pobsync/__init__.py
index 4e3a74d..db084d0 100644
--- a/src/pobsync/__init__.py
+++ b/src/pobsync/__init__.py
@@ -1,3 +1,2 @@
__all__ = ["__version__"]
-__version__ = "0.1.0"
-
+__version__ = "1.0.0"
diff --git a/src/pobsync/cli.py b/src/pobsync/cli.py
index 46a7c8f..bf5d05b 100644
--- a/src/pobsync/cli.py
+++ b/src/pobsync/cli.py
@@ -6,6 +6,8 @@ from typing import Sequence
from django.core.management import execute_from_command_line
+from pobsync import __version__
+
COMMAND_ALIASES = {
"backup": "run_pobsync_backup",
@@ -34,6 +36,9 @@ Configuration is managed from the Django control panel. Use
def main(argv: Sequence[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
+ if args and args[0] in {"--version", "version"}:
+ print(f"pobsync {__version__}")
+ return 0
if not args or args[0] in {"-h", "--help", "help"}:
print(_usage())
return 0
diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py
index 327ecb0..f5a2cde 100644
--- a/src/pobsync_backend/admin.py
+++ b/src/pobsync_backend/admin.py
@@ -6,7 +6,7 @@ from django.urls import reverse
from django.utils.html import format_html
from django.utils.http import urlencode
-from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
+from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
@admin.register(SshCredential)
@@ -173,6 +173,16 @@ class SnapshotRecordAdmin(admin.ModelAdmin):
return format_html('{} ', url, count)
+@admin.register(PurgedSnapshot)
+class PurgedSnapshotAdmin(admin.ModelAdmin):
+ list_display = ("host_name", "kind", "dirname", "action", "reason", "triggered_by", "purged_at")
+ list_filter = ("action", "kind", "purged_at")
+ search_fields = ("host_name", "dirname", "path", "reason", "triggered_by")
+ list_select_related = ("host",)
+ readonly_fields = ("purged_at",)
+ date_hierarchy = "purged_at"
+
+
@admin.register(ScheduleConfig)
class ScheduleConfigAdmin(admin.ModelAdmin):
list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at")
diff --git a/src/pobsync_backend/backup_runner.py b/src/pobsync_backend/backup_runner.py
index 68bbb6f..f9975fb 100644
--- a/src/pobsync_backend/backup_runner.py
+++ b/src/pobsync_backend/backup_runner.py
@@ -1,6 +1,8 @@
from __future__ import annotations
-from datetime import timedelta
+import os
+import socket
+from datetime import timedelta, timezone as datetime_timezone
from pathlib import Path
from django.db import transaction
@@ -107,6 +109,7 @@ def execute_backup_run(
protect_bases=bool(prune_protect_bases),
yes=True,
max_delete=int(prune_max_delete),
+ action=run.run_type,
acquire_lock=False,
)
except Exception as exc:
@@ -158,10 +161,10 @@ def claim_next_queued_run() -> BackupRun | None:
return run
-def reconcile_running_runs(*, grace_seconds: int = 300) -> int:
+def reconcile_running_runs(*, grace_seconds: int = 300, stale_worker_seconds: int = 24 * 60 * 60) -> int:
reconciled = 0
for run in BackupRun.objects.select_related("host").filter(status=BackupRun.Status.RUNNING).order_by("started_at", "id"):
- if _reconcile_running_run(run=run, grace_seconds=grace_seconds):
+ if _reconcile_running_run(run=run, grace_seconds=grace_seconds, stale_worker_seconds=stale_worker_seconds):
reconciled += 1
return reconciled
@@ -176,7 +179,9 @@ def requested_options(run: BackupRun) -> dict[str, object]:
def _running_result(*, run: BackupRun, dry_run: bool) -> dict[str, object]:
result = dict(run.result) if isinstance(run.result, dict) else {}
execution = {
+ **_worker_execution_details(),
"started_at": (run.started_at or timezone.now()).isoformat(),
+ "heartbeat_at": timezone.now().isoformat(),
}
if dry_run:
execution["log"] = str(dry_run_log_path(run.host.host, run_id=run.id))
@@ -185,24 +190,56 @@ def _running_result(*, run: BackupRun, dry_run: bool) -> dict[str, object]:
def _run_cancel_requested(run_id: int) -> bool:
- return BackupRun.objects.filter(id=run_id, status=BackupRun.Status.CANCELLED).exists()
+ try:
+ run = BackupRun.objects.only("id", "status", "result").get(id=run_id)
+ except BackupRun.DoesNotExist:
+ return True
+ if run.status == BackupRun.Status.CANCELLED:
+ return True
+ if run.status == BackupRun.Status.RUNNING:
+ _refresh_run_heartbeat(run)
+ return False
-def _reconcile_running_run(*, run: BackupRun, grace_seconds: int) -> bool:
+def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_seconds: int) -> bool:
result = run.result if isinstance(run.result, dict) else {}
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
+ stale_worker = _running_worker_timed_out(run=run, stale_worker_seconds=stale_worker_seconds)
if not requested.get("dry_run"):
+ if stale_worker:
+ result.update(
+ {
+ "ok": False,
+ "host": run.host.host,
+ "failure": {
+ "category": "worker",
+ "message": "The worker heartbeat stopped before the run finished.",
+ "hint": "Check pobsync-worker.service logs before retrying the backup.",
+ },
+ }
+ )
+ run.status = BackupRun.Status.FAILED
+ run.ended_at = timezone.now()
+ run.result = result
+ run.save(update_fields=["status", "ended_at", "result"])
+ return True
return False
log_path = _execution_log_path(result)
log_tail = _read_log_tail(log_path) if log_path is not None else []
terminal_log = _terminal_rsync_log(log_tail)
timed_out = _running_dry_run_timed_out(run=run, grace_seconds=grace_seconds)
- if not terminal_log and not timed_out:
+ if not terminal_log and not timed_out and not stale_worker:
return False
- exit_code = _exit_code_from_log(log_tail) or (124 if timed_out else 255)
+ exit_code = _exit_code_from_log(log_tail) or (124 if timed_out or stale_worker else 255)
failure = classify_rsync_failure(exit_code, log_tail)
+ if stale_worker and not terminal_log:
+ failure = {
+ "category": "worker",
+ "message": "The worker heartbeat stopped before the dry-run finished.",
+ "hint": "Check pobsync-worker.service logs before retrying the dry-run.",
+ }
result.update(
{
"ok": False,
@@ -226,6 +263,30 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int) -> bool:
return True
+def _worker_execution_details() -> dict[str, object]:
+ return {
+ "worker_pid": os.getpid(),
+ "worker_host": socket.gethostname(),
+ "claimed_at": timezone.now().isoformat(),
+ }
+
+
+def _refresh_run_heartbeat(run: BackupRun, *, interval_seconds: int = 30) -> None:
+ result = run.result if isinstance(run.result, dict) else {}
+ execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
+ heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
+ if heartbeat_at is not None and timezone.now() < heartbeat_at + timedelta(seconds=interval_seconds):
+ return
+ result["execution"] = {
+ **execution,
+ "worker_pid": os.getpid(),
+ "worker_host": socket.gethostname(),
+ "heartbeat_at": timezone.now().isoformat(),
+ }
+ run.result = result
+ run.save(update_fields=["result"])
+
+
def _execution_log_path(result: dict[str, object]) -> Path | None:
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
log = execution.get("log") or result.get("log")
@@ -266,3 +327,28 @@ def _running_dry_run_timed_out(*, run: BackupRun, grace_seconds: int) -> bool:
if not isinstance(timeout_seconds, int) or timeout_seconds <= 0:
timeout_seconds = DEFAULT_DRY_RUN_TIMEOUT_SECONDS
return timezone.now() >= run.started_at + timedelta(seconds=timeout_seconds + grace_seconds)
+
+
+def _running_worker_timed_out(*, run: BackupRun, stale_worker_seconds: int) -> bool:
+ if stale_worker_seconds <= 0:
+ return False
+ result = run.result if isinstance(run.result, dict) else {}
+ execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
+ heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
+ if heartbeat_at is None:
+ heartbeat_at = run.started_at
+ if heartbeat_at is None:
+ return False
+ return timezone.now() >= heartbeat_at + timedelta(seconds=stale_worker_seconds)
+
+
+def _parse_iso_datetime(value: object):
+ if not isinstance(value, str) or not value:
+ return None
+ try:
+ parsed = timezone.datetime.fromisoformat(value)
+ except ValueError:
+ return None
+ if timezone.is_naive(parsed):
+ return timezone.make_aware(parsed, timezone=datetime_timezone.utc)
+ return parsed
diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py
index b177a40..174ca00 100644
--- a/src/pobsync_backend/forms.py
+++ b/src/pobsync_backend/forms.py
@@ -274,6 +274,36 @@ class RetentionApplyForm(forms.Form):
return value
+class IncompleteCleanupForm(forms.Form):
+ max_delete = forms.IntegerField(min_value=0, initial=0)
+ confirm_delete_count = forms.IntegerField(min_value=0)
+ confirm_host = forms.CharField()
+
+ def __init__(self, *args, host_name: str, expected_delete_count: int, **kwargs) -> None:
+ self.host_name = host_name
+ self.expected_delete_count = expected_delete_count
+ super().__init__(*args, **kwargs)
+ self.fields["confirm_host"].help_text = f"Type {host_name} to confirm incomplete snapshot cleanup."
+ self.fields["confirm_delete_count"].help_text = (
+ f"Type {expected_delete_count} to confirm the current number of incomplete snapshots."
+ )
+ self.fields["max_delete"].help_text = (
+ f"Must be at least {expected_delete_count} for the incomplete snapshots shown here."
+ )
+
+ def clean_confirm_host(self) -> str:
+ value = self.cleaned_data["confirm_host"].strip()
+ if value != self.host_name:
+ raise forms.ValidationError(f"Type {self.host_name} to confirm.")
+ return value
+
+ def clean_confirm_delete_count(self) -> int:
+ value = self.cleaned_data["confirm_delete_count"]
+ if value != self.expected_delete_count:
+ raise forms.ValidationError(f"Type {self.expected_delete_count} to confirm the incomplete count.")
+ return value
+
+
class ScheduleConfigForm(forms.ModelForm):
cron_expr = forms.CharField(
label="Schedule expression",
diff --git a/src/pobsync_backend/management/commands/run_pobsync_retention.py b/src/pobsync_backend/management/commands/run_pobsync_retention.py
index 2100a09..cbd098d 100644
--- a/src/pobsync_backend/management/commands/run_pobsync_retention.py
+++ b/src/pobsync_backend/management/commands/run_pobsync_retention.py
@@ -36,6 +36,7 @@ class Command(BaseCommand):
protect_bases=bool(options["protect_bases"]),
yes=True,
max_delete=int(options["max_delete"]),
+ action="cli",
)
else:
result = run_sql_retention_plan(
diff --git a/src/pobsync_backend/management/commands/run_pobsync_worker.py b/src/pobsync_backend/management/commands/run_pobsync_worker.py
index 1b3b200..b44ff91 100644
--- a/src/pobsync_backend/management/commands/run_pobsync_worker.py
+++ b/src/pobsync_backend/management/commands/run_pobsync_worker.py
@@ -19,6 +19,12 @@ class Command(BaseCommand):
parser.add_argument("--once", action="store_true", help="Process one queued run and exit")
parser.add_argument("--loop", action="store_true", help="Keep checking for queued runs")
parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds")
+ parser.add_argument(
+ "--stale-running-seconds",
+ type=int,
+ default=24 * 60 * 60,
+ help="Mark running runs failed after this many seconds without a worker heartbeat; use 0 to disable",
+ )
def handle(self, *args: Any, **options: Any) -> None:
if not options["once"] and not options["loop"]:
@@ -26,14 +32,14 @@ class Command(BaseCommand):
paths = PobsyncPaths(home=Path(options["prefix"]))
while True:
- count = self._run_once(prefix=paths.home)
+ count = self._run_once(prefix=paths.home, stale_running_seconds=int(options["stale_running_seconds"]))
self.stdout.write(f"Ran {count} queued backup run(s).")
if options["once"]:
return
time.sleep(max(1, int(options["interval"])))
- def _run_once(self, *, prefix: Path) -> int:
- reconciled = reconcile_running_runs()
+ def _run_once(self, *, prefix: Path, stale_running_seconds: int = 24 * 60 * 60) -> int:
+ reconciled = reconcile_running_runs(stale_worker_seconds=stale_running_seconds)
run = claim_next_queued_run()
if run is None:
return reconciled
diff --git a/src/pobsync_backend/migrations/0012_review_state.py b/src/pobsync_backend/migrations/0012_review_state.py
new file mode 100644
index 0000000..3d282a8
--- /dev/null
+++ b/src/pobsync_backend/migrations/0012_review_state.py
@@ -0,0 +1,30 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("pobsync_backend", "0011_remove_globalconfig_data"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="backuprun",
+ name="reviewed_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="backuprun",
+ name="reviewed_by",
+ field=models.CharField(blank=True, max_length=150),
+ ),
+ migrations.AddField(
+ model_name="snapshotrecord",
+ name="reviewed_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="snapshotrecord",
+ name="reviewed_by",
+ field=models.CharField(blank=True, max_length=150),
+ ),
+ ]
diff --git a/src/pobsync_backend/migrations/0013_purgedsnapshot.py b/src/pobsync_backend/migrations/0013_purgedsnapshot.py
new file mode 100644
index 0000000..fe66bb1
--- /dev/null
+++ b/src/pobsync_backend/migrations/0013_purgedsnapshot.py
@@ -0,0 +1,50 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("pobsync_backend", "0012_review_state"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PurgedSnapshot",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("host_name", models.CharField(max_length=255)),
+ ("kind", models.CharField(max_length=16)),
+ ("dirname", models.CharField(max_length=255)),
+ ("path", models.CharField(max_length=1024)),
+ ("reason", models.CharField(blank=True, max_length=512)),
+ (
+ "action",
+ models.CharField(
+ choices=[
+ ("manual", "Manual"),
+ ("scheduled", "Scheduled"),
+ ("cli", "CLI"),
+ ("incomplete_cleanup", "Incomplete cleanup"),
+ ],
+ max_length=32,
+ ),
+ ),
+ ("triggered_by", models.CharField(blank=True, max_length=150)),
+ ("metadata", models.JSONField(blank=True, default=dict)),
+ ("purged_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "host",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="purged_snapshots",
+ to="pobsync_backend.hostconfig",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-purged_at", "host_name", "dirname"],
+ },
+ ),
+ ]
diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py
index 93faa50..e890165 100644
--- a/src/pobsync_backend/models.py
+++ b/src/pobsync_backend/models.py
@@ -124,6 +124,8 @@ class BackupRun(models.Model):
rsync_exit_code = models.IntegerField(null=True, blank=True)
result = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
+ reviewed_at = models.DateTimeField(null=True, blank=True)
+ reviewed_by = models.CharField(max_length=150, blank=True)
class Meta:
ordering = ["-created_at"]
@@ -158,6 +160,8 @@ class SnapshotRecord(models.Model):
ended_at = models.DateTimeField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
discovered_at = models.DateTimeField(auto_now_add=True)
+ reviewed_at = models.DateTimeField(null=True, blank=True)
+ reviewed_by = models.CharField(max_length=150, blank=True)
class Meta:
constraints = [
@@ -169,6 +173,31 @@ class SnapshotRecord(models.Model):
return f"{self.host}/{self.kind}/{self.dirname}"
+class PurgedSnapshot(models.Model):
+ class Action(models.TextChoices):
+ MANUAL = "manual", "Manual"
+ SCHEDULED = "scheduled", "Scheduled"
+ CLI = "cli", "CLI"
+ INCOMPLETE_CLEANUP = "incomplete_cleanup", "Incomplete cleanup"
+
+ host = models.ForeignKey(HostConfig, on_delete=models.SET_NULL, null=True, blank=True, related_name="purged_snapshots")
+ host_name = models.CharField(max_length=255)
+ kind = models.CharField(max_length=16)
+ dirname = models.CharField(max_length=255)
+ path = models.CharField(max_length=1024)
+ reason = models.CharField(max_length=512, blank=True)
+ action = models.CharField(max_length=32, choices=Action.choices)
+ triggered_by = models.CharField(max_length=150, blank=True)
+ metadata = models.JSONField(default=dict, blank=True)
+ purged_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ["-purged_at", "host_name", "dirname"]
+
+ def __str__(self) -> str:
+ return f"{self.host_name}/{self.kind}/{self.dirname}"
+
+
class ScheduleConfig(TimestampedModel):
host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule")
cron_expr = models.CharField(max_length=128)
diff --git a/src/pobsync_backend/retention.py b/src/pobsync_backend/retention.py
index d054078..c658db3 100644
--- a/src/pobsync_backend/retention.py
+++ b/src/pobsync_backend/retention.py
@@ -12,7 +12,7 @@ from pobsync.paths import PobsyncPaths
from pobsync.retention import Snapshot, apply_base_protection, build_retention_plan
from pobsync.util import sanitize_host
-from .models import HostConfig, SnapshotRecord
+from .models import HostConfig, PurgedSnapshot, SnapshotRecord
def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict[str, Any]:
@@ -65,6 +65,8 @@ def run_sql_retention_apply(
protect_bases: bool,
yes: bool,
max_delete: int,
+ action: str = PurgedSnapshot.Action.MANUAL,
+ triggered_by: str = "",
acquire_lock: bool = True,
) -> dict[str, Any]:
host = sanitize_host(host)
@@ -101,6 +103,7 @@ def run_sql_retention_apply(
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
+ reason = str(item.get("reason") or "outside retention policy")
if not path.exists():
actions.append(f"skip missing {snap_kind}/{dirname}")
continue
@@ -108,9 +111,19 @@ def run_sql_retention_apply(
raise ConfigError(f"Refusing to delete non-directory path: {path}")
_remove_snapshot_tree(path)
+ _record_purged_snapshot(
+ host_config=_enabled_host_config(host),
+ kind=snap_kind,
+ dirname=dirname,
+ path=path,
+ reason=reason,
+ action=action,
+ triggered_by=triggered_by,
+ metadata={"source": "retention", "protect_bases": bool(protect_bases), "retention_kind": kind},
+ )
SnapshotRecord.objects.filter(host__host=host, kind=snap_kind, dirname=dirname).delete()
actions.append(f"deleted {snap_kind} {dirname}")
- deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path)})
+ deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path), "reason": reason})
return {
"ok": True,
@@ -131,6 +144,87 @@ def run_sql_retention_apply(
return _do_apply()
+def run_incomplete_cleanup(
+ *,
+ prefix: Path,
+ host: str,
+ yes: bool,
+ max_delete: int,
+ triggered_by: str = "",
+ acquire_lock: bool = True,
+) -> dict[str, Any]:
+ host = sanitize_host(host)
+ if not yes:
+ raise ConfigError("Refusing to delete incomplete snapshots without --yes")
+ if max_delete < 0:
+ raise ConfigError("--max-delete must be >= 0")
+
+ paths = PobsyncPaths(home=prefix)
+
+ def _do_cleanup() -> dict[str, Any]:
+ host_config = _enabled_host_config(host)
+ incomplete_list = [
+ _snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
+ for snapshot in _incomplete_snapshots_for_host(host_config)
+ ]
+ if max_delete == 0 and len(incomplete_list) > 0:
+ raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
+ if len(incomplete_list) > max_delete:
+ raise ConfigError(
+ f"Refusing to delete {len(incomplete_list)} incomplete snapshots (exceeds --max-delete={max_delete})"
+ )
+
+ actions: list[str] = []
+ deleted: list[dict[str, Any]] = []
+
+ for item in incomplete_list:
+ dirname = item["dirname"]
+ snap_path = Path(item["path"])
+ path = _snapshot_delete_path(path=snap_path, dirname=dirname)
+ _validate_incomplete_delete_path(host=host, path=path, dirname=dirname)
+
+ if not path.exists():
+ actions.append(f"skip missing incomplete/{dirname}")
+ elif not path.is_dir():
+ raise ConfigError(f"Refusing to delete non-directory path: {path}")
+ else:
+ _remove_snapshot_tree(path)
+ actions.append(f"deleted incomplete {dirname}")
+
+ _record_purged_snapshot(
+ host_config=host_config,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname=dirname,
+ path=path,
+ reason="manual incomplete cleanup",
+ action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP,
+ triggered_by=triggered_by,
+ metadata={"source": "incomplete_cleanup"},
+ )
+ SnapshotRecord.objects.filter(
+ host__host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname=dirname,
+ ).delete()
+ deleted.append({"dirname": dirname, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(path)})
+
+ return {
+ "ok": True,
+ "host": host,
+ "kind": SnapshotRecord.Kind.INCOMPLETE,
+ "max_delete": max_delete,
+ "source": "sql",
+ "planned_delete_count": len(incomplete_list),
+ "deleted": deleted,
+ "actions": actions,
+ }
+
+ if acquire_lock:
+ with acquire_host_lock(paths.locks_dir, host, command="incomplete-cleanup"):
+ return _do_cleanup()
+ return _do_cleanup()
+
+
def _enabled_host_config(host: str) -> HostConfig:
try:
return HostConfig.objects.get(host=host, enabled=True)
@@ -212,6 +306,39 @@ def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
return path
+def _record_purged_snapshot(
+ *,
+ host_config: HostConfig,
+ kind: str,
+ dirname: str,
+ path: Path,
+ reason: str,
+ action: str,
+ triggered_by: str,
+ metadata: dict[str, Any],
+) -> None:
+ PurgedSnapshot.objects.create(
+ host=host_config,
+ host_name=host_config.host,
+ kind=kind,
+ dirname=dirname,
+ path=str(path),
+ reason=reason,
+ action=action,
+ triggered_by=triggered_by,
+ metadata=metadata,
+ )
+
+
+def _validate_incomplete_delete_path(*, host: str, path: Path, dirname: str) -> None:
+ path_parts = path.parts
+ if path.name != dirname or ".incomplete" not in path_parts or host not in path_parts:
+ raise ConfigError(f"Refusing to delete unexpected incomplete snapshot path: {path}")
+ incomplete_index = path_parts.index(".incomplete")
+ if incomplete_index == 0 or path_parts[incomplete_index - 1] != host:
+ raise ConfigError(f"Refusing to delete incomplete snapshot outside host backup root: {path}")
+
+
def _remove_snapshot_tree(path: Path) -> None:
_make_directories_user_writable(path)
shutil.rmtree(path)
diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py
index 03de25d..62d5134 100644
--- a/src/pobsync_backend/stats_summary.py
+++ b/src/pobsync_backend/stats_summary.py
@@ -94,6 +94,7 @@ def _run_summary(run: BackupRun) -> dict[str, Any]:
"snapshot": run.snapshot,
"snapshot_path": run.snapshot_path,
"status": run.status,
+ "reviewed_at": run.reviewed_at,
"has_stats": bool(stats),
"duration_seconds": _int_at(stats, "duration_seconds"),
"rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {},
@@ -130,7 +131,7 @@ def _is_real_run(run: BackupRun) -> bool:
def _first_run_with_status(runs: list[dict[str, Any]], statuses: set[str]) -> dict[str, Any]:
for run in runs:
- if run["status"] in statuses:
+ if run["status"] in statuses and run.get("reviewed_at") is None:
return run
return {}
diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html
index 7177a6e..a54e024 100644
--- a/src/pobsync_backend/templates/pobsync_backend/base.html
+++ b/src/pobsync_backend/templates/pobsync_backend/base.html
@@ -375,6 +375,8 @@
SSH Keys
Self Check
Logs
+ Purged
+ Changelog
Status API
{{ request.user.username }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/changelog.html b/src/pobsync_backend/templates/pobsync_backend/changelog.html
new file mode 100644
index 0000000..87570fb
--- /dev/null
+++ b/src/pobsync_backend/templates/pobsync_backend/changelog.html
@@ -0,0 +1,41 @@
+{% extends "pobsync_backend/base.html" %}
+
+{% block title %}Changelog - pobsync{% endblock %}
+
+{% block content %}
+
Changelog
+
+
+
+
+
+
Installed version: {{ app_version }}
+
Source: {{ changelog_path }}
+ {% if missing %}
+
missing
+ {% endif %}
+
+
+
+ {% for block in changelog_blocks %}
+ {% if block.kind == "heading" %}
+ {% if block.level == 1 %}
+
{{ block.text }}
+ {% else %}
+
{{ block.text }}
+ {% endif %}
+ {% elif block.kind == "list" %}
+
+ {% for item in block.items %}
+ {{ item }}
+ {% endfor %}
+
+ {% else %}
+
{{ block.text }}
+ {% endif %}
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html
index b595b34..b6067fb 100644
--- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html
+++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html
@@ -68,7 +68,7 @@
{% endif %}
{% elif counts.hosts %}
- ok No queued, running, warning, or failed runs.
+ ok No queued, running, or unreviewed warning/failed runs.
{% else %}
Add a host to start tracking backup status here.
{% endif %}
@@ -243,6 +243,10 @@
{% endif %}
{% if host.retention_warning.incomplete_count %}
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
+
{% endif %}
{% if host.retention_warning.error %}
{{ host.retention_warning.error }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html
index e10f681..dae7006 100644
--- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html
+++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html
@@ -84,6 +84,10 @@
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
snapshots automatically; inspect them before cleanup.
+
{% endif %}
{% if retention_warning.error %}
{{ retention_warning.error }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html b/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html
new file mode 100644
index 0000000..8f54524
--- /dev/null
+++ b/src/pobsync_backend/templates/pobsync_backend/purged_snapshots.html
@@ -0,0 +1,74 @@
+{% extends "pobsync_backend/base.html" %}
+
+{% block title %}Purged Snapshots | pobsync{% endblock %}
+
+{% block content %}
+ Purged Snapshots
+
+
+
+
+
+
+ Purged Snapshot History
+ Showing up to 200 of {{ total_count }} purged snapshot record(s).
+
+
+
+ Purged
+ Host
+ Kind
+ Dirname
+ Action
+ Reason
+ Triggered by
+ Path
+
+
+
+ {% for snapshot in purged_snapshots %}
+
+ {{ snapshot.purged_at }}
+ {% if snapshot.host %}{{ snapshot.host_name }} {% else %}{{ snapshot.host_name }}{% endif %}
+ {{ snapshot.kind }}
+ {{ snapshot.dirname }}
+ {{ snapshot.get_action_display }}
+ {{ snapshot.reason|default:"" }}
+ {{ snapshot.triggered_by|default:"" }}
+ {{ snapshot.path }}
+
+ {% empty %}
+ No purged snapshots recorded yet.
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html
index ccacb38..617b7dc 100644
--- a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html
+++ b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html
@@ -40,6 +40,10 @@
{{ plan.incomplete|length }} incomplete snapshot(s) exist for this host. Retention does not delete incomplete
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
+
+ After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their
+ SQL records. Successful scheduled and manual snapshots are not touched by this cleanup.
+
{% endif %}
@@ -190,6 +194,37 @@
{% endfor %}
+
+ Cleanup Incomplete Snapshots
+
{% endif %}
{% endblock %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/run_detail.html b/src/pobsync_backend/templates/pobsync_backend/run_detail.html
index 6d82121..b9722c7 100644
--- a/src/pobsync_backend/templates/pobsync_backend/run_detail.html
+++ b/src/pobsync_backend/templates/pobsync_backend/run_detail.html
@@ -13,6 +13,14 @@
Cancel run
{% endif %}
+ {% if run.status == "failed" or run.status == "warning" %}
+ {% if not run.reviewed_at %}
+
+ {% endif %}
+ {% endif %}
{% endif %}
+ {% if run.reviewed_at %}
+
+ Review
+
+
Reviewed: {{ run.reviewed_at }}
+
Reviewed by: {{ run.reviewed_by|default:"unknown" }}
+
+
+ {% endif %}
+
{% if dry_run_summary %}
Dry Run Summary
@@ -79,6 +97,10 @@
Created: {{ run.created_at }}
Started: {{ run.started_at|default:"" }}
Ended: {{ run.ended_at|default:"" }}
+ {% if execution %}
+ Worker: {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}
+ Worker heartbeat: {{ execution.heartbeat_at|default:"" }}
+ {% endif %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html
index ee061bb..543915d 100644
--- a/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html
+++ b/src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html
@@ -45,9 +45,21 @@
{% if credential %}
Delete SSH Key
+ {% if credential.hosts.exists or credential.global_configs.exists %}
+
+ This SSH key is still selected by {{ credential.hosts.count }} host(s) or
+ {{ credential.global_configs.count }} global config(s). Select another key there before deleting it.
+
+ {% else %}
+ Type {{ credential.name }} to confirm deletion.
+ {% endif %}
{% endif %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html b/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html
index 71ec49e..cc031a6 100644
--- a/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html
+++ b/src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html
@@ -23,6 +23,7 @@
Known hosts
Hosts
Updated
+ Actions
@@ -35,9 +36,10 @@
{{ credential.known_hosts|yesno:"yes,no" }}
{{ credential.hosts.count }}
{{ credential.updated_at }}
+ Edit
{% empty %}
- No SSH credentials configured yet.
+ No SSH credentials configured yet.
{% endfor %}
diff --git a/src/pobsync_backend/tests/test_backup_worker.py b/src/pobsync_backend/tests/test_backup_worker.py
index 1f7175c..99db08d 100644
--- a/src/pobsync_backend/tests/test_backup_worker.py
+++ b/src/pobsync_backend/tests/test_backup_worker.py
@@ -61,6 +61,9 @@ class BackupWorkerTests(TestCase):
def fake_run_scheduled(**kwargs):
run.refresh_from_db()
self.assertIn("execution", run.result)
+ self.assertIn("worker_pid", run.result["execution"])
+ self.assertIn("worker_host", run.result["execution"])
+ self.assertIn("heartbeat_at", run.result["execution"])
return {
"ok": True,
"dry_run": False,
@@ -82,6 +85,57 @@ class BackupWorkerTests(TestCase):
self.assertEqual(SnapshotRecord.objects.count(), 1)
self.assertEqual(run.snapshot, SnapshotRecord.objects.get())
+ def test_worker_refreshes_heartbeat_while_run_is_active(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)
+
+ with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
+ def fake_run_scheduled(**kwargs):
+ run.refresh_from_db()
+ old_heartbeat = timezone.now() - timedelta(seconds=120)
+ run.result["execution"]["heartbeat_at"] = old_heartbeat.isoformat()
+ run.save(update_fields=["result"])
+
+ self.assertFalse(kwargs["cancel_check"]())
+ run.refresh_from_db()
+ self.assertGreater(
+ timezone.datetime.fromisoformat(run.result["execution"]["heartbeat_at"]),
+ old_heartbeat,
+ )
+ return {
+ "ok": True,
+ "dry_run": False,
+ "host": host.host,
+ "snapshot": "",
+ "base": None,
+ "rsync": {"exit_code": 0},
+ }
+
+ run_scheduled.side_effect = fake_run_scheduled
+ Command()._run_once(prefix=Path(tmp) / "home")
+
+ def test_worker_reconciles_stale_real_run_after_heartbeat_timeout(self) -> None:
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ run = queue_backup_run(host=host)
+ run.status = BackupRun.Status.RUNNING
+ run.started_at = timezone.now() - timedelta(seconds=120)
+ run.result["execution"] = {
+ "worker_pid": 123,
+ "worker_host": "backup",
+ "heartbeat_at": (timezone.now() - timedelta(seconds=90)).isoformat(),
+ }
+ run.save(update_fields=["status", "started_at", "result"])
+
+ reconciled = reconcile_running_runs(stale_worker_seconds=30)
+
+ self.assertEqual(reconciled, 1)
+ run.refresh_from_db()
+ self.assertEqual(run.status, BackupRun.Status.FAILED)
+ self.assertEqual(run.result["failure"]["category"], "worker")
+ self.assertIn("heartbeat stopped", run.result["failure"]["message"])
+
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"))
diff --git a/src/pobsync_backend/tests/test_console_entrypoint.py b/src/pobsync_backend/tests/test_console_entrypoint.py
index 8baa148..b5ce58a 100644
--- a/src/pobsync_backend/tests/test_console_entrypoint.py
+++ b/src/pobsync_backend/tests/test_console_entrypoint.py
@@ -9,6 +9,14 @@ from pobsync.cli import main
class ConsoleEntrypointTests(SimpleTestCase):
+ def test_version_prints_package_version(self) -> None:
+ stdout = StringIO()
+ with patch("sys.stdout", stdout):
+ exit_code = main(["--version"])
+
+ self.assertEqual(exit_code, 0)
+ self.assertEqual(stdout.getvalue().strip(), "pobsync 1.0.0")
+
def test_maps_backup_alias_to_django_command(self) -> None:
with patch("pobsync.cli.execute_from_command_line") as execute:
exit_code = main(["backup", "web-01", "--dry-run"])
diff --git a/src/pobsync_backend/tests/test_run_backup_records_snapshot.py b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py
index 493713b..291dfa2 100644
--- a/src/pobsync_backend/tests/test_run_backup_records_snapshot.py
+++ b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py
@@ -96,6 +96,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
protect_bases=True,
yes=True,
max_delete=3,
+ action=BackupRun.RunType.SCHEDULED,
acquire_lock=False,
)
run = BackupRun.objects.get()
diff --git a/src/pobsync_backend/tests/test_sql_retention.py b/src/pobsync_backend/tests/test_sql_retention.py
index bdb903f..9918353 100644
--- a/src/pobsync_backend/tests/test_sql_retention.py
+++ b/src/pobsync_backend/tests/test_sql_retention.py
@@ -11,8 +11,8 @@ from django.core.management import call_command
from django.test import TestCase
from pobsync.errors import ConfigError
-from pobsync_backend.models import HostConfig, SnapshotRecord
-from pobsync_backend.retention import run_sql_retention_apply, run_sql_retention_plan
+from pobsync_backend.models import HostConfig, PurgedSnapshot, SnapshotRecord
+from pobsync_backend.retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
class SqlRetentionTests(TestCase):
@@ -87,10 +87,26 @@ class SqlRetentionTests(TestCase):
self.assertTrue(new_dir.exists())
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
- self.assertEqual(result["deleted"], [{"dirname": old.dirname, "kind": "scheduled", "path": str(old_dir)}])
+ self.assertEqual(
+ result["deleted"],
+ [
+ {
+ "dirname": old.dirname,
+ "kind": "scheduled",
+ "path": str(old_dir),
+ "reason": "outside retention policy",
+ }
+ ],
+ )
self.assertEqual(result["planned_delete_count"], 1)
self.assertEqual(result["max_delete"], 1)
self.assertEqual(result["incomplete_ignored_count"], 0)
+ purged = PurgedSnapshot.objects.get(dirname=old.dirname)
+ self.assertEqual(purged.host_name, host.host)
+ self.assertEqual(purged.kind, "scheduled")
+ self.assertEqual(purged.path, str(old_dir))
+ self.assertEqual(purged.reason, "outside retention policy")
+ self.assertEqual(purged.action, PurgedSnapshot.Action.MANUAL)
def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None:
with TemporaryDirectory() as tmp:
@@ -126,7 +142,17 @@ class SqlRetentionTests(TestCase):
self.assertFalse(old_dir.exists())
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
- self.assertEqual(result["deleted"], [{"dirname": old.dirname, "kind": "scheduled", "path": str(old_dir)}])
+ self.assertEqual(
+ result["deleted"],
+ [
+ {
+ "dirname": old.dirname,
+ "kind": "scheduled",
+ "path": str(old_dir),
+ "reason": "outside retention policy",
+ }
+ ],
+ )
def test_apply_respects_max_delete(self) -> None:
host = HostConfig.objects.create(
@@ -152,6 +178,81 @@ class SqlRetentionTests(TestCase):
acquire_lock=False,
)
+ def test_incomplete_cleanup_deletes_directory_and_record(self) -> None:
+ with TemporaryDirectory() as tmp:
+ prefix = Path(tmp) / "home"
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ incomplete_dir = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-031500Z__BROKEN01"
+ incomplete_dir.mkdir(parents=True)
+ incomplete_dir.joinpath("partial-file").write_text("interrupted\n")
+ record = SnapshotRecord.objects.create(
+ host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname=incomplete_dir.name,
+ path=str(incomplete_dir),
+ status="failed",
+ started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
+ )
+
+ result = run_incomplete_cleanup(
+ prefix=prefix,
+ host=host.host,
+ yes=True,
+ max_delete=1,
+ acquire_lock=False,
+ )
+
+ self.assertFalse(incomplete_dir.exists())
+ self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
+ self.assertEqual(
+ result["deleted"],
+ [{"dirname": incomplete_dir.name, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(incomplete_dir)}],
+ )
+ self.assertEqual(result["planned_delete_count"], 1)
+ purged = PurgedSnapshot.objects.get(dirname=incomplete_dir.name)
+ self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
+ self.assertEqual(purged.reason, "manual incomplete cleanup")
+
+ def test_incomplete_cleanup_respects_max_delete(self) -> None:
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ SnapshotRecord.objects.create(
+ host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname="20260519-031500Z__BROKEN01",
+ path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
+ status="failed",
+ started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
+ )
+
+ with self.assertRaisesRegex(ConfigError, "blocked by --max-delete=0"):
+ run_incomplete_cleanup(
+ prefix=Path("/tmp/pobsync-test"),
+ host=host.host,
+ yes=True,
+ max_delete=0,
+ acquire_lock=False,
+ )
+
+ def test_incomplete_cleanup_rejects_unexpected_path(self) -> None:
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ SnapshotRecord.objects.create(
+ host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname="20260519-031500Z__BROKEN01",
+ path=f"/backups/{host.host}/scheduled/20260519-031500Z__BROKEN01",
+ status="failed",
+ started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
+ )
+
+ with self.assertRaisesRegex(ConfigError, "unexpected incomplete snapshot path"):
+ run_incomplete_cleanup(
+ prefix=Path("/tmp/pobsync-test"),
+ host=host.host,
+ yes=True,
+ max_delete=1,
+ acquire_lock=False,
+ )
+
def test_management_command_plans_from_sql(self) -> None:
host = HostConfig.objects.create(
host="web-01",
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py
index 0303bf9..f65f82a 100644
--- a/src/pobsync_backend/tests/test_views.py
+++ b/src/pobsync_backend/tests/test_views.py
@@ -12,7 +12,15 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from pobsync.util import write_yaml_atomic
-from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
+from pobsync_backend.models import (
+ BackupRun,
+ GlobalConfig,
+ HostConfig,
+ PurgedSnapshot,
+ ScheduleConfig,
+ SnapshotRecord,
+ SshCredential,
+)
class ViewTests(TestCase):
@@ -31,6 +39,30 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
+ def test_changelog_requires_staff_login(self) -> None:
+ response = self.client.get(reverse("changelog"))
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("/admin/login/", response["Location"])
+
+ def test_changelog_renders_repository_changelog(self) -> None:
+ self.client.force_login(self.staff_user)
+ with TemporaryDirectory() as tmp:
+ changelog = Path(tmp) / "CHANGELOG.md"
+ changelog.write_text(
+ "# Changelog\n\n## 1.0.0 - 2026-05-21\n\n- Django control panel\n- Native systemd installer\n",
+ encoding="utf-8",
+ )
+
+ with override_settings(BASE_DIR=Path(tmp)):
+ response = self.client.get(reverse("changelog"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Installed version:")
+ self.assertContains(response, "1.0.0 - 2026-05-21")
+ self.assertContains(response, "Django control panel")
+ self.assertContains(response, "Native systemd installer")
+
def test_dashboard_renders_hosts_and_latest_runs(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
@@ -162,7 +194,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Operational Status")
- self.assertContains(response, "No queued, running, warning, or failed runs.")
+ self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
def test_dashboard_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
@@ -192,6 +224,30 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Scheduled prune would delete 2 snapshot(s), above max 1.")
self.assertContains(response, "1 incomplete snapshot(s) need review.")
+ self.assertContains(response, "Mark reviewed")
+
+ def test_dashboard_ignores_reviewed_problem_runs(self) -> None:
+ self.client.force_login(self.staff_user)
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ BackupRun.objects.create(
+ host=host,
+ status=BackupRun.Status.FAILED,
+ reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
+ reviewed_by="admin",
+ )
+ BackupRun.objects.create(
+ host=host,
+ status=BackupRun.Status.WARNING,
+ reviewed_at=datetime(2026, 5, 19, 4, 20, tzinfo=timezone.utc),
+ reviewed_by="admin",
+ )
+
+ response = self.client.get(reverse("dashboard"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
+ self.assertNotContains(response, "failed 1")
+ self.assertNotContains(response, "warning 1")
def test_dashboard_links_latest_snapshot_for_each_host(self) -> None:
self.client.force_login(self.staff_user)
@@ -280,6 +336,61 @@ class ViewTests(TestCase):
self.assertIn("--since", command)
self.assertIn("6 hours ago", command)
+ def test_purged_snapshots_view_renders_history(self) -> None:
+ self.client.force_login(self.staff_user)
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ PurgedSnapshot.objects.create(
+ host=host,
+ host_name=host.host,
+ kind=SnapshotRecord.Kind.SCHEDULED,
+ dirname="20260518-021500Z__OLDSNAP",
+ path=f"/backups/{host.host}/scheduled/20260518-021500Z__OLDSNAP",
+ reason="outside retention policy",
+ action=PurgedSnapshot.Action.SCHEDULED,
+ triggered_by="pobsync-scheduler",
+ )
+
+ response = self.client.get(reverse("purged_snapshots"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Purged Snapshots")
+ self.assertContains(response, "20260518-021500Z__OLDSNAP")
+ self.assertContains(response, "outside retention policy")
+ self.assertContains(response, "Scheduled")
+ self.assertContains(response, "pobsync-scheduler")
+
+ def test_purged_snapshots_view_filters_by_host_and_action(self) -> None:
+ self.client.force_login(self.staff_user)
+ web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
+ PurgedSnapshot.objects.create(
+ host=web,
+ host_name=web.host,
+ kind=SnapshotRecord.Kind.SCHEDULED,
+ dirname="20260518-021500Z__WEBOLD",
+ path=f"/backups/{web.host}/scheduled/20260518-021500Z__WEBOLD",
+ reason="outside retention policy",
+ action=PurgedSnapshot.Action.MANUAL,
+ )
+ PurgedSnapshot.objects.create(
+ host=db,
+ host_name=db.host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname="20260518-021500Z__DBBROKEN",
+ path=f"/backups/{db.host}/.incomplete/20260518-021500Z__DBBROKEN",
+ reason="manual incomplete cleanup",
+ action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP,
+ )
+
+ response = self.client.get(
+ reverse("purged_snapshots"),
+ {"host": db.host, "action": PurgedSnapshot.Action.INCOMPLETE_CLEANUP},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "20260518-021500Z__DBBROKEN")
+ self.assertNotContains(response, "20260518-021500Z__WEBOLD")
+
def test_ssh_credentials_view_creates_key(self) -> None:
self.client.force_login(self.staff_user)
@@ -358,13 +469,46 @@ class ViewTests(TestCase):
generate_ssh_key(credential)
key_path = Path(credential.key_path)
- response = self.client.post(reverse("delete_ssh_credential", args=[credential.id]), follow=True)
+ response = self.client.post(
+ reverse("delete_ssh_credential", args=[credential.id]),
+ {"confirm_name": credential.name},
+ follow=True,
+ )
self.assertRedirects(response, reverse("ssh_credentials"))
self.assertContains(response, "SSH key deleted: generated-key.")
self.assertFalse(SshCredential.objects.exists())
self.assertFalse(key_path.exists())
+ def test_ssh_credentials_view_requires_delete_confirmation(self) -> None:
+ self.client.force_login(self.staff_user)
+ credential = SshCredential.objects.create(name="backup-key")
+
+ response = self.client.post(
+ reverse("delete_ssh_credential", args=[credential.id]),
+ {"confirm_name": "wrong"},
+ follow=True,
+ )
+
+ self.assertRedirects(response, reverse("edit_ssh_credential", args=[credential.id]))
+ self.assertContains(response, "Type backup-key to confirm SSH key deletion.")
+ self.assertTrue(SshCredential.objects.filter(pk=credential.pk).exists())
+
+ def test_ssh_credentials_view_blocks_delete_when_key_is_in_use(self) -> None:
+ self.client.force_login(self.staff_user)
+ credential = SshCredential.objects.create(name="backup-key")
+ HostConfig.objects.create(host="web-01", address="web-01.example.test", ssh_credential=credential)
+
+ response = self.client.post(
+ reverse("delete_ssh_credential", args=[credential.id]),
+ {"confirm_name": credential.name},
+ follow=True,
+ )
+
+ self.assertRedirects(response, reverse("edit_ssh_credential", args=[credential.id]))
+ self.assertContains(response, "SSH key backup-key is still in use and cannot be deleted.")
+ self.assertTrue(SshCredential.objects.filter(pk=credential.pk).exists())
+
def test_ssh_credentials_view_rejects_invalid_key(self) -> None:
self.client.force_login(self.staff_user)
@@ -428,7 +572,7 @@ class ViewTests(TestCase):
response = self.client.post(
reverse("edit_ssh_credential", args=[credential.id]),
{
- "name": "backup-key",
+ "name": "renamed-backup-key",
"private_key": "UPDATED KEY",
"public_key": "",
"known_hosts": "",
@@ -439,6 +583,7 @@ class ViewTests(TestCase):
self.assertRedirects(response, reverse("ssh_credentials"))
credential.refresh_from_db()
+ self.assertEqual(credential.name, "renamed-backup-key")
self.assertEqual(credential.private_key, "UPDATED KEY\n")
self.assertEqual(credential.public_key, "UPDATED PUBLIC KEY")
self.assertEqual(credential.notes, "rotated")
@@ -1286,6 +1431,34 @@ class ViewTests(TestCase):
self.assertContains(response, "Incomplete ignored")
self.assertContains(response, "deleted scheduled 20260518-021500Z__OLD")
+ def test_run_review_action_marks_problem_run_reviewed(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.FAILED, result={"ok": False})
+
+ response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True)
+
+ run.refresh_from_db()
+ self.assertIsNotNone(run.reviewed_at)
+ self.assertEqual(run.reviewed_by, self.staff_user.username)
+ self.assertRedirects(response, reverse("run_detail", args=[run.id]))
+ self.assertContains(response, f"Run {run.id} marked reviewed.")
+ self.assertContains(response, "Review")
+ self.assertContains(response, self.staff_user.username)
+ self.assertNotContains(response, "Mark reviewed")
+
+ def test_run_review_action_ignores_successful_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.SUCCESS, result={"ok": True})
+
+ response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True)
+
+ run.refresh_from_db()
+ self.assertIsNone(run.reviewed_at)
+ self.assertRedirects(response, reverse("run_detail", args=[run.id]))
+ self.assertContains(response, f"Run {run.id} does not need review.")
+
def test_run_detail_surfaces_host_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(
@@ -1349,6 +1522,29 @@ class ViewTests(TestCase):
self.assertContains(response, "Cancel run")
self.assertContains(response, reverse("cancel_run", args=[run.id]))
+ def test_run_detail_renders_worker_execution_metadata(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,
+ result={
+ "execution": {
+ "worker_host": "backup-01",
+ "worker_pid": 4242,
+ "heartbeat_at": "2026-05-21T10:30:00+00:00",
+ }
+ },
+ )
+
+ response = self.client.get(reverse("run_detail", args=[run.id]))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Worker:")
+ self.assertContains(response, "backup-01")
+ self.assertContains(response, "pid 4242")
+ self.assertContains(response, "Worker heartbeat:")
+
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")
@@ -1573,6 +1769,68 @@ class ViewTests(TestCase):
self.assertContains(response, "Incomplete Snapshots")
self.assertContains(response, "20260519-031500Z__BROKEN01")
self.assertContains(response, "excluded from retention cleanup")
+ self.assertContains(response, "Delete incomplete snapshots")
+ self.assertContains(response, "Type 1 to confirm the current number of incomplete snapshots.")
+
+ def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None:
+ self.client.force_login(self.staff_user)
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp) / "home"
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ incomplete_dir = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-031500Z__BROKEN01"
+ incomplete_dir.mkdir(parents=True)
+ incomplete_dir.joinpath("partial-file").write_text("interrupted\n")
+ record = SnapshotRecord.objects.create(
+ host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname=incomplete_dir.name,
+ path=str(incomplete_dir),
+ status="failed",
+ started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
+ )
+
+ with override_settings(POBSYNC_HOME=str(home)):
+ response = self.client.post(
+ reverse("cleanup_host_incomplete_snapshots", args=[host.host]),
+ {
+ "max_delete": "1",
+ "confirm_host": host.host,
+ "confirm_delete_count": "1",
+ },
+ follow=True,
+ )
+
+ self.assertFalse(incomplete_dir.exists())
+
+ self.assertRedirects(response, reverse("host_retention_plan", args=[host.host]))
+ self.assertContains(response, "Deleted 1 incomplete snapshot(s) for web-01.")
+ self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
+
+ def test_incomplete_cleanup_rejects_bad_confirmation(self) -> None:
+ self.client.force_login(self.staff_user)
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ SnapshotRecord.objects.create(
+ host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname="20260519-031500Z__BROKEN01",
+ path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
+ status="failed",
+ started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
+ )
+
+ response = self.client.post(
+ reverse("cleanup_host_incomplete_snapshots", args=[host.host]),
+ {
+ "max_delete": "1",
+ "confirm_host": "wrong",
+ "confirm_delete_count": "1",
+ },
+ follow=True,
+ )
+
+ self.assertRedirects(response, reverse("host_retention_plan", args=[host.host]))
+ self.assertContains(response, "Incomplete cleanup confirmation is invalid.")
+ self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
def test_host_detail_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
@@ -1595,6 +1853,46 @@ class ViewTests(TestCase):
self.assertContains(response, "Retention Warnings")
self.assertContains(response, "Scheduled pruning would delete 2 snapshot(s), above max delete")
+ def test_host_detail_can_mark_incomplete_snapshots_reviewed(self) -> None:
+ self.client.force_login(self.staff_user)
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ incomplete = SnapshotRecord.objects.create(
+ host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname="20260519-031500Z__BROKEN01",
+ path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
+ status="failed",
+ started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
+ )
+
+ response = self.client.post(reverse("resolve_host_incomplete_reviews", args=[host.host]), follow=True)
+
+ incomplete.refresh_from_db()
+ self.assertIsNotNone(incomplete.reviewed_at)
+ self.assertEqual(incomplete.reviewed_by, self.staff_user.username)
+ self.assertRedirects(response, reverse("host_detail", args=[host.host]))
+ self.assertContains(response, "Marked 1 incomplete snapshot(s) reviewed for web-01.")
+ self.assertNotContains(response, "Retention Warnings")
+
+ def test_host_detail_does_not_warn_for_reviewed_incomplete_snapshots(self) -> None:
+ self.client.force_login(self.staff_user)
+ host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
+ SnapshotRecord.objects.create(
+ host=host,
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ dirname="20260519-031500Z__BROKEN01",
+ path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
+ status="failed",
+ started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
+ reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
+ reviewed_by="admin",
+ )
+
+ response = self.client.get(reverse("host_detail", args=[host.host]))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Retention Warnings")
+
def test_retention_plan_rejects_invalid_kind(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
@@ -1646,6 +1944,10 @@ class ViewTests(TestCase):
self.assertContains(response, "Retention deleted 1 snapshot(s) for web-01.")
self.assertFalse(SnapshotRecord.objects.filter(pk=old_snapshot.pk).exists())
self.assertTrue(SnapshotRecord.objects.filter(pk=new_snapshot.pk).exists())
+ purged = PurgedSnapshot.objects.get(dirname=old_snapshot.dirname)
+ self.assertEqual(purged.action, PurgedSnapshot.Action.MANUAL)
+ self.assertEqual(purged.triggered_by, self.staff_user.username)
+ self.assertEqual(purged.reason, "outside retention policy")
def test_retention_apply_rejects_bad_confirmation(self) -> None:
self.client.force_login(self.staff_user)
diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py
index bc78428..bca4d2d 100644
--- a/src/pobsync_backend/views.py
+++ b/src/pobsync_backend/views.py
@@ -15,6 +15,7 @@ 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 import __version__
from pobsync.errors import PobsyncError
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS
@@ -24,6 +25,7 @@ from .forms import (
CreateHostConfigForm,
GlobalConfigForm,
HostConfigForm,
+ IncompleteCleanupForm,
ManualBackupForm,
RetentionApplyForm,
SshCredentialGenerateForm,
@@ -31,9 +33,9 @@ from .forms import (
SshCredentialForm,
)
from .host_ops import ensure_host_directories
-from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
+from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
from .preflight import collect_backup_gate, effective_host_config_preview, run_remote_preflight
-from .retention import run_sql_retention_apply, run_sql_retention_plan
+from .retention import run_incomplete_cleanup, 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
@@ -51,8 +53,16 @@ def dashboard(request):
run_count=Count("runs", distinct=True),
queued_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.QUEUED), distinct=True),
running_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.RUNNING), distinct=True),
- warning_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.WARNING), distinct=True),
- failed_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.FAILED), distinct=True),
+ warning_run_count=Count(
+ "runs",
+ filter=Q(runs__status=BackupRun.Status.WARNING, runs__reviewed_at__isnull=True),
+ distinct=True,
+ ),
+ failed_run_count=Count(
+ "runs",
+ filter=Q(runs__status=BackupRun.Status.FAILED, runs__reviewed_at__isnull=True),
+ distinct=True,
+ ),
)
.order_by("host")
)
@@ -81,13 +91,41 @@ def dashboard(request):
"runs": BackupRun.objects.count(),
"queued_runs": BackupRun.objects.filter(status=BackupRun.Status.QUEUED).count(),
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
- "warning_runs": BackupRun.objects.filter(status=BackupRun.Status.WARNING).count(),
- "failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
+ "warning_runs": BackupRun.objects.filter(
+ status=BackupRun.Status.WARNING,
+ reviewed_at__isnull=True,
+ ).count(),
+ "failed_runs": BackupRun.objects.filter(
+ status=BackupRun.Status.FAILED,
+ reviewed_at__isnull=True,
+ ).count(),
},
}
return render(request, "pobsync_backend/dashboard.html", context)
+@staff_member_required
+def changelog(request):
+ changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
+ try:
+ changelog_text = changelog_path.read_text(encoding="utf-8")
+ missing = False
+ except FileNotFoundError:
+ changelog_text = "CHANGELOG.md was not found in this installation."
+ missing = True
+
+ return render(
+ request,
+ "pobsync_backend/changelog.html",
+ {
+ "app_version": __version__,
+ "changelog_blocks": _parse_changelog(changelog_text),
+ "changelog_path": changelog_path,
+ "missing": missing,
+ },
+ )
+
+
@staff_member_required
def self_check(request):
checks = collect_self_checks()
@@ -107,6 +145,27 @@ def logs(request):
return render(request, "pobsync_backend/logs.html", context)
+@staff_member_required
+def purged_snapshots(request):
+ host = request.GET.get("host", "").strip()
+ action = request.GET.get("action", "").strip()
+ purged = PurgedSnapshot.objects.select_related("host").order_by("-purged_at", "host_name", "dirname")
+ if host:
+ purged = purged.filter(host_name=host)
+ if action:
+ purged = purged.filter(action=action)
+
+ context = {
+ "purged_snapshots": purged[:200],
+ "hosts": HostConfig.objects.order_by("host"),
+ "actions": PurgedSnapshot.Action.choices,
+ "selected_host": host,
+ "selected_action": action,
+ "total_count": purged.count(),
+ }
+ return render(request, "pobsync_backend/purged_snapshots.html", context)
+
+
@staff_member_required
def ssh_credentials(request):
context = {
@@ -201,6 +260,9 @@ def delete_ssh_credential(request, credential_id: int):
if credential.hosts.exists() or credential.global_configs.exists():
messages.error(request, f"SSH key {credential.name} is still in use and cannot be deleted.")
return redirect("edit_ssh_credential", credential_id=credential.id)
+ if request.POST.get("confirm_name", "").strip() != credential.name:
+ messages.error(request, f"Type {credential.name} to confirm SSH key deletion.")
+ return redirect("edit_ssh_credential", credential_id=credential.id)
name = credential.name
try:
@@ -307,7 +369,10 @@ def host_detail(request, host: str):
"runs": host_config.runs.count(),
"queued_runs": queued_runs.count(),
"running_runs": running_runs.count(),
- "failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(),
+ "failed_runs": host_config.runs.filter(
+ status=BackupRun.Status.FAILED,
+ reviewed_at__isnull=True,
+ ).count(),
"incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(),
},
}
@@ -425,6 +490,7 @@ def run_detail(request, run_id: int):
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
failure = result.get("failure") if isinstance(result.get("failure"), dict) else {}
prune_result = result.get("prune") if isinstance(result.get("prune"), dict) else {}
+ execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
rsync_log_path = _run_rsync_log_path(run)
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
@@ -432,6 +498,7 @@ def run_detail(request, run_id: int):
"run": run,
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
"requested": requested,
+ "execution": execution,
"stats": run_stats if isinstance(run_stats, dict) else {},
"rsync": rsync_result,
"rsync_command": _run_rsync_command(rsync_result),
@@ -489,6 +556,40 @@ def cancel_run(request, run_id: int):
return redirect("run_detail", run_id=run.id)
+@staff_member_required
+@require_POST
+def resolve_run_review(request, run_id: int):
+ run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
+ if run.status not in {BackupRun.Status.FAILED, BackupRun.Status.WARNING}:
+ messages.warning(request, f"Run {run.id} does not need review.")
+ return redirect("run_detail", run_id=run.id)
+ if run.reviewed_at:
+ messages.info(request, f"Run {run.id} was already marked reviewed.")
+ return redirect("run_detail", run_id=run.id)
+
+ run.reviewed_at = timezone.now()
+ run.reviewed_by = request.user.get_username()
+ run.save(update_fields=["reviewed_at", "reviewed_by"])
+ messages.success(request, f"Run {run.id} marked reviewed.")
+ return redirect("run_detail", run_id=run.id)
+
+
+@staff_member_required
+@require_POST
+def resolve_host_incomplete_reviews(request, host: str):
+ host_config = get_object_or_404(HostConfig, host=host)
+ reviewed_count = host_config.snapshots.filter(
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ reviewed_at__isnull=True,
+ ).update(reviewed_at=timezone.now(), reviewed_by=request.user.get_username())
+
+ if reviewed_count:
+ messages.success(request, f"Marked {reviewed_count} incomplete snapshot(s) reviewed for {host_config.host}.")
+ else:
+ messages.info(request, f"No incomplete snapshots needed review for {host_config.host}.")
+ return redirect("host_detail", host=host_config.host)
+
+
@staff_member_required
def snapshot_detail(request, snapshot_id: int):
snapshot = get_object_or_404(
@@ -544,6 +645,7 @@ def host_retention_plan(request, host: str):
schedule = _schedule_for_host(host_config)
scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None
delete_count = len(plan["delete"])
+ incomplete_count = len(plan["incomplete"])
context = {
"host": host_config,
"kind": kind,
@@ -562,6 +664,14 @@ def host_retention_plan(request, host: str):
"confirm_delete_count": delete_count,
},
),
+ "incomplete_cleanup_form": IncompleteCleanupForm(
+ host_name=host_config.host,
+ expected_delete_count=incomplete_count,
+ initial={
+ "max_delete": incomplete_count,
+ "confirm_delete_count": incomplete_count,
+ },
+ ),
}
return render(request, "pobsync_backend/retention_plan.html", context)
@@ -604,6 +714,8 @@ def apply_host_retention(request, host: str):
protect_bases=protect_bases,
yes=True,
max_delete=form.cleaned_data["max_delete"],
+ action=PurgedSnapshot.Action.MANUAL,
+ triggered_by=request.user.get_username(),
)
except PobsyncError as exc:
messages.error(request, str(exc))
@@ -618,6 +730,41 @@ def apply_host_retention(request, host: str):
return target
+@staff_member_required
+@require_POST
+def cleanup_host_incomplete_snapshots(request, host: str):
+ host_config = get_object_or_404(HostConfig, host=host)
+ try:
+ plan = run_sql_retention_plan(host=host_config.host, kind="all", protect_bases=True)
+ except PobsyncError as exc:
+ messages.error(request, str(exc))
+ return redirect("host_retention_plan", host=host_config.host)
+
+ incomplete_count = len(plan.get("incomplete") or [])
+ form = IncompleteCleanupForm(
+ request.POST,
+ host_name=host_config.host,
+ expected_delete_count=incomplete_count,
+ )
+ if not form.is_valid():
+ messages.error(request, "Incomplete cleanup confirmation is invalid.")
+ return redirect("host_retention_plan", host=host_config.host)
+
+ try:
+ result = run_incomplete_cleanup(
+ prefix=Path(settings.POBSYNC_HOME),
+ host=host_config.host,
+ yes=True,
+ max_delete=form.cleaned_data["max_delete"],
+ triggered_by=request.user.get_username(),
+ )
+ except PobsyncError as exc:
+ messages.error(request, str(exc))
+ else:
+ messages.success(request, f"Deleted {len(result['deleted'])} incomplete snapshot(s) for {host_config.host}.")
+ return redirect("host_retention_plan", host=host_config.host)
+
+
@staff_member_required
def edit_host_config(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -695,7 +842,10 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon
def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]:
- incomplete_count = host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count()
+ incomplete_count = host_config.snapshots.filter(
+ kind=SnapshotRecord.Kind.INCOMPLETE,
+ reviewed_at__isnull=True,
+ ).count()
warning: dict[str, object] = {
"has_warning": incomplete_count > 0,
"incomplete_count": incomplete_count,
@@ -793,6 +943,49 @@ def _pretty_json(value: object) -> str:
return json.dumps(value or {}, indent=2, sort_keys=True)
+def _parse_changelog(text: str) -> list[dict[str, object]]:
+ blocks: list[dict[str, object]] = []
+ paragraph: list[str] = []
+ list_items: list[str] = []
+
+ def flush_paragraph() -> None:
+ if paragraph:
+ blocks.append({"kind": "paragraph", "text": " ".join(paragraph)})
+ paragraph.clear()
+
+ def flush_list() -> None:
+ if list_items:
+ blocks.append({"kind": "list", "items": list(list_items)})
+ list_items.clear()
+
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line:
+ flush_paragraph()
+ flush_list()
+ continue
+
+ if line.startswith("#"):
+ flush_paragraph()
+ flush_list()
+ marker, _space, heading = line.partition(" ")
+ level = min(max(len(marker), 1), 3)
+ blocks.append({"kind": "heading", "level": level, "text": heading.strip() or line.lstrip("#").strip()})
+ continue
+
+ if line.startswith("- "):
+ flush_paragraph()
+ list_items.append(line[2:].strip())
+ continue
+
+ flush_list()
+ paragraph.append(line)
+
+ flush_paragraph()
+ flush_list()
+ return blocks
+
+
def _snapshot_restore_guidance(snapshot: SnapshotRecord) -> dict[str, str]:
source_path = Path(snapshot.path) / "data"
destination_path = Path("/restore") / snapshot.host.host
diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py
index 32cb3b9..6f77f1c 100644
--- a/src/pobsync_server/urls.py
+++ b/src/pobsync_server/urls.py
@@ -8,8 +8,10 @@ from pobsync_backend import api, views
urlpatterns = [
path("", views.dashboard, name="dashboard"),
+ path("changelog/", views.changelog, name="changelog"),
path("self-check/", views.self_check, name="self_check"),
path("logs/", views.logs, name="logs"),
+ path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
path("config/global/", views.edit_global_config, name="edit_global_config"),
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
@@ -26,10 +28,21 @@ urlpatterns = [
path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
path("hosts//retention-apply/", views.apply_host_retention, name="apply_host_retention"),
path("hosts//retention-plan/", views.host_retention_plan, name="host_retention_plan"),
+ path(
+ "hosts//incomplete-cleanup/",
+ views.cleanup_host_incomplete_snapshots,
+ name="cleanup_host_incomplete_snapshots",
+ ),
path("hosts//schedule/", views.edit_host_schedule, name="edit_host_schedule"),
path("runs//", views.run_detail, name="run_detail"),
path("runs//rsync-log/", views.run_rsync_log, name="run_rsync_log"),
path("runs//cancel/", views.cancel_run, name="cancel_run"),
+ path("runs//resolve-review/", views.resolve_run_review, name="resolve_run_review"),
+ path(
+ "hosts//resolve-incomplete-reviews/",
+ views.resolve_host_incomplete_reviews,
+ name="resolve_host_incomplete_reviews",
+ ),
path("snapshots//", views.snapshot_detail, name="snapshot_detail"),
path("api/", api.api_index),
path("api/status/", api.status),