Compare commits
3 Commits
0fe2aa439f
...
5dd6ebb3db
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dd6ebb3db | |||
| 864a40e862 | |||
| 9412feaa58 |
@@ -285,9 +285,91 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.inline-form { margin: 0; }
|
||||
.status-overview {
|
||||
.dashboard-priority-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.priority-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.priority-panel > h2:first-child { margin-bottom: 0; }
|
||||
.action-list,
|
||||
.activity-list,
|
||||
.schedule-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-row,
|
||||
.activity-row,
|
||||
.schedule-row {
|
||||
align-items: start;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.action-row,
|
||||
.activity-row {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
}
|
||||
.schedule-row {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
}
|
||||
.action-row:hover,
|
||||
.activity-row:hover,
|
||||
.schedule-row:hover {
|
||||
background: var(--panel-subtle);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.action-row.failed { border-color: #e8b4b4; background: #fff7f7; }
|
||||
.action-row.warning { border-color: #e7cf8a; background: #fffaf0; }
|
||||
.action-row span:not(.status),
|
||||
.activity-row span:not(.status),
|
||||
.schedule-row span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.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;
|
||||
@@ -312,12 +394,6 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.status-summary .summary-action {
|
||||
color: var(--muted-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
margin-left: auto;
|
||||
}
|
||||
.operator-state {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -564,6 +640,9 @@
|
||||
}
|
||||
.page-header .actions { justify-content: flex-start; }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
.dashboard-priority-grid { grid-template-columns: 1fr; }
|
||||
.schedule-row { grid-template-columns: 1fr; }
|
||||
.schedule-time { justify-items: start; text-align: left; }
|
||||
.host-card-header { display: grid; }
|
||||
.host-card-status { justify-content: flex-start; max-width: none; }
|
||||
.host-card-layout { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -32,79 +32,150 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="dashboard-priority-grid" aria-label="Operator priorities">
|
||||
<article class="panel priority-panel">
|
||||
<h2>Required Action</h2>
|
||||
{% if action_items %}
|
||||
<div class="action-list">
|
||||
{% for item in action_items %}
|
||||
<a class="action-row {{ item.status }}" href="{{ item.url }}">
|
||||
<span class="status {{ item.status }}">{{ item.label }}</span>
|
||||
<span>
|
||||
<strong>{{ item.host.host }}</strong>
|
||||
<span class="muted">{{ item.message }}</span>
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif counts.hosts %}
|
||||
<p><span class="status ok">ok</span> No queued, running, unreviewed warning/failed runs, or retention warnings.</p>
|
||||
{% else %}
|
||||
<p class="muted">Add a host to start tracking backup status here.</p>
|
||||
{% endif %}
|
||||
{% if counts.running_runs or counts.queued_runs %}
|
||||
<div class="operator-state">
|
||||
{% if counts.running_runs %}
|
||||
<a class="status-summary running" href="{% url 'runs_list' %}?status=running">
|
||||
<span class="status running">running</span>
|
||||
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if counts.queued_runs %}
|
||||
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
|
||||
<span class="status queued">queued</span>
|
||||
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting.</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel priority-panel">
|
||||
<h2>Next Scheduled Work <a class="button-link secondary compact" href="{% url 'schedules_list' %}">View all</a></h2>
|
||||
{% if next_schedule_rows %}
|
||||
<div class="schedule-list">
|
||||
{% for row in next_schedule_rows %}
|
||||
<a class="schedule-row" href="{% url 'host_detail' row.schedule.host.host %}">
|
||||
<span>
|
||||
<strong>{{ row.schedule.host.host }}</strong>
|
||||
<span class="muted">{{ row.schedule.cron_expr }}</span>
|
||||
</span>
|
||||
<span class="schedule-time">
|
||||
{% if row.next_run_at %}
|
||||
{{ row.next_run_at|date:"Y-m-d H:i T" }}
|
||||
<span class="muted">{{ scheduler_timezone }}</span>
|
||||
{% else %}
|
||||
<span class="muted">not due</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No enabled schedules yet.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel priority-panel">
|
||||
<h2>Recent Activity <a class="button-link secondary compact" href="{% url 'runs_list' %}">View all</a></h2>
|
||||
{% if recent_runs %}
|
||||
<div class="activity-list">
|
||||
{% for run in recent_runs %}
|
||||
<a class="activity-row" href="{% url 'run_detail' run.id %}">
|
||||
<span class="status {{ run.status }}">{{ run.status }}</span>
|
||||
<span>
|
||||
<strong>Run {{ run.id }}</strong>
|
||||
<span class="muted">{{ run.host.host }} · {{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<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">
|
||||
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
|
||||
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
|
||||
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
|
||||
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>
|
||||
<a class="metric metric-link {% if counts.queued_runs %}queued{% endif %}" href="{% url 'runs_list' %}?status=queued"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></a>
|
||||
<a class="metric metric-link {% if counts.running_runs %}running{% endif %}" href="{% url 'runs_list' %}?status=running"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></a>
|
||||
<a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
|
||||
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Operational Status</h2>
|
||||
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
|
||||
<div class="status-overview">
|
||||
{% if counts.failed_runs %}
|
||||
<a class="status-summary failed" href="{% url 'runs_list' %}?status=failed&review=needed">
|
||||
<span class="status failed">failed</span>
|
||||
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
|
||||
<span class="summary-action">Review failed runs</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if counts.warning_runs %}
|
||||
<a class="status-summary warning" href="{% url 'runs_list' %}?status=warning&review=needed">
|
||||
<span class="status warning">warning</span>
|
||||
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
|
||||
<span class="summary-action">Review warnings</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if counts.running_runs %}
|
||||
<a class="status-summary running" href="{% url 'runs_list' %}?status=running">
|
||||
<span class="status running">running</span>
|
||||
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||
<span class="summary-action">View running runs</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if counts.queued_runs %}
|
||||
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
|
||||
<span class="status queued">queued</span>
|
||||
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
|
||||
<span class="summary-action">View queued runs</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif counts.hosts %}
|
||||
<p><span class="status ok">ok</span> No queued, running, or unreviewed warning/failed runs.</p>
|
||||
{% else %}
|
||||
<p class="muted">Add a host to start tracking backup status here.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="hosts">
|
||||
<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">
|
||||
@@ -145,7 +216,7 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel" id="hosts">
|
||||
<h2>Hosts</h2>
|
||||
<div class="host-list">
|
||||
{% for host in hosts %}
|
||||
@@ -269,31 +340,4 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Latest Runs <a class="button-link secondary" href="{% url 'runs_list' %}">View all</a></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Snapshot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in latest_runs %}
|
||||
<tr>
|
||||
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
|
||||
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No backup runs recorded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -120,20 +120,24 @@ 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, "Required Action")
|
||||
self.assertContains(response, "Failed runs")
|
||||
self.assertContains(response, "1 failed run(s) need review.")
|
||||
self.assertContains(response, "1 run(s) completed with warnings.")
|
||||
self.assertContains(response, "1 backup run in progress.")
|
||||
self.assertContains(response, "1 backup run waiting for the worker.")
|
||||
self.assertContains(response, "Review failed runs")
|
||||
self.assertContains(response, "Review warnings")
|
||||
self.assertContains(response, "View running runs")
|
||||
self.assertContains(response, "View queued runs")
|
||||
self.assertContains(response, "1 backup run waiting.")
|
||||
self.assertContains(response, "Next Scheduled Work")
|
||||
self.assertContains(response, "Recent Activity")
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&review=needed"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&review=needed"', html=False)
|
||||
self.assertContains(
|
||||
response,
|
||||
f'href="{reverse("runs_list")}?host=web-01&status=failed&review=needed"',
|
||||
html=False,
|
||||
)
|
||||
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
|
||||
|
||||
@@ -173,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")
|
||||
@@ -208,8 +212,8 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Operational Status")
|
||||
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
||||
self.assertContains(response, "Required Action")
|
||||
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||
|
||||
def test_runs_list_filters_by_status_and_review(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -342,7 +346,7 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
||||
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||
self.assertNotContains(response, "failed 1")
|
||||
self.assertNotContains(response, "warning 1")
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import json
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone as datetime_timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
@@ -12,6 +14,7 @@ from django.conf import settings
|
||||
from django.http import FileResponse, Http404
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
@@ -74,13 +77,18 @@ def dashboard(request):
|
||||
)
|
||||
host_config.next_run_at = _next_run_for_host(host_config)
|
||||
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
|
||||
action_items = _dashboard_action_items(hosts)
|
||||
next_schedule_rows = _dashboard_next_schedule_rows()
|
||||
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
|
||||
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
|
||||
context = {
|
||||
"hosts": hosts,
|
||||
"global_config": global_config,
|
||||
"stats_summary": stats_summary,
|
||||
"scheduler_timezone": timezone.get_current_timezone_name(),
|
||||
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
||||
"action_items": action_items,
|
||||
"next_schedule_rows": next_schedule_rows,
|
||||
"recent_runs": recent_runs,
|
||||
"counts": {
|
||||
"global_configs": GlobalConfig.objects.count(),
|
||||
"hosts": HostConfig.objects.count(),
|
||||
@@ -104,6 +112,74 @@ def dashboard(request):
|
||||
return render(request, "pobsync_backend/dashboard.html", context)
|
||||
|
||||
|
||||
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
|
||||
action_items: list[dict[str, object]] = []
|
||||
for host_config in hosts:
|
||||
if host_config.failed_run_count:
|
||||
action_items.append(
|
||||
{
|
||||
"host": host_config,
|
||||
"status": BackupRun.Status.FAILED,
|
||||
"label": "Failed runs",
|
||||
"message": f"{host_config.failed_run_count} failed run(s) need review.",
|
||||
"url": _runs_list_url(host=host_config.host, status="failed", review="needed"),
|
||||
}
|
||||
)
|
||||
if host_config.warning_run_count:
|
||||
action_items.append(
|
||||
{
|
||||
"host": host_config,
|
||||
"status": BackupRun.Status.WARNING,
|
||||
"label": "Warnings",
|
||||
"message": f"{host_config.warning_run_count} run(s) completed with warnings.",
|
||||
"url": _runs_list_url(host=host_config.host, status="warning", review="needed"),
|
||||
}
|
||||
)
|
||||
if host_config.retention_warning.get("has_warning"):
|
||||
action_items.append(
|
||||
{
|
||||
"host": host_config,
|
||||
"status": BackupRun.Status.WARNING,
|
||||
"label": "Retention",
|
||||
"message": _retention_warning_summary(host_config.retention_warning),
|
||||
"url": reverse("host_detail", args=[host_config.host]),
|
||||
}
|
||||
)
|
||||
return action_items
|
||||
|
||||
|
||||
def _runs_list_url(**params: str) -> str:
|
||||
return f"{reverse('runs_list')}?{urlencode(params)}"
|
||||
|
||||
|
||||
def _dashboard_next_schedule_rows() -> list[dict[str, object]]:
|
||||
rows = []
|
||||
schedules = ScheduleConfig.objects.select_related("host").filter(enabled=True).order_by("host__host")
|
||||
for schedule in schedules[:200]:
|
||||
rows.append(
|
||||
{
|
||||
"schedule": schedule,
|
||||
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: row["next_run_at"] or datetime.max.replace(tzinfo=datetime_timezone.utc))
|
||||
return rows[:6]
|
||||
|
||||
|
||||
def _retention_warning_summary(retention_warning) -> str:
|
||||
parts = []
|
||||
if retention_warning.get("prune_exceeded"):
|
||||
parts.append(
|
||||
f"Scheduled prune would delete {retention_warning.get('delete_count')} snapshot(s), "
|
||||
f"above max {retention_warning.get('max_delete')}."
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def changelog(request):
|
||||
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
|
||||
|
||||
Reference in New Issue
Block a user