## 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

@@ -752,6 +752,15 @@
.host-card-warning > * {
min-width: 0;
}
.host-card-actions {
align-items: center;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
}
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
.message {
background: var(--panel);
@@ -909,6 +918,7 @@
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
<span class="nav-primary" aria-label="Primary navigation">
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{% url 'hosts_list' %}" {% if request.resolver_match.url_name == "hosts_list" or request.resolver_match.url_name == "host_detail" or request.resolver_match.url_name == "create_host_config" or request.resolver_match.url_name == "edit_host_config" or request.resolver_match.url_name == "edit_host_schedule" %}aria-current="page"{% endif %}>Hosts</a>
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>

View File

@@ -42,7 +42,7 @@
</div>
<section class="grid dashboard-summary-grid" aria-label="Summary">
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'hosts_list' %}"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>

View File

@@ -0,0 +1,43 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Hosts | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Inventory</div>
<h1>Hosts</h1>
<div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div>
</div>
<section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
</section>
</header>
<section class="grid dashboard-summary-grid" aria-label="Host summary">
<a class="metric metric-link" href="{% url 'hosts_list' %}"><div class="label">Showing</div><div class="value">{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'hosts_list' %}?enabled=yes"><div class="label">Enabled</div><div class="value">{{ counts.enabled_hosts }}</div></a>
<a class="metric metric-link" href="{% url 'hosts_list' %}?enabled=no"><div class="label">Disabled</div><div class="value">{{ counts.disabled_hosts }}</div></a>
<a class="metric metric-link" href="{% url 'dashboard' %}"><div class="label">Total</div><div class="value">{{ total_count }}</div></a>
</section>
<section class="panel">
<h2>Filters</h2>
<form class="filter-form" method="get">
<div class="field">
<label for="enabled">Host state</label>
<select id="enabled" name="enabled">
<option value="" {% if selected_enabled == "" %}selected{% endif %}>All hosts</option>
<option value="yes" {% if selected_enabled == "yes" %}selected{% endif %}>Enabled only</option>
<option value="no" {% if selected_enabled == "no" %}selected{% endif %}>Disabled only</option>
</select>
</div>
<div class="form-actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'hosts_list' %}">Reset</a>
</div>
</form>
</section>
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
{% endblock %}

View File

@@ -22,6 +22,14 @@
{% if host.failed_run_count %}
<span class="status failed">failed {{ host.failed_run_count }}</span>
{% endif %}
{% if show_host_controls %}
{% if host.schedule %}
<span class="status {% if host.schedule.enabled %}ok{% else %}skipped{% endif %}">schedule {{ host.schedule.enabled|yesno:"on,paused" }}</span>
<span class="status {% if host.schedule.prune %}ok{% else %}skipped{% endif %}">retention {{ host.schedule.prune|yesno:"on,paused" }}</span>
{% else %}
<span class="status skipped">no schedule</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="host-card-layout">
@@ -115,6 +123,33 @@
{% endif %}
</div>
{% endif %}
{% if show_host_controls %}
<div class="host-card-actions">
<a class="button-link compact secondary" href="{% url 'host_detail' host.host %}">Open</a>
<a class="button-link compact secondary" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link compact secondary" href="{% url 'edit_host_schedule' host.host %}">{% if host.schedule %}Edit schedule{% else %}Create schedule{% endif %}</a>
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.enabled %}disable_host{% else %}enable_host{% endif %}">
<button class="compact {% if host.enabled %}secondary{% endif %}" type="submit">{{ host.enabled|yesno:"Disable host,Enable host" }}</button>
</form>
{% if host.schedule %}
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.schedule.enabled %}disable_schedule{% else %}enable_schedule{% endif %}">
<button class="compact secondary" type="submit">{{ host.schedule.enabled|yesno:"Pause schedule,Resume schedule" }}</button>
</form>
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.schedule.prune %}disable_prune{% else %}enable_prune{% endif %}">
<button class="compact secondary" type="submit">{{ host.schedule.prune|yesno:"Pause retention,Resume retention" }}</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% empty %}
<p class="muted">No hosts configured yet.</p>

View File

@@ -48,6 +48,7 @@ class ViewTests(TestCase):
self.assertContains(response, 'aria-label="Primary navigation"', html=False)
self.assertContains(response, 'aria-label="System navigation"', html=False)
self.assertContains(response, reverse("dashboard"))
self.assertContains(response, reverse("hosts_list"))
self.assertContains(response, reverse("ssh_credentials"))
self.assertContains(response, reverse("logs"))
self.assertContains(response, reverse("purged_snapshots"))
@@ -202,6 +203,72 @@ class ViewTests(TestCase):
self.assertContains(response, "Snapshot health")
self.assertNotContains(response, "<html", html=False)
def test_hosts_list_renders_host_cards_and_controls(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", enabled=False)
ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True)
BackupRun.objects.create(host=web, status=BackupRun.Status.RUNNING)
response = self.client.get(reverse("hosts_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Inventory")
self.assertContains(response, "Configured backup targets")
self.assertContains(response, "web-01")
self.assertContains(response, "db-01")
self.assertContains(response, "running 1")
self.assertContains(response, "schedule on")
self.assertContains(response, "retention on")
self.assertContains(response, "Disable host")
self.assertContains(response, "Enable host")
self.assertContains(response, "Pause schedule")
self.assertContains(response, "Pause retention")
self.assertContains(response, reverse("update_host_state", args=[web.host]))
self.assertContains(response, reverse("edit_host_config", args=[web.host]))
self.assertContains(response, reverse("edit_host_schedule", args=[web.host]))
def test_hosts_list_filters_by_enabled_state(self) -> None:
self.client.force_login(self.staff_user)
HostConfig.objects.create(host="web-01", address="web-01.example.test")
HostConfig.objects.create(host="db-01", address="db-01.example.test", enabled=False)
response = self.client.get(reverse("hosts_list"), {"enabled": "no"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "db-01")
self.assertNotContains(response, "web-01")
def test_update_host_state_toggles_host_schedule_and_retention(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
schedule = ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True)
response = self.client.post(
reverse("update_host_state", args=[host.host]),
{"action": "disable_host", "next": reverse("hosts_list")},
follow=True,
)
self.assertRedirects(response, reverse("hosts_list"))
host.refresh_from_db()
self.assertFalse(host.enabled)
self.assertContains(response, "Disabled host web-01.")
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "disable_schedule"})
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "disable_prune"})
schedule.refresh_from_db()
self.assertFalse(schedule.enabled)
self.assertFalse(schedule.prune)
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_host"})
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_schedule"})
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_prune"})
host.refresh_from_db()
schedule.refresh_from_db()
self.assertTrue(host.enabled)
self.assertTrue(schedule.enabled)
self.assertTrue(schedule.prune)
def test_dashboard_renders_backup_trend_summary(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/missing-backup-root")

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:

View File

@@ -21,8 +21,10 @@ urlpatterns = [
path("ssh-credentials/generate/", views.generate_ssh_credential, name="generate_ssh_credential"),
path("ssh-credentials/<int:credential_id>/", views.edit_ssh_credential, name="edit_ssh_credential"),
path("ssh-credentials/<int:credential_id>/delete/", views.delete_ssh_credential, name="delete_ssh_credential"),
path("hosts/", views.hosts_list, name="hosts_list"),
path("hosts/new/", views.create_host_config, name="create_host_config"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("hosts/<str:host>/state/", views.update_host_state, name="update_host_state"),
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
path("hosts/<str:host>/prepare-directories/", views.prepare_host_directories, name="prepare_host_directories"),
path("hosts/<str:host>/scan-known-key/", views.scan_host_known_key, name="scan_host_known_key"),