From b4fc5a14b2376d39075a6c606e4dd0714e9aac3a Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 01:46:49 +0200 Subject: [PATCH] (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. --- src/pobsync_backend/stats_summary.py | 5 +- .../templates/pobsync_backend/base.html | 38 ++++++++++ .../templates/pobsync_backend/dashboard.html | 73 ++++++++++++++++--- src/pobsync_backend/tests/test_views.py | 23 +++++- 4 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index c682203..03de25d 100644 --- a/src/pobsync_backend/stats_summary.py +++ b/src/pobsync_backend/stats_summary.py @@ -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, diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index fdab62d..ba29361 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -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; } } diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 5ff1775..e0299ce 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -38,18 +38,67 @@
Failed
{{ counts.failed_runs }}
- {% if stats_summary.runs_sampled %} -
-
Backup Root Used
{{ stats_summary.capacity.used_percent|default:"" }}{% if stats_summary.capacity.used_percent is not None %}%{% endif %}
-
Available
{{ stats_summary.capacity.available_bytes|filesizeformat }}
-
Avg New Data
{{ stats_summary.avg_literal_data_bytes|filesizeformat }}
-
Avg Daily New
{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}
-
Avg Duration
{{ stats_summary.avg_duration_seconds|default:"" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}
-
Link-Dest Savings
{{ stats_summary.link_dest_savings_ratio|default:"" }}
-
Runs Until Full
{{ stats_summary.estimated_runs_until_full|default:"" }}
-
Days Until Full
{{ stats_summary.estimated_days_until_full|default:"" }}
-
- {% endif %} +
+

Backup Trends

+ {% if stats_summary.runs_sampled %} +
+
+
Storage Used
+
+ {% if stats_summary.capacity.used_percent is not None %} + {{ stats_summary.capacity.used_percent|floatformat:1 }}% + {% else %} + unknown + {% endif %} +
+ {% if stats_summary.capacity.used_percent is not None %} +
+ +
+ {% endif %} +
+ {{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root. +
+
+
+
Runway
+
+ {% 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 %} +
+
Estimated from average new data per day.
+
+
+
New Data
+
{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day
+
{{ stats_summary.avg_literal_data_bytes|filesizeformat }} per backup on average.
+
+
+
Link-Dest Savings
+
+ {% if stats_summary.link_dest_savings_percent is not None %} + {{ stats_summary.link_dest_savings_percent|floatformat:1 }}% + {% else %} + unknown + {% endif %} +
+
{{ stats_summary.total_matched_data_bytes|filesizeformat }} reused across sampled runs.
+
+
+
Average Duration
+
{{ stats_summary.avg_duration_seconds|default:"unknown" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}
+
Based on {{ stats_summary.runs_sampled }} completed backup run{{ stats_summary.runs_sampled|pluralize }} with stats.
+
+
+ {% else %} +

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.

+ {% endif %} +

Hosts

diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index f96e886..64c79f1 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -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(