(ui) Add dashboard operational status summary #15

Merged
parkel merged 4 commits from issue-5-dashboard-1-0 into master 2026-05-21 01:54:48 +02:00
3 changed files with 77 additions and 4 deletions
Showing only changes of commit a75b97c4c0 - Show all commits

View File

@@ -61,6 +61,10 @@
.metric { padding: 14px; } .metric { padding: 14px; }
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; } .metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; } .metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
.metric.failed { border-color: #e8b4b4; background: #fff7f7; }
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; } .panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
.panel.highlight { border-left: 4px solid var(--border); } .panel.highlight { border-left: 4px solid var(--border); }
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; } .panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
@@ -118,6 +122,23 @@
cursor: not-allowed; cursor: not-allowed;
} }
.inline-form { margin: 0; } .inline-form { margin: 0; }
.status-overview {
display: grid;
gap: 8px;
}
.status-summary {
align-items: center;
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
}
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
.status-summary.warning,
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
.operator-state { .operator-state {
align-items: center; align-items: center;
display: flex; display: flex;

View File

@@ -32,10 +32,46 @@
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div> <div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div> <div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div> <div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div> <div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div> <div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div> <div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
<div class="metric"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div> <div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
</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 %}
<div class="status-summary failed">
<span class="status failed">failed</span>
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
</div>
{% endif %}
{% if counts.warning_runs %}
<div class="status-summary warning">
<span class="status warning">warning</span>
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
</div>
{% endif %}
{% if counts.running_runs %}
<div class="status-summary running">
<span class="status running">running</span>
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
</div>
{% endif %}
{% if counts.queued_runs %}
<div class="status-summary queued">
<span class="status queued">queued</span>
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
</div>
{% endif %}
</div>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, warning, or failed runs.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %}
</section> </section>
<section class="panel"> <section class="panel">

View File

@@ -84,6 +84,11 @@ 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, "1 failed run needs review.")
self.assertContains(response, "1 run completed with warnings.")
self.assertContains(response, "1 backup run in progress.")
self.assertContains(response, "1 backup run waiting for the worker.")
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)
@@ -148,6 +153,17 @@ class ViewTests(TestCase):
self.assertContains(response, "No completed backup runs with stats yet.") self.assertContains(response, "No completed backup runs with stats yet.")
self.assertContains(response, "growth estimates") self.assertContains(response, "growth estimates")
def test_dashboard_shows_all_clear_operational_status(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/opt/pobsync/backups")
HostConfig.objects.create(host="web-01", address="web-01.example.test")
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Operational Status")
self.assertContains(response, "No queued, running, warning, or failed runs.")
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(