(ui) Add schedule overview for dashboard drill-down
Add a staff-only schedules page with filters for host, enabled state, and prune state, including next run and last scheduler state. Wire the dashboard Schedules metric to the new overview so all primary dashboard count cards have useful destinations. Refs #23
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
|
||||
<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="#hosts"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</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>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}Schedules | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Scheduler</div>
|
||||
<h1>Schedules</h1>
|
||||
<div class="page-subtitle">Review configured backup schedules, next run times, prune settings, and recent scheduler state.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Schedule list actions">
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filters</h2>
|
||||
<form method="get" class="form-grid">
|
||||
<div class="field">
|
||||
<label for="host">Host</label>
|
||||
<select id="host" name="host">
|
||||
<option value="">All hosts</option>
|
||||
{% for host in hosts %}
|
||||
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="enabled">Enabled</label>
|
||||
<select id="enabled" name="enabled">
|
||||
<option value="">All schedules</option>
|
||||
<option value="yes" {% if selected_enabled == "yes" %}selected{% endif %}>Enabled</option>
|
||||
<option value="no" {% if selected_enabled == "no" %}selected{% endif %}>Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="prune">Prune</label>
|
||||
<select id="prune" name="prune">
|
||||
<option value="">All prune states</option>
|
||||
<option value="yes" {% if selected_prune == "yes" %}selected{% endif %}>Prune enabled</option>
|
||||
<option value="no" {% if selected_prune == "no" %}selected{% endif %}>Prune disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="button-link secondary" href="{% url 'schedules_list' %}">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Configured Schedules</h2>
|
||||
<p class="muted">Showing up to 200 of {{ total_count }} schedule{{ total_count|pluralize }}. Times use {{ scheduler_timezone }}.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Expression</th>
|
||||
<th>Enabled</th>
|
||||
<th>Next Run</th>
|
||||
<th>Prune</th>
|
||||
<th>Last Status</th>
|
||||
<th>Last Started</th>
|
||||
<th>Last Finished</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in schedule_rows %}
|
||||
{% with schedule=row.schedule %}
|
||||
<tr>
|
||||
<td><a href="{% url 'host_detail' schedule.host.host %}">{{ schedule.host.host }}</a></td>
|
||||
<td><code>{{ schedule.cron_expr }}</code></td>
|
||||
<td><span class="status {% if schedule.enabled %}ok{% else %}skipped{% endif %}">{{ schedule.enabled|yesno:"enabled,disabled" }}</span></td>
|
||||
<td>
|
||||
{% if row.next_run_at %}
|
||||
{{ row.next_run_at|date:"Y-m-d H:i T" }}
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status {% if schedule.prune %}ok{% else %}skipped{% endif %}">{{ schedule.prune|yesno:"enabled,disabled" }}</span>
|
||||
{% if schedule.prune %}
|
||||
<div class="muted">max {{ schedule.prune_max_delete }}{% if schedule.prune_protect_bases %}, protects bases{% endif %}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if schedule.last_status %}<span class="status {{ schedule.last_status }}">{{ schedule.last_status }}</span>{% else %}<span class="muted">none</span>{% endif %}</td>
|
||||
<td>{{ schedule.last_started_at|default:"" }}</td>
|
||||
<td>{{ schedule.last_finished_at|default:"" }}</td>
|
||||
<td><a class="button-link secondary" href="{% url 'edit_host_schedule' schedule.host.host %}">Edit</a></td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<tr><td colspan="9" class="muted">No schedules matched the current filter.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -135,6 +135,7 @@ class ViewTests(TestCase):
|
||||
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("snapshots_list")}"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
|
||||
|
||||
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -249,6 +250,24 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertNotContains(response, scheduled.dirname)
|
||||
|
||||
def test_schedules_list_filters_by_enabled_and_prune(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||
ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True, last_status="success")
|
||||
ScheduleConfig.objects.create(host=db, cron_expr="30 3 * * *", enabled=False, prune=False, last_status="failed")
|
||||
|
||||
response = self.client.get(reverse("schedules_list"), {"enabled": "yes", "prune": "yes"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Schedules")
|
||||
self.assertContains(response, "Review configured backup schedules")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "success")
|
||||
self.assertContains(response, "UTC")
|
||||
self.assertNotContains(response, "30 3 * * *")
|
||||
|
||||
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(
|
||||
|
||||
@@ -203,6 +203,44 @@ def snapshots_list(request):
|
||||
return render(request, "pobsync_backend/snapshots_list.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def schedules_list(request):
|
||||
enabled = request.GET.get("enabled", "").strip()
|
||||
prune = request.GET.get("prune", "").strip()
|
||||
host = request.GET.get("host", "").strip()
|
||||
schedules = ScheduleConfig.objects.select_related("host").order_by("host__host")
|
||||
if enabled == "yes":
|
||||
schedules = schedules.filter(enabled=True)
|
||||
elif enabled == "no":
|
||||
schedules = schedules.filter(enabled=False)
|
||||
if prune == "yes":
|
||||
schedules = schedules.filter(prune=True)
|
||||
elif prune == "no":
|
||||
schedules = schedules.filter(prune=False)
|
||||
if host:
|
||||
schedules = schedules.filter(host__host=host)
|
||||
|
||||
schedule_rows = []
|
||||
for schedule in schedules[:200]:
|
||||
schedule_rows.append(
|
||||
{
|
||||
"schedule": schedule,
|
||||
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
|
||||
}
|
||||
)
|
||||
|
||||
context = {
|
||||
"schedule_rows": schedule_rows,
|
||||
"total_count": schedules.count(),
|
||||
"hosts": HostConfig.objects.order_by("host"),
|
||||
"selected_enabled": enabled,
|
||||
"selected_prune": prune,
|
||||
"selected_host": host,
|
||||
"scheduler_timezone": timezone.get_current_timezone_name(),
|
||||
}
|
||||
return render(request, "pobsync_backend/schedules_list.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def purged_snapshots(request):
|
||||
host = request.GET.get("host", "").strip()
|
||||
|
||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
||||
path("self-check/", views.self_check, name="self_check"),
|
||||
path("logs/", views.logs, name="logs"),
|
||||
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
||||
path("schedules/", views.schedules_list, name="schedules_list"),
|
||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||
|
||||
Reference in New Issue
Block a user