Make dashboard cards link to operational lists #31

Merged
parkel merged 3 commits from issue-23-dashboard-list-pages into master 2026-05-21 12:49:15 +02:00
7 changed files with 341 additions and 13 deletions
Showing only changes of commit 4e8e4f75fd - Show all commits

View File

@@ -168,6 +168,21 @@
.metric.warning { border-color: #e7cf8a; background: #fffaf0; } .metric.warning { border-color: #e7cf8a; background: #fffaf0; }
.metric.running { border-color: #e7cf8a; background: #fffaf0; } .metric.running { border-color: #e7cf8a; background: #fffaf0; }
.metric.queued { border-color: #b5cdea; background: #eef6ff; } .metric.queued { border-color: #b5cdea; background: #eef6ff; }
.metric-link {
color: inherit;
display: block;
text-decoration: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.metric-link:hover {
border-color: #9eb2c8;
box-shadow: var(--shadow);
transform: translateY(-1px);
}
.metric-link:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 2px;
}
.panel { .panel {
margin-bottom: 18px; margin-bottom: 18px;
overflow: auto; overflow: auto;

View File

@@ -33,17 +33,17 @@
{% endif %} {% endif %}
<section class="grid" aria-label="Summary"> <section class="grid" aria-label="Summary">
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div> <a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div> <a class="metric metric-link" href="#hosts"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div> <a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div> <a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div> <a class="metric metric-link {% if counts.queued_runs %}queued{% endif %}" href="{% url 'runs_list' %}?status=queued"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></a>
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div> <a class="metric metric-link {% if counts.running_runs %}running{% endif %}" href="{% url 'runs_list' %}?status=running"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></a>
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div> <a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&amp;review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div> <a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&amp;review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
</section> </section>
<section class="panel"> <section class="panel" id="hosts">
<h2>Operational Status</h2> <h2>Operational Status</h2>
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %} {% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
<div class="status-overview"> <div class="status-overview">
@@ -266,7 +266,7 @@
</section> </section>
<section class="panel"> <section class="panel">
<h2>Latest Runs</h2> <h2>Latest Runs <a class="button-link secondary" href="{% url 'runs_list' %}">View all</a></h2>
<table> <table>
<thead> <thead>
<tr> <tr>

View File

@@ -0,0 +1,106 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Runs | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Activity</div>
<h1>Runs</h1>
<div class="page-subtitle">Review queued, running, completed, warning, failed, and cancelled backup runs.</div>
</div>
<section class="actions" aria-label="Run list actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filters</h2>
<form method="get" class="form-grid">
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">All statuses</option>
{% for value, label in statuses %}
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="type">Type</label>
<select id="type" name="type">
<option value="">All types</option>
{% for value, label in run_types %}
<option value="{{ value }}" {% if selected_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="host">Host</label>
<select id="host" name="host">
<option value="">All hosts</option>
{% for host in hosts %}
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="review">Review</label>
<select id="review" name="review">
<option value="">All review states</option>
<option value="needed" {% if selected_review == "needed" %}selected{% endif %}>Needs review</option>
<option value="reviewed" {% if selected_review == "reviewed" %}selected{% endif %}>Reviewed</option>
</select>
</div>
<div class="actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'runs_list' %}">Clear</a>
</div>
</form>
</section>
<section class="panel">
<h2>Backup Runs</h2>
<p class="muted">Showing up to 200 of {{ total_count }} run{{ total_count|pluralize }}.</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Host</th>
<th>Status</th>
<th>Type</th>
<th>Created</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
<th>Review</th>
</tr>
</thead>
<tbody>
{% for run in runs %}
<tr>
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
<td>{{ run.run_type }}</td>
<td>{{ run.created_at }}</td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.ended_at|default:"" }}</td>
<td>
{% if run.snapshot %}
<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>
{% elif run.snapshot_path %}
<span class="muted">{{ run.snapshot_path }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td>{% if run.reviewed_at %}reviewed{% elif run.status == "failed" or run.status == "warning" %}<span class="status warning">needed</span>{% else %}<span class="muted">none</span>{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="9" class="muted">No runs matched the current filter.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Snapshots | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Snapshots</div>
<h1>Snapshots</h1>
<div class="page-subtitle">Browse discovered scheduled, manual, and incomplete snapshots across all hosts.</div>
</div>
<section class="actions" aria-label="Snapshot list actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filters</h2>
<form method="get" class="form-grid">
<div class="field">
<label for="host">Host</label>
<select id="host" name="host">
<option value="">All hosts</option>
{% for host in hosts %}
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="kind">Kind</label>
<select id="kind" name="kind">
<option value="">All kinds</option>
{% for value, label in kinds %}
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">All statuses</option>
{% for value in statuses %}
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</div>
<div class="actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'snapshots_list' %}">Clear</a>
</div>
</form>
</section>
<section class="panel">
<h2>Snapshot Records</h2>
<p class="muted">Showing up to 200 of {{ total_count }} snapshot{{ total_count|pluralize }}.</p>
<table>
<thead>
<tr>
<th>Snapshot</th>
<th>Host</th>
<th>Kind</th>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Base</th>
<th>Path</th>
</tr>
</thead>
<tbody>
{% for snapshot in snapshots %}
<tr>
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
<td><a href="{% url 'host_detail' snapshot.host.host %}">{{ snapshot.host.host }}</a></td>
<td>{{ snapshot.kind }}</td>
<td>{% if snapshot.status %}<span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>{% else %}<span class="muted">unknown</span>{% endif %}</td>
<td>{{ snapshot.started_at|default:"" }}</td>
<td>{{ snapshot.ended_at|default:"" }}</td>
<td>
{% if snapshot.base %}
<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>
{% elif snapshot.base_dirname %}
<span class="muted">{{ snapshot.base_dirname }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td class="muted">{{ snapshot.path }}</td>
</tr>
{% empty %}
<tr><td colspan="8" class="muted">No snapshots matched the current filter.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -125,6 +125,12 @@ class ViewTests(TestCase):
self.assertContains(response, "1 run completed with warnings.") self.assertContains(response, "1 run completed with warnings.")
self.assertContains(response, "1 backup run in progress.") self.assertContains(response, "1 backup run in progress.")
self.assertContains(response, "1 backup run waiting for the worker.") self.assertContains(response, "1 backup run waiting for the worker.")
self.assertContains(response, f'href="{reverse("runs_list")}"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&amp;review=needed"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&amp;review=needed"', html=False)
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
def test_dashboard_renders_backup_trend_summary(self) -> None: def test_dashboard_renders_backup_trend_summary(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -200,6 +206,45 @@ class ViewTests(TestCase):
self.assertContains(response, "Operational Status") self.assertContains(response, "Operational Status")
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
def test_runs_list_filters_by_status_and_review(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")
failed = BackupRun.objects.create(host=web, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL)
success = BackupRun.objects.create(host=db, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED)
BackupRun.objects.create(
host=web,
status=BackupRun.Status.WARNING,
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
response = self.client.get(reverse("runs_list"), {"status": "failed", "review": "needed"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Runs")
self.assertContains(response, "Review queued, running, completed")
self.assertContains(response, f"Run {failed.id}")
self.assertContains(response, "web-01")
self.assertContains(response, "needed")
self.assertNotContains(response, f"Run {success.id}")
def test_snapshots_list_filters_by_host_and_kind(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")
manual = self._snapshot(web, "20260519-021500Z__MANUAL01", kind=SnapshotRecord.Kind.MANUAL)
scheduled = self._snapshot(db, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
response = self.client.get(reverse("snapshots_list"), {"host": web.host, "kind": SnapshotRecord.Kind.MANUAL})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Snapshots")
self.assertContains(response, "Browse discovered scheduled, manual, and incomplete snapshots")
self.assertContains(response, manual.dirname)
self.assertContains(response, "web-01")
self.assertNotContains(response, scheduled.dirname)
def test_dashboard_surfaces_retention_warnings(self) -> None: def test_dashboard_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
host = HostConfig.objects.create( host = HostConfig.objects.create(
@@ -2223,13 +2268,19 @@ class ViewTests(TestCase):
self.assertEqual(host.excludes_add, []) self.assertEqual(host.excludes_add, [])
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"]) self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord: def _snapshot(
self,
host: HostConfig,
dirname: str,
*,
kind: str = SnapshotRecord.Kind.SCHEDULED,
) -> SnapshotRecord:
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc) started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
return SnapshotRecord.objects.create( return SnapshotRecord.objects.create(
host=host, host=host,
kind=SnapshotRecord.Kind.SCHEDULED, kind=kind,
dirname=dirname, dirname=dirname,
path=f"/backups/{host.host}/scheduled/{dirname}", path=f"/backups/{host.host}/{kind}/{dirname}",
status="success", status="success",
started_at=started_at, started_at=started_at,
) )

View File

@@ -145,6 +145,64 @@ def logs(request):
return render(request, "pobsync_backend/logs.html", context) return render(request, "pobsync_backend/logs.html", context)
@staff_member_required
def runs_list(request):
status = request.GET.get("status", "").strip()
run_type = request.GET.get("type", "").strip()
host = request.GET.get("host", "").strip()
review = request.GET.get("review", "").strip()
runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")
if status:
runs = runs.filter(status=status)
if run_type:
runs = runs.filter(run_type=run_type)
if host:
runs = runs.filter(host__host=host)
if review == "needed":
runs = runs.filter(status__in=[BackupRun.Status.FAILED, BackupRun.Status.WARNING], reviewed_at__isnull=True)
elif review == "reviewed":
runs = runs.filter(reviewed_at__isnull=False)
context = {
"runs": runs[:200],
"total_count": runs.count(),
"hosts": HostConfig.objects.order_by("host"),
"statuses": BackupRun.Status.choices,
"run_types": BackupRun.RunType.choices,
"selected_status": status,
"selected_type": run_type,
"selected_host": host,
"selected_review": review,
}
return render(request, "pobsync_backend/runs_list.html", context)
@staff_member_required
def snapshots_list(request):
kind = request.GET.get("kind", "").strip()
status = request.GET.get("status", "").strip()
host = request.GET.get("host", "").strip()
snapshots = SnapshotRecord.objects.select_related("host", "base").order_by("-started_at", "-discovered_at", "-id")
if kind:
snapshots = snapshots.filter(kind=kind)
if status:
snapshots = snapshots.filter(status=status)
if host:
snapshots = snapshots.filter(host__host=host)
context = {
"snapshots": snapshots[:200],
"total_count": snapshots.count(),
"hosts": HostConfig.objects.order_by("host"),
"kinds": SnapshotRecord.Kind.choices,
"statuses": SnapshotRecord.objects.exclude(status="").order_by("status").values_list("status", flat=True).distinct(),
"selected_kind": kind,
"selected_status": status,
"selected_host": host,
}
return render(request, "pobsync_backend/snapshots_list.html", context)
@staff_member_required @staff_member_required
def purged_snapshots(request): def purged_snapshots(request):
host = request.GET.get("host", "").strip() host = request.GET.get("host", "").strip()

View File

@@ -34,6 +34,7 @@ urlpatterns = [
name="cleanup_host_incomplete_snapshots", name="cleanup_host_incomplete_snapshots",
), ),
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"), path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
path("runs/", views.runs_list, name="runs_list"),
path("runs/<int:run_id>/", views.run_detail, name="run_detail"), path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"), path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"), path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
@@ -43,6 +44,7 @@ urlpatterns = [
views.resolve_host_incomplete_reviews, views.resolve_host_incomplete_reviews,
name="resolve_host_incomplete_reviews", name="resolve_host_incomplete_reviews",
), ),
path("snapshots/", views.snapshots_list, name="snapshots_list"),
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"), path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
path("api/", api.api_index), path("api/", api.api_index),
path("api/status/", api.status), path("api/status/", api.status),