(ui) Surface storage pressure in dashboard priorities

Move backup root usage, runway, daily new data, and available capacity into
the top dashboard priority area so storage risk is visible before deeper trend
details.

Refs #27
This commit is contained in:
2026-05-21 13:27:39 +02:00
parent 9412feaa58
commit 864a40e862
4 changed files with 86 additions and 29 deletions

View File

@@ -288,7 +288,7 @@
.dashboard-priority-grid {
display: grid;
gap: 14px;
grid-template-columns: minmax(280px, 1.3fr) repeat(2, minmax(240px, 1fr));
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
margin-bottom: 20px;
}
.priority-panel {
@@ -342,6 +342,34 @@
.schedule-time {
justify-items: end;
text-align: right;
}
.storage-priority {
display: grid;
gap: 12px;
}
.storage-priority .label {
color: var(--muted);
font-size: 12px;
font-weight: 650;
text-transform: uppercase;
}
.storage-priority .value {
font-size: 27px;
font-weight: 760;
line-height: 1.15;
margin-top: 4px;
}
.storage-priority-facts {
display: grid;
gap: 8px;
}
.storage-priority-facts > div {
align-items: baseline;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
justify-content: space-between;
padding-top: 8px;
}
.status-summary {
align-items: center;

View File

@@ -114,6 +114,53 @@
<p class="muted">No backup runs recorded yet.</p>
{% endif %}
</article>
<article class="panel priority-panel">
<h2>Storage Pressure</h2>
{% if stats_summary.runs_sampled %}
<div class="storage-priority">
<div>
<div class="label">Backup root 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>
<div class="storage-priority-facts">
<div>
<span class="label">Runway</span>
<strong>
{% 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 %}
</strong>
</div>
<div>
<span class="label">New data</span>
<strong>{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</strong>
</div>
<div>
<span class="label">Available</span>
<strong>{{ stats_summary.capacity.available_bytes|filesizeformat }}</strong>
</div>
</div>
</div>
{% else %}
<p class="muted">Storage pressure appears after the first completed backup with stats.</p>
{% endif %}
</article>
</section>
<section class="grid" aria-label="Summary">
@@ -129,24 +176,6 @@
<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">

View File

@@ -177,14 +177,14 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Backup Trends")
self.assertContains(response, "Storage Used")
self.assertContains(response, "Storage Pressure")
self.assertContains(response, "Backup root 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")

View File

@@ -135,7 +135,7 @@ def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
"url": _runs_list_url(host=host_config.host, status="warning", review="needed"),
}
)
if host_config.retention_warning.has_warning:
if host_config.retention_warning.get("has_warning"):
action_items.append(
{
"host": host_config,
@@ -168,15 +168,15 @@ def _dashboard_next_schedule_rows() -> list[dict[str, object]]:
def _retention_warning_summary(retention_warning) -> str:
parts = []
if retention_warning.prune_exceeded:
if retention_warning.get("prune_exceeded"):
parts.append(
f"Scheduled prune would delete {retention_warning.delete_count} snapshot(s), "
f"above max {retention_warning.max_delete}."
f"Scheduled prune would delete {retention_warning.get('delete_count')} snapshot(s), "
f"above max {retention_warning.get('max_delete')}."
)
if retention_warning.incomplete_count:
parts.append(f"{retention_warning.incomplete_count} incomplete snapshot(s) need review.")
if retention_warning.error:
parts.append(str(retention_warning.error))
if retention_warning.get("incomplete_count"):
parts.append(f"{retention_warning.get('incomplete_count')} incomplete snapshot(s) need review.")
if retention_warning.get("error"):
parts.append(str(retention_warning.get("error")))
return " ".join(parts)