8 Commits

Author SHA1 Message Date
833edb2466 Merge pull request '(release) Prepare pobsync 1.1.0' (#44) from release/1.1.0 into master
Reviewed-on: #44
2026-05-21 15:24:55 +02:00
c7e9e69345 (release) Prepare pobsync 1.1.0
Bump the package and runtime version to 1.1.0, add release notes for
the UI-focused control panel update, and refresh version assertions for
the CLI entrypoint.

Refs 1.1
2026-05-21 15:23:50 +02:00
e79d871f36 Merge pull request 'issue-36-lightweight-live-refresh' (#43) from issue-36-lightweight-live-refresh into master
Reviewed-on: #43
2026-05-21 15:18:46 +02:00
ad45fbe46e (feature) Add live refresh for dashboard status panels
Split dashboard priority and host status sections into server-rendered
partials and wire them into the shared refresh hook so operational state
updates without a full page reload.

Refs #36
2026-05-21 15:17:11 +02:00
3cac7b61ac (feature) Add live refresh for run detail status
Add a server-rendered run detail partial and a small vanilla JavaScript
refresh hook so active backup runs update status, controls, timing, and
rsync log output without a full page reload.

Document the Django-template-first refresh pattern for future control
panel work.

Refs #36
2026-05-21 15:10:37 +02:00
1d6c21764b Merge pull request 'issue-37-clean-primary-navigation' (#42) from issue-37-clean-primary-navigation into master
Reviewed-on: #42
2026-05-21 15:02:02 +02:00
6f392bef65 (ui) Highlight current navigation section
Mark the active primary or system navigation link with aria-current and a
subtle visual state so staff users can see where they are in the control
panel without making Admin a primary app route.

Refs #37
2026-05-21 15:01:14 +02:00
6035c547ae (ui) Split primary and system navigation
Move operator routes into a primary navigation group and demote admin,
self-check, changelog, and status API links into a secondary system group
so the header reflects the control panel workflow more clearly.

Refs #37
2026-05-21 14:56:30 +02:00
15 changed files with 656 additions and 418 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:
```

View File

@@ -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 = [

View File

@@ -1,2 +1,2 @@
__all__ = ["__version__"]
__version__ = "1.0.0"
__version__ = "1.1.0"

View File

@@ -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;
@@ -801,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;
@@ -866,15 +894,20 @@
<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>
@@ -887,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>

View File

@@ -32,136 +32,14 @@
</section>
{% endif %}
<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
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>
{% 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>
<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>
@@ -216,128 +94,13 @@
{% endif %}
</section>
<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
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>
<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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -14,135 +14,13 @@
</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 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
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 %}
@@ -168,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>

View File

@@ -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:

View File

@@ -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"))
@@ -135,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)
@@ -148,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")
@@ -1680,6 +1734,46 @@ class ViewTests(TestCase):
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)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -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

View File

@@ -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"),