(ui) Add dashboard operational status summary #15

Merged
parkel merged 4 commits from issue-5-dashboard-1-0 into master 2026-05-21 01:54:48 +02:00
5 changed files with 361 additions and 79 deletions

View File

@@ -37,6 +37,8 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
available = _int_at(capacity, "available_bytes") available = _int_at(capacity, "available_bytes")
daily_literal = _average_daily_literal(real_runs) daily_literal = _average_daily_literal(real_runs)
link_dest_savings_ratio = round(total_matched / savings_basis, 4) if savings_basis else None
return { return {
"runs_sampled": len(real_runs), "runs_sampled": len(real_runs),
"avg_duration_seconds": _average(duration_values), "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, "avg_literal_data_bytes": avg_literal,
"total_literal_data_bytes": total_literal, "total_literal_data_bytes": total_literal,
"total_matched_data_bytes": total_matched, "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_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, "estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
"capacity": capacity, "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]: 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)] 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 = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {} 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 { return {
"runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in trend_runs], "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, "latest_snapshot": latest_snapshot_stats,
"avg_literal_data_bytes": _average(literal_values), "avg_literal_data_bytes": _average(literal_values),
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs), "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, "ended_at": run.ended_at,
"snapshot": run.snapshot, "snapshot": run.snapshot,
"snapshot_path": run.snapshot_path, "snapshot_path": run.snapshot_path,
"status": run.status,
"has_stats": bool(stats), "has_stats": bool(stats),
"duration_seconds": _int_at(stats, "duration_seconds"), "duration_seconds": _int_at(stats, "duration_seconds"),
"rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {}, "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 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]: def _capacity_from_system(global_config: GlobalConfig | None) -> dict[str, Any]:
if global_config is None or not global_config.backup_root: if global_config is None or not global_config.backup_root:
return {} return {}

View File

@@ -61,6 +61,10 @@
.metric { padding: 14px; } .metric { padding: 14px; }
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; } .metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; } .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 { padding: 16px; margin-bottom: 18px; overflow: auto; }
.panel.highlight { border-left: 4px solid var(--border); } .panel.highlight { border-left: 4px solid var(--border); }
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; } .panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
@@ -118,6 +122,23 @@
cursor: not-allowed; cursor: not-allowed;
} }
.inline-form { margin: 0; } .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 { .operator-state {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -149,6 +170,43 @@
font-size: 12px; font-size: 12px;
gap: 10px; 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 { .host-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -176,23 +234,42 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.host-card-status { .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 { .host-card-layout {
display: grid; display: grid;
gap: 18px; gap: 24px;
grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); 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 { .host-card-timeline {
display: grid; display: grid;
gap: 12px; gap: 16px 22px;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
} }
.host-card-stats { .host-card-stats {
align-content: start; align-content: start;
display: grid; display: grid;
gap: 10px; border-top: 1px solid #e6edf4;
gap: 12px 18px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: 12px;
} }
.host-card-item { .host-card-item {
display: grid; display: grid;
@@ -209,13 +286,9 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.host-card-stat { .host-card-stat {
background: #f8fafc;
border: 1px solid #e6edf4;
border-radius: 6px;
display: grid; display: grid;
gap: 3px; gap: 3px;
min-width: 0; min-width: 0;
padding: 10px;
} }
.host-card-stat .label { .host-card-stat .label {
color: var(--muted); color: var(--muted);
@@ -286,7 +359,11 @@
main { padding: 16px; } main { padding: 16px; }
nav { padding: 0; } nav { padding: 0; }
.two-col { grid-template-columns: 1fr; } .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-layout { grid-template-columns: 1fr; }
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.insight-grid { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>

View File

@@ -32,22 +32,109 @@
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div> <div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div> <div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div> <div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div> <div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
<div class="metric"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div> <div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
</section> </section>
{% if stats_summary.runs_sampled %} <section class="panel">
<section class="grid" aria-label="Backup trends"> <h2>Operational Status</h2>
<div class="metric"><div class="label">Backup Root Used</div><div class="value">{{ stats_summary.capacity.used_percent|default:"" }}{% if stats_summary.capacity.used_percent is not None %}%{% endif %}</div></div> {% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
<div class="metric"><div class="label">Available</div><div class="value">{{ stats_summary.capacity.available_bytes|filesizeformat }}</div></div> <div class="status-overview">
<div class="metric"><div class="label">Avg New Data</div><div class="value">{{ stats_summary.avg_literal_data_bytes|filesizeformat }}</div></div> {% if counts.failed_runs %}
<div class="metric"><div class="label">Avg Daily New</div><div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}</div></div> <div class="status-summary failed">
<div class="metric"><div class="label">Avg Duration</div><div class="value">{{ stats_summary.avg_duration_seconds|default:"" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div></div> <span class="status failed">failed</span>
<div class="metric"><div class="label">Link-Dest Savings</div><div class="value">{{ stats_summary.link_dest_savings_ratio|default:"" }}</div></div> <strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
<div class="metric"><div class="label">Runs Until Full</div><div class="value">{{ stats_summary.estimated_runs_until_full|default:"" }}</div></div> </div>
<div class="metric"><div class="label">Days Until Full</div><div class="value">{{ stats_summary.estimated_days_until_full|default:"" }}</div></div>
</section>
{% endif %} {% endif %}
{% if counts.warning_runs %}
<div class="status-summary warning">
<span class="status warning">warning</span>
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
</div>
{% endif %}
{% if counts.running_runs %}
<div class="status-summary running">
<span class="status running">running</span>
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
</div>
{% endif %}
{% if counts.queued_runs %}
<div class="status-summary queued">
<span class="status queued">queued</span>
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
</div>
{% endif %}
</div>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, warning, or failed runs.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %}
</section>
<section class="panel">
<h2>Backup Trends</h2>
{% if stats_summary.runs_sampled %}
<div class="insight-grid" aria-label="Backup trends">
<div class="insight-main">
<div class="label">Storage Used</div>
<div class="value">
{% if stats_summary.capacity.used_percent is not None %}
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
{% else %}
unknown
{% endif %}
</div>
{% if stats_summary.capacity.used_percent is not None %}
<div class="storage-meter" aria-label="Backup root storage usage">
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
</div>
{% endif %}
<div class="muted">
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root.
</div>
</div>
<div class="insight-item">
<div class="label">Runway</div>
<div class="value">
{% 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 %}
</div>
<div class="muted">Estimated from average new data per day.</div>
</div>
<div class="insight-item">
<div class="label">New Data</div>
<div class="value">{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</div>
<div class="muted">{{ stats_summary.avg_literal_data_bytes|filesizeformat }} per backup on average.</div>
</div>
<div class="insight-item">
<div class="label">Link-Dest Savings</div>
<div class="value">
{% if stats_summary.link_dest_savings_percent is not None %}
{{ stats_summary.link_dest_savings_percent|floatformat:1 }}%
{% else %}
unknown
{% endif %}
</div>
<div class="muted">{{ stats_summary.total_matched_data_bytes|filesizeformat }} reused across sampled runs.</div>
</div>
<div class="insight-item">
<div class="label">Average Duration</div>
<div class="value">{{ stats_summary.avg_duration_seconds|default:"unknown" }}{% if stats_summary.avg_duration_seconds is not None %}s{% endif %}</div>
<div class="muted">Based on {{ stats_summary.runs_sampled }} completed backup run{{ stats_summary.runs_sampled|pluralize }} with stats.</div>
</div>
</div>
{% else %}
<p class="muted">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.</p>
{% endif %}
</section>
<section class="panel"> <section class="panel">
<h2>Hosts</h2> <h2>Hosts</h2>
@@ -61,9 +148,23 @@
</div> </div>
<div class="host-card-status"> <div class="host-card-status">
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span> <span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
{% if host.queued_run_count %}
<span class="status queued">queued {{ host.queued_run_count }}</span>
{% endif %}
{% if host.running_run_count %}
<span class="status running">running {{ host.running_run_count }}</span>
{% endif %}
{% if host.warning_run_count %}
<span class="status warning">warning {{ host.warning_run_count }}</span>
{% endif %}
{% if host.failed_run_count %}
<span class="status failed">failed {{ host.failed_run_count }}</span>
{% endif %}
</div> </div>
</div> </div>
<div class="host-card-layout"> <div class="host-card-layout">
<div class="host-card-section">
<div class="host-card-section-title">Backup activity</div>
<div class="host-card-timeline"> <div class="host-card-timeline">
<div class="host-card-item"> <div class="host-card-item">
<div class="label">Latest Snapshot</div> <div class="label">Latest Snapshot</div>
@@ -77,11 +178,23 @@
</div> </div>
</div> </div>
<div class="host-card-item"> <div class="host-card-item">
<div class="label">Latest Run</div> <div class="label">Last Good Backup</div>
<div class="value"> <div class="value">
{% if host.stats_summary.latest_run.id %} {% if host.stats_summary.latest_good_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_run.id %}">Run {{ host.stats_summary.latest_run.id }}</a> <a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
<div class="muted">{{ 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 %}</div> <div class="muted">{{ 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 %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Latest Issue</div>
<div class="value">
{% if host.stats_summary.latest_problem_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
<div class="muted">{{ 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 %}</div>
{% else %} {% else %}
<span class="muted">none</span> <span class="muted">none</span>
{% endif %} {% endif %}
@@ -99,6 +212,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="host-card-section">
<div class="host-card-section-title">Snapshot health</div>
<div class="host-card-stats"> <div class="host-card-stats">
<div class="host-card-stat"> <div class="host-card-stat">
<div class="label">Snapshots</div> <div class="label">Snapshots</div>
@@ -118,6 +234,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{% if host.retention_warning.has_warning %} {% if host.retention_warning.has_warning %}
<div class="host-card-warning"> <div class="host-card-warning">
<span class="status warning">retention</span> <span class="status warning">retention</span>

View File

@@ -42,6 +42,27 @@ class ViewTests(TestCase):
snapshot=snapshot, snapshot=snapshot,
started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc), 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")) response = self.client.get(reverse("dashboard"))
@@ -50,8 +71,24 @@ class ViewTests(TestCase):
self.assertContains(response, "web-01") self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "success") 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 {run.id}")
self.assertContains(response, f"Run {warning_run.id}")
self.assertContains(response, "warning")
self.assertContains(response, "manual") 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: def test_dashboard_renders_backup_trend_summary(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -88,10 +125,15 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Backup Root Used") self.assertContains(response, "Backup Trends")
self.assertContains(response, "Runs Until Full") self.assertContains(response, "Storage Used")
self.assertContains(response, "Avg Daily New") self.assertContains(response, "Runway")
self.assertContains(response, "Days Until Full") 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, "Next Run")
self.assertContains(response, "UTC") self.assertContains(response, "UTC")
self.assertContains(response, "10") self.assertContains(response, "10")
@@ -99,6 +141,29 @@ class ViewTests(TestCase):
self.assertContains(response, "manual") self.assertContains(response, "manual")
self.assertContains(response, "1000") 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: def test_dashboard_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
host = HostConfig.objects.create( host = HostConfig.objects.create(

View File

@@ -9,7 +9,7 @@ from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.conf import settings from django.conf import settings
from django.http import FileResponse, Http404 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.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@@ -45,7 +45,14 @@ def dashboard(request):
global_config = GlobalConfig.objects.filter(name="default").first() global_config = GlobalConfig.objects.filter(name="default").first()
hosts = list( hosts = list(
HostConfig.objects.select_related("schedule") 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") .order_by("host")
) )
for host_config in hosts: for host_config in hosts:
@@ -71,7 +78,9 @@ def dashboard(request):
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(), "enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
"snapshots": SnapshotRecord.objects.count(), "snapshots": SnapshotRecord.objects.count(),
"runs": BackupRun.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(), "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(), "failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
}, },
} }