(ui) Add dashboard operational status summary #15
@@ -37,6 +37,8 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
|
|||||||
available = _int_at(capacity, "available_bytes")
|
available = _int_at(capacity, "available_bytes")
|
||||||
daily_literal = _average_daily_literal(real_runs)
|
daily_literal = _average_daily_literal(real_runs)
|
||||||
|
|
||||||
|
link_dest_savings_ratio = round(total_matched / savings_basis, 4) if savings_basis else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"runs_sampled": len(real_runs),
|
"runs_sampled": len(real_runs),
|
||||||
"avg_duration_seconds": _average(duration_values),
|
"avg_duration_seconds": _average(duration_values),
|
||||||
@@ -44,7 +46,8 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
|
|||||||
"avg_literal_data_bytes": avg_literal,
|
"avg_literal_data_bytes": avg_literal,
|
||||||
"total_literal_data_bytes": total_literal,
|
"total_literal_data_bytes": total_literal,
|
||||||
"total_matched_data_bytes": total_matched,
|
"total_matched_data_bytes": total_matched,
|
||||||
"link_dest_savings_ratio": round(total_matched / savings_basis, 4) if savings_basis else None,
|
"link_dest_savings_ratio": link_dest_savings_ratio,
|
||||||
|
"link_dest_savings_percent": round(link_dest_savings_ratio * 100, 1) if link_dest_savings_ratio is not None else None,
|
||||||
"estimated_runs_until_full": int(available / avg_literal) if available and avg_literal else None,
|
"estimated_runs_until_full": int(available / avg_literal) if available and avg_literal else None,
|
||||||
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
|
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
|
||||||
"capacity": capacity,
|
"capacity": capacity,
|
||||||
|
|||||||
@@ -149,6 +149,43 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.insight-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px 24px;
|
||||||
|
grid-template-columns: minmax(260px, 1.3fr) repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.insight-main,
|
||||||
|
.insight-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.insight-main .label,
|
||||||
|
.insight-item .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.insight-main .value,
|
||||||
|
.insight-item .value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.storage-meter {
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 10px;
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.storage-meter span {
|
||||||
|
background: var(--link);
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
.host-list {
|
.host-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -305,6 +342,7 @@
|
|||||||
.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; }
|
||||||
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||||
|
.insight-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -38,18 +38,67 @@
|
|||||||
<div class="metric"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
<div class="metric"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if stats_summary.runs_sampled %}
|
<section class="panel">
|
||||||
<section class="grid" aria-label="Backup trends">
|
<h2>Backup Trends</h2>
|
||||||
<div class="metric"><div class="label">Backup Root Used</div><div class="value">{{ stats_summary.capacity.used_percent|default:"" }}{% if stats_summary.capacity.used_percent is not None %}%{% endif %}</div></div>
|
{% if stats_summary.runs_sampled %}
|
||||||
<div class="metric"><div class="label">Available</div><div class="value">{{ stats_summary.capacity.available_bytes|filesizeformat }}</div></div>
|
<div class="insight-grid" aria-label="Backup trends">
|
||||||
<div class="metric"><div class="label">Avg New Data</div><div class="value">{{ stats_summary.avg_literal_data_bytes|filesizeformat }}</div></div>
|
<div class="insight-main">
|
||||||
<div class="metric"><div class="label">Avg Daily New</div><div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}</div></div>
|
<div class="label">Storage Used</div>
|
||||||
<div class="metric"><div class="label">Avg Duration</div><div class="value">{{ stats_summary.avg_duration_seconds|default:"" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div></div>
|
<div class="value">
|
||||||
<div class="metric"><div class="label">Link-Dest Savings</div><div class="value">{{ stats_summary.link_dest_savings_ratio|default:"" }}</div></div>
|
{% if stats_summary.capacity.used_percent is not None %}
|
||||||
<div class="metric"><div class="label">Runs Until Full</div><div class="value">{{ stats_summary.estimated_runs_until_full|default:"" }}</div></div>
|
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
||||||
<div class="metric"><div class="label">Days Until Full</div><div class="value">{{ stats_summary.estimated_days_until_full|default:"" }}</div></div>
|
{% else %}
|
||||||
</section>
|
unknown
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if stats_summary.capacity.used_percent is not None %}
|
||||||
|
<div class="storage-meter" aria-label="Backup root storage usage">
|
||||||
|
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="muted">
|
||||||
|
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Runway</div>
|
||||||
|
<div class="value">
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
<div class="muted">Estimated from average new data per day.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">New Data</div>
|
||||||
|
<div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</div>
|
||||||
|
<div class="muted">{{ stats_summary.avg_literal_data_bytes|filesizeformat }} per backup on average.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Link-Dest Savings</div>
|
||||||
|
<div class="value">
|
||||||
|
{% if stats_summary.link_dest_savings_percent is not None %}
|
||||||
|
{{ stats_summary.link_dest_savings_percent|floatformat:1 }}%
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="muted">{{ stats_summary.total_matched_data_bytes|filesizeformat }} reused across sampled runs.</div>
|
||||||
|
</div>
|
||||||
|
<div class="insight-item">
|
||||||
|
<div class="label">Average Duration</div>
|
||||||
|
<div class="value">{{ stats_summary.avg_duration_seconds|default:"unknown" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div>
|
||||||
|
<div class="muted">Based on {{ stats_summary.runs_sampled }} completed backup run{{ stats_summary.runs_sampled|pluralize }} with stats.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No completed backup runs with stats yet. This section will show disk usage, growth estimates, and link-dest savings after the first real backup finishes.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Hosts</h2>
|
<h2>Hosts</h2>
|
||||||
|
|||||||
@@ -120,10 +120,13 @@ 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, "Backup Root Used")
|
self.assertContains(response, "Backup Trends")
|
||||||
self.assertContains(response, "Runs Until Full")
|
self.assertContains(response, "Storage Used")
|
||||||
self.assertContains(response, "Avg Daily New")
|
self.assertContains(response, "Runway")
|
||||||
self.assertContains(response, "Days Until Full")
|
self.assertContains(response, "New Data")
|
||||||
|
self.assertContains(response, "Link-Dest Savings")
|
||||||
|
self.assertContains(response, "80.0%")
|
||||||
|
self.assertContains(response, "10 days")
|
||||||
self.assertContains(response, "Warnings")
|
self.assertContains(response, "Warnings")
|
||||||
self.assertContains(response, "Queued")
|
self.assertContains(response, "Queued")
|
||||||
self.assertContains(response, "Next Run")
|
self.assertContains(response, "Next Run")
|
||||||
@@ -133,6 +136,18 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "manual")
|
self.assertContains(response, "manual")
|
||||||
self.assertContains(response, "1000")
|
self.assertContains(response, "1000")
|
||||||
|
|
||||||
|
def test_dashboard_explains_missing_backup_trends(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, "Backup Trends")
|
||||||
|
self.assertContains(response, "No completed backup runs with stats yet.")
|
||||||
|
self.assertContains(response, "growth estimates")
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user