diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index bbeae97..15ffc61 100644 --- a/src/pobsync_backend/stats_summary.py +++ b/src/pobsync_backend/stats_summary.py @@ -3,6 +3,8 @@ from __future__ import annotations from pathlib import Path from typing import Any, Iterable +from django.utils import timezone + from pobsync.run_stats import filesystem_capacity from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord @@ -33,15 +35,18 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa savings_basis = total_literal + total_matched capacity = _capacity_from_system(global_config) or _latest_capacity_from_runs(real_runs) or {} available = _int_at(capacity, "available_bytes") + daily_literal = _average_daily_literal(real_runs) return { "runs_sampled": len(real_runs), "avg_duration_seconds": _average(duration_values), + "avg_daily_literal_data_bytes": daily_literal, "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, "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, } @@ -57,12 +62,15 @@ def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]: literal_values = [value for value in literal_values if value is not None] matched_values = [_int_at(run, "rsync", "matched_data_bytes") for run in real_runs] matched_values = [value for value in matched_values if value is not None] + max_literal = max(literal_values) if literal_values else 0 + max_matched = max(matched_values) if matched_values else 0 return { - "runs": real_runs, + "runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in real_runs], "latest_run": real_runs[0] if real_runs else {}, "latest_snapshot": latest_snapshot_stats, "avg_literal_data_bytes": _average(literal_values), + "avg_daily_literal_data_bytes": _average_daily_literal(real_runs), "total_literal_data_bytes": sum(literal_values), "total_matched_data_bytes": sum(matched_values), } @@ -132,6 +140,41 @@ def _average(values: list[int]) -> int | None: return int(sum(values) / len(values)) +def _average_daily_literal(runs: list[dict[str, Any]]) -> int | None: + values = [_int_at(run, "rsync", "literal_data_bytes") for run in runs] + values = [value for value in values if value is not None] + if not values: + return None + + timestamps = [run["started_at"] for run in runs if run.get("started_at") is not None] + if len(timestamps) < 2: + return _average(values) + + oldest = min(timestamps) + newest = max(timestamps) + if timezone.is_naive(oldest): + oldest = timezone.make_aware(oldest) + if timezone.is_naive(newest): + newest = timezone.make_aware(newest) + span_days = max((newest - oldest).total_seconds() / 86400, 1) + return int(sum(values) / span_days) + + +def _with_bar_percentages(run: dict[str, Any], *, max_literal: int, max_matched: int) -> dict[str, Any]: + run = dict(run) + literal = _int_at(run, "rsync", "literal_data_bytes") or 0 + matched = _int_at(run, "rsync", "matched_data_bytes") or 0 + run["literal_percent"] = _percentage(literal, max_literal) + run["matched_percent"] = _percentage(matched, max_matched) + return run + + +def _percentage(value: int, maximum: int) -> int: + if maximum <= 0 or value <= 0: + return 0 + return max(1, min(100, int(value / maximum * 100))) + + def _dict_at(data: dict[str, Any], *keys: str) -> dict[str, Any]: value: Any = data for key in keys: diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 4f26055..403ced1 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -120,6 +120,30 @@ gap: 8px; margin-bottom: 14px; } + .trend-bars { + display: grid; + gap: 5px; + min-width: 150px; + } + .trend-bar { + background: #edf2f7; + border-radius: 4px; + height: 8px; + overflow: hidden; + } + .trend-bar span { + background: var(--link); + display: block; + height: 100%; + min-width: 0; + } + .trend-bar.matched span { background: var(--success); } + .trend-legend { + color: var(--muted); + display: flex; + font-size: 12px; + gap: 10px; + } .messages { display: grid; gap: 8px; margin-bottom: 18px; } .message { background: var(--panel); diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index a187f67..a7119fd 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -42,9 +42,11 @@
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 %} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 710f5cf..09db4de 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -83,6 +83,7 @@

Backup Trends

Avg New Data
{{ stats_summary.avg_literal_data_bytes|filesizeformat }}
+
Avg Daily New
{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}
Total New Data
{{ stats_summary.total_literal_data_bytes|filesizeformat }}
Matched Data
{{ stats_summary.total_matched_data_bytes|filesizeformat }}
Latest Duration
{{ stats_summary.latest_run.duration_seconds|default:"" }}{% if stats_summary.latest_run.duration_seconds is not None %}s{% endif %}
@@ -96,6 +97,7 @@ Files New Data Matched + Trend Snapshot @@ -108,6 +110,13 @@ {{ run.rsync.files_total|default:"" }} {{ run.rsync.literal_data_bytes|filesizeformat }} {{ run.rsync.matched_data_bytes|filesizeformat }} + +
+
+
+
newmatched
+
+ {% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}{{ run.snapshot_path }}{% endif %} {% endfor %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index c205552..25246aa 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -85,6 +85,8 @@ class ViewTests(TestCase): 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, "10") self.assertContains(response, f"Run {run.id}") self.assertContains(response, "1000") @@ -582,16 +584,38 @@ class ViewTests(TestCase): }, }, ) + BackupRun.objects.create( + host=host, + status=BackupRun.Status.SUCCESS, + snapshot=snapshot, + started_at=datetime(2026, 5, 18, 2, 15, tzinfo=timezone.utc), + result={ + "ok": True, + "dry_run": False, + "stats": { + "duration_seconds": 35, + "rsync": { + "files_total": 150, + "literal_data_bytes": 1024, + "matched_data_bytes": 4096, + }, + }, + }, + ) response = self.client.get(reverse("host_detail", args=[host.host])) self.assertEqual(response.status_code, 200) self.assertContains(response, "Backup Trends") self.assertContains(response, "Avg New Data") + self.assertContains(response, "Avg Daily New") self.assertContains(response, "45s") self.assertContains(response, "250") self.assertContains(response, "2.0") self.assertContains(response, "KB") + self.assertContains(response, "Run data trend") + self.assertContains(response, "width: 100%") + self.assertContains(response, "width: 50%") def test_prepare_host_directories_action_creates_missing_directories(self) -> None: self.client.force_login(self.staff_user)