Compare commits
17 Commits
c0eca3da55
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 833edb2466 | |||
| c7e9e69345 | |||
| e79d871f36 | |||
| ad45fbe46e | |||
| 3cac7b61ac | |||
| 1d6c21764b | |||
| 6f392bef65 | |||
| 6035c547ae | |||
| a3a8fea071 | |||
| 0e2f48ab65 | |||
| b55950e24a | |||
| 025cd0336c | |||
| 4c76ae9f52 | |||
| 7a552715fe | |||
| 0f0de5dc30 | |||
| 1604f0f6f4 | |||
| af548f11c4 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.0 - 2026-05-21
|
||||
|
||||
UI-focused release for the Django control panel.
|
||||
|
||||
### Added
|
||||
|
||||
- Dedicated list pages for runs, snapshots, schedules, purged snapshots, and changelog navigation.
|
||||
- Dashboard priority panels for required action, next scheduled work, recent activity, and storage pressure.
|
||||
- Dashboard host cards with clearer backup activity, snapshot health, next run, and retention status.
|
||||
- Lightweight live refresh for active run detail pages, including status, timing, controls, and rsync log output.
|
||||
- Lightweight live refresh for dashboard priority and host status sections.
|
||||
- Current-page navigation states for primary and system navigation.
|
||||
- Responsive dashboard behavior for narrower screens.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the primary navigation around day-to-day operator workflows and moved admin/system links out of the main path.
|
||||
- Simplified legacy-facing labels and removed source-of-truth wording that no longer applies to the Django-first model.
|
||||
- Improved run and snapshot detail pages with clearer links between backup runs, snapshots, logs, and review actions.
|
||||
- Improved dashboard spacing and card layouts to reduce cramped or overlapping text.
|
||||
- Documented the Django-template-first partial refresh pattern for future UI work.
|
||||
|
||||
## 1.0.0 - 2026-05-21
|
||||
|
||||
Initial stable release of the Django-first pobsync control panel.
|
||||
|
||||
@@ -156,7 +156,7 @@ The UI includes:
|
||||
|
||||
## Restoring Data
|
||||
|
||||
pobsync 1.0 treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
|
||||
pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
|
||||
detail page, but it does not run restore commands for you yet. That is deliberate: restores should be inspected and
|
||||
tested before data is copied back into a live system.
|
||||
|
||||
|
||||
@@ -50,6 +50,16 @@ python3 manage.py showmigrations pobsync_backend
|
||||
The short `pobsync` aliases are limited to operational actions that are useful while debugging a running install.
|
||||
Configuration aliases are intentionally not public commands; use the Django UI or explicit management commands instead.
|
||||
|
||||
## UI Refresh Pattern
|
||||
|
||||
The control panel stays Django-template-first. Pages that need live status should expose a small server-rendered partial
|
||||
view and opt into refresh with `data-refresh-url` and `data-refresh-interval` on the container that should be replaced.
|
||||
The shared script in `base.html` polls only those explicit regions, skips refreshes while the browser tab is hidden, and
|
||||
lets the partial response turn polling off with the `X-Pobsync-Refresh-Active: false` header.
|
||||
|
||||
Use this for operational status surfaces such as running backup details. Avoid refreshing form-heavy sections while an
|
||||
operator might be typing.
|
||||
|
||||
Worker and scheduler commands are normally run by systemd services:
|
||||
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pobsync"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
@@ -80,6 +80,28 @@
|
||||
padding-left: 0;
|
||||
}
|
||||
nav strong a:hover { background: transparent; }
|
||||
nav a[aria-current="page"] {
|
||||
background: #eaf3fb;
|
||||
color: var(--link-strong);
|
||||
font-weight: 720;
|
||||
}
|
||||
.nav-primary,
|
||||
.nav-secondary {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-secondary {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.nav-secondary a {
|
||||
font-size: 13px;
|
||||
}
|
||||
.nav-user {
|
||||
margin-left: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
nav .spacer { flex: 1; }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
@@ -179,10 +201,23 @@
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.metric-link:focus-visible {
|
||||
outline: 3px solid #93c5fd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.metric-link:focus-visible {
|
||||
outline: 3px solid #93c5fd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
.dashboard-summary-grid .metric {
|
||||
min-height: 78px;
|
||||
}
|
||||
.dashboard-summary-grid .metric .value {
|
||||
font-size: 25px;
|
||||
}
|
||||
.dashboard-trends-panel,
|
||||
.dashboard-hosts-panel {
|
||||
overflow: visible;
|
||||
}
|
||||
.panel {
|
||||
margin-bottom: 18px;
|
||||
overflow: auto;
|
||||
@@ -280,6 +315,17 @@
|
||||
}
|
||||
button.secondary:hover,
|
||||
.button-link.secondary:hover { background: #eef3f8; }
|
||||
button.danger,
|
||||
.button-link.danger {
|
||||
background: var(--failed);
|
||||
border-color: var(--failed);
|
||||
color: #fff;
|
||||
}
|
||||
button.danger:hover,
|
||||
.button-link.danger:hover {
|
||||
background: #842828;
|
||||
border-color: #842828;
|
||||
}
|
||||
button.compact,
|
||||
.button-link.compact {
|
||||
font-size: 12px;
|
||||
@@ -293,17 +339,23 @@
|
||||
}
|
||||
.inline-form { margin: 0; }
|
||||
.dashboard-priority-grid {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.priority-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.priority-panel > h2:first-child {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.priority-panel > h2:first-child { margin-bottom: 0; }
|
||||
.action-list,
|
||||
.activity-list,
|
||||
.schedule-list {
|
||||
@@ -374,6 +426,9 @@
|
||||
.activity-row {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
}
|
||||
.activity-row .status {
|
||||
justify-self: start;
|
||||
}
|
||||
.schedule-row {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
}
|
||||
@@ -393,6 +448,14 @@
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.action-row strong,
|
||||
.action-row .muted,
|
||||
.activity-row strong,
|
||||
.activity-row .muted,
|
||||
.schedule-row strong,
|
||||
.schedule-row .muted {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.schedule-time {
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
@@ -425,6 +488,10 @@
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.storage-priority-facts strong {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-control-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -561,6 +628,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
.host-card:hover {
|
||||
@@ -594,8 +662,8 @@
|
||||
}
|
||||
.host-card-layout {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.9fr);
|
||||
}
|
||||
.host-card-section {
|
||||
align-content: start;
|
||||
@@ -612,7 +680,7 @@
|
||||
.host-card-timeline {
|
||||
display: grid;
|
||||
gap: 16px 22px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
.host-card-stats {
|
||||
align-content: start;
|
||||
@@ -638,6 +706,10 @@
|
||||
.host-card-item .value {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-card-item .value a {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.host-card-stat {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
@@ -668,6 +740,9 @@
|
||||
margin-top: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
.host-card-warning > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
||||
.message {
|
||||
background: var(--panel);
|
||||
@@ -679,6 +754,30 @@
|
||||
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
|
||||
.message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); }
|
||||
.form-grid { display: grid; gap: 15px; max-width: 720px; }
|
||||
.filter-form {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
max-width: none;
|
||||
}
|
||||
.form-actions {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.form-actions .button-link.secondary { margin-left: auto; }
|
||||
.filter-form .form-actions {
|
||||
border-top: 0;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.filter-form .form-actions .button-link.secondary { margin-left: 0; }
|
||||
.field { display: grid; gap: 6px; }
|
||||
.field label { font-weight: 700; }
|
||||
.field input[type="text"], .field input[type="number"], .field select, .field textarea {
|
||||
@@ -724,6 +823,12 @@
|
||||
padding: 8px 0;
|
||||
}
|
||||
nav strong { flex-basis: 100%; margin-right: 0; }
|
||||
.nav-secondary {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.nav-user {
|
||||
margin-left: 0;
|
||||
}
|
||||
nav .spacer { display: none; }
|
||||
.page-header {
|
||||
align-items: stretch;
|
||||
@@ -736,27 +841,73 @@
|
||||
.host-control-grid { grid-template-columns: 1fr; }
|
||||
.schedule-row { grid-template-columns: 1fr; }
|
||||
.schedule-time { justify-items: start; text-align: left; }
|
||||
.form-actions .button-link.secondary { margin-left: 0; }
|
||||
.host-card-header { display: grid; }
|
||||
.host-card-status { justify-content: flex-start; max-width: none; }
|
||||
.host-card-layout { grid-template-columns: 1fr; }
|
||||
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||
.insight-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-priority-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.host-card-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.host-card-status {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
.schedule-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.schedule-time {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.metric {
|
||||
min-height: 76px;
|
||||
padding: 12px;
|
||||
}
|
||||
.metric .value {
|
||||
font-size: 24px;
|
||||
}
|
||||
.host-card {
|
||||
padding: 13px;
|
||||
}
|
||||
.host-card-stats {
|
||||
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="{% url 'ssh_credentials' %}">SSH Keys</a>
|
||||
<a href="{% url 'self_check' %}">Self Check</a>
|
||||
<a href="{% url 'logs' %}">Logs</a>
|
||||
<a href="{% url 'purged_snapshots' %}">Purged</a>
|
||||
<a href="{% url 'changelog' %}">Changelog</a>
|
||||
<a href="/api/status/">Status API</a>
|
||||
<span class="nav-primary" aria-label="Primary navigation">
|
||||
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
|
||||
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
|
||||
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
|
||||
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">{{ request.user.username }}</span>
|
||||
<span class="nav-secondary" aria-label="System navigation">
|
||||
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
|
||||
<a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a>
|
||||
<a href="/api/status/">Status API</a>
|
||||
<a href="{% url 'admin:index' %}">Admin</a>
|
||||
</span>
|
||||
<span class="muted nav-user">{{ request.user.username }}</span>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
@@ -769,5 +920,29 @@
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const refreshRegion = async (region) => {
|
||||
if (region.dataset.refreshActive !== "true" || document.hidden) return;
|
||||
try {
|
||||
const response = await fetch(region.dataset.refreshUrl, {
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
region.innerHTML = await response.text();
|
||||
const refreshActive = response.headers.get("X-Pobsync-Refresh-Active");
|
||||
if (refreshActive) region.dataset.refreshActive = refreshActive;
|
||||
} catch (error) {
|
||||
// Keep the current server-rendered content visible if a refresh fails.
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-refresh-url]").forEach((region) => {
|
||||
const interval = Number.parseInt(region.dataset.refreshInterval || "5000", 10);
|
||||
window.setInterval(() => refreshRegion(region), Number.isFinite(interval) ? interval : 5000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -32,138 +32,16 @@
|
||||
</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>
|
||||
<div
|
||||
data-refresh-url="{% url 'dashboard_priority_live' %}"
|
||||
data-refresh-interval="10000"
|
||||
data-refresh-active="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
{% include "pobsync_backend/partials/dashboard_priority.html" %}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<section class="grid dashboard-summary-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>
|
||||
@@ -172,7 +50,7 @@
|
||||
<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">
|
||||
<section class="panel dashboard-trends-panel">
|
||||
<h2>Backup Trends</h2>
|
||||
{% if stats_summary.runs_sampled %}
|
||||
<div class="insight-grid" aria-label="Backup trends">
|
||||
@@ -216,128 +94,13 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="hosts">
|
||||
<h2>Hosts</h2>
|
||||
<div class="host-list">
|
||||
{% for host in hosts %}
|
||||
<article class="host-card">
|
||||
<div class="host-card-header">
|
||||
<div class="host-card-title">
|
||||
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
|
||||
<span class="muted">{{ host.address }}</span>
|
||||
</div>
|
||||
<div class="host-card-status">
|
||||
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
|
||||
{% if host.queued_run_count %}
|
||||
<span class="status queued">queued {{ host.queued_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.running_run_count %}
|
||||
<span class="status running">running {{ host.running_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.warning_run_count %}
|
||||
<span class="status warning">warning {{ host.warning_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.failed_run_count %}
|
||||
<span class="status failed">failed {{ host.failed_run_count }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-layout">
|
||||
<div class="host-card-section">
|
||||
<div class="host-card-section-title">Backup activity</div>
|
||||
<div class="host-card-timeline">
|
||||
<div class="host-card-item">
|
||||
<div class="label">Latest Snapshot</div>
|
||||
<div class="value">
|
||||
{% if host.latest_snapshot %}
|
||||
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
|
||||
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Last Good Backup</div>
|
||||
<div class="value">
|
||||
{% if host.stats_summary.latest_good_run.id %}
|
||||
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
|
||||
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Latest Issue</div>
|
||||
<div class="value">
|
||||
{% if host.stats_summary.latest_problem_run.id %}
|
||||
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
|
||||
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
|
||||
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Next Run</div>
|
||||
<div class="value">
|
||||
{% if host.next_run_at %}
|
||||
{{ host.next_run_at|date:"Y-m-d H:i T" }}
|
||||
<div class="muted">{{ scheduler_timezone }}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-section">
|
||||
<div class="host-card-section-title">Snapshot health</div>
|
||||
<div class="host-card-stats">
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Snapshots</div>
|
||||
<div class="value">{{ host.snapshot_count }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Runs</div>
|
||||
<div class="value">{{ host.run_count }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">New Data</div>
|
||||
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Retention</div>
|
||||
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if host.retention_warning.has_warning %}
|
||||
<div class="host-card-warning">
|
||||
<span class="status warning">retention</span>
|
||||
{% if host.retention_warning.prune_exceeded %}
|
||||
Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}.
|
||||
{% endif %}
|
||||
{% if host.retention_warning.incomplete_count %}
|
||||
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
|
||||
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Mark reviewed</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if host.retention_warning.error %}
|
||||
{{ host.retention_warning.error }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% empty %}
|
||||
<p class="muted">No hosts configured yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
data-refresh-url="{% url 'dashboard_hosts_live' %}"
|
||||
data-refresh-interval="15000"
|
||||
data-refresh-active="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Save global config</button>
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -433,7 +433,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -33,8 +33,13 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">{% if host %}Save config{% else %}Create host{% endif %}</button>
|
||||
{% if host %}
|
||||
<a class="button-link secondary" href="{% url 'host_detail' host.host %}">Cancel</a>
|
||||
{% else %}
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filter</h2>
|
||||
<form method="get" class="form-grid">
|
||||
<form method="get" class="filter-form">
|
||||
<div class="field">
|
||||
<label for="unit">Unit</label>
|
||||
<select id="unit" name="unit">
|
||||
@@ -54,8 +54,9 @@
|
||||
<label for="q">Message contains</label>
|
||||
<input id="q" name="q" value="{{ query }}">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Filter logs</button>
|
||||
<a class="button-link secondary" href="{% url 'logs' %}">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<section class="panel dashboard-hosts-panel" id="hosts">
|
||||
<h2>Hosts</h2>
|
||||
<div class="host-list">
|
||||
{% for host in hosts %}
|
||||
<article class="host-card">
|
||||
<div class="host-card-header">
|
||||
<div class="host-card-title">
|
||||
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
|
||||
<span class="muted">{{ host.address }}</span>
|
||||
</div>
|
||||
<div class="host-card-status">
|
||||
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
|
||||
{% if host.queued_run_count %}
|
||||
<span class="status queued">queued {{ host.queued_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.running_run_count %}
|
||||
<span class="status running">running {{ host.running_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.warning_run_count %}
|
||||
<span class="status warning">warning {{ host.warning_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.failed_run_count %}
|
||||
<span class="status failed">failed {{ host.failed_run_count }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-layout">
|
||||
<div class="host-card-section">
|
||||
<div class="host-card-section-title">Backup activity</div>
|
||||
<div class="host-card-timeline">
|
||||
<div class="host-card-item">
|
||||
<div class="label">Latest Snapshot</div>
|
||||
<div class="value">
|
||||
{% if host.latest_snapshot %}
|
||||
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
|
||||
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Last Good Backup</div>
|
||||
<div class="value">
|
||||
{% if host.stats_summary.latest_good_run.id %}
|
||||
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
|
||||
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Latest Issue</div>
|
||||
<div class="value">
|
||||
{% if host.stats_summary.latest_problem_run.id %}
|
||||
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
|
||||
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
|
||||
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Next Run</div>
|
||||
<div class="value">
|
||||
{% if host.next_run_at %}
|
||||
{{ host.next_run_at|date:"Y-m-d H:i T" }}
|
||||
<div class="muted">{{ scheduler_timezone }}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-section">
|
||||
<div class="host-card-section-title">Snapshot health</div>
|
||||
<div class="host-card-stats">
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Snapshots</div>
|
||||
<div class="value">{{ host.snapshot_count }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Runs</div>
|
||||
<div class="value">{{ host.run_count }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">New Data</div>
|
||||
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Retention</div>
|
||||
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if host.retention_warning.has_warning %}
|
||||
<div class="host-card-warning">
|
||||
<span class="status warning">retention</span>
|
||||
{% if host.retention_warning.prune_exceeded %}
|
||||
Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}.
|
||||
{% endif %}
|
||||
{% if host.retention_warning.incomplete_count %}
|
||||
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
|
||||
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Mark reviewed</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if host.retention_warning.error %}
|
||||
{{ host.retention_warning.error }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% empty %}
|
||||
<p class="muted">No hosts configured yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,130 @@
|
||||
<section class="dashboard-priority-grid" aria-label="Operator priorities">
|
||||
<article class="panel priority-panel dashboard-panel-required">
|
||||
<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 dashboard-panel-schedules">
|
||||
<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 dashboard-panel-activity">
|
||||
<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 dashboard-panel-storage">
|
||||
<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>
|
||||
@@ -0,0 +1,150 @@
|
||||
<section class="grid" aria-label="Run summary">
|
||||
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
||||
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
|
||||
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
||||
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
||||
</section>
|
||||
|
||||
{% if can_cancel %}
|
||||
<section class="panel highlight warning">
|
||||
<h2>Run Control</h2>
|
||||
<p>
|
||||
Cancelling a queued run stops it immediately. Cancelling a running run asks the worker to stop
|
||||
and records the cancellation request on this run.
|
||||
</p>
|
||||
<form method="post" action="{% url 'cancel_run' run.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger">Cancel run</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if failure %}
|
||||
<section class="panel highlight failed">
|
||||
<h2>Failure</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
||||
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
||||
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if run.status == "failed" or run.status == "warning" %}
|
||||
{% if not run.reviewed_at %}
|
||||
<section class="panel highlight warning">
|
||||
<h2>Review Required</h2>
|
||||
<p>Mark this run as reviewed after you have checked the failure or warning and no longer need it in the action queue.</p>
|
||||
<form method="post" action="{% url 'resolve_run_review' run.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="secondary">Mark reviewed</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if run.reviewed_at %}
|
||||
<section class="panel highlight success">
|
||||
<h2>Review</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
|
||||
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if dry_run_summary %}
|
||||
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
||||
<h2>Dry Run Summary</h2>
|
||||
<section class="grid" aria-label="Dry run summary">
|
||||
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
|
||||
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
|
||||
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
|
||||
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
||||
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
|
||||
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
|
||||
</section>
|
||||
<div class="stack">
|
||||
{% if dry_run_summary.duration_seconds is not None %}
|
||||
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>Log:</strong>
|
||||
{% if dry_run_summary.log_available %}
|
||||
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||
{% elif rsync_log_path %}
|
||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||
{% else %}
|
||||
<span class="muted">not recorded yet</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if dry_run_summary.warnings %}
|
||||
<div><strong>Warnings:</strong></div>
|
||||
<ul>
|
||||
{% for warning in dry_run_summary.warnings %}
|
||||
<li>{{ warning }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div><strong>Warnings:</strong> none recorded</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Timing</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Created:</strong> {{ run.created_at }}</div>
|
||||
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
|
||||
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
|
||||
{% if execution %}
|
||||
<div><strong>Worker:</strong> {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}</div>
|
||||
<div><strong>Worker heartbeat:</strong> {{ execution.heartbeat_at|default:"" }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshot</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
|
||||
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
|
||||
<div>
|
||||
<strong>Rsync log:</strong>
|
||||
{% if rsync_log_exists %}
|
||||
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
|
||||
{% elif rsync_log_path %}
|
||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Rsync Log</h2>
|
||||
<div class="stack spaced">
|
||||
{% if rsync_log_exists %}
|
||||
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
||||
<div class="muted">{{ rsync_log_path }}</div>
|
||||
{% elif rsync_log_path %}
|
||||
<div class="muted">{{ rsync_log_path }} (missing)</div>
|
||||
{% else %}
|
||||
<div class="muted">No rsync log path recorded yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if rsync_log_tail %}
|
||||
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
|
||||
{% endif %}{% endfor %}</pre>
|
||||
{% else %}
|
||||
<p class="muted">No recent rsync log output recorded yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filters</h2>
|
||||
<form method="get" class="form-grid">
|
||||
<form method="get" class="filter-form">
|
||||
<div class="field">
|
||||
<label for="host">Host</label>
|
||||
<select id="host" name="host">
|
||||
@@ -35,7 +35,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="button-link secondary" href="{% url 'purged_snapshots' %}">Clear</a>
|
||||
</div>
|
||||
|
||||
@@ -104,8 +104,12 @@
|
||||
</section>
|
||||
|
||||
{% if plan.delete %}
|
||||
<section class="panel">
|
||||
<section class="panel highlight warning">
|
||||
<h2>Apply Retention</h2>
|
||||
<p class="muted">
|
||||
This permanently deletes the snapshot directories listed in Would Delete. Confirm the host and delete count
|
||||
before applying the plan.
|
||||
</p>
|
||||
<form method="post" action="{% url 'apply_host_retention' host.host %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
{{ apply_form.non_field_errors }}
|
||||
@@ -138,8 +142,9 @@
|
||||
<div class="helptext">{{ apply_form.confirm_delete_count.help_text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Apply retention</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger">Apply retention</button>
|
||||
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -200,6 +205,10 @@
|
||||
</table>
|
||||
|
||||
<h3>Cleanup Incomplete Snapshots</h3>
|
||||
<p class="muted">
|
||||
This deletes only incomplete snapshot directories and their tracking records. Successful manual and scheduled
|
||||
snapshots are not touched.
|
||||
</p>
|
||||
<form method="post" action="{% url 'cleanup_host_incomplete_snapshots' host.host %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
{{ incomplete_cleanup_form.non_field_errors }}
|
||||
@@ -225,8 +234,9 @@
|
||||
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Delete incomplete snapshots</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger">Delete incomplete snapshots</button>
|
||||
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -11,121 +11,16 @@
|
||||
</div>
|
||||
<section class="actions" aria-label="Run actions">
|
||||
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
|
||||
{% if can_cancel %}
|
||||
<form method="post" action="{% url 'cancel_run' run.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Cancel run</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if run.status == "failed" or run.status == "warning" %}
|
||||
{% if not run.reviewed_at %}
|
||||
<form method="post" action="{% url 'resolve_run_review' run.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Mark reviewed</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="grid" aria-label="Run summary">
|
||||
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
||||
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
|
||||
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
||||
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
||||
</section>
|
||||
|
||||
{% if failure %}
|
||||
<section class="panel highlight failed">
|
||||
<h2>Failure</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
||||
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
||||
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if run.reviewed_at %}
|
||||
<section class="panel highlight success">
|
||||
<h2>Review</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
|
||||
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if dry_run_summary %}
|
||||
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
|
||||
<h2>Dry Run Summary</h2>
|
||||
<section class="grid" aria-label="Dry run summary">
|
||||
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
|
||||
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
|
||||
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
|
||||
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
|
||||
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
|
||||
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
|
||||
</section>
|
||||
<div class="stack">
|
||||
{% if dry_run_summary.duration_seconds is not None %}
|
||||
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>Log:</strong>
|
||||
{% if dry_run_summary.log_available %}
|
||||
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
|
||||
{% elif rsync_log_path %}
|
||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||
{% else %}
|
||||
<span class="muted">not recorded yet</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if dry_run_summary.warnings %}
|
||||
<div><strong>Warnings:</strong></div>
|
||||
<ul>
|
||||
{% for warning in dry_run_summary.warnings %}
|
||||
<li>{{ warning }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div><strong>Warnings:</strong> none recorded</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Timing</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Created:</strong> {{ run.created_at }}</div>
|
||||
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
|
||||
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
|
||||
{% if execution %}
|
||||
<div><strong>Worker:</strong> {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}</div>
|
||||
<div><strong>Worker heartbeat:</strong> {{ execution.heartbeat_at|default:"" }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshot</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
|
||||
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
|
||||
<div>
|
||||
<strong>Rsync log:</strong>
|
||||
{% if rsync_log_exists %}
|
||||
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
|
||||
{% elif rsync_log_path %}
|
||||
<span class="muted">{{ rsync_log_path }} (missing)</span>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
data-refresh-url="{% url 'run_detail_live' run.id %}"
|
||||
data-refresh-interval="5000"
|
||||
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
|
||||
aria-live="polite"
|
||||
>
|
||||
{% include "pobsync_backend/partials/run_detail_live.html" %}
|
||||
</div>
|
||||
|
||||
{% if requested %}
|
||||
@@ -151,26 +46,6 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Rsync Log</h2>
|
||||
<div class="stack spaced">
|
||||
{% if rsync_log_exists %}
|
||||
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
||||
<div class="muted">{{ rsync_log_path }}</div>
|
||||
{% elif rsync_log_path %}
|
||||
<div class="muted">{{ rsync_log_path }} (missing)</div>
|
||||
{% else %}
|
||||
<div class="muted">No rsync log path recorded yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if rsync_log_tail %}
|
||||
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
|
||||
{% endif %}{% endfor %}</pre>
|
||||
{% else %}
|
||||
<p class="muted">No recent rsync log output recorded yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if stats %}
|
||||
<section class="panel">
|
||||
<h2>Stats</h2>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filters</h2>
|
||||
<form method="get" class="form-grid">
|
||||
<form method="get" class="filter-form">
|
||||
<div class="field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
@@ -52,7 +52,7 @@
|
||||
<option value="reviewed" {% if selected_review == "reviewed" %}selected{% endif %}>Reviewed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="button-link secondary" href="{% url 'runs_list' %}">Clear</a>
|
||||
</div>
|
||||
|
||||
@@ -30,8 +30,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Save schedule</button>
|
||||
<a class="button-link secondary" href="{% url 'host_detail' host.host %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filters</h2>
|
||||
<form method="get" class="form-grid">
|
||||
<form method="get" class="filter-form">
|
||||
<div class="field">
|
||||
<label for="host">Host</label>
|
||||
<select id="host" name="host">
|
||||
@@ -42,7 +42,7 @@
|
||||
<option value="no" {% if selected_prune == "no" %}selected{% endif %}>Prune disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="button-link secondary" href="{% url 'schedules_list' %}">Clear</a>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filters</h2>
|
||||
<form method="get" class="form-grid">
|
||||
<form method="get" class="filter-form">
|
||||
<div class="field">
|
||||
<label for="host">Host</label>
|
||||
<select id="host" name="host">
|
||||
@@ -44,7 +44,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="button-link secondary" href="{% url 'snapshots_list' %}">Clear</a>
|
||||
</div>
|
||||
|
||||
@@ -41,8 +41,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">{% if credential %}Save SSH key{% else %}Create SSH key{% endif %}</button>
|
||||
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -64,7 +65,10 @@
|
||||
<label for="confirm_name">Confirm key name</label>
|
||||
<input id="confirm_name" name="confirm_name" type="text" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>
|
||||
</div>
|
||||
<button type="submit" class="danger" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>Delete SSH key</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>Delete SSH key</button>
|
||||
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -29,8 +29,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Generate SSH key</button>
|
||||
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -15,7 +15,7 @@ class ConsoleEntrypointTests(SimpleTestCase):
|
||||
exit_code = main(["--version"])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.0.0")
|
||||
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.1.0")
|
||||
|
||||
def test_maps_backup_alias_to_django_command(self) -> None:
|
||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||
|
||||
@@ -39,6 +39,32 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/admin/login/", response["Location"])
|
||||
|
||||
def test_base_navigation_groups_primary_and_system_links(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'aria-label="Primary navigation"', html=False)
|
||||
self.assertContains(response, 'aria-label="System navigation"', html=False)
|
||||
self.assertContains(response, reverse("dashboard"))
|
||||
self.assertContains(response, reverse("ssh_credentials"))
|
||||
self.assertContains(response, reverse("logs"))
|
||||
self.assertContains(response, reverse("purged_snapshots"))
|
||||
self.assertContains(response, reverse("self_check"))
|
||||
self.assertContains(response, reverse("changelog"))
|
||||
self.assertContains(response, "/api/status/")
|
||||
self.assertContains(response, reverse("admin:index"))
|
||||
self.assertContains(response, '<a href="/" aria-current="page">Dashboard</a>', html=False)
|
||||
|
||||
def test_base_navigation_marks_current_secondary_page(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
response = self.client.get(reverse("self_check"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, f'<a href="{reverse("self_check")}" aria-current="page">Self Check</a>', html=False)
|
||||
|
||||
def test_changelog_requires_staff_login(self) -> None:
|
||||
response = self.client.get(reverse("changelog"))
|
||||
|
||||
@@ -103,6 +129,13 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Control panel")
|
||||
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
|
||||
self.assertContains(response, "dashboard-panel-required")
|
||||
self.assertContains(response, "dashboard-panel-schedules")
|
||||
self.assertContains(response, "dashboard-panel-activity")
|
||||
self.assertContains(response, "dashboard-panel-storage")
|
||||
self.assertContains(response, "dashboard-summary-grid")
|
||||
self.assertContains(response, "dashboard-trends-panel")
|
||||
self.assertContains(response, "dashboard-hosts-panel")
|
||||
self.assertContains(response, "Dashboard")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||
@@ -128,6 +161,8 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "1 backup run waiting.")
|
||||
self.assertContains(response, "Next Scheduled Work")
|
||||
self.assertContains(response, "Recent Activity")
|
||||
self.assertContains(response, f'data-refresh-url="{reverse("dashboard_priority_live")}"', html=False)
|
||||
self.assertContains(response, f'data-refresh-url="{reverse("dashboard_hosts_live")}"', html=False)
|
||||
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)
|
||||
@@ -141,6 +176,32 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
|
||||
|
||||
def test_dashboard_priority_live_returns_status_partial(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING)
|
||||
|
||||
response = self.client.get(reverse("dashboard_priority_live"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Required Action")
|
||||
self.assertContains(response, "Recent Activity")
|
||||
self.assertContains(response, "running")
|
||||
self.assertNotContains(response, "<html", html=False)
|
||||
|
||||
def test_dashboard_hosts_live_returns_hosts_partial(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
BackupRun.objects.create(host=host, status=BackupRun.Status.QUEUED)
|
||||
|
||||
response = self.client.get(reverse("dashboard_hosts_live"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "queued 1")
|
||||
self.assertContains(response, "Snapshot health")
|
||||
self.assertNotContains(response, "<html", html=False)
|
||||
|
||||
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
GlobalConfig.objects.create(name="default", backup_root="/missing-backup-root")
|
||||
@@ -233,6 +294,9 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Runs")
|
||||
self.assertContains(response, "Review queued, running, completed")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("runs_list"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, f"Run {failed.id}")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "needed")
|
||||
@@ -275,6 +339,9 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Snapshots")
|
||||
self.assertContains(response, "Browse discovered scheduled, manual, and incomplete snapshots")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("snapshots_list"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, manual.dirname)
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertNotContains(response, scheduled.dirname)
|
||||
@@ -291,6 +358,9 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Schedules")
|
||||
self.assertContains(response, "Review configured backup schedules")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("schedules_list"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "success")
|
||||
@@ -428,6 +498,9 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Logs")
|
||||
self.assertContains(response, "Filter pobsync service logs")
|
||||
self.assertContains(response, "Filter logs")
|
||||
self.assertContains(response, reverse("logs"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, "web-01 failed backup run 12")
|
||||
self.assertNotContains(response, "web-02 failed backup run 12")
|
||||
self.assertNotContains(response, "started")
|
||||
@@ -458,6 +531,9 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Purged Snapshots")
|
||||
self.assertContains(response, "Audit trail for snapshots removed")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("purged_snapshots"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, "20260518-021500Z__OLDSNAP")
|
||||
self.assertContains(response, "outside retention policy")
|
||||
self.assertContains(response, "Scheduled")
|
||||
@@ -542,6 +618,21 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
|
||||
self.assertEqual(credential.public_key, "DERIVED PUBLIC KEY")
|
||||
|
||||
def test_ssh_credential_forms_render_cancel_actions(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
credential = SshCredential.objects.create(name="backup-key")
|
||||
|
||||
create_response = self.client.get(reverse("create_ssh_credential"))
|
||||
edit_response = self.client.get(reverse("edit_ssh_credential", args=[credential.id]))
|
||||
generate_response = self.client.get(reverse("generate_ssh_credential"))
|
||||
|
||||
for response in (create_response, edit_response, generate_response):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("ssh_credentials"))
|
||||
self.assertContains(edit_response, "Delete SSH key")
|
||||
self.assertContains(edit_response, 'class="danger"', html=False)
|
||||
|
||||
def test_ssh_credentials_view_generates_filesystem_key(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||
@@ -746,6 +837,8 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, f'value="{credential.id}" selected')
|
||||
self.assertContains(response, "--archive")
|
||||
self.assertContains(response, "/proc/***")
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("dashboard"))
|
||||
|
||||
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1635,8 +1728,51 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Run Control")
|
||||
self.assertContains(response, "Cancelling a queued run stops it immediately")
|
||||
self.assertContains(response, "Cancel run")
|
||||
self.assertContains(response, reverse("cancel_run", args=[run.id]))
|
||||
self.assertContains(response, 'class="danger"', html=False)
|
||||
|
||||
def test_run_detail_enables_live_refresh_for_active_run(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING)
|
||||
|
||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, f'data-refresh-url="{reverse("run_detail_live", args=[run.id])}"', html=False)
|
||||
self.assertContains(response, 'data-refresh-interval="5000"', html=False)
|
||||
self.assertContains(response, 'data-refresh-active="true"', html=False)
|
||||
|
||||
def test_run_detail_live_returns_partial_for_active_run(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = BackupRun.objects.create(
|
||||
host=host,
|
||||
status=BackupRun.Status.RUNNING,
|
||||
result={"rsync": {"log_tail": ["sending incremental file list"]}},
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("run_detail_live", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["X-Pobsync-Refresh-Active"], "true")
|
||||
self.assertContains(response, "Run Control")
|
||||
self.assertContains(response, "sending incremental file list")
|
||||
self.assertNotContains(response, "<html", html=False)
|
||||
|
||||
def test_run_detail_live_stops_refresh_for_terminal_run(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS)
|
||||
|
||||
response = self.client.get(reverse("run_detail_live", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["X-Pobsync-Refresh-Active"], "false")
|
||||
self.assertNotContains(response, "Run Control")
|
||||
|
||||
def test_run_detail_renders_worker_execution_metadata(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1820,6 +1956,9 @@ class ViewTests(TestCase):
|
||||
self.assertNotContains(response, "<div class=\"label\">Source</div>", html=True)
|
||||
self.assertContains(response, "Confirm delete count")
|
||||
self.assertContains(response, "Type 1 to confirm the current number of planned deletions.")
|
||||
self.assertContains(response, "This permanently deletes the snapshot directories listed in Would Delete.")
|
||||
self.assertContains(response, 'class="danger"', html=False)
|
||||
self.assertContains(response, "Cancel")
|
||||
|
||||
def test_retention_plan_warns_when_scheduled_prune_limit_is_exceeded(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1895,6 +2034,8 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "excluded from retention cleanup")
|
||||
self.assertContains(response, "Delete incomplete snapshots")
|
||||
self.assertContains(response, "Type 1 to confirm the current number of incomplete snapshots.")
|
||||
self.assertContains(response, "This deletes only incomplete snapshot directories")
|
||||
self.assertContains(response, 'class="danger"', html=False)
|
||||
|
||||
def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -2142,6 +2283,8 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "evaluated by the pobsync scheduler service")
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "Save schedule")
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("host_detail", args=[host.host]))
|
||||
|
||||
def test_schedule_form_creates_schedule(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -2243,6 +2386,8 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "/srv")
|
||||
self.assertContains(response, "*.tmp")
|
||||
self.assertContains(response, "--numeric-ids")
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("host_detail", args=[host.host]))
|
||||
|
||||
def test_host_config_form_renders_effective_config_check(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
@@ -48,6 +48,20 @@ from .stats_summary import collect_dashboard_stats, collect_host_stats
|
||||
|
||||
@staff_member_required
|
||||
def dashboard(request):
|
||||
return render(request, "pobsync_backend/dashboard.html", _dashboard_context())
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def dashboard_priority_live(request):
|
||||
return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context())
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def dashboard_hosts_live(request):
|
||||
return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context())
|
||||
|
||||
|
||||
def _dashboard_context() -> dict[str, object]:
|
||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||
hosts = list(
|
||||
HostConfig.objects.select_related("schedule")
|
||||
@@ -109,7 +123,7 @@ def dashboard(request):
|
||||
).count(),
|
||||
},
|
||||
}
|
||||
return render(request, "pobsync_backend/dashboard.html", context)
|
||||
return context
|
||||
|
||||
|
||||
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
|
||||
@@ -657,6 +671,19 @@ def queue_manual_backup(request, host: str):
|
||||
@staff_member_required
|
||||
def run_detail(request, run_id: int):
|
||||
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
||||
return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run))
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def run_detail_live(request, run_id: int):
|
||||
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
||||
context = _run_detail_context(run)
|
||||
response = render(request, "pobsync_backend/partials/run_detail_live.html", context)
|
||||
response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false"
|
||||
return response
|
||||
|
||||
|
||||
def _run_detail_context(run: BackupRun) -> dict[str, object]:
|
||||
result = run.result if isinstance(run.result, dict) else {}
|
||||
run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
|
||||
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
|
||||
@@ -666,9 +693,11 @@ def run_detail(request, run_id: int):
|
||||
rsync_log_path = _run_rsync_log_path(run)
|
||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||
context = {
|
||||
can_cancel = run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}
|
||||
return {
|
||||
"run": run,
|
||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
||||
"can_cancel": can_cancel,
|
||||
"can_auto_refresh": can_cancel,
|
||||
"requested": requested,
|
||||
"execution": execution,
|
||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
||||
@@ -692,7 +721,6 @@ def run_detail(request, run_id: int):
|
||||
),
|
||||
"result_json": _pretty_json(run.result),
|
||||
}
|
||||
return render(request, "pobsync_backend/run_detail.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
|
||||
@@ -8,6 +8,8 @@ from pobsync_backend import api, views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
path("dashboard/priority-live/", views.dashboard_priority_live, name="dashboard_priority_live"),
|
||||
path("dashboard/hosts-live/", views.dashboard_hosts_live, name="dashboard_hosts_live"),
|
||||
path("changelog/", views.changelog, name="changelog"),
|
||||
path("self-check/", views.self_check, name="self_check"),
|
||||
path("logs/", views.logs, name="logs"),
|
||||
@@ -37,6 +39,7 @@ urlpatterns = [
|
||||
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||
path("runs/", views.runs_list, name="runs_list"),
|
||||
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
||||
path("runs/<int:run_id>/live/", views.run_detail_live, name="run_detail_live"),
|
||||
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
||||
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
||||
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
||||
|
||||
Reference in New Issue
Block a user