(ui) Clarify dashboard backup growth trends
Replace the dashboard trend metric grid with an operational summary that explains storage usage, runway, average new data, link-dest savings, and average duration in a more readable way. Also add an empty state for fresh installs before completed backup stats exist.
This commit is contained in:
@@ -37,6 +37,8 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
|
||||
available = _int_at(capacity, "available_bytes")
|
||||
daily_literal = _average_daily_literal(real_runs)
|
||||
|
||||
link_dest_savings_ratio = round(total_matched / savings_basis, 4) if savings_basis else None
|
||||
|
||||
return {
|
||||
"runs_sampled": len(real_runs),
|
||||
"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,
|
||||
"total_literal_data_bytes": total_literal,
|
||||
"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_days_until_full": int(available / daily_literal) if available and daily_literal else None,
|
||||
"capacity": capacity,
|
||||
|
||||
@@ -149,6 +149,43 @@
|
||||
font-size: 12px;
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -305,6 +342,7 @@
|
||||
.host-card-status { justify-content: flex-start; max-width: none; }
|
||||
.host-card-layout { grid-template-columns: 1fr; }
|
||||
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||
.insight-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -38,18 +38,67 @@
|
||||
<div class="metric"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
</section>
|
||||
|
||||
{% if stats_summary.runs_sampled %}
|
||||
<section class="grid" aria-label="Backup trends">
|
||||
<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>
|
||||
<div class="metric"><div class="label">Available</div><div class="value">{{ stats_summary.capacity.available_bytes|filesizeformat }}</div></div>
|
||||
<div class="metric"><div class="label">Avg New Data</div><div class="value">{{ stats_summary.avg_literal_data_bytes|filesizeformat }}</div></div>
|
||||
<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="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="metric"><div class="label">Link-Dest Savings</div><div class="value">{{ stats_summary.link_dest_savings_ratio|default:"" }}</div></div>
|
||||
<div class="metric"><div class="label">Runs Until Full</div><div class="value">{{ stats_summary.estimated_runs_until_full|default:"" }}</div></div>
|
||||
<div class="metric"><div class="label">Days Until Full</div><div class="value">{{ stats_summary.estimated_days_until_full|default:"" }}</div></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="panel">
|
||||
<h2>Backup Trends</h2>
|
||||
{% if stats_summary.runs_sampled %}
|
||||
<div class="insight-grid" aria-label="Backup trends">
|
||||
<div class="insight-main">
|
||||
<div class="label">Storage Used</div>
|
||||
<div class="value">
|
||||
{% if stats_summary.capacity.used_percent is not None %}
|
||||
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
||||
{% else %}
|
||||
unknown
|
||||
{% 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">
|
||||
<h2>Hosts</h2>
|
||||
|
||||
@@ -120,10 +120,13 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Backup Root Used")
|
||||
self.assertContains(response, "Runs Until Full")
|
||||
self.assertContains(response, "Avg Daily New")
|
||||
self.assertContains(response, "Days Until Full")
|
||||
self.assertContains(response, "Backup Trends")
|
||||
self.assertContains(response, "Storage Used")
|
||||
self.assertContains(response, "Runway")
|
||||
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, "Queued")
|
||||
self.assertContains(response, "Next Run")
|
||||
@@ -133,6 +136,18 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "manual")
|
||||
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:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(
|
||||
|
||||
Reference in New Issue
Block a user