(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:
2026-05-21 01:46:49 +02:00
parent a0fd33fcb8
commit b4fc5a14b2
4 changed files with 122 additions and 17 deletions

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(