Turn the host detail page into a more useful operator surface for starting greenfield backups from Django. Add quick actions for dry-run and real backup runs, keep the advanced manual options available, and show whether a host is ready, disabled, or blocked by missing global config. Surface queued and running counts plus a direct link to the active run. Expose requested backup options on the run detail page and cover the new control flow with view tests.
372 lines
13 KiB
Python
372 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from django.contrib import messages
|
|
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.views.decorators.http import require_POST
|
|
|
|
from pobsync.errors import PobsyncError
|
|
|
|
from .backup_runner import queue_backup_run
|
|
from .forms import (
|
|
CreateHostConfigForm,
|
|
GlobalConfigForm,
|
|
HostConfigForm,
|
|
ManualBackupForm,
|
|
RetentionApplyForm,
|
|
ScheduleConfigForm,
|
|
)
|
|
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
|
|
from .retention import run_sql_retention_apply, run_sql_retention_plan
|
|
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
|
|
|
|
|
@staff_member_required
|
|
def dashboard(request):
|
|
hosts = list(
|
|
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
|
|
.order_by("host")
|
|
)
|
|
for host_config in hosts:
|
|
host_config.latest_snapshot = (
|
|
host_config.snapshots.select_related("base")
|
|
.order_by("-started_at", "-discovered_at", "-id")
|
|
.first()
|
|
)
|
|
context = {
|
|
"hosts": hosts,
|
|
"global_config": GlobalConfig.objects.filter(name="default").first(),
|
|
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
|
"counts": {
|
|
"global_configs": GlobalConfig.objects.count(),
|
|
"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)
|
|
|
|
|
|
@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:
|
|
form = GlobalConfigForm(instance=global_config) if global_config else GlobalConfigForm(initial=_default_global_initial())
|
|
|
|
return render(
|
|
request,
|
|
"pobsync_backend/global_form.html",
|
|
{
|
|
"global_config": global_config,
|
|
"form": form,
|
|
"backup_root": settings.POBSYNC_BACKUP_ROOT,
|
|
},
|
|
)
|
|
|
|
|
|
@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,
|
|
},
|
|
)
|
|
|
|
|
|
@staff_member_required
|
|
def host_detail(request, host: str):
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
queued_runs = host_config.runs.filter(status=BackupRun.Status.QUEUED)
|
|
running_runs = host_config.runs.filter(status=BackupRun.Status.RUNNING)
|
|
active_run = host_config.runs.filter(
|
|
status__in=[BackupRun.Status.QUEUED, BackupRun.Status.RUNNING]
|
|
).order_by("created_at", "id").first()
|
|
has_global_config = GlobalConfig.objects.filter(name="default").exists()
|
|
context = {
|
|
"host": host_config,
|
|
"schedule": _schedule_for_host(host_config),
|
|
"discovery": inspect_snapshot_discovery(host=host_config),
|
|
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
|
|
"can_queue_backup": host_config.enabled and has_global_config,
|
|
"has_global_config": has_global_config,
|
|
"active_run": active_run,
|
|
"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(),
|
|
"queued_runs": queued_runs.count(),
|
|
"running_runs": running_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)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@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,
|
|
"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
|
|
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)
|
|
|
|
|
|
@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:
|
|
summary = (
|
|
f"Snapshot discovery scanned {result['scanned']} items for {host_config.host}: "
|
|
f"{result['created']} created, {result['updated']} updated."
|
|
)
|
|
if result["scanned"]:
|
|
messages.success(request, summary)
|
|
else:
|
|
discovery = inspect_snapshot_discovery(host=host_config)
|
|
messages.warning(request, f"{summary} {discovery['message']}")
|
|
return redirect("host_detail", host=host_config.host)
|
|
|
|
|
|
@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 PobsyncError 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,
|
|
"apply_form": RetentionApplyForm(
|
|
host_name=host_config.host,
|
|
initial={
|
|
"kind": kind,
|
|
"protect_bases": protect_bases,
|
|
"max_delete": len(plan["delete"]),
|
|
},
|
|
),
|
|
}
|
|
return render(request, "pobsync_backend/retention_plan.html", context)
|
|
|
|
|
|
@staff_member_required
|
|
@require_POST
|
|
def apply_host_retention(request, host: str):
|
|
host_config = get_object_or_404(HostConfig, host=host)
|
|
form = RetentionApplyForm(request.POST, host_name=host_config.host)
|
|
if not form.is_valid():
|
|
messages.error(request, "Retention apply confirmation is invalid.")
|
|
return redirect("host_retention_plan", host=host_config.host)
|
|
|
|
kind = form.cleaned_data["kind"]
|
|
protect_bases = bool(form.cleaned_data["protect_bases"])
|
|
try:
|
|
result = run_sql_retention_apply(
|
|
prefix=Path(settings.POBSYNC_HOME),
|
|
host=host_config.host,
|
|
kind=kind,
|
|
protect_bases=protect_bases,
|
|
yes=True,
|
|
max_delete=form.cleaned_data["max_delete"],
|
|
)
|
|
except PobsyncError as exc:
|
|
messages.error(request, str(exc))
|
|
else:
|
|
messages.success(request, f"Retention deleted {len(result['deleted'])} snapshot(s) for {host_config.host}.")
|
|
|
|
target = redirect("host_retention_plan", host=host_config.host)
|
|
query = f"kind={kind}"
|
|
if protect_bases:
|
|
query += "&protect_bases=1"
|
|
target["Location"] = f"{target['Location']}?{query}"
|
|
return target
|
|
|
|
|
|
@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,
|
|
},
|
|
)
|
|
|
|
|
|
@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,
|
|
},
|
|
)
|
|
|
|
|
|
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
|
|
try:
|
|
return host_config.schedule
|
|
except ScheduleConfig.DoesNotExist:
|
|
return None
|
|
|
|
|
|
def _default_schedule_initial() -> dict[str, object]:
|
|
return {
|
|
"cron_expr": "15 2 * * *",
|
|
"user": "root",
|
|
"enabled": True,
|
|
"prune_max_delete": 10,
|
|
}
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
def _pretty_json(value: object) -> str:
|
|
return json.dumps(value or {}, indent=2, sort_keys=True)
|