Compare commits
12 Commits
4c76ae9f52
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 833edb2466 | |||
| c7e9e69345 | |||
| e79d871f36 | |||
| ad45fbe46e | |||
| 3cac7b61ac | |||
| 1d6c21764b | |||
| 6f392bef65 | |||
| 6035c547ae | |||
| a3a8fea071 | |||
| 0e2f48ab65 | |||
| b55950e24a | |||
| 025cd0336c |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.0 - 2026-05-21
|
||||||
|
|
||||||
Initial stable release of the Django-first pobsync control panel.
|
Initial stable release of the Django-first pobsync control panel.
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ The UI includes:
|
|||||||
|
|
||||||
## Restoring Data
|
## 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
|
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.
|
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.
|
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.
|
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:
|
Worker and scheduler commands are normally run by systemd services:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pobsync"
|
name = "pobsync"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
|
|||||||
@@ -80,6 +80,28 @@
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
nav strong a:hover { background: transparent; }
|
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; }
|
nav .spacer { flex: 1; }
|
||||||
main {
|
main {
|
||||||
max-width: 1180px;
|
max-width: 1180px;
|
||||||
@@ -182,6 +204,19 @@
|
|||||||
.metric-link:focus-visible {
|
.metric-link:focus-visible {
|
||||||
outline: 3px solid #93c5fd;
|
outline: 3px solid #93c5fd;
|
||||||
outline-offset: 2px;
|
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 {
|
.panel {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
@@ -304,17 +339,23 @@
|
|||||||
}
|
}
|
||||||
.inline-form { margin: 0; }
|
.inline-form { margin: 0; }
|
||||||
.dashboard-priority-grid {
|
.dashboard-priority-grid {
|
||||||
|
align-items: start;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
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;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
.priority-panel {
|
.priority-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 0;
|
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,
|
.action-list,
|
||||||
.activity-list,
|
.activity-list,
|
||||||
.schedule-list {
|
.schedule-list {
|
||||||
@@ -385,6 +426,9 @@
|
|||||||
.activity-row {
|
.activity-row {
|
||||||
grid-template-columns: max-content minmax(0, 1fr);
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
.activity-row .status {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
.schedule-row {
|
.schedule-row {
|
||||||
grid-template-columns: minmax(0, 1fr) max-content;
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
}
|
}
|
||||||
@@ -404,6 +448,14 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 0;
|
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 {
|
.schedule-time {
|
||||||
justify-items: end;
|
justify-items: end;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -436,6 +488,10 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
.storage-priority-facts strong {
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.host-control-grid {
|
.host-control-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -572,6 +628,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
min-width: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.host-card:hover {
|
.host-card:hover {
|
||||||
@@ -605,8 +662,8 @@
|
|||||||
}
|
}
|
||||||
.host-card-layout {
|
.host-card-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 18px;
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.9fr);
|
||||||
}
|
}
|
||||||
.host-card-section {
|
.host-card-section {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -623,7 +680,7 @@
|
|||||||
.host-card-timeline {
|
.host-card-timeline {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px 22px;
|
gap: 16px 22px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
.host-card-stats {
|
.host-card-stats {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -649,6 +706,10 @@
|
|||||||
.host-card-item .value {
|
.host-card-item .value {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.host-card-item .value a {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
.host-card-stat {
|
.host-card-stat {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
@@ -679,6 +740,9 @@
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
.host-card-warning > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
||||||
.message {
|
.message {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -759,6 +823,12 @@
|
|||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
nav strong { flex-basis: 100%; margin-right: 0; }
|
nav strong { flex-basis: 100%; margin-right: 0; }
|
||||||
|
.nav-secondary {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.nav-user {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
nav .spacer { display: none; }
|
nav .spacer { display: none; }
|
||||||
.page-header {
|
.page-header {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -778,21 +848,66 @@
|
|||||||
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||||
.insight-grid { grid-template-columns: 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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
||||||
<a href="{% url 'admin:index' %}">Admin</a>
|
<span class="nav-primary" aria-label="Primary navigation">
|
||||||
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
|
||||||
<a href="{% url 'self_check' %}">Self Check</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' %}">Logs</a>
|
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
|
||||||
<a href="{% url 'purged_snapshots' %}">Purged</a>
|
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
|
||||||
<a href="{% url 'changelog' %}">Changelog</a>
|
</span>
|
||||||
<a href="/api/status/">Status API</a>
|
|
||||||
<span class="spacer"></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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
@@ -805,5 +920,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -32,138 +32,16 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="dashboard-priority-grid" aria-label="Operator priorities">
|
<div
|
||||||
<article class="panel priority-panel">
|
data-refresh-url="{% url 'dashboard_priority_live' %}"
|
||||||
<h2>Required Action</h2>
|
data-refresh-interval="10000"
|
||||||
{% if action_items %}
|
data-refresh-active="true"
|
||||||
<div class="action-list">
|
aria-live="polite"
|
||||||
{% for item in action_items %}
|
>
|
||||||
<a class="action-row {{ item.status }}" href="{{ item.url }}">
|
{% include "pobsync_backend/partials/dashboard_priority.html" %}
|
||||||
<span class="status {{ item.status }}">{{ item.label }}</span>
|
|
||||||
<span>
|
|
||||||
<strong>{{ item.host.host }}</strong>
|
|
||||||
<span class="muted">{{ item.message }}</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</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">
|
<section class="grid dashboard-summary-grid" aria-label="Summary">
|
||||||
<h2>Next Scheduled Work <a class="button-link secondary compact" href="{% url 'schedules_list' %}">View all</a></h2>
|
|
||||||
{% if next_schedule_rows %}
|
|
||||||
<div class="schedule-list">
|
|
||||||
{% for row in next_schedule_rows %}
|
|
||||||
<a class="schedule-row" href="{% url 'host_detail' row.schedule.host.host %}">
|
|
||||||
<span>
|
|
||||||
<strong>{{ row.schedule.host.host }}</strong>
|
|
||||||
<span class="muted">{{ row.schedule.cron_expr }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="schedule-time">
|
|
||||||
{% if row.next_run_at %}
|
|
||||||
{{ row.next_run_at|date:"Y-m-d H:i T" }}
|
|
||||||
<span class="muted">{{ scheduler_timezone }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="muted">not due</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">No enabled schedules yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel priority-panel">
|
|
||||||
<h2>Recent Activity <a class="button-link secondary compact" href="{% url 'runs_list' %}">View all</a></h2>
|
|
||||||
{% if recent_runs %}
|
|
||||||
<div class="activity-list">
|
|
||||||
{% for run in recent_runs %}
|
|
||||||
<a class="activity-row" href="{% url 'run_detail' run.id %}">
|
|
||||||
<span class="status {{ run.status }}">{{ run.status }}</span>
|
|
||||||
<span>
|
|
||||||
<strong>Run {{ run.id }}</strong>
|
|
||||||
<span class="muted">{{ run.host.host }} · {{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">No backup runs recorded yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel priority-panel">
|
|
||||||
<h2>Storage Pressure</h2>
|
|
||||||
{% if stats_summary.runs_sampled %}
|
|
||||||
<div class="storage-priority">
|
|
||||||
<div>
|
|
||||||
<div class="label">Backup root used</div>
|
|
||||||
<div class="value">
|
|
||||||
{% if stats_summary.capacity.used_percent is not None %}
|
|
||||||
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
|
||||||
{% else %}
|
|
||||||
unknown
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if stats_summary.capacity.used_percent is not None %}
|
|
||||||
<div class="storage-meter" aria-label="Backup root storage usage">
|
|
||||||
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="storage-priority-facts">
|
|
||||||
<div>
|
|
||||||
<span class="label">Runway</span>
|
|
||||||
<strong>
|
|
||||||
{% if stats_summary.estimated_days_until_full %}
|
|
||||||
{{ stats_summary.estimated_days_until_full }} days
|
|
||||||
{% elif stats_summary.estimated_runs_until_full %}
|
|
||||||
{{ stats_summary.estimated_runs_until_full }} runs
|
|
||||||
{% else %}
|
|
||||||
unknown
|
|
||||||
{% endif %}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="label">New data</span>
|
|
||||||
<strong>{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="label">Available</span>
|
|
||||||
<strong>{{ stats_summary.capacity.available_bytes|filesizeformat }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">Storage pressure appears after the first completed backup with stats.</p>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid" aria-label="Summary">
|
|
||||||
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
|
<a class="metric metric-link" href="#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 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
|
||||||
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
|
<a class="metric metric-link" href="{% url '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>
|
<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>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel dashboard-trends-panel">
|
||||||
<h2>Backup Trends</h2>
|
<h2>Backup Trends</h2>
|
||||||
{% if stats_summary.runs_sampled %}
|
{% if stats_summary.runs_sampled %}
|
||||||
<div class="insight-grid" aria-label="Backup trends">
|
<div class="insight-grid" aria-label="Backup trends">
|
||||||
@@ -216,128 +94,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="hosts">
|
<div
|
||||||
<h2>Hosts</h2>
|
data-refresh-url="{% url 'dashboard_hosts_live' %}"
|
||||||
<div class="host-list">
|
data-refresh-interval="15000"
|
||||||
{% for host in hosts %}
|
data-refresh-active="true"
|
||||||
<article class="host-card">
|
aria-live="polite"
|
||||||
<div class="host-card-header">
|
>
|
||||||
<div class="host-card-title">
|
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
|
||||||
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
|
|
||||||
<span class="muted">{{ host.address }}</span>
|
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -14,135 +14,13 @@
|
|||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid" aria-label="Run summary">
|
<div
|
||||||
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
data-refresh-url="{% url 'run_detail_live' run.id %}"
|
||||||
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
|
data-refresh-interval="5000"
|
||||||
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
|
||||||
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
aria-live="polite"
|
||||||
</section>
|
>
|
||||||
|
{% include "pobsync_backend/partials/run_detail_live.html" %}
|
||||||
{% 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>
|
</div>
|
||||||
|
|
||||||
{% if requested %}
|
{% if requested %}
|
||||||
@@ -168,26 +46,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</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 %}
|
{% if stats %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Stats</h2>
|
<h2>Stats</h2>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ConsoleEntrypointTests(SimpleTestCase):
|
|||||||
exit_code = main(["--version"])
|
exit_code = main(["--version"])
|
||||||
|
|
||||||
self.assertEqual(exit_code, 0)
|
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:
|
def test_maps_backup_alias_to_django_command(self) -> None:
|
||||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||||
|
|||||||
@@ -39,6 +39,32 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn("/admin/login/", response["Location"])
|
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:
|
def test_changelog_requires_staff_login(self) -> None:
|
||||||
response = self.client.get(reverse("changelog"))
|
response = self.client.get(reverse("changelog"))
|
||||||
|
|
||||||
@@ -103,6 +129,13 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Control panel")
|
self.assertContains(response, "Control panel")
|
||||||
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
|
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, "Dashboard")
|
||||||
self.assertContains(response, "web-01")
|
self.assertContains(response, "web-01")
|
||||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||||
@@ -128,6 +161,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "1 backup run waiting.")
|
self.assertContains(response, "1 backup run waiting.")
|
||||||
self.assertContains(response, "Next Scheduled Work")
|
self.assertContains(response, "Next Scheduled Work")
|
||||||
self.assertContains(response, "Recent Activity")
|
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")}"', html=False)
|
||||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
|
||||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False)
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=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("snapshots_list")}"', html=False)
|
||||||
self.assertContains(response, f'href="{reverse("schedules_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:
|
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
GlobalConfig.objects.create(name="default", backup_root="/missing-backup-root")
|
GlobalConfig.objects.create(name="default", backup_root="/missing-backup-root")
|
||||||
@@ -1673,6 +1734,46 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, reverse("cancel_run", args=[run.id]))
|
self.assertContains(response, reverse("cancel_run", args=[run.id]))
|
||||||
self.assertContains(response, 'class="danger"', html=False)
|
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:
|
def test_run_detail_renders_worker_execution_metadata(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ from .stats_summary import collect_dashboard_stats, collect_host_stats
|
|||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def dashboard(request):
|
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()
|
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||||
hosts = list(
|
hosts = list(
|
||||||
HostConfig.objects.select_related("schedule")
|
HostConfig.objects.select_related("schedule")
|
||||||
@@ -109,7 +123,7 @@ def dashboard(request):
|
|||||||
).count(),
|
).count(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return render(request, "pobsync_backend/dashboard.html", context)
|
return context
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
|
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
|
@staff_member_required
|
||||||
def run_detail(request, run_id: int):
|
def run_detail(request, run_id: int):
|
||||||
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
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 {}
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
run_stats = result.get("stats") if isinstance(result.get("stats"), 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 {}
|
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_path = _run_rsync_log_path(run)
|
||||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
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,
|
"run": run,
|
||||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
"can_cancel": can_cancel,
|
||||||
|
"can_auto_refresh": can_cancel,
|
||||||
"requested": requested,
|
"requested": requested,
|
||||||
"execution": execution,
|
"execution": execution,
|
||||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
"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),
|
"result_json": _pretty_json(run.result),
|
||||||
}
|
}
|
||||||
return render(request, "pobsync_backend/run_detail.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from pobsync_backend import api, views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
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("changelog/", views.changelog, name="changelog"),
|
||||||
path("self-check/", views.self_check, name="self_check"),
|
path("self-check/", views.self_check, name="self_check"),
|
||||||
path("logs/", views.logs, name="logs"),
|
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("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||||
path("runs/", views.runs_list, name="runs_list"),
|
path("runs/", views.runs_list, name="runs_list"),
|
||||||
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
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>/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>/cancel/", views.cancel_run, name="cancel_run"),
|
||||||
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
||||||
|
|||||||
Reference in New Issue
Block a user