## Summary

- Add a dedicated `/hosts/` page with host cards and enabled/disabled filtering.
- Link the dashboard Hosts metric and top navigation to the new page.
- Add host enable/disable plus schedule and scheduled-retention pause/resume actions.

## Tests
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_views.ViewTests.test_base_navigation_groups_primary_and_system_links src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_renders_hosts_and_latest_runs src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_hosts_live_returns_hosts_partial src.pobsync_backend.tests.test_views.ViewTests.test_hosts_list_renders_host_cards_and_controls src.pobsync_backend.tests.test_views.ViewTests.test_hosts_list_filters_by_enabled_state src.pobsync_backend.tests.test_views.ViewTests.test_update_host_state_toggles_host_schedule_and_retention --verbosity 2`
- `.venv/bin/python manage.py check`
- `.venv/bin/python manage.py test src.pobsync_backend --verbosity 2`

Closes #48
Closes #49
This commit is contained in:
2026-05-23 01:13:32 +02:00
parent 8e83fee7b5
commit ce1cb9d157
7 changed files with 245 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from pobsync import __version__
@@ -62,8 +63,7 @@ def dashboard_hosts_live(request):
return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context())
def _dashboard_context() -> dict[str, object]:
global_config = GlobalConfig.objects.filter(name="default").first()
def _host_cards_context(*, enabled: str = "") -> dict[str, object]:
hosts = list(
HostConfig.objects.select_related("schedule")
.annotate(
@@ -84,6 +84,11 @@ def _dashboard_context() -> dict[str, object]:
)
.order_by("host")
)
if enabled == "yes":
hosts = [host for host in hosts if host.enabled]
elif enabled == "no":
hosts = [host for host in hosts if not host.enabled]
for host_config in hosts:
host_config.latest_snapshot = (
host_config.snapshots.select_related("base")
@@ -92,6 +97,22 @@ def _dashboard_context() -> dict[str, object]:
)
host_config.next_run_at = _next_run_for_host(host_config)
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
return {
"hosts": hosts,
"scheduler_timezone": timezone.get_current_timezone_name(),
"selected_enabled": enabled,
"counts": {
"hosts": len(hosts),
"enabled_hosts": sum(1 for host in hosts if host.enabled),
"disabled_hosts": sum(1 for host in hosts if not host.enabled),
},
}
def _dashboard_context() -> dict[str, object]:
global_config = GlobalConfig.objects.filter(name="default").first()
host_context = _host_cards_context()
hosts = host_context["hosts"]
action_items = _dashboard_action_items(hosts)
next_schedule_rows = _dashboard_next_schedule_rows()
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
@@ -100,7 +121,7 @@ def _dashboard_context() -> dict[str, object]:
"hosts": hosts,
"global_config": global_config,
"stats_summary": stats_summary,
"scheduler_timezone": timezone.get_current_timezone_name(),
"scheduler_timezone": host_context["scheduler_timezone"],
"action_items": action_items,
"next_schedule_rows": next_schedule_rows,
"recent_runs": recent_runs,
@@ -127,6 +148,69 @@ def _dashboard_context() -> dict[str, object]:
return context
@staff_member_required
def hosts_list(request):
enabled = request.GET.get("enabled", "").strip()
if enabled not in {"", "yes", "no"}:
enabled = ""
global_config = GlobalConfig.objects.filter(name="default").first()
context = _host_cards_context(enabled=enabled)
collect_dashboard_stats(hosts=context["hosts"], global_config=global_config)
return render(
request,
"pobsync_backend/hosts_list.html",
{
**context,
"global_config": global_config,
"show_host_controls": True,
"total_count": HostConfig.objects.count(),
},
)
@staff_member_required
@require_POST
def update_host_state(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
action = request.POST.get("action", "").strip()
next_url = request.POST.get("next") or reverse("hosts_list")
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
next_url = reverse("hosts_list")
if action == "enable_host":
host_config.enabled = True
host_config.save(update_fields=["enabled", "updated_at"])
messages.success(request, f"Enabled host {host_config.host}.")
elif action == "disable_host":
host_config.enabled = False
host_config.save(update_fields=["enabled", "updated_at"])
messages.success(request, f"Disabled host {host_config.host}.")
elif action in {"enable_schedule", "disable_schedule", "enable_prune", "disable_prune"}:
try:
schedule = host_config.schedule
except ScheduleConfig.DoesNotExist:
messages.warning(request, f"{host_config.host} does not have a schedule yet.")
else:
if action == "enable_schedule":
schedule.enabled = True
message = f"Enabled backup schedule for {host_config.host}."
elif action == "disable_schedule":
schedule.enabled = False
message = f"Paused backup schedule for {host_config.host}."
elif action == "enable_prune":
schedule.prune = True
message = f"Enabled scheduled retention for {host_config.host}."
else:
schedule.prune = False
message = f"Paused scheduled retention for {host_config.host}."
schedule.save(update_fields=["enabled", "prune", "updated_at"])
messages.success(request, message)
else:
messages.error(request, "Unknown host state action.")
return redirect(next_url)
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
action_items: list[dict[str, object]] = []
for host_config in hosts: