Merge pull request '## Summary' (#60) from issue-48-49-hosts-page-controls into master
Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user