2026-05-19 11:53:32 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-05-19 12:31:47 +02:00
|
|
|
import json
|
|
|
|
|
|
2026-05-19 11:56:45 +02:00
|
|
|
from django.contrib import messages
|
2026-05-19 11:53:32 +02:00
|
|
|
from django.contrib.admin.views.decorators import staff_member_required
|
2026-05-19 13:14:22 +02:00
|
|
|
from django.conf import settings
|
2026-05-19 11:53:32 +02:00
|
|
|
from django.db.models import Count
|
2026-05-19 11:56:45 +02:00
|
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
|
|
|
from django.views.decorators.http import require_POST
|
2026-05-19 11:53:32 +02:00
|
|
|
|
2026-05-19 12:00:19 +02:00
|
|
|
from pobsync.errors import ConfigError
|
|
|
|
|
|
2026-05-19 13:04:50 +02:00
|
|
|
from .backup_runner import queue_backup_run
|
|
|
|
|
from .forms import CreateHostConfigForm, GlobalConfigForm, HostConfigForm, ManualBackupForm, ScheduleConfigForm
|
2026-05-19 12:25:45 +02:00
|
|
|
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
|
2026-05-19 12:00:19 +02:00
|
|
|
from .retention import run_sql_retention_plan
|
2026-05-19 11:56:45 +02:00
|
|
|
from .snapshot_discovery import discover_snapshots
|
2026-05-19 11:53:32 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@staff_member_required
|
|
|
|
|
def dashboard(request):
|
|
|
|
|
host_qs = (
|
|
|
|
|
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
|
|
|
|
|
.order_by("host")
|
|
|
|
|
)
|
|
|
|
|
context = {
|
|
|
|
|
"hosts": host_qs,
|
2026-05-19 12:25:45 +02:00
|
|
|
"global_config": GlobalConfig.objects.filter(name="default").first(),
|
2026-05-19 11:53:32 +02:00
|
|
|
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
|
|
|
|
"counts": {
|
2026-05-19 12:25:45 +02:00
|
|
|
"global_configs": GlobalConfig.objects.count(),
|
2026-05-19 11:53:32 +02:00
|
|
|
"hosts": HostConfig.objects.count(),
|
|
|
|
|
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
|
|
|
|
|
"schedules": ScheduleConfig.objects.count(),
|
|
|
|
|
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
|
|
|
|
|
"snapshots": SnapshotRecord.objects.count(),
|
|
|
|
|
"runs": BackupRun.objects.count(),
|
|
|
|
|
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
|
|
|
|
|
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
return render(request, "pobsync_backend/dashboard.html", context)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 12:25:45 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
def edit_global_config(request):
|
|
|
|
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
form = GlobalConfigForm(request.POST, instance=global_config)
|
|
|
|
|
if form.is_valid():
|
|
|
|
|
saved_config = form.save()
|
|
|
|
|
messages.success(request, f"Global config saved for {saved_config.name}.")
|
|
|
|
|
return redirect("dashboard")
|
|
|
|
|
else:
|
2026-05-19 12:48:32 +02:00
|
|
|
form = GlobalConfigForm(instance=global_config) if global_config else GlobalConfigForm(initial=_default_global_initial())
|
2026-05-19 12:25:45 +02:00
|
|
|
|
|
|
|
|
return render(
|
|
|
|
|
request,
|
|
|
|
|
"pobsync_backend/global_form.html",
|
|
|
|
|
{
|
|
|
|
|
"global_config": global_config,
|
|
|
|
|
"form": form,
|
2026-05-19 13:14:22 +02:00
|
|
|
"backup_root": settings.POBSYNC_BACKUP_ROOT,
|
2026-05-19 12:25:45 +02:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@staff_member_required
|
|
|
|
|
def create_host_config(request):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
form = CreateHostConfigForm(request.POST)
|
|
|
|
|
if form.is_valid():
|
|
|
|
|
host_config = form.save()
|
|
|
|
|
messages.success(request, f"Host config created for {host_config.host}.")
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
else:
|
|
|
|
|
form = CreateHostConfigForm(initial=_default_host_initial())
|
|
|
|
|
|
|
|
|
|
return render(
|
|
|
|
|
request,
|
|
|
|
|
"pobsync_backend/host_form.html",
|
|
|
|
|
{
|
|
|
|
|
"host": None,
|
|
|
|
|
"form": form,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 11:53:32 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
def host_detail(request, host: str):
|
|
|
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
|
|
|
context = {
|
|
|
|
|
"host": host_config,
|
|
|
|
|
"schedule": _schedule_for_host(host_config),
|
2026-05-19 13:04:50 +02:00
|
|
|
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
|
2026-05-19 11:53:32 +02:00
|
|
|
"latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10],
|
|
|
|
|
"snapshots": host_config.snapshots.select_related("base").order_by("-started_at", "dirname")[:20],
|
|
|
|
|
"counts": {
|
|
|
|
|
"snapshots": host_config.snapshots.count(),
|
|
|
|
|
"runs": host_config.runs.count(),
|
|
|
|
|
"failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(),
|
|
|
|
|
"incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
return render(request, "pobsync_backend/host_detail.html", context)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 13:04:50 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
@require_POST
|
|
|
|
|
def queue_manual_backup(request, host: str):
|
|
|
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
|
|
|
if not host_config.enabled:
|
|
|
|
|
messages.error(request, f"Cannot queue backup for disabled host {host_config.host}.")
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
if not GlobalConfig.objects.filter(name="default").exists():
|
|
|
|
|
messages.error(request, "Create the default global config before queueing backups.")
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
|
|
|
|
|
form = ManualBackupForm(request.POST)
|
|
|
|
|
if not form.is_valid():
|
|
|
|
|
messages.error(request, "Manual backup options are invalid.")
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
|
|
|
|
|
run = queue_backup_run(
|
|
|
|
|
host=host_config,
|
|
|
|
|
dry_run=form.cleaned_data["dry_run"],
|
|
|
|
|
prune=form.cleaned_data["prune"],
|
|
|
|
|
prune_max_delete=form.cleaned_data["prune_max_delete"],
|
|
|
|
|
prune_protect_bases=form.cleaned_data["prune_protect_bases"],
|
|
|
|
|
)
|
|
|
|
|
messages.success(request, f"Queued manual backup run {run.id} for {host_config.host}.")
|
|
|
|
|
return redirect("run_detail", run_id=run.id)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 12:31:47 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
def run_detail(request, run_id: int):
|
|
|
|
|
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
|
|
|
|
context = {
|
|
|
|
|
"run": run,
|
|
|
|
|
"result_json": _pretty_json(run.result),
|
|
|
|
|
}
|
|
|
|
|
return render(request, "pobsync_backend/run_detail.html", context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@staff_member_required
|
|
|
|
|
def snapshot_detail(request, snapshot_id: int):
|
|
|
|
|
snapshot = get_object_or_404(
|
|
|
|
|
SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"),
|
|
|
|
|
id=snapshot_id,
|
|
|
|
|
)
|
|
|
|
|
context = {
|
|
|
|
|
"snapshot": snapshot,
|
|
|
|
|
"metadata_json": _pretty_json(snapshot.metadata),
|
|
|
|
|
"backup_runs": snapshot.backup_runs.select_related("host").order_by("-created_at"),
|
|
|
|
|
"derived_snapshots": snapshot.derived_snapshots.select_related("host").order_by("-started_at", "dirname"),
|
|
|
|
|
}
|
|
|
|
|
return render(request, "pobsync_backend/snapshot_detail.html", context)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 11:56:45 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
@require_POST
|
|
|
|
|
def discover_host_snapshots(request, host: str):
|
|
|
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
|
|
|
try:
|
|
|
|
|
result = discover_snapshots(host=host_config)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
messages.error(request, f"Snapshot discovery failed for {host_config.host}: {exc}")
|
|
|
|
|
else:
|
|
|
|
|
messages.success(
|
|
|
|
|
request,
|
|
|
|
|
(
|
|
|
|
|
f"Snapshot discovery scanned {result['scanned']} items for {host_config.host}: "
|
|
|
|
|
f"{result['created']} created, {result['updated']} updated."
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 12:00:19 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
def host_retention_plan(request, host: str):
|
|
|
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
|
|
|
kind = request.GET.get("kind", "scheduled")
|
|
|
|
|
if kind not in {"scheduled", "manual", "all"}:
|
|
|
|
|
messages.error(request, "Retention kind must be scheduled, manual, or all.")
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
protect_bases = request.GET.get("protect_bases") in {"1", "true", "on", "yes"}
|
|
|
|
|
try:
|
|
|
|
|
plan = run_sql_retention_plan(host=host_config.host, kind=kind, protect_bases=protect_bases)
|
|
|
|
|
except ConfigError as exc:
|
|
|
|
|
messages.error(request, str(exc))
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
context = {
|
|
|
|
|
"host": host_config,
|
|
|
|
|
"kind": kind,
|
|
|
|
|
"protect_bases": protect_bases,
|
|
|
|
|
"plan": plan,
|
|
|
|
|
}
|
|
|
|
|
return render(request, "pobsync_backend/retention_plan.html", context)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 12:17:17 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
def edit_host_config(request, host: str):
|
|
|
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
form = HostConfigForm(request.POST, instance=host_config)
|
|
|
|
|
if form.is_valid():
|
|
|
|
|
form.save()
|
|
|
|
|
messages.success(request, f"Host config saved for {host_config.host}.")
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
else:
|
|
|
|
|
form = HostConfigForm(instance=host_config)
|
|
|
|
|
|
|
|
|
|
return render(
|
|
|
|
|
request,
|
|
|
|
|
"pobsync_backend/host_form.html",
|
|
|
|
|
{
|
|
|
|
|
"host": host_config,
|
|
|
|
|
"form": form,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 12:13:12 +02:00
|
|
|
@staff_member_required
|
|
|
|
|
def edit_host_schedule(request, host: str):
|
|
|
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
|
|
|
schedule = _schedule_for_host(host_config)
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
form = ScheduleConfigForm(request.POST, instance=schedule)
|
|
|
|
|
if form.is_valid():
|
|
|
|
|
saved_schedule = form.save(commit=False)
|
|
|
|
|
saved_schedule.host = host_config
|
|
|
|
|
saved_schedule.save()
|
|
|
|
|
messages.success(request, f"Schedule saved for {host_config.host}.")
|
|
|
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
else:
|
|
|
|
|
form = ScheduleConfigForm(instance=schedule, initial=_default_schedule_initial())
|
|
|
|
|
|
|
|
|
|
return render(
|
|
|
|
|
request,
|
|
|
|
|
"pobsync_backend/schedule_form.html",
|
|
|
|
|
{
|
|
|
|
|
"host": host_config,
|
|
|
|
|
"schedule": schedule,
|
|
|
|
|
"form": form,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 11:53:32 +02:00
|
|
|
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
|
|
|
|
|
try:
|
|
|
|
|
return host_config.schedule
|
|
|
|
|
except ScheduleConfig.DoesNotExist:
|
|
|
|
|
return None
|
2026-05-19 12:13:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default_schedule_initial() -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"cron_expr": "15 2 * * *",
|
|
|
|
|
"user": "root",
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"prune_max_delete": 10,
|
|
|
|
|
}
|
2026-05-19 12:25:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default_global_initial() -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"name": "default",
|
|
|
|
|
"ssh_user": "root",
|
|
|
|
|
"ssh_port": 22,
|
|
|
|
|
"rsync_binary": "rsync",
|
|
|
|
|
"default_source_root": "/",
|
|
|
|
|
"retention_daily": 14,
|
|
|
|
|
"retention_weekly": 8,
|
|
|
|
|
"retention_monthly": 12,
|
|
|
|
|
"retention_yearly": 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default_host_initial() -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"retention_daily": 14,
|
|
|
|
|
"retention_weekly": 8,
|
|
|
|
|
"retention_monthly": 12,
|
|
|
|
|
"retention_yearly": 0,
|
|
|
|
|
}
|
2026-05-19 12:31:47 +02:00
|
|
|
|
|
|
|
|
|
2026-05-19 13:04:50 +02:00
|
|
|
def _default_manual_backup_initial(host_config: HostConfig) -> dict[str, object]:
|
|
|
|
|
schedule = _schedule_for_host(host_config)
|
|
|
|
|
return {
|
|
|
|
|
"dry_run": True,
|
|
|
|
|
"prune": bool(schedule.prune) if schedule else False,
|
|
|
|
|
"prune_max_delete": schedule.prune_max_delete if schedule else 10,
|
|
|
|
|
"prune_protect_bases": bool(schedule.prune_protect_bases) if schedule else False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 12:31:47 +02:00
|
|
|
def _pretty_json(value: object) -> str:
|
|
|
|
|
return json.dumps(value or {}, indent=2, sort_keys=True)
|