(feature) Add staff-only Django dashboard views
Add a small template-based UI for inspecting pobsync state through Django. The dashboard shows host, schedule, snapshot, and backup run summaries, while host detail pages show config, schedule, recent runs, and discovered snapshots. Keep the views read-only and staff-protected, document the new dashboard URL, and cover the routes with focused view tests.
This commit is contained in:
@@ -33,6 +33,7 @@ python3 manage.py runserver
|
||||
|
||||
The admin is available at:
|
||||
|
||||
- http://127.0.0.1:8000/
|
||||
- http://127.0.0.1:8000/admin/
|
||||
|
||||
Staff-only JSON endpoints are available at:
|
||||
@@ -116,6 +117,7 @@ docker compose up --build web
|
||||
|
||||
This starts Django on:
|
||||
|
||||
- http://127.0.0.1:8010/
|
||||
- http://127.0.0.1:8010/admin/
|
||||
- http://127.0.0.1:8010/api/
|
||||
- http://127.0.0.1:8010/api/status/
|
||||
@@ -150,6 +152,7 @@ base record when it is known.
|
||||
The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem.
|
||||
Post-backup pruning from Django also uses the SQL retention service after the completed snapshot is recorded.
|
||||
Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection.
|
||||
Staff-only dashboard views expose the same operational state through Django templates.
|
||||
|
||||
The remaining internal engine code still contains reusable backup primitives:
|
||||
|
||||
|
||||
103
src/pobsync_backend/templates/pobsync_backend/base.html
Normal file
103
src/pobsync_backend/templates/pobsync_backend/base.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}pobsync{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fa;
|
||||
--panel: #ffffff;
|
||||
--border: #d9e0e8;
|
||||
--text: #17202a;
|
||||
--muted: #657386;
|
||||
--link: #0b5cad;
|
||||
--success: #176b3a;
|
||||
--failed: #a12828;
|
||||
--running: #8a5a00;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
nav strong { font-size: 16px; }
|
||||
nav .spacer { flex: 1; }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 { font-size: 26px; margin: 0 0 18px; }
|
||||
h2 { font-size: 18px; margin: 0 0 12px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.metric, .panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.metric { padding: 14px; }
|
||||
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
|
||||
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 640px; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.status {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
|
||||
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
|
||||
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
||||
.stack { display: grid; gap: 4px; }
|
||||
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
@media (max-width: 800px) {
|
||||
main { padding: 16px; }
|
||||
nav { padding: 0; }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
||||
<a href="{% url 'admin:index' %}">Admin</a>
|
||||
<a href="/api/status/">Status API</a>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">{{ request.user.username }}</span>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
76
src/pobsync_backend/templates/pobsync_backend/dashboard.html
Normal file
76
src/pobsync_backend/templates/pobsync_backend/dashboard.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}pobsync dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<section class="grid" aria-label="Summary">
|
||||
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</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">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"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Hosts</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Address</th>
|
||||
<th>Enabled</th>
|
||||
<th>Snapshots</th>
|
||||
<th>Runs</th>
|
||||
<th>Retention</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for host in hosts %}
|
||||
<tr>
|
||||
<td><a href="{% url 'host_detail' host.host %}">{{ host.host }}</a></td>
|
||||
<td>{{ host.address }}</td>
|
||||
<td>{{ host.enabled|yesno:"yes,no" }}</td>
|
||||
<td>{{ host.snapshot_count }}</td>
|
||||
<td>{{ host.run_count }}</td>
|
||||
<td>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="muted">No hosts configured yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Latest Runs</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Snapshot</th>
|
||||
<th>Rsync</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><span class="status {{ run.status }}">{{ run.status }}</span></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="muted">No backup runs recorded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
101
src/pobsync_backend/templates/pobsync_backend/host_detail.html
Normal file
101
src/pobsync_backend/templates/pobsync_backend/host_detail.html
Normal file
@@ -0,0 +1,101 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}{{ host.host }} | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ host.host }}</h1>
|
||||
|
||||
<section class="grid" aria-label="Host summary">
|
||||
<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">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Config</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Address:</strong> {{ host.address }}</div>
|
||||
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
|
||||
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
|
||||
<div><strong>Source:</strong> {{ host.source_root|default:"global default" }}</div>
|
||||
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Schedule</h2>
|
||||
{% if schedule %}
|
||||
<div class="stack">
|
||||
<div><strong>Cron:</strong> {{ schedule.cron_expr }}</div>
|
||||
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
|
||||
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
|
||||
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
|
||||
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
|
||||
<div><strong>Last finished:</strong> {{ schedule.last_finished_at|default:"" }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No schedule configured.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Latest Runs</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Snapshot</th>
|
||||
<th>Base</th>
|
||||
<th>Rsync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in latest_runs %}
|
||||
<tr>
|
||||
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{% if run.snapshot %}{{ run.snapshot.dirname }}{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{{ run.base_path|default:"" }}</td>
|
||||
<td>{{ run.rsync_exit_code|default:"" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="muted">No backup runs recorded for this host.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshots</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Dirname</th>
|
||||
<th>Base</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for snapshot in snapshots %}
|
||||
<tr>
|
||||
<td>{{ snapshot.kind }}</td>
|
||||
<td>{{ snapshot.status }}</td>
|
||||
<td>{{ snapshot.started_at|default:"" }}</td>
|
||||
<td>{{ snapshot.dirname }}</td>
|
||||
<td>{% if snapshot.base %}{{ snapshot.base.dirname }}{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
83
src/pobsync_backend/tests/test_views.py
Normal file
83
src/pobsync_backend/tests/test_views.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from pobsync_backend.models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
||||
|
||||
|
||||
class ViewTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
user_model = get_user_model()
|
||||
self.staff_user = user_model.objects.create_user(
|
||||
username="admin",
|
||||
password="secret",
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
|
||||
def test_dashboard_requires_staff_login(self) -> None:
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/admin/login/", response["Location"])
|
||||
|
||||
def test_dashboard_renders_hosts_and_latest_runs(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
||||
BackupRun.objects.create(
|
||||
host=host,
|
||||
status=BackupRun.Status.SUCCESS,
|
||||
snapshot=snapshot,
|
||||
started_at=datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Dashboard")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||
self.assertContains(response, "success")
|
||||
|
||||
def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
source_root="/srv",
|
||||
retention_daily=7,
|
||||
)
|
||||
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", prune=True, last_status="success")
|
||||
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
||||
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "web-01.example.test")
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||
|
||||
def test_host_detail_returns_404_for_unknown_host(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
response = self.client.get(reverse("host_detail", args=["missing-host"]))
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
|
||||
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||
return SnapshotRecord.objects.create(
|
||||
host=host,
|
||||
kind=SnapshotRecord.Kind.SCHEDULED,
|
||||
dirname=dirname,
|
||||
path=f"/backups/{host.host}/scheduled/{dirname}",
|
||||
status="success",
|
||||
started_at=started_at,
|
||||
)
|
||||
55
src/pobsync_backend/views.py
Normal file
55
src/pobsync_backend/views.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def dashboard(request):
|
||||
host_qs = (
|
||||
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
|
||||
.order_by("host")
|
||||
)
|
||||
context = {
|
||||
"hosts": host_qs,
|
||||
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
||||
"counts": {
|
||||
"hosts": HostConfig.objects.count(),
|
||||
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
|
||||
"schedules": ScheduleConfig.objects.count(),
|
||||
"enabled_schedules": ScheduleConfig.objects.filter(enabled=True).count(),
|
||||
"snapshots": SnapshotRecord.objects.count(),
|
||||
"runs": BackupRun.objects.count(),
|
||||
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
|
||||
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
|
||||
},
|
||||
}
|
||||
return render(request, "pobsync_backend/dashboard.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def host_detail(request, host: str):
|
||||
host_config = get_object_or_404(HostConfig, host=host)
|
||||
context = {
|
||||
"host": host_config,
|
||||
"schedule": _schedule_for_host(host_config),
|
||||
"latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10],
|
||||
"snapshots": host_config.snapshots.select_related("base").order_by("-started_at", "dirname")[:20],
|
||||
"counts": {
|
||||
"snapshots": host_config.snapshots.count(),
|
||||
"runs": host_config.runs.count(),
|
||||
"failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(),
|
||||
"incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(),
|
||||
},
|
||||
}
|
||||
return render(request, "pobsync_backend/host_detail.html", context)
|
||||
|
||||
|
||||
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
|
||||
try:
|
||||
return host_config.schedule
|
||||
except ScheduleConfig.DoesNotExist:
|
||||
return None
|
||||
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
from pobsync_backend import api
|
||||
from pobsync_backend import api, views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
|
||||
path("api/", api.api_index),
|
||||
path("api/status/", api.status),
|
||||
path("api/hosts/", api.hosts),
|
||||
|
||||
Reference in New Issue
Block a user