Merge pull request 'issue-27-dashboard-information-architecture' (#33) from issue-27-dashboard-information-architecture into master

Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
2026-05-21 13:29:18 +02:00
4 changed files with 312 additions and 109 deletions

View File

@@ -285,9 +285,91 @@
cursor: not-allowed; cursor: not-allowed;
} }
.inline-form { margin: 0; } .inline-form { margin: 0; }
.status-overview { .dashboard-priority-grid {
display: grid;
gap: 14px;
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
margin-bottom: 20px;
}
.priority-panel {
display: grid;
gap: 12px;
margin-bottom: 0;
}
.priority-panel > h2:first-child { margin-bottom: 0; }
.action-list,
.activity-list,
.schedule-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
}
.action-row,
.activity-row,
.schedule-row {
align-items: start;
border: 1px solid var(--border);
border-radius: 7px;
color: inherit;
display: grid;
gap: 9px;
padding: 10px;
text-decoration: none;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.action-row,
.activity-row {
grid-template-columns: max-content minmax(0, 1fr);
}
.schedule-row {
grid-template-columns: minmax(0, 1fr) max-content;
}
.action-row:hover,
.activity-row:hover,
.schedule-row:hover {
background: var(--panel-subtle);
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
}
.action-row.failed { border-color: #e8b4b4; background: #fff7f7; }
.action-row.warning { border-color: #e7cf8a; background: #fffaf0; }
.action-row span:not(.status),
.activity-row span:not(.status),
.schedule-row span {
display: grid;
gap: 2px;
min-width: 0;
}
.schedule-time {
justify-items: end;
text-align: right;
}
.storage-priority {
display: grid;
gap: 12px;
}
.storage-priority .label {
color: var(--muted);
font-size: 12px;
font-weight: 650;
text-transform: uppercase;
}
.storage-priority .value {
font-size: 27px;
font-weight: 760;
line-height: 1.15;
margin-top: 4px;
}
.storage-priority-facts {
display: grid;
gap: 8px;
}
.storage-priority-facts > div {
align-items: baseline;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
justify-content: space-between;
padding-top: 8px;
} }
.status-summary { .status-summary {
align-items: center; align-items: center;
@@ -312,12 +394,6 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transform: translateY(-1px); transform: translateY(-1px);
} }
.status-summary .summary-action {
color: var(--muted-strong);
font-size: 12px;
font-weight: 650;
margin-left: auto;
}
.operator-state { .operator-state {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -564,6 +640,9 @@
} }
.page-header .actions { justify-content: flex-start; } .page-header .actions { justify-content: flex-start; }
.two-col { grid-template-columns: 1fr; } .two-col { grid-template-columns: 1fr; }
.dashboard-priority-grid { grid-template-columns: 1fr; }
.schedule-row { grid-template-columns: 1fr; }
.schedule-time { justify-items: start; text-align: left; }
.host-card-header { display: grid; } .host-card-header { display: grid; }
.host-card-status { justify-content: flex-start; max-width: none; } .host-card-status { justify-content: flex-start; max-width: none; }
.host-card-layout { grid-template-columns: 1fr; } .host-card-layout { grid-template-columns: 1fr; }

View File

@@ -32,63 +32,95 @@
</section> </section>
{% endif %} {% endif %}
<section class="grid" aria-label="Summary"> <section class="dashboard-priority-grid" aria-label="Operator priorities">
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a> <article class="panel priority-panel">
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a> <h2>Required Action</h2>
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a> {% if action_items %}
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a> <div class="action-list">
<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> {% for item in action_items %}
<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> <a class="action-row {{ item.status }}" href="{{ item.url }}">
<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> <span class="status {{ item.status }}">{{ item.label }}</span>
<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> <span>
</section> <strong>{{ item.host.host }}</strong>
<span class="muted">{{ item.message }}</span>
<section class="panel"> </span>
<h2>Operational Status</h2>
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
<div class="status-overview">
{% if counts.failed_runs %}
<a class="status-summary failed" href="{% url 'runs_list' %}?status=failed&amp;review=needed">
<span class="status failed">failed</span>
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
<span class="summary-action">Review failed runs</span>
</a>
{% endif %}
{% if counts.warning_runs %}
<a class="status-summary warning" href="{% url 'runs_list' %}?status=warning&amp;review=needed">
<span class="status warning">warning</span>
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
<span class="summary-action">Review warnings</span>
</a> </a>
{% endfor %}
</div>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, unreviewed warning/failed runs, or retention warnings.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %} {% endif %}
{% if counts.running_runs or counts.queued_runs %}
<div class="operator-state">
{% if counts.running_runs %} {% if counts.running_runs %}
<a class="status-summary running" href="{% url 'runs_list' %}?status=running"> <a class="status-summary running" href="{% url 'runs_list' %}?status=running">
<span class="status running">running</span> <span class="status running">running</span>
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong> <strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
<span class="summary-action">View running runs</span>
</a> </a>
{% endif %} {% endif %}
{% if counts.queued_runs %} {% if counts.queued_runs %}
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued"> <a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
<span class="status queued">queued</span> <span class="status queued">queued</span>
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong> <strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting.</strong>
<span class="summary-action">View queued runs</span>
</a> </a>
{% endif %} {% endif %}
</div> </div>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, or unreviewed warning/failed runs.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %} {% endif %}
</section> </article>
<section class="panel" id="hosts"> <article class="panel priority-panel">
<h2>Backup Trends</h2> <h2>Next Scheduled Work <a class="button-link secondary compact" href="{% url 'schedules_list' %}">View all</a></h2>
{% if next_schedule_rows %}
<div class="schedule-list">
{% for row in next_schedule_rows %}
<a class="schedule-row" href="{% url 'host_detail' row.schedule.host.host %}">
<span>
<strong>{{ row.schedule.host.host }}</strong>
<span class="muted">{{ row.schedule.cron_expr }}</span>
</span>
<span class="schedule-time">
{% if row.next_run_at %}
{{ row.next_run_at|date:"Y-m-d H:i T" }}
<span class="muted">{{ scheduler_timezone }}</span>
{% else %}
<span class="muted">not due</span>
{% endif %}
</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="muted">No enabled schedules yet.</p>
{% endif %}
</article>
<article class="panel priority-panel">
<h2>Recent Activity <a class="button-link secondary compact" href="{% url 'runs_list' %}">View all</a></h2>
{% if recent_runs %}
<div class="activity-list">
{% for run in recent_runs %}
<a class="activity-row" href="{% url 'run_detail' run.id %}">
<span class="status {{ run.status }}">{{ run.status }}</span>
<span>
<strong>Run {{ run.id }}</strong>
<span class="muted">{{ run.host.host }} · {{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="muted">No backup runs recorded yet.</p>
{% endif %}
</article>
<article class="panel priority-panel">
<h2>Storage Pressure</h2>
{% if stats_summary.runs_sampled %} {% if stats_summary.runs_sampled %}
<div class="insight-grid" aria-label="Backup trends"> <div class="storage-priority">
<div class="insight-main"> <div>
<div class="label">Storage Used</div> <div class="label">Backup root used</div>
<div class="value"> <div class="value">
{% if stats_summary.capacity.used_percent is not None %} {% if stats_summary.capacity.used_percent is not None %}
{{ stats_summary.capacity.used_percent|floatformat:1 }}% {{ stats_summary.capacity.used_percent|floatformat:1 }}%
@@ -101,10 +133,49 @@
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span> <span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
</div> </div>
{% endif %} {% endif %}
<div class="muted"> </div>
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root. <div class="storage-priority-facts">
<div>
<span class="label">Runway</span>
<strong>
{% if stats_summary.estimated_days_until_full %}
{{ stats_summary.estimated_days_until_full }} days
{% elif stats_summary.estimated_runs_until_full %}
{{ stats_summary.estimated_runs_until_full }} runs
{% else %}
unknown
{% endif %}
</strong>
</div>
<div>
<span class="label">New data</span>
<strong>{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</strong>
</div>
<div>
<span class="label">Available</span>
<strong>{{ stats_summary.capacity.available_bytes|filesizeformat }}</strong>
</div> </div>
</div> </div>
</div>
{% else %}
<p class="muted">Storage pressure appears after the first completed backup with stats.</p>
{% endif %}
</article>
</section>
<section class="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 '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>
<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>
<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 class="panel">
<h2>Backup Trends</h2>
{% if stats_summary.runs_sampled %}
<div class="insight-grid" aria-label="Backup trends">
<div class="insight-item"> <div class="insight-item">
<div class="label">Runway</div> <div class="label">Runway</div>
<div class="value"> <div class="value">
@@ -145,7 +216,7 @@
{% endif %} {% endif %}
</section> </section>
<section class="panel"> <section class="panel" id="hosts">
<h2>Hosts</h2> <h2>Hosts</h2>
<div class="host-list"> <div class="host-list">
{% for host in hosts %} {% for host in hosts %}
@@ -269,31 +340,4 @@
</div> </div>
</section> </section>
<section class="panel">
<h2>Latest Runs <a class="button-link secondary" href="{% url 'runs_list' %}">View all</a></h2>
<table>
<thead>
<tr>
<th>Host</th>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
</tr>
</thead>
<tbody>
{% for run in latest_runs %}
<tr>
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></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>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">No backup runs recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %} {% endblock %}

View File

@@ -120,20 +120,24 @@ class ViewTests(TestCase):
self.assertContains(response, "running 1") self.assertContains(response, "running 1")
self.assertContains(response, "warning 1") self.assertContains(response, "warning 1")
self.assertContains(response, "failed 1") self.assertContains(response, "failed 1")
self.assertContains(response, "Operational Status") self.assertContains(response, "Required Action")
self.assertContains(response, "1 failed run needs review.") self.assertContains(response, "Failed runs")
self.assertContains(response, "1 run completed with warnings.") self.assertContains(response, "1 failed run(s) need review.")
self.assertContains(response, "1 run(s) 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.")
self.assertContains(response, "Review failed runs") self.assertContains(response, "Next Scheduled Work")
self.assertContains(response, "Review warnings") self.assertContains(response, "Recent Activity")
self.assertContains(response, "View running runs")
self.assertContains(response, "View queued runs")
self.assertContains(response, f'href="{reverse("runs_list")}"', html=False) 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=queued"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', 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=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("runs_list")}?status=failed&amp;review=needed"', html=False)
self.assertContains(
response,
f'href="{reverse("runs_list")}?host=web-01&amp;status=failed&amp;review=needed"',
html=False,
)
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False) self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False) self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
@@ -173,14 +177,14 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Backup Trends") self.assertContains(response, "Backup Trends")
self.assertContains(response, "Storage Used") self.assertContains(response, "Storage Pressure")
self.assertContains(response, "Backup root used")
self.assertContains(response, "Runway") self.assertContains(response, "Runway")
self.assertContains(response, "New Data") self.assertContains(response, "New Data")
self.assertContains(response, "Link-Dest Savings") self.assertContains(response, "Link-Dest Savings")
self.assertContains(response, "80.0%") self.assertContains(response, "80.0%")
self.assertContains(response, "10 days") self.assertContains(response, "10 days")
self.assertContains(response, "Warnings") self.assertContains(response, "Warnings")
self.assertContains(response, "Queued")
self.assertContains(response, "Next Run") self.assertContains(response, "Next Run")
self.assertContains(response, "UTC") self.assertContains(response, "UTC")
self.assertContains(response, "10") self.assertContains(response, "10")
@@ -208,8 +212,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Operational Status") self.assertContains(response, "Required Action")
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
def test_runs_list_filters_by_status_and_review(self) -> None: def test_runs_list_filters_by_status_and_review(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -342,7 +346,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.") self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
self.assertNotContains(response, "failed 1") self.assertNotContains(response, "failed 1")
self.assertNotContains(response, "warning 1") self.assertNotContains(response, "warning 1")

View File

@@ -4,7 +4,9 @@ import json
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
from datetime import datetime, timezone as datetime_timezone
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode
from django.contrib import messages from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
@@ -12,6 +14,7 @@ from django.conf import settings
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@@ -74,13 +77,18 @@ def dashboard(request):
) )
host_config.next_run_at = _next_run_for_host(host_config) 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)) host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
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]
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config) stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
context = { context = {
"hosts": hosts, "hosts": hosts,
"global_config": global_config, "global_config": global_config,
"stats_summary": stats_summary, "stats_summary": stats_summary,
"scheduler_timezone": timezone.get_current_timezone_name(), "scheduler_timezone": timezone.get_current_timezone_name(),
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10], "action_items": action_items,
"next_schedule_rows": next_schedule_rows,
"recent_runs": recent_runs,
"counts": { "counts": {
"global_configs": GlobalConfig.objects.count(), "global_configs": GlobalConfig.objects.count(),
"hosts": HostConfig.objects.count(), "hosts": HostConfig.objects.count(),
@@ -104,6 +112,74 @@ def dashboard(request):
return render(request, "pobsync_backend/dashboard.html", context) return render(request, "pobsync_backend/dashboard.html", context)
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
action_items: list[dict[str, object]] = []
for host_config in hosts:
if host_config.failed_run_count:
action_items.append(
{
"host": host_config,
"status": BackupRun.Status.FAILED,
"label": "Failed runs",
"message": f"{host_config.failed_run_count} failed run(s) need review.",
"url": _runs_list_url(host=host_config.host, status="failed", review="needed"),
}
)
if host_config.warning_run_count:
action_items.append(
{
"host": host_config,
"status": BackupRun.Status.WARNING,
"label": "Warnings",
"message": f"{host_config.warning_run_count} run(s) completed with warnings.",
"url": _runs_list_url(host=host_config.host, status="warning", review="needed"),
}
)
if host_config.retention_warning.get("has_warning"):
action_items.append(
{
"host": host_config,
"status": BackupRun.Status.WARNING,
"label": "Retention",
"message": _retention_warning_summary(host_config.retention_warning),
"url": reverse("host_detail", args=[host_config.host]),
}
)
return action_items
def _runs_list_url(**params: str) -> str:
return f"{reverse('runs_list')}?{urlencode(params)}"
def _dashboard_next_schedule_rows() -> list[dict[str, object]]:
rows = []
schedules = ScheduleConfig.objects.select_related("host").filter(enabled=True).order_by("host__host")
for schedule in schedules[:200]:
rows.append(
{
"schedule": schedule,
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
}
)
rows.sort(key=lambda row: row["next_run_at"] or datetime.max.replace(tzinfo=datetime_timezone.utc))
return rows[:6]
def _retention_warning_summary(retention_warning) -> str:
parts = []
if retention_warning.get("prune_exceeded"):
parts.append(
f"Scheduled prune would delete {retention_warning.get('delete_count')} snapshot(s), "
f"above max {retention_warning.get('max_delete')}."
)
if retention_warning.get("incomplete_count"):
parts.append(f"{retention_warning.get('incomplete_count')} incomplete snapshot(s) need review.")
if retention_warning.get("error"):
parts.append(str(retention_warning.get("error")))
return " ".join(parts)
@staff_member_required @staff_member_required
def changelog(request): def changelog(request):
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md" changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"