diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index 8382891..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, @@ -52,9 +55,10 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]: - runs = list(host.runs.select_related("snapshot").filter(status__in=_COMPLETED_BACKUP_STATUSES).order_by("-started_at", "-created_at")[:50]) + runs = list(host.runs.select_related("snapshot").order_by("-started_at", "-created_at")[:50]) real_runs = [_run_summary(run) for run in runs if _is_real_run(run)] - trend_runs = [run for run in real_runs if run["has_stats"]][:limit] + completed_real_runs = [run for run in real_runs if run["status"] in _COMPLETED_BACKUP_STATUSES] + trend_runs = [run for run in completed_real_runs if run["has_stats"]][:limit] latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first() latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {} @@ -67,7 +71,9 @@ def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]: return { "runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in trend_runs], - "latest_run": real_runs[0] if real_runs else {}, + "latest_run": completed_real_runs[0] if completed_real_runs else {}, + "latest_good_run": _first_run_with_status(real_runs, {BackupRun.Status.SUCCESS}), + "latest_problem_run": _first_run_with_status(real_runs, {BackupRun.Status.WARNING, BackupRun.Status.FAILED}), "latest_snapshot": latest_snapshot_stats, "avg_literal_data_bytes": _average(literal_values), "avg_daily_literal_data_bytes": _average_daily_literal(trend_runs), @@ -87,6 +93,7 @@ def _run_summary(run: BackupRun) -> dict[str, Any]: "ended_at": run.ended_at, "snapshot": run.snapshot, "snapshot_path": run.snapshot_path, + "status": run.status, "has_stats": bool(stats), "duration_seconds": _int_at(stats, "duration_seconds"), "rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {}, @@ -121,6 +128,13 @@ def _is_real_run(run: BackupRun) -> bool: return requested.get("dry_run") is not True +def _first_run_with_status(runs: list[dict[str, Any]], statuses: set[str]) -> dict[str, Any]: + for run in runs: + if run["status"] in statuses: + return run + return {} + + def _capacity_from_system(global_config: GlobalConfig | None) -> dict[str, Any]: if global_config is None or not global_config.backup_root: return {} diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 7cf0dc8..7177a6e 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -61,6 +61,10 @@ .metric { padding: 14px; } .metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; } .metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; } + .metric.failed { border-color: #e8b4b4; background: #fff7f7; } + .metric.warning { border-color: #e7cf8a; background: #fffaf0; } + .metric.running { border-color: #e7cf8a; background: #fffaf0; } + .metric.queued { border-color: #b5cdea; background: #eef6ff; } .panel { padding: 16px; margin-bottom: 18px; overflow: auto; } .panel.highlight { border-left: 4px solid var(--border); } .panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; } @@ -118,6 +122,23 @@ cursor: not-allowed; } .inline-form { margin: 0; } + .status-overview { + display: grid; + gap: 8px; + } + .status-summary { + align-items: center; + border: 1px solid var(--border); + border-radius: 6px; + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px; + } + .status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); } + .status-summary.warning, + .status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); } + .status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); } .operator-state { align-items: center; display: flex; @@ -149,6 +170,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; @@ -176,23 +234,42 @@ overflow-wrap: anywhere; } .host-card-status { - flex: 0 0 auto; + display: flex; + flex: 0 1 auto; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + max-width: 50%; } .host-card-layout { display: grid; - gap: 18px; - grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); + gap: 24px; + grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); + } + .host-card-section { + align-content: start; + display: grid; + gap: 10px; + min-width: 0; + } + .host-card-section-title { + color: var(--muted); + font-size: 12px; + font-weight: 650; + text-transform: uppercase; } .host-card-timeline { display: grid; - gap: 12px; + gap: 16px 22px; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); } .host-card-stats { align-content: start; display: grid; - gap: 10px; + border-top: 1px solid #e6edf4; + gap: 12px 18px; grid-template-columns: repeat(2, minmax(0, 1fr)); + padding-top: 12px; } .host-card-item { display: grid; @@ -209,13 +286,9 @@ overflow-wrap: anywhere; } .host-card-stat { - background: #f8fafc; - border: 1px solid #e6edf4; - border-radius: 6px; display: grid; gap: 3px; min-width: 0; - padding: 10px; } .host-card-stat .label { color: var(--muted); @@ -286,7 +359,11 @@ main { padding: 16px; } nav { padding: 0; } .two-col { grid-template-columns: 1fr; } + .host-card-header { display: grid; } + .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 9b2b6ba..b595b34 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -32,22 +32,109 @@
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
-
Running
{{ counts.running_runs }}
-
Failed
{{ counts.failed_runs }}
+
Queued
{{ counts.queued_runs }}
+
Running
{{ counts.running_runs }}
+
Warnings
{{ counts.warning_runs }}
+
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 %} +
+

Operational Status

+ {% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %} +
+ {% if counts.failed_runs %} +
+ failed + {{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review. +
+ {% endif %} + {% if counts.warning_runs %} +
+ warning + {{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings. +
+ {% endif %} + {% if counts.running_runs %} +
+ running + {{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress. +
+ {% endif %} + {% if counts.queued_runs %} +
+ queued + {{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker. +
+ {% endif %} +
+ {% elif counts.hosts %} +

ok No queued, running, warning, or failed runs.

+ {% else %} +

Add a host to start tracking backup status here.

+ {% 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

@@ -61,60 +148,90 @@
{{ host.enabled|yesno:"enabled,disabled" }} + {% if host.queued_run_count %} + queued {{ host.queued_run_count }} + {% endif %} + {% if host.running_run_count %} + running {{ host.running_run_count }} + {% endif %} + {% if host.warning_run_count %} + warning {{ host.warning_run_count }} + {% endif %} + {% if host.failed_run_count %} + failed {{ host.failed_run_count }} + {% endif %}
-
-
-
Latest Snapshot
-
- {% if host.latest_snapshot %} - {{ host.latest_snapshot.dirname }} -
{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}
- {% else %} - none - {% endif %} +
+
Backup activity
+
+
+
Latest Snapshot
+
+ {% if host.latest_snapshot %} + {{ host.latest_snapshot.dirname }} +
{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}
+ {% else %} + none + {% endif %} +
-
-
-
Latest Run
-
- {% if host.stats_summary.latest_run.id %} - Run {{ host.stats_summary.latest_run.id }} -
{{ host.stats_summary.latest_run.run_type }} {{ host.stats_summary.latest_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_run.duration_seconds is not None %}s{% endif %}
- {% else %} - none - {% endif %} +
+
Last Good Backup
+
+ {% if host.stats_summary.latest_good_run.id %} + Run {{ host.stats_summary.latest_good_run.id }} +
{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}
+ {% else %} + none + {% endif %} +
-
-
-
Next Run
-
- {% if host.next_run_at %} - {{ host.next_run_at|date:"Y-m-d H:i T" }} -
{{ scheduler_timezone }}
- {% else %} - none - {% endif %} +
+
Latest Issue
+
+ {% if host.stats_summary.latest_problem_run.id %} + Run {{ host.stats_summary.latest_problem_run.id }} +
{{ host.stats_summary.latest_problem_run.status }}
+
{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}
+ {% else %} + none + {% endif %} +
+
+
+
Next Run
+
+ {% if host.next_run_at %} + {{ host.next_run_at|date:"Y-m-d H:i T" }} +
{{ scheduler_timezone }}
+ {% else %} + none + {% endif %} +
-
-
-
Snapshots
-
{{ host.snapshot_count }}
-
-
-
Runs
-
{{ host.run_count }}
-
-
-
New Data
-
{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}
-
-
-
Retention
-
d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}
+
+
Snapshot health
+
+
+
Snapshots
+
{{ host.snapshot_count }}
+
+
+
Runs
+
{{ host.run_count }}
+
+
+
New Data
+
{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}
+
+
+
Retention
+
d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}
+
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index e5f80b4..5fcaa29 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -42,6 +42,27 @@ class ViewTests(TestCase): snapshot=snapshot, started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc), ) + warning_run = BackupRun.objects.create( + host=host, + run_type=BackupRun.RunType.SCHEDULED, + status=BackupRun.Status.WARNING, + started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc), + result={ + "ok": True, + "prune": { + "ok": False, + "error": "Retention warning", + }, + }, + ) + BackupRun.objects.create(host=host, status=BackupRun.Status.QUEUED) + BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING) + BackupRun.objects.create( + host=host, + run_type=BackupRun.RunType.MANUAL, + status=BackupRun.Status.FAILED, + started_at=datetime(2026, 5, 19, 1, 15, tzinfo=timezone.utc), + ) response = self.client.get(reverse("dashboard")) @@ -50,8 +71,24 @@ class ViewTests(TestCase): self.assertContains(response, "web-01") self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "success") + self.assertContains(response, "Last Good Backup") + self.assertContains(response, "Latest Issue") self.assertContains(response, f"Run {run.id}") + self.assertContains(response, f"Run {warning_run.id}") + self.assertContains(response, "warning") self.assertContains(response, "manual") + self.assertContains(response, "scheduled") + self.assertContains(response, "Backup activity") + self.assertContains(response, "Snapshot health") + self.assertContains(response, "queued 1") + self.assertContains(response, "running 1") + self.assertContains(response, "warning 1") + self.assertContains(response, "failed 1") + self.assertContains(response, "Operational Status") + self.assertContains(response, "1 failed run needs review.") + self.assertContains(response, "1 run completed with warnings.") + self.assertContains(response, "1 backup run in progress.") + self.assertContains(response, "1 backup run waiting for the worker.") def test_dashboard_renders_backup_trend_summary(self) -> None: self.client.force_login(self.staff_user) @@ -88,10 +125,15 @@ 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") self.assertContains(response, "UTC") self.assertContains(response, "10") @@ -99,6 +141,29 @@ 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_shows_all_clear_operational_status(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, "Operational Status") + self.assertContains(response, "No queued, running, warning, or failed runs.") + def test_dashboard_surfaces_retention_warnings(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create( diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index cf469a4..03a739a 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -9,7 +9,7 @@ from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required from django.conf import settings from django.http import FileResponse, Http404 -from django.db.models import Count +from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.http import require_POST @@ -45,7 +45,14 @@ def dashboard(request): global_config = GlobalConfig.objects.filter(name="default").first() hosts = list( HostConfig.objects.select_related("schedule") - .annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True)) + .annotate( + snapshot_count=Count("snapshots", distinct=True), + run_count=Count("runs", distinct=True), + queued_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.QUEUED), distinct=True), + running_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.RUNNING), distinct=True), + warning_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.WARNING), distinct=True), + failed_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.FAILED), distinct=True), + ) .order_by("host") ) for host_config in hosts: @@ -71,7 +78,9 @@ def dashboard(request): "enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(), "snapshots": SnapshotRecord.objects.count(), "runs": BackupRun.objects.count(), + "queued_runs": BackupRun.objects.filter(status=BackupRun.Status.QUEUED).count(), "running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(), + "warning_runs": BackupRun.objects.filter(status=BackupRun.Status.WARNING).count(), "failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(), }, }