(ui) Rework dashboard around operator priorities
Move required actions, upcoming scheduled work, and recent run activity to the top of the dashboard so the first screen answers what needs attention next. Keep summary metrics, trends, and host cards as supporting drill-down content. Refs #27
This commit is contained in:
@@ -285,9 +285,63 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.inline-form { margin: 0; }
|
||||
.status-overview {
|
||||
.dashboard-priority-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(280px, 1.3fr) repeat(2, minmax(240px, 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;
|
||||
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;
|
||||
}
|
||||
.status-summary {
|
||||
align-items: center;
|
||||
@@ -312,12 +366,6 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.status-summary .summary-action {
|
||||
color: var(--muted-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
margin-left: auto;
|
||||
}
|
||||
.operator-state {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -564,6 +612,9 @@
|
||||
}
|
||||
.page-header .actions { justify-content: flex-start; }
|
||||
.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-status { justify-content: flex-start; max-width: none; }
|
||||
.host-card-layout { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -32,58 +32,100 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="dashboard-priority-grid" aria-label="Operator priorities">
|
||||
<article class="panel priority-panel">
|
||||
<h2>Required Action</h2>
|
||||
{% if action_items %}
|
||||
<div class="action-list">
|
||||
{% for item in action_items %}
|
||||
<a class="action-row {{ item.status }}" href="{{ item.url }}">
|
||||
<span class="status {{ item.status }}">{{ item.label }}</span>
|
||||
<span>
|
||||
<strong>{{ item.host.host }}</strong>
|
||||
<span class="muted">{{ item.message }}</span>
|
||||
</span>
|
||||
</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 %}
|
||||
{% if counts.running_runs or counts.queued_runs %}
|
||||
<div class="operator-state">
|
||||
{% if counts.running_runs %}
|
||||
<a class="status-summary running" href="{% url 'runs_list' %}?status=running">
|
||||
<span class="status running">running</span>
|
||||
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if counts.queued_runs %}
|
||||
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
|
||||
<span class="status queued">queued</span>
|
||||
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting.</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel priority-panel">
|
||||
<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>
|
||||
</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.queued_runs %}queued{% endif %}" href="{% url 'runs_list' %}?status=queued"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></a>
|
||||
<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="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&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&review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<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&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&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>
|
||||
{% endif %}
|
||||
{% if counts.running_runs %}
|
||||
<a class="status-summary running" href="{% url 'runs_list' %}?status=running">
|
||||
<span class="status running">running</span>
|
||||
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||
<span class="summary-action">View running runs</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if counts.queued_runs %}
|
||||
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
|
||||
<span class="status queued">queued</span>
|
||||
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
|
||||
<span class="summary-action">View queued runs</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="hosts">
|
||||
<h2>Backup Trends</h2>
|
||||
{% if stats_summary.runs_sampled %}
|
||||
<div class="insight-grid" aria-label="Backup trends">
|
||||
@@ -145,7 +187,7 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="hosts">
|
||||
<h2>Hosts</h2>
|
||||
<div class="host-list">
|
||||
{% for host in hosts %}
|
||||
@@ -269,31 +311,4 @@
|
||||
</div>
|
||||
</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 %}
|
||||
|
||||
@@ -120,20 +120,24 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "running 1")
|
||||
self.assertContains(response, "warning 1")
|
||||
self.assertContains(response, "failed 1")
|
||||
self.assertContains(response, "Operational Status")
|
||||
self.assertContains(response, "1 failed run needs review.")
|
||||
self.assertContains(response, "1 run completed with warnings.")
|
||||
self.assertContains(response, "Required Action")
|
||||
self.assertContains(response, "Failed runs")
|
||||
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 waiting for the worker.")
|
||||
self.assertContains(response, "Review failed runs")
|
||||
self.assertContains(response, "Review warnings")
|
||||
self.assertContains(response, "View running runs")
|
||||
self.assertContains(response, "View queued runs")
|
||||
self.assertContains(response, "1 backup run waiting.")
|
||||
self.assertContains(response, "Next Scheduled Work")
|
||||
self.assertContains(response, "Recent Activity")
|
||||
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&review=needed"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&review=needed"', html=False)
|
||||
self.assertContains(
|
||||
response,
|
||||
f'href="{reverse("runs_list")}?host=web-01&status=failed&review=needed"',
|
||||
html=False,
|
||||
)
|
||||
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
|
||||
|
||||
@@ -208,8 +212,8 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Operational Status")
|
||||
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
||||
self.assertContains(response, "Required Action")
|
||||
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||
|
||||
def test_runs_list_filters_by_status_and_review(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -342,7 +346,7 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
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, "warning 1")
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import json
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone as datetime_timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import messages
|
||||
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.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.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.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)
|
||||
context = {
|
||||
"hosts": hosts,
|
||||
"global_config": global_config,
|
||||
"stats_summary": stats_summary,
|
||||
"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": {
|
||||
"global_configs": GlobalConfig.objects.count(),
|
||||
"hosts": HostConfig.objects.count(),
|
||||
@@ -104,6 +112,74 @@ def dashboard(request):
|
||||
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.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.prune_exceeded:
|
||||
parts.append(
|
||||
f"Scheduled prune would delete {retention_warning.delete_count} snapshot(s), "
|
||||
f"above max {retention_warning.max_delete}."
|
||||
)
|
||||
if retention_warning.incomplete_count:
|
||||
parts.append(f"{retention_warning.incomplete_count} incomplete snapshot(s) need review.")
|
||||
if retention_warning.error:
|
||||
parts.append(str(retention_warning.error))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def changelog(request):
|
||||
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
|
||||
|
||||
Reference in New Issue
Block a user