Compare commits
3 Commits
2778a589ea
...
123583a502
| Author | SHA1 | Date | |
|---|---|---|---|
| 123583a502 | |||
| 3f3bdf2d45 | |||
| b0c6afad09 |
@@ -33,6 +33,7 @@ python3 manage.py runserver
|
|||||||
|
|
||||||
The admin is available at:
|
The admin is available at:
|
||||||
|
|
||||||
|
- http://127.0.0.1:8000/
|
||||||
- http://127.0.0.1:8000/admin/
|
- http://127.0.0.1:8000/admin/
|
||||||
|
|
||||||
Staff-only JSON endpoints are available at:
|
Staff-only JSON endpoints are available at:
|
||||||
@@ -116,6 +117,7 @@ docker compose up --build web
|
|||||||
|
|
||||||
This starts Django on:
|
This starts Django on:
|
||||||
|
|
||||||
|
- http://127.0.0.1:8010/
|
||||||
- http://127.0.0.1:8010/admin/
|
- http://127.0.0.1:8010/admin/
|
||||||
- http://127.0.0.1:8010/api/
|
- http://127.0.0.1:8010/api/
|
||||||
- http://127.0.0.1:8010/api/status/
|
- http://127.0.0.1:8010/api/status/
|
||||||
@@ -150,6 +152,9 @@ base record when it is known.
|
|||||||
The Django retention command plans from `SnapshotRecord` instead of rediscovering snapshots from the filesystem.
|
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.
|
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 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.
|
||||||
|
Host pages include a safe snapshot discovery action that records existing snapshots into SQL.
|
||||||
|
Host pages also include a read-only SQL retention plan view before any destructive pruning action.
|
||||||
|
|
||||||
The remaining internal engine code still contains reusable backup primitives:
|
The remaining internal engine code still contains reusable backup primitives:
|
||||||
|
|
||||||
|
|||||||
132
src/pobsync_backend/templates/pobsync_backend/base.html
Normal file
132
src/pobsync_backend/templates/pobsync_backend/base.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<!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); }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
|
||||||
|
button, .button-link {
|
||||||
|
appearance: none;
|
||||||
|
background: #17202a;
|
||||||
|
border: 1px solid #17202a;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
|
||||||
|
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
||||||
|
.message {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
|
||||||
|
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
|
||||||
|
@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>
|
||||||
|
{% if messages %}
|
||||||
|
<section class="messages" aria-label="Messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="message {{ message.tags }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% 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 %}
|
||||||
109
src/pobsync_backend/templates/pobsync_backend/host_detail.html
Normal file
109
src/pobsync_backend/templates/pobsync_backend/host_detail.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ host.host }} | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Host actions">
|
||||||
|
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit">Discover snapshots</button>
|
||||||
|
</form>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Retention plan | {{ host.host }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Retention Plan: {{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Retention filters">
|
||||||
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=manual">Manual</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Retention plan summary">
|
||||||
|
<div class="metric"><div class="label">Source</div><div class="value">{{ plan.source }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Keep</div><div class="value">{{ plan.keep|length }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Would Delete</div><div class="value">{{ plan.delete|length }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Policy</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div><strong>Daily:</strong> {{ plan.retention.daily }}</div>
|
||||||
|
<div><strong>Weekly:</strong> {{ plan.retention.weekly }}</div>
|
||||||
|
<div><strong>Monthly:</strong> {{ plan.retention.monthly }}</div>
|
||||||
|
<div><strong>Yearly:</strong> {{ plan.retention.yearly }}</div>
|
||||||
|
<div><strong>Protect bases:</strong> {{ protect_bases|yesno:"yes,no" }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Would Delete</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for snapshot in plan.delete %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ snapshot.kind }}</td>
|
||||||
|
<td>{{ snapshot.dirname }}</td>
|
||||||
|
<td>{{ snapshot.dt }}</td>
|
||||||
|
<td>{{ snapshot.status|default:"" }}</td>
|
||||||
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5" class="muted">Retention would not delete snapshots for this selection.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Keep Reasons</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dirname</th>
|
||||||
|
<th>Reasons</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for dirname, reasons in plan.reasons.items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ dirname }}</td>
|
||||||
|
<td>{{ reasons|join:", " }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="2" class="muted">No snapshots matched this retention selection.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
164
src/pobsync_backend/tests/test_views.py
Normal file
164
src/pobsync_backend/tests/test_views.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from pobsync.util import write_yaml_atomic
|
||||||
|
from pobsync_backend.models import BackupRun, GlobalConfig, 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")
|
||||||
|
self.assertContains(response, "Discover snapshots")
|
||||||
|
|
||||||
|
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 test_discover_host_snapshots_action_discovers_and_redirects(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
backup_root = Path(tmp) / "backups"
|
||||||
|
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
|
||||||
|
meta_dir = snapshot_dir / "meta"
|
||||||
|
meta_dir.mkdir(parents=True)
|
||||||
|
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
|
||||||
|
|
||||||
|
response = self.client.post(reverse("discover_host_snapshots", args=[host.host]), follow=True)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||||
|
self.assertContains(response, "Snapshot discovery scanned 1 items")
|
||||||
|
self.assertTrue(SnapshotRecord.objects.filter(host=host, dirname=snapshot_dir.name).exists())
|
||||||
|
|
||||||
|
def test_discover_host_snapshots_requires_post(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("discover_host_snapshots", args=[host.host]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
def test_retention_plan_renders_keep_and_delete_sets(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
old_snapshot = self._snapshot(host, "20260518-021500Z__OLDSNAP")
|
||||||
|
new_snapshot = self._snapshot(host, "20260519-021500Z__NEWSNAP")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Retention Plan: web-01")
|
||||||
|
self.assertContains(response, old_snapshot.dirname)
|
||||||
|
self.assertContains(response, new_snapshot.dirname)
|
||||||
|
self.assertContains(response, "newest")
|
||||||
|
self.assertContains(response, "Would Delete")
|
||||||
|
|
||||||
|
def test_retention_plan_can_enable_base_protection(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
retention_daily=0,
|
||||||
|
retention_weekly=0,
|
||||||
|
retention_monthly=0,
|
||||||
|
retention_yearly=0,
|
||||||
|
)
|
||||||
|
base = self._snapshot(host, "20260518-021500Z__BASESNAP")
|
||||||
|
child = self._snapshot(host, "20260519-021500Z__CHILDSNP")
|
||||||
|
child.base = base
|
||||||
|
child.save(update_fields=["base"])
|
||||||
|
|
||||||
|
response = self.client.get(reverse("host_retention_plan", args=[host.host]), {"protect_bases": "1"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Protect bases:</strong> yes")
|
||||||
|
self.assertContains(response, f"base-of:{child.dirname}")
|
||||||
|
|
||||||
|
def test_retention_plan_rejects_invalid_kind(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("host_retention_plan", args=[host.host]), {"kind": "weird"}, follow=True)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||||
|
self.assertContains(response, "Retention kind must be scheduled, manual, or all.")
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
102
src/pobsync_backend/views.py
Normal file
102
src/pobsync_backend/views.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from pobsync.errors import ConfigError
|
||||||
|
|
||||||
|
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
||||||
|
from .retention import run_sql_retention_plan
|
||||||
|
from .snapshot_discovery import discover_snapshots
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_POST
|
||||||
|
def discover_host_snapshots(request, host: str):
|
||||||
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
|
try:
|
||||||
|
result = discover_snapshots(host=host_config)
|
||||||
|
except Exception as exc:
|
||||||
|
messages.error(request, f"Snapshot discovery failed for {host_config.host}: {exc}")
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
(
|
||||||
|
f"Snapshot discovery scanned {result['scanned']} items for {host_config.host}: "
|
||||||
|
f"{result['created']} created, {result['updated']} updated."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def host_retention_plan(request, host: str):
|
||||||
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
|
kind = request.GET.get("kind", "scheduled")
|
||||||
|
if kind not in {"scheduled", "manual", "all"}:
|
||||||
|
messages.error(request, "Retention kind must be scheduled, manual, or all.")
|
||||||
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
protect_bases = request.GET.get("protect_bases") in {"1", "true", "on", "yes"}
|
||||||
|
try:
|
||||||
|
plan = run_sql_retention_plan(host=host_config.host, kind=kind, protect_bases=protect_bases)
|
||||||
|
except ConfigError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
context = {
|
||||||
|
"host": host_config,
|
||||||
|
"kind": kind,
|
||||||
|
"protect_bases": protect_bases,
|
||||||
|
"plan": plan,
|
||||||
|
}
|
||||||
|
return render(request, "pobsync_backend/retention_plan.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
|
||||||
|
try:
|
||||||
|
return host_config.schedule
|
||||||
|
except ScheduleConfig.DoesNotExist:
|
||||||
|
return None
|
||||||
@@ -3,10 +3,14 @@ from __future__ import annotations
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from pobsync_backend import api
|
from pobsync_backend import api, views
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("", views.dashboard, name="dashboard"),
|
||||||
|
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
|
||||||
|
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
|
||||||
|
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
|
||||||
path("api/", api.api_index),
|
path("api/", api.api_index),
|
||||||
path("api/status/", api.status),
|
path("api/status/", api.status),
|
||||||
path("api/hosts/", api.hosts),
|
path("api/hosts/", api.hosts),
|
||||||
|
|||||||
Reference in New Issue
Block a user