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(