From ef1761385e4c1262794c0fdced04382a54085f09 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 01:34:38 +0200 Subject: [PATCH 1/4] (ui) Separate healthy and problematic dashboard runs Split dashboard host cards into last successful backup and latest warning or failed run so operators can quickly see whether a host is protected even when recent activity produced an issue. Also add queued and warning run counts to the dashboard summary metrics. --- src/pobsync_backend/stats_summary.py | 17 +++++++++++--- .../templates/pobsync_backend/dashboard.html | 22 +++++++++++++++---- src/pobsync_backend/tests/test_views.py | 20 +++++++++++++++++ src/pobsync_backend/views.py | 2 ++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index 8382891..c682203 100644 --- a/src/pobsync_backend/stats_summary.py +++ b/src/pobsync_backend/stats_summary.py @@ -52,9 +52,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 +68,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 +90,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 +125,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/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 9b2b6ba..9a6b755 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -32,7 +32,9 @@
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
+
Queued
{{ counts.queued_runs }}
Running
{{ counts.running_runs }}
+
Warnings
{{ counts.warning_runs }}
Failed
{{ counts.failed_runs }}
@@ -77,11 +79,23 @@
-
Latest Run
+
Last Good Backup
- {% 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 %}
+ {% 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 %} +
+
+
+
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 %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index e5f80b4..cad4cce 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -42,6 +42,19 @@ 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", + }, + }, + ) response = self.client.get(reverse("dashboard")) @@ -50,8 +63,13 @@ 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") def test_dashboard_renders_backup_trend_summary(self) -> None: self.client.force_login(self.staff_user) @@ -92,6 +110,8 @@ class ViewTests(TestCase): self.assertContains(response, "Runs Until Full") self.assertContains(response, "Avg Daily New") self.assertContains(response, "Days Until Full") + self.assertContains(response, "Warnings") + self.assertContains(response, "Queued") self.assertContains(response, "Next Run") self.assertContains(response, "UTC") self.assertContains(response, "10") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index cf469a4..4a23de8 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -71,7 +71,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(), }, } -- 2.43.0 From a0fd33fcb861b35aa18eadc61f073807af56c970 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 01:41:45 +0200 Subject: [PATCH 2/4] (ui) Improve dashboard host card scanability Add per-host status chips for queued, running, warning, and failed runs so the dashboard shows operational pressure without needing to open each host. Restructure host cards into clearer backup activity and snapshot health sections, with less visual clutter and better mobile wrapping. --- .../templates/pobsync_backend/base.html | 36 +++-- .../templates/pobsync_backend/dashboard.html | 132 ++++++++++-------- src/pobsync_backend/tests/test_views.py | 14 ++ src/pobsync_backend/views.py | 11 +- 4 files changed, 125 insertions(+), 68 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 7cf0dc8..fdab62d 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -176,23 +176,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 +228,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 +301,10 @@ 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)); } } diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 9a6b755..5ff1775 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -63,72 +63,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 %} +
-
-
-
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 %} +
+
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 %} +
-
-
-
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 %} +
+
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 %} +
+
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 cad4cce..f96e886 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -55,6 +55,14 @@ class ViewTests(TestCase): }, }, ) + 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")) @@ -70,6 +78,12 @@ class ViewTests(TestCase): 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") def test_dashboard_renders_backup_trend_summary(self) -> None: self.client.force_login(self.staff_user) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 4a23de8..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: -- 2.43.0 From b4fc5a14b2376d39075a6c606e4dd0714e9aac3a Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 01:46:49 +0200 Subject: [PATCH 3/4] (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( -- 2.43.0 From a75b97c4c0f72c08f557b16cbe89cfabeb2984ec Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 01:51:13 +0200 Subject: [PATCH 4/4] (ui) Add dashboard operational status summary Make queued, running, warning, and failed run states more visible at the top of the dashboard with contextual status summaries and highlighted summary metrics. Also show an all-clear message when configured hosts have no active or problematic runs. --- .../templates/pobsync_backend/base.html | 21 +++++++++ .../templates/pobsync_backend/dashboard.html | 44 +++++++++++++++++-- src/pobsync_backend/tests/test_views.py | 16 +++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index ba29361..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; diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index e0299ce..b595b34 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -32,10 +32,46 @@
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
Runs
{{ counts.runs }}
-
Queued
{{ counts.queued_runs }}
-
Running
{{ counts.running_runs }}
-
Warnings
{{ counts.warning_runs }}
-
Failed
{{ counts.failed_runs }}
+
Queued
{{ counts.queued_runs }}
+
Running
{{ counts.running_runs }}
+
Warnings
{{ counts.warning_runs }}
+
Failed
{{ counts.failed_runs }}
+
+ +
+

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 %}
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 64c79f1..5fcaa29 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -84,6 +84,11 @@ class ViewTests(TestCase): 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) @@ -148,6 +153,17 @@ class ViewTests(TestCase): 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( -- 2.43.0