diff --git a/src/pobsync_backend/stats_summary.py b/src/pobsync_backend/stats_summary.py index 773ef53..5c39a69 100644 --- a/src/pobsync_backend/stats_summary.py +++ b/src/pobsync_backend/stats_summary.py @@ -5,7 +5,7 @@ from typing import Any, Iterable from django.utils import timezone -from pobsync.run_stats import filesystem_capacity, tree_usage +from pobsync.run_stats import filesystem_capacity from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord @@ -118,14 +118,26 @@ def _backup_data_by_kind(host: HostConfig) -> dict[str, Any]: for snapshot in host.snapshots.all(): summary = _snapshot_summary(snapshot) row = rows.setdefault(snapshot.kind, _empty_snapshot_data_row()) - allocated = summary.get("allocated_size_bytes") or summary.get("apparent_size_bytes") or 0 - apparent = summary.get("apparent_size_bytes") or 0 - unique_apparent = summary.get("unique_apparent_size_bytes") or 0 row["count"] += 1 + total["count"] += 1 + if not summary.get("storage_measured"): + row["unknown_count"] += 1 + total["unknown_count"] += 1 + continue + + allocated = summary.get("allocated_size_bytes") + if allocated is None: + allocated = summary.get("apparent_size_bytes") + apparent = summary.get("apparent_size_bytes") + unique_apparent = summary.get("unique_apparent_size_bytes") + allocated = int(allocated or 0) + apparent = int(apparent or 0) + unique_apparent = int(unique_apparent or 0) + row["measured_count"] += 1 row["allocated_size_bytes"] += int(allocated) row["apparent_size_bytes"] += int(apparent) row["unique_apparent_size_bytes"] += int(unique_apparent) - total["count"] += 1 + total["measured_count"] += 1 total["allocated_size_bytes"] += int(allocated) total["apparent_size_bytes"] += int(apparent) total["unique_apparent_size_bytes"] += int(unique_apparent) @@ -141,6 +153,8 @@ def _backup_data_by_kind(host: HostConfig) -> dict[str, Any]: def _empty_snapshot_data_row() -> dict[str, int]: return { "count": 0, + "measured_count": 0, + "unknown_count": 0, "allocated_size_bytes": 0, "apparent_size_bytes": 0, "unique_apparent_size_bytes": 0, @@ -159,6 +173,8 @@ def _sum_backup_data_by_kind(rows: Iterable[dict[str, dict[str, int]]]) -> dict[ for kind, values in row.items(): total_row = total_rows.setdefault(kind, _empty_snapshot_data_row()) total_row["count"] += values.get("count", 0) + total_row["measured_count"] += values.get("measured_count", 0) + total_row["unknown_count"] += values.get("unknown_count", 0) total_row["allocated_size_bytes"] += values.get("allocated_size_bytes", 0) total_row["apparent_size_bytes"] += values.get("apparent_size_bytes", 0) total_row["unique_apparent_size_bytes"] += values.get("unique_apparent_size_bytes", 0) @@ -173,15 +189,7 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]: stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {} storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {} snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {} - if snapshot.kind == SnapshotRecord.Kind.INCOMPLETE: - snapshot_storage = _snapshot_storage_from_filesystem(snapshot) - else: - has_recorded_size = ( - _int_at(snapshot_storage, "allocated_size_bytes") is not None - or _int_at(snapshot_storage, "apparent_size_bytes") is not None - ) - if not has_recorded_size: - snapshot_storage = _snapshot_storage_from_filesystem(snapshot) + storage_measured = _has_recorded_snapshot_storage(snapshot_storage) apparent_size = _int_at(snapshot_storage, "apparent_size_bytes") hardlinked_apparent = _int_at(snapshot_storage, "hardlinked_apparent_size_bytes") or 0 return { @@ -195,19 +203,18 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]: "hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"), "hardlinked_apparent_size_bytes": hardlinked_apparent, "unique_apparent_size_bytes": max((apparent_size or 0) - hardlinked_apparent, 0), + "storage_measured": storage_measured, } -def _snapshot_storage_from_filesystem(snapshot: SnapshotRecord) -> dict[str, Any]: - if not snapshot.path: - return {} - snapshot_path = Path(snapshot.path) - data_path = snapshot_path / "data" - if snapshot_path.name == "data": - return tree_usage(snapshot_path) - if data_path.exists(): - return tree_usage(data_path) - return tree_usage(snapshot_path) +def _has_recorded_snapshot_storage(snapshot_storage: dict[str, Any]) -> bool: + return any( + _int_at(snapshot_storage, key) is not None + for key in ( + "allocated_size_bytes", + "apparent_size_bytes", + ) + ) def _is_real_run(run: BackupRun) -> bool: diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index da7e16e..ab2b5ad 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -190,27 +190,35 @@
Scheduled
{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}
-
unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}
+
+ unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.scheduled.unknown_count %}; {{ stats_summary.backup_data.scheduled.unknown_count }} not measured{% endif %} +
Manual
{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}
-
unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}
+
+ unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.manual.unknown_count %}; {{ stats_summary.backup_data.manual.unknown_count }} not measured{% endif %} +
Incomplete
{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}
-
measured from disk
+
+ stored metadata{% if stats_summary.backup_data.incomplete.unknown_count %}; {{ stats_summary.backup_data.incomplete.unknown_count }} not measured{% endif %} +
Total
{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}
-
unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}
+
+ unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.total.unknown_count %}; {{ stats_summary.backup_data.total.unknown_count }} not measured{% endif %} +

- Main totals use allocated snapshot size. Unique values estimate non-hardlinked visible data; incomplete - snapshots are measured from disk because their metadata can be stale. + Main totals use stored snapshot metadata. Unique values estimate non-hardlinked visible data; snapshots without + recorded storage metadata are shown as not measured until a backup or metrics refresh records them.

diff --git a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html index 31d2617..1c00a0f 100644 --- a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html +++ b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_hosts.html @@ -105,22 +105,30 @@
Scheduled data
{{ host.stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}
-
unique {{ host.stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}
+
+ unique {{ host.stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}{% if host.stats_summary.backup_data.scheduled.unknown_count %}; {{ host.stats_summary.backup_data.scheduled.unknown_count }} not measured{% endif %} +
Manual data
{{ host.stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}
-
unique {{ host.stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}
+
+ unique {{ host.stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}{% if host.stats_summary.backup_data.manual.unknown_count %}; {{ host.stats_summary.backup_data.manual.unknown_count }} not measured{% endif %} +
Incomplete data
{{ host.stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}
-
measured from disk
+
+ stored metadata{% if host.stats_summary.backup_data.incomplete.unknown_count %}; {{ host.stats_summary.backup_data.incomplete.unknown_count }} not measured{% endif %} +
Total data
{{ host.stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}
-
unique {{ host.stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}
+
+ unique {{ host.stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}{% if host.stats_summary.backup_data.total.unknown_count %}; {{ host.stats_summary.backup_data.total.unknown_count }} not measured{% endif %} +
diff --git a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html index 7b673a1..1f7f05c 100644 --- a/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html +++ b/src/pobsync_backend/templates/pobsync_backend/partials/dashboard_priority.html @@ -130,22 +130,30 @@
Scheduled data {{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }} - unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }} + + unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.scheduled.unknown_count %}; {{ stats_summary.backup_data.scheduled.unknown_count }} not measured{% endif %} +
Manual data {{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }} - unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }} + + unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.manual.unknown_count %}; {{ stats_summary.backup_data.manual.unknown_count }} not measured{% endif %} +
Incomplete data {{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }} - measured from disk + + stored metadata{% if stats_summary.backup_data.incomplete.unknown_count %}; {{ stats_summary.backup_data.incomplete.unknown_count }} not measured{% endif %} +
Total snapshot data {{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }} - unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }} + + unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}{% if stats_summary.backup_data.total.unknown_count %}; {{ stats_summary.backup_data.total.unknown_count }} not measured{% endif %} +
diff --git a/src/pobsync_backend/tests/test_stats_summary.py b/src/pobsync_backend/tests/test_stats_summary.py index d64ab30..10bdcc1 100644 --- a/src/pobsync_backend/tests/test_stats_summary.py +++ b/src/pobsync_backend/tests/test_stats_summary.py @@ -1,12 +1,10 @@ from __future__ import annotations from datetime import datetime, timezone -from pathlib import Path -from tempfile import TemporaryDirectory +from unittest.mock import patch from django.test import TestCase -from pobsync.run_stats import tree_usage from pobsync_backend.models import HostConfig, SnapshotRecord from pobsync_backend.stats_summary import collect_dashboard_stats, collect_host_stats @@ -18,114 +16,109 @@ class StatsSummaryTests(TestCase): self._snapshot(web, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100) self._snapshot(web, "20260519-031500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=200) self._snapshot(db, "20260519-041500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=300) - with TemporaryDirectory() as tmp: - incomplete_usage = self._incomplete_snapshot_on_disk( - db, - Path(tmp), - "20260519-051500Z__BROKEN1", - ) + self._snapshot(db, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400) - stats = collect_dashboard_stats(hosts=[web, db], global_config=None) + stats = collect_dashboard_stats(hosts=[web, db], global_config=None) self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2) self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 400) self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 200) - self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"]) + self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 400) self.assertEqual(stats["backup_data"]["total"]["count"], 4) - self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"]) - self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"]) + self.assertEqual(stats["backup_data"]["total"]["measured_count"], 4) + self.assertEqual(stats["backup_data"]["total"]["unknown_count"], 0) + self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000) + self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 2000) def test_collect_host_stats_sums_backup_data_by_snapshot_kind(self) -> None: host = HostConfig.objects.create(host="web-01", address="web-01.example.test") self._snapshot(host, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100) self._snapshot(host, "20260519-031500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=200) self._snapshot(host, "20260519-041500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=300) - with TemporaryDirectory() as tmp: - incomplete_usage = self._incomplete_snapshot_on_disk( - host, - Path(tmp), - "20260519-051500Z__BROKEN1", - ) + self._snapshot(host, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400) - stats = collect_host_stats(host=host) + stats = collect_host_stats(host=host) self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2) self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 300) self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 300) - self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"]) + self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 400) self.assertEqual(stats["backup_data"]["total"]["count"], 4) - self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"]) - self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"]) + self.assertEqual(stats["backup_data"]["total"]["measured_count"], 4) + self.assertEqual(stats["backup_data"]["total"]["unknown_count"], 0) + self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000) + self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 2000) - def test_collect_host_stats_falls_back_to_filesystem_usage_for_snapshots_without_metadata(self) -> None: + def test_collect_host_stats_marks_snapshots_without_storage_metadata_unknown(self) -> None: host = HostConfig.objects.create(host="web-01", address="web-01.example.test") - with TemporaryDirectory() as tmp: - incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-051500Z__BROKEN1" - data_dir = incomplete_dir / "data" - meta_dir = incomplete_dir / "meta" - data_dir.mkdir(parents=True) - meta_dir.mkdir() - data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8") - meta_dir.joinpath("rsync.log").write_text("not part of the backup data total\n", encoding="utf-8") - expected_usage = tree_usage(data_dir) - SnapshotRecord.objects.create( - host=host, - kind=SnapshotRecord.Kind.INCOMPLETE, - dirname=incomplete_dir.name, - path=str(incomplete_dir), - status="failed", - metadata={}, - ) + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-051500Z__BROKEN1", + path="/backups/web-01/.incomplete/20260519-051500Z__BROKEN1", + status="failed", + metadata={}, + ) + with patch("pobsync_backend.stats_summary.tree_usage", create=True) as tree_usage: stats = collect_host_stats(host=host) + tree_usage.assert_not_called() self.assertEqual(stats["backup_data"]["incomplete"]["count"], 1) - self.assertEqual( - stats["backup_data"]["incomplete"]["allocated_size_bytes"], - expected_usage["allocated_size_bytes"], - ) - self.assertEqual( - stats["backup_data"]["incomplete"]["apparent_size_bytes"], - expected_usage["apparent_size_bytes"], - ) - self.assertEqual( - stats["backup_data"]["total"]["allocated_size_bytes"], - expected_usage["allocated_size_bytes"], - ) + self.assertEqual(stats["backup_data"]["incomplete"]["measured_count"], 0) + self.assertEqual(stats["backup_data"]["incomplete"]["unknown_count"], 1) + self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 0) + self.assertEqual(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0) + self.assertEqual(stats["backup_data"]["total"]["unknown_count"], 1) - def test_collect_host_stats_measures_incomplete_data_from_disk_even_with_stale_metadata(self) -> None: + def test_collect_host_stats_uses_recorded_zero_storage_without_rescanning(self) -> None: host = HostConfig.objects.create(host="web-01", address="web-01.example.test") - with TemporaryDirectory() as tmp: - incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-051500Z__BROKEN1" - data_dir = incomplete_dir / "data" - data_dir.mkdir(parents=True) - data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8") - expected_usage = tree_usage(data_dir) - SnapshotRecord.objects.create( - host=host, - kind=SnapshotRecord.Kind.INCOMPLETE, - dirname=incomplete_dir.name, - path=str(incomplete_dir), - status="failed", - metadata={ - "stats": { - "storage": { - "snapshot": { - "apparent_size_bytes": 0, - "allocated_size_bytes": 0, - } + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-051500Z__BROKEN1", + path="/backups/web-01/.incomplete/20260519-051500Z__BROKEN1", + status="failed", + metadata={ + "stats": { + "storage": { + "snapshot": { + "apparent_size_bytes": 0, + "allocated_size_bytes": 0, } } - }, - ) + } + }, + ) + with patch("pobsync_backend.stats_summary.tree_usage", create=True) as tree_usage: stats = collect_host_stats(host=host) - self.assertEqual( - stats["backup_data"]["incomplete"]["allocated_size_bytes"], - expected_usage["allocated_size_bytes"], + tree_usage.assert_not_called() + self.assertEqual(stats["backup_data"]["incomplete"]["measured_count"], 1) + self.assertEqual(stats["backup_data"]["incomplete"]["unknown_count"], 0) + self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], 0) + self.assertEqual(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0) + + def test_collect_dashboard_stats_does_not_scan_filesystem_for_missing_snapshot_metadata(self) -> None: + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.SCHEDULED, + dirname="20260519-051500Z__SCHED01", + path="/backups/web-01/scheduled/20260519-051500Z__SCHED01", + status="success", + metadata={}, ) - self.assertGreater(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0) + + with patch("pobsync_backend.stats_summary.tree_usage", create=True) as tree_usage: + stats = collect_dashboard_stats(hosts=[host], global_config=None) + + tree_usage.assert_not_called() + self.assertEqual(stats["backup_data"]["scheduled"]["count"], 1) + self.assertEqual(stats["backup_data"]["scheduled"]["measured_count"], 0) + self.assertEqual(stats["backup_data"]["scheduled"]["unknown_count"], 1) + self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 0) def test_collect_host_stats_reports_non_hardlinked_snapshot_data(self) -> None: host = HostConfig.objects.create(host="web-01", address="web-01.example.test") @@ -147,21 +140,6 @@ class StatsSummaryTests(TestCase): def _snapshot(self, host: HostConfig, dirname: str, kind: str, *, allocated: int) -> SnapshotRecord: return self._snapshot_with_sizes(host, dirname, kind, allocated=allocated) - def _incomplete_snapshot_on_disk(self, host: HostConfig, root: Path, dirname: str) -> dict: - incomplete_dir = root / host.host / ".incomplete" / dirname - data_dir = incomplete_dir / "data" - data_dir.mkdir(parents=True) - data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8") - usage = tree_usage(data_dir) - SnapshotRecord.objects.create( - host=host, - kind=SnapshotRecord.Kind.INCOMPLETE, - dirname=dirname, - path=str(incomplete_dir), - status="failed", - ) - return usage - def _snapshot_with_sizes( self, host: HostConfig, diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 5f85bfd..6c6a8da 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -8,11 +8,9 @@ from unittest.mock import patch from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile -from django.template.defaultfilters import filesizeformat from django.test import TestCase, override_settings from django.urls import reverse -from pobsync.run_stats import tree_usage from pobsync.util import write_yaml_atomic from pobsync_backend.models import ( BackupRun, @@ -464,23 +462,12 @@ class ViewTests(TestCase): db = HostConfig.objects.create(host="db-01", address="db-01.example.test") scheduled = self._snapshot(web, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED) manual = self._snapshot(web, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL) + incomplete = self._snapshot(db, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE) self._set_snapshot_storage(scheduled, allocated=100) self._set_snapshot_storage(manual, allocated=200) - with TemporaryDirectory() as tmp: - incomplete_dir = Path(tmp) / db.host / ".incomplete" / "20260519-041500Z__BROKEN1" - data_dir = incomplete_dir / "data" - data_dir.mkdir(parents=True) - data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8") - expected_usage = tree_usage(data_dir) - SnapshotRecord.objects.create( - host=db, - kind=SnapshotRecord.Kind.INCOMPLETE, - dirname=incomplete_dir.name, - path=str(incomplete_dir), - status="failed", - ) + self._set_snapshot_storage(incomplete, allocated=400) - response = self.client.get(reverse("dashboard_priority_live")) + response = self.client.get(reverse("dashboard_priority_live")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Scheduled data") @@ -489,8 +476,8 @@ class ViewTests(TestCase): self.assertContains(response, "Total snapshot data") self.assertContains(response, "100 bytes", html=True) self.assertContains(response, "200 bytes", html=True) - self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"])) - self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"])) + self.assertContains(response, "400 bytes", html=True) + self.assertContains(response, "700 bytes", html=True) def test_dashboard_hosts_live_returns_hosts_partial(self) -> None: self.client.force_login(self.staff_user) @@ -510,23 +497,12 @@ class ViewTests(TestCase): host = HostConfig.objects.create(host="web-01", address="web-01.example.test") scheduled = self._snapshot(host, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED) manual = self._snapshot(host, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL) + incomplete = self._snapshot(host, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE) self._set_snapshot_storage(scheduled, allocated=100) self._set_snapshot_storage(manual, allocated=200) - with TemporaryDirectory() as tmp: - incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-041500Z__BROKEN1" - data_dir = incomplete_dir / "data" - data_dir.mkdir(parents=True) - data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8") - expected_usage = tree_usage(data_dir) - SnapshotRecord.objects.create( - host=host, - kind=SnapshotRecord.Kind.INCOMPLETE, - dirname=incomplete_dir.name, - path=str(incomplete_dir), - status="failed", - ) + self._set_snapshot_storage(incomplete, allocated=400) - response = self.client.get(reverse("dashboard_hosts_live")) + response = self.client.get(reverse("dashboard_hosts_live")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Scheduled data") @@ -535,32 +511,27 @@ class ViewTests(TestCase): self.assertContains(response, "Total data") self.assertContains(response, "100 bytes", html=True) self.assertContains(response, "200 bytes", html=True) - self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"])) - self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"])) + self.assertContains(response, "400 bytes", html=True) + self.assertContains(response, "700 bytes", html=True) - def test_dashboard_host_cards_measure_incomplete_data_without_snapshot_metadata(self) -> None: + def test_dashboard_host_cards_mark_incomplete_data_without_snapshot_metadata_unmeasured(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") - with TemporaryDirectory() as tmp: - incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-041500Z__BROKEN1" - data_dir = incomplete_dir / "data" - data_dir.mkdir(parents=True) - data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8") - expected_usage = tree_usage(data_dir) - SnapshotRecord.objects.create( - host=host, - kind=SnapshotRecord.Kind.INCOMPLETE, - dirname=incomplete_dir.name, - path=str(incomplete_dir), - status="failed", - metadata={}, - ) + SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.INCOMPLETE, + dirname="20260519-041500Z__BROKEN1", + path="/backups/web-01/.incomplete/20260519-041500Z__BROKEN1", + status="failed", + metadata={}, + ) - response = self.client.get(reverse("dashboard_hosts_live")) + response = self.client.get(reverse("dashboard_hosts_live")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Incomplete data") - self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"])) + self.assertContains(response, "0 bytes", html=True) + self.assertContains(response, "1 not measured") def test_hosts_list_renders_host_cards_and_controls(self) -> None: self.client.force_login(self.staff_user)