8 Commits

Author SHA1 Message Date
01b779c862 (ui) Add schedule overview for dashboard drill-down
Add a staff-only schedules page with filters for host, enabled state, and
prune state, including next run and last scheduler state.

Wire the dashboard Schedules metric to the new overview so all primary
dashboard count cards have useful destinations.

Refs #23
2026-05-21 12:39:57 +02:00
67d1af0baa (ui) Make dashboard operational status actionable
Turn the dashboard operational status rows into direct links to filtered run
lists, so failed, warning, running, and queued states can be investigated from
the first screen.

Also move the hosts anchor back to the actual Hosts section.

Refs #23
2026-05-21 12:00:06 +02:00
4e8e4f75fd (ui) Add dashboard-linked run and snapshot lists
Add staff-only list pages for backup runs and snapshots with practical
filters, then wire the dashboard summary cards and latest-runs panel to
those overviews.

This gives the dashboard real drill-down paths for run and snapshot counts
instead of leaving the data only partially visible on the first screen.

Refs #23
2026-05-21 11:52:35 +02:00
2be2d11b4a Merge pull request 'Create cohesive control panel redesign' (#30) from issue-28-control-panel-redesign into master
Reviewed-on: #30
2026-05-21 11:44:28 +02:00
b67ae7ff8b (ui) Extend page headers across utility views
Apply the shared page-header pattern to configuration, access,
operations, retention, log, and changelog pages so the control panel
uses one consistent title, context, and action structure.

Add representative view assertions for the new page context on utility
pages.

Refs #28
2026-05-21 11:42:01 +02:00
ad2cc5585e (ui) Add consistent page headers to key views
Introduce a shared page-header pattern with kicker, title, subtitle, and
actions, then apply it to the dashboard, host detail, run detail, snapshot
detail, and retention plan pages.

Scope the global app header styles to avoid leaking sticky navigation styles
onto page-level headers, and add view assertions for the new page context.

Refs #28
2026-05-21 11:37:25 +02:00
8aa3f1d1f5 (ui) Establish cohesive control panel styling
Refresh the shared base styling so the Django control panel has a calmer,
more polished production-tool feel across all pages. Update typography,
navigation, panels, metrics, host cards, tables, forms, buttons, messages,
focus states, and responsive behavior through reusable CSS variables and
component styles.

Refs #28
2026-05-21 11:31:49 +02:00
30cf93df27 Merge pull request 'Remove legacy-facing UI labels' (#29)
Reviewed-on: #29
2026-05-21 11:19:48 +02:00
22 changed files with 1092 additions and 322 deletions

View File

@@ -7,79 +7,227 @@
<style>
:root {
color-scheme: light;
--bg: #f5f7fa;
--bg: #f2f5f8;
--bg-soft: #f8fafc;
--panel: #ffffff;
--border: #d9e0e8;
--text: #17202a;
--muted: #657386;
--link: #0b5cad;
--success: #176b3a;
--failed: #a12828;
--panel-subtle: #fbfcfe;
--border: #d8e1eb;
--border-strong: #c7d2df;
--text: #121a24;
--muted: #65758a;
--muted-strong: #46566a;
--link: #075fae;
--link-strong: #064b89;
--success: #17633a;
--failed: #a73333;
--running: #8a5a00;
--queued: #075fae;
--shadow-sm: 0 1px 2px rgba(18, 26, 36, 0.05);
--shadow-md: 0 10px 28px rgba(18, 26, 36, 0.08);
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
a { color: var(--link); text-decoration: none; }
a { color: var(--link); font-weight: 560; text-decoration: none; }
a:hover { text-decoration: underline; }
header {
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(7, 95, 174, 0.24);
outline-offset: 2px;
}
body > header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
box-shadow: var(--shadow-sm);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 20;
}
nav {
display: flex;
align-items: center;
gap: 18px;
gap: 6px;
max-width: 1180px;
margin: 0 auto;
min-height: 48px;
}
nav strong { font-size: 16px; }
nav strong {
font-size: 16px;
margin-right: 10px;
}
nav a {
border-radius: 6px;
color: var(--muted-strong);
padding: 6px 8px;
}
nav a:hover {
background: var(--bg-soft);
color: var(--link-strong);
text-decoration: none;
}
nav strong a {
color: var(--link-strong);
font-weight: 750;
padding-left: 0;
}
nav strong a:hover { background: transparent; }
nav .spacer { flex: 1; }
main {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
padding: 28px 24px 42px;
}
h1 {
font-size: clamp(28px, 3vw, 36px);
letter-spacing: 0;
line-height: 1.15;
margin: 0 0 18px;
}
h2 {
font-size: 18px;
letter-spacing: 0;
line-height: 1.25;
margin: 0 0 14px;
}
h3 {
font-size: 15px;
letter-spacing: 0;
margin: 16px 0 8px;
}
p { margin: 0 0 12px; }
p:last-child { margin-bottom: 0; }
.page-header {
align-items: end;
display: flex;
gap: 18px;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
display: grid;
gap: 5px;
min-width: 0;
}
.page-title h1 { margin-bottom: 0; overflow-wrap: anywhere; }
.page-kicker {
color: var(--muted);
font-size: 12px;
font-weight: 750;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.page-subtitle {
color: var(--muted);
max-width: 760px;
}
.page-header .actions {
flex: 0 0 auto;
justify-content: flex-end;
margin-bottom: 0;
}
h1 { font-size: 26px; margin: 0 0 18px; }
h2 { font-size: 18px; margin: 0 0 12px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
margin-bottom: 22px;
margin-bottom: 20px;
}
.metric, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.metric {
min-height: 88px;
padding: 14px 15px;
}
.metric .label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.metric .value {
font-size: 27px;
font-weight: 760;
line-height: 1.15;
margin-top: 6px;
overflow-wrap: anywhere;
}
.metric { padding: 14px; }
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
.metric.failed { border-color: #e8b4b4; background: #fff7f7; }
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
.metric-link {
color: inherit;
display: block;
text-decoration: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.metric-link:hover {
border-color: #9eb2c8;
box-shadow: var(--shadow);
transform: translateY(-1px);
}
.metric-link:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 2px;
}
.panel {
margin-bottom: 18px;
overflow: auto;
padding: 18px;
}
.panel > h2:first-child {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
}
.panel.highlight { border-left: 4px solid var(--border); }
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
.panel.highlight.warning { border-left-color: var(--running); background: #fffaf0; }
.panel.highlight.success { border-left-color: var(--success); background: #f5fbf7; }
table { width: 100%; border-collapse: collapse; min-width: 640px; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; }
table {
border-collapse: collapse;
min-width: 640px;
width: 100%;
}
th, td {
border-bottom: 1px solid var(--border);
padding: 10px 9px;
text-align: left;
vertical-align: top;
}
th {
background: var(--panel-subtle);
color: var(--muted);
font-size: 11px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
tbody tr:hover td { background: #f9fbfd; }
tr:last-child td { border-bottom: 0; }
.muted { color: var(--muted); }
.status {
display: inline-block;
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
padding: 3px 8px;
white-space: nowrap;
}
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
@@ -88,29 +236,39 @@
.status.blocked { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.status.warning { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; }
.status.queued { color: var(--queued); border-color: #b5cdea; background: #eef6ff; }
.status.skipped { color: var(--muted); background: #f7f9fb; }
.stack { display: grid; gap: 4px; }
.stack { display: grid; gap: 5px; }
.stack.spaced { margin-bottom: 14px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
.actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0 0 20px;
}
.actions.inline { margin: 12px 0 0; }
button, .button-link {
appearance: none;
background: #17202a;
border: 1px solid #17202a;
background: var(--text);
border: 1px solid var(--text);
border-radius: 6px;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 650;
padding: 8px 12px;
font-weight: 700;
line-height: 1.25;
padding: 8px 13px;
}
button:hover, .button-link:hover {
background: #273343;
text-decoration: none;
}
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
button.secondary,
.button-link.secondary {
background: #fff;
border-color: var(--border);
border-color: var(--border-strong);
color: var(--text);
}
button.secondary:hover,
@@ -129,16 +287,32 @@
.status-summary {
align-items: center;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius);
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
padding: 11px 12px;
}
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
.status-summary.warning,
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
a.status-summary {
color: inherit;
text-decoration: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
a.status-summary:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.status-summary .summary-action {
color: var(--muted-strong);
font-size: 12px;
font-weight: 650;
margin-left: auto;
}
.operator-state {
align-items: center;
display: flex;
@@ -191,7 +365,7 @@
.insight-main .value,
.insight-item .value {
font-size: 22px;
font-weight: 650;
font-weight: 760;
overflow-wrap: anywhere;
}
.storage-meter {
@@ -213,9 +387,14 @@
}
.host-card {
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 16px;
}
.host-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
}
.host-card-header {
align-items: start;
display: flex;
@@ -230,7 +409,7 @@
}
.host-card-title a {
font-size: 17px;
font-weight: 650;
font-weight: 750;
overflow-wrap: anywhere;
}
.host-card-status {
@@ -266,10 +445,12 @@
.host-card-stats {
align-content: start;
display: grid;
border-top: 1px solid #e6edf4;
background: var(--bg-soft);
border: 1px solid #e6edf4;
border-radius: var(--radius);
gap: 12px 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: 12px;
padding: 12px;
}
.host-card-item {
display: grid;
@@ -298,7 +479,7 @@
}
.host-card-stat .value {
font-size: 16px;
font-weight: 650;
font-weight: 750;
overflow-wrap: anywhere;
}
.host-card-stat.wide {
@@ -319,28 +500,34 @@
.message {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
padding: 10px 12px;
}
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
.message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); }
.form-grid { display: grid; gap: 14px; max-width: 680px; }
.field { display: grid; gap: 5px; }
.field label { font-weight: 650; }
.form-grid { display: grid; gap: 15px; max-width: 720px; }
.field { display: grid; gap: 6px; }
.field label { font-weight: 700; }
.field input[type="text"], .field input[type="number"], .field select, .field textarea {
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
padding: 8px 10px;
padding: 9px 10px;
width: 100%;
}
.field input[type="text"]:focus,
.field input[type="number"]:focus,
.field select:focus,
.field textarea:focus {
border-color: #8bb9e3;
}
.field textarea { min-height: 92px; resize: vertical; }
.field .helptext { color: var(--muted); font-size: 12px; }
.field input[type="checkbox"] { justify-self: start; }
pre {
background: #101820;
border-radius: 6px;
border-radius: var(--radius);
color: #edf4fb;
line-height: 1.5;
margin: 0;
@@ -356,8 +543,21 @@
padding: 0;
}
@media (max-width: 800px) {
main { padding: 16px; }
nav { padding: 0; }
body > header { padding: 0 14px; position: static; }
main { padding: 18px 14px 32px; }
nav {
align-items: flex-start;
flex-wrap: wrap;
gap: 4px;
padding: 8px 0;
}
nav strong { flex-basis: 100%; margin-right: 0; }
nav .spacer { display: none; }
.page-header {
align-items: stretch;
display: grid;
}
.page-header .actions { justify-content: flex-start; }
.two-col { grid-template-columns: 1fr; }
.host-card-header { display: grid; }
.host-card-status { justify-content: flex-start; max-width: none; }

View File

@@ -3,11 +3,16 @@
{% block title %}Changelog - pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Release notes</div>
<h1>Changelog</h1>
<div class="page-subtitle">Installed release notes rendered from the repository changelog.</div>
</div>
<section class="actions" aria-label="Changelog actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<div class="stack spaced">

View File

@@ -3,12 +3,17 @@
{% block title %}pobsync dashboard{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Control panel</div>
<h1>Dashboard</h1>
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</div>
<section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section>
</header>
{% if not global_config or not counts.hosts %}
<section class="panel">
@@ -28,14 +33,14 @@
{% endif %}
<section class="grid" aria-label="Summary">
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>
<a class="metric metric-link {% if counts.queued_runs %}queued{% endif %}" href="{% url 'runs_list' %}?status=queued"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></a>
<a class="metric metric-link {% if counts.running_runs %}running{% endif %}" href="{% url 'runs_list' %}?status=running"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></a>
<a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&amp;review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&amp;review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
</section>
<section class="panel">
@@ -43,28 +48,32 @@
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
<div class="status-overview">
{% if counts.failed_runs %}
<div class="status-summary failed">
<a class="status-summary failed" href="{% url 'runs_list' %}?status=failed&amp;review=needed">
<span class="status failed">failed</span>
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need{{ counts.failed_runs|pluralize:"s," }} review.</strong>
</div>
<span class="summary-action">Review failed runs</span>
</a>
{% endif %}
{% if counts.warning_runs %}
<div class="status-summary warning">
<a class="status-summary warning" href="{% url 'runs_list' %}?status=warning&amp;review=needed">
<span class="status warning">warning</span>
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
</div>
<span class="summary-action">Review warnings</span>
</a>
{% endif %}
{% if counts.running_runs %}
<div class="status-summary running">
<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>
</div>
<span class="summary-action">View running runs</span>
</a>
{% endif %}
{% if counts.queued_runs %}
<div class="status-summary queued">
<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 for the worker.</strong>
</div>
<span class="summary-action">View queued runs</span>
</a>
{% endif %}
</div>
{% elif counts.hosts %}
@@ -74,7 +83,7 @@
{% endif %}
</section>
<section class="panel">
<section class="panel" id="hosts">
<h2>Backup Trends</h2>
{% if stats_summary.runs_sampled %}
<div class="insight-grid" aria-label="Backup trends">
@@ -261,7 +270,7 @@
</section>
<section class="panel">
<h2>Latest Runs</h2>
<h2>Latest Runs <a class="button-link secondary" href="{% url 'runs_list' %}">View all</a></h2>
<table>
<thead>
<tr>

View File

@@ -3,11 +3,16 @@
{% block title %}Global Config{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Configuration</div>
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
<div class="page-subtitle">Defaults used by hosts unless a host overrides them explicitly.</div>
</div>
<section class="actions" aria-label="Global config actions">
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>

View File

@@ -3,8 +3,12 @@
{% block title %}{{ host.host }} | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Host</div>
<h1>{{ host.host }}</h1>
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
</div>
<section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
@@ -26,6 +30,7 @@
<button type="submit" class="secondary">Run connection preflight</button>
</form>
</section>
</header>
<section class="grid" aria-label="Host summary">
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>

View File

@@ -3,8 +3,12 @@
{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Configuration</div>
<h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
<div class="page-subtitle">Host-specific backup, retention, SSH, include, and exclude settings.</div>
</div>
<section class="actions" aria-label="Config actions">
{% if host %}
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
@@ -12,6 +16,7 @@
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
{% endif %}
</section>
</header>
<section class="panel">
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>

View File

@@ -3,11 +3,16 @@
{% block title %}Logs | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Operations</div>
<h1>Logs</h1>
<div class="page-subtitle">Filter pobsync service logs by unit, priority, host, run, or message content.</div>
</div>
<section class="actions" aria-label="Log actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filter</h2>

View File

@@ -3,11 +3,16 @@
{% block title %}Purged Snapshots | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Retention</div>
<h1>Purged Snapshots</h1>
<div class="page-subtitle">Audit trail for snapshots removed by retention or manual purge actions.</div>
</div>
<section class="actions" aria-label="Purged snapshot actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filters</h2>

View File

@@ -3,8 +3,12 @@
{% block title %}Retention plan | {{ host.host }}{% endblock %}
{% block content %}
<h1>Retention Plan: {{ host.host }}</h1>
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Retention</div>
<h1>{{ host.host }}</h1>
<div class="page-subtitle">Preview which snapshots stay, which would be deleted, and whether incomplete cleanup is needed.</div>
</div>
<section class="actions" aria-label="Retention filters">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
@@ -12,6 +16,7 @@
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
</section>
</header>
<section class="grid" aria-label="Retention plan summary">
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>

View File

@@ -3,8 +3,12 @@
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Backup run</div>
<h1>Run {{ run.id }}</h1>
<div class="page-subtitle">{{ run.host.host }} · {{ run.run_type }} · {{ run.status }}</div>
</div>
<section class="actions" aria-label="Run actions">
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
{% if can_cancel %}
@@ -22,6 +26,7 @@
{% endif %}
{% endif %}
</section>
</header>
<section class="grid" aria-label="Run summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>

View File

@@ -0,0 +1,106 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Runs | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Activity</div>
<h1>Runs</h1>
<div class="page-subtitle">Review queued, running, completed, warning, failed, and cancelled backup runs.</div>
</div>
<section class="actions" aria-label="Run list actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filters</h2>
<form method="get" class="form-grid">
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">All statuses</option>
{% for value, label in statuses %}
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="type">Type</label>
<select id="type" name="type">
<option value="">All types</option>
{% for value, label in run_types %}
<option value="{{ value }}" {% if selected_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="host">Host</label>
<select id="host" name="host">
<option value="">All hosts</option>
{% for host in hosts %}
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="review">Review</label>
<select id="review" name="review">
<option value="">All review states</option>
<option value="needed" {% if selected_review == "needed" %}selected{% endif %}>Needs review</option>
<option value="reviewed" {% if selected_review == "reviewed" %}selected{% endif %}>Reviewed</option>
</select>
</div>
<div class="actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'runs_list' %}">Clear</a>
</div>
</form>
</section>
<section class="panel">
<h2>Backup Runs</h2>
<p class="muted">Showing up to 200 of {{ total_count }} run{{ total_count|pluralize }}.</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Host</th>
<th>Status</th>
<th>Type</th>
<th>Created</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
<th>Review</th>
</tr>
</thead>
<tbody>
{% for run in runs %}
<tr>
<td><a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a></td>
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
<td><span class="status {{ run.status }}">{{ run.status }}</span></td>
<td>{{ run.run_type }}</td>
<td>{{ run.created_at }}</td>
<td>{{ run.started_at|default:"" }}</td>
<td>{{ run.ended_at|default:"" }}</td>
<td>
{% if run.snapshot %}
<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>
{% elif run.snapshot_path %}
<span class="muted">{{ run.snapshot_path }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td>{% if run.reviewed_at %}reviewed{% elif run.status == "failed" or run.status == "warning" %}<span class="status warning">needed</span>{% else %}<span class="muted">none</span>{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="9" class="muted">No runs matched the current filter.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -3,11 +3,16 @@
{% block title %}Schedule | {{ host.host }}{% endblock %}
{% block content %}
<h1>Schedule: {{ host.host }}</h1>
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Schedule</div>
<h1>{{ host.host }}</h1>
<div class="page-subtitle">Automatic backup timing and scheduled prune behavior for this host.</div>
</div>
<section class="actions" aria-label="Schedule actions">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
</section>
</header>
<section class="panel">
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2>

View File

@@ -0,0 +1,101 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Schedules | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Scheduler</div>
<h1>Schedules</h1>
<div class="page-subtitle">Review configured backup schedules, next run times, prune settings, and recent scheduler state.</div>
</div>
<section class="actions" aria-label="Schedule list actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filters</h2>
<form method="get" class="form-grid">
<div class="field">
<label for="host">Host</label>
<select id="host" name="host">
<option value="">All hosts</option>
{% for host in hosts %}
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="enabled">Enabled</label>
<select id="enabled" name="enabled">
<option value="">All schedules</option>
<option value="yes" {% if selected_enabled == "yes" %}selected{% endif %}>Enabled</option>
<option value="no" {% if selected_enabled == "no" %}selected{% endif %}>Disabled</option>
</select>
</div>
<div class="field">
<label for="prune">Prune</label>
<select id="prune" name="prune">
<option value="">All prune states</option>
<option value="yes" {% if selected_prune == "yes" %}selected{% endif %}>Prune enabled</option>
<option value="no" {% if selected_prune == "no" %}selected{% endif %}>Prune disabled</option>
</select>
</div>
<div class="actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'schedules_list' %}">Clear</a>
</div>
</form>
</section>
<section class="panel">
<h2>Configured Schedules</h2>
<p class="muted">Showing up to 200 of {{ total_count }} schedule{{ total_count|pluralize }}. Times use {{ scheduler_timezone }}.</p>
<table>
<thead>
<tr>
<th>Host</th>
<th>Expression</th>
<th>Enabled</th>
<th>Next Run</th>
<th>Prune</th>
<th>Last Status</th>
<th>Last Started</th>
<th>Last Finished</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for row in schedule_rows %}
{% with schedule=row.schedule %}
<tr>
<td><a href="{% url 'host_detail' schedule.host.host %}">{{ schedule.host.host }}</a></td>
<td><code>{{ schedule.cron_expr }}</code></td>
<td><span class="status {% if schedule.enabled %}ok{% else %}skipped{% endif %}">{{ schedule.enabled|yesno:"enabled,disabled" }}</span></td>
<td>
{% if row.next_run_at %}
{{ row.next_run_at|date:"Y-m-d H:i T" }}
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td>
<span class="status {% if schedule.prune %}ok{% else %}skipped{% endif %}">{{ schedule.prune|yesno:"enabled,disabled" }}</span>
{% if schedule.prune %}
<div class="muted">max {{ schedule.prune_max_delete }}{% if schedule.prune_protect_bases %}, protects bases{% endif %}</div>
{% endif %}
</td>
<td>{% if schedule.last_status %}<span class="status {{ schedule.last_status }}">{{ schedule.last_status }}</span>{% else %}<span class="muted">none</span>{% endif %}</td>
<td>{{ schedule.last_started_at|default:"" }}</td>
<td>{{ schedule.last_finished_at|default:"" }}</td>
<td><a class="button-link secondary" href="{% url 'edit_host_schedule' schedule.host.host %}">Edit</a></td>
</tr>
{% endwith %}
{% empty %}
<tr><td colspan="9" class="muted">No schedules matched the current filter.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -3,11 +3,16 @@
{% block title %}Self Check | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Operations</div>
<h1>Self Check</h1>
<div class="page-subtitle">Runtime, filesystem, service, and configuration checks for this pobsync installation.</div>
</div>
<section class="actions" aria-label="Self check actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="grid" aria-label="Self check summary">
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div>

View File

@@ -3,11 +3,16 @@
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Snapshot</div>
<h1>{{ snapshot.dirname }}</h1>
<div class="page-subtitle">{{ snapshot.host.host }} · {{ snapshot.kind }} · {{ snapshot.status }}</div>
</div>
<section class="actions" aria-label="Snapshot actions">
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
</section>
</header>
<section class="grid" aria-label="Snapshot summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>

View File

@@ -0,0 +1,96 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Snapshots | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Snapshots</div>
<h1>Snapshots</h1>
<div class="page-subtitle">Browse discovered scheduled, manual, and incomplete snapshots across all hosts.</div>
</div>
<section class="actions" aria-label="Snapshot list actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filters</h2>
<form method="get" class="form-grid">
<div class="field">
<label for="host">Host</label>
<select id="host" name="host">
<option value="">All hosts</option>
{% for host in hosts %}
<option value="{{ host.host }}" {% if selected_host == host.host %}selected{% endif %}>{{ host.host }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="kind">Kind</label>
<select id="kind" name="kind">
<option value="">All kinds</option>
{% for value, label in kinds %}
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">All statuses</option>
{% for value in statuses %}
<option value="{{ value }}" {% if selected_status == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</div>
<div class="actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'snapshots_list' %}">Clear</a>
</div>
</form>
</section>
<section class="panel">
<h2>Snapshot Records</h2>
<p class="muted">Showing up to 200 of {{ total_count }} snapshot{{ total_count|pluralize }}.</p>
<table>
<thead>
<tr>
<th>Snapshot</th>
<th>Host</th>
<th>Kind</th>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Base</th>
<th>Path</th>
</tr>
</thead>
<tbody>
{% for snapshot in snapshots %}
<tr>
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
<td><a href="{% url 'host_detail' snapshot.host.host %}">{{ snapshot.host.host }}</a></td>
<td>{{ snapshot.kind }}</td>
<td>{% if snapshot.status %}<span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>{% else %}<span class="muted">unknown</span>{% endif %}</td>
<td>{{ snapshot.started_at|default:"" }}</td>
<td>{{ snapshot.ended_at|default:"" }}</td>
<td>
{% if snapshot.base %}
<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>
{% elif snapshot.base_dirname %}
<span class="muted">{{ snapshot.base_dirname }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td class="muted">{{ snapshot.path }}</td>
</tr>
{% empty %}
<tr><td colspan="8" class="muted">No snapshots matched the current filter.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -3,11 +3,16 @@
{% block title %}{% if credential %}SSH Key | {{ credential.name }}{% else %}New SSH Key{% endif %} | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Access</div>
<h1>{% if credential %}SSH Key: {{ credential.name }}{% else %}New SSH Key{% endif %}</h1>
<div class="page-subtitle">{% if credential %}Review key metadata, known hosts, and deletion safety for this credential.{% else %}Register an existing private key for use by pobsync backups.{% endif %}</div>
</div>
<section class="actions" aria-label="SSH key form actions">
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
</section>
</header>
<section class="panel">
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>

View File

@@ -3,11 +3,16 @@
{% block title %}Generate SSH Key | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Access</div>
<h1>Generate SSH Key</h1>
<div class="page-subtitle">Create a pobsync-managed SSH key pair for one or more backup targets.</div>
</div>
<section class="actions" aria-label="SSH key form actions">
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
</section>
</header>
<section class="panel">
<h2>Create Key Pair</h2>

View File

@@ -3,13 +3,18 @@
{% block title %}SSH Keys | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Access</div>
<h1>SSH Keys</h1>
<div class="page-subtitle">Manage the key pairs pobsync uses to reach backup targets.</div>
</div>
<section class="actions" aria-label="SSH key actions">
<a class="button-link" href="{% url 'generate_ssh_credential' %}">Generate SSH key</a>
<a class="button-link secondary" href="{% url 'create_ssh_credential' %}">Add existing key</a>
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Credentials</h2>

View File

@@ -101,6 +101,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Control panel")
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
self.assertContains(response, "Dashboard")
self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
@@ -123,6 +125,17 @@ class ViewTests(TestCase):
self.assertContains(response, "1 run completed with warnings.")
self.assertContains(response, "1 backup run in progress.")
self.assertContains(response, "1 backup run waiting for the worker.")
self.assertContains(response, "Review failed runs")
self.assertContains(response, "Review warnings")
self.assertContains(response, "View running runs")
self.assertContains(response, "View queued runs")
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)
self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&amp;review=needed"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&amp;review=needed"', html=False)
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
def test_dashboard_renders_backup_trend_summary(self) -> None:
self.client.force_login(self.staff_user)
@@ -198,6 +211,63 @@ class ViewTests(TestCase):
self.assertContains(response, "Operational Status")
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
def test_runs_list_filters_by_status_and_review(self) -> None:
self.client.force_login(self.staff_user)
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
failed = BackupRun.objects.create(host=web, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL)
success = BackupRun.objects.create(host=db, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED)
BackupRun.objects.create(
host=web,
status=BackupRun.Status.WARNING,
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
response = self.client.get(reverse("runs_list"), {"status": "failed", "review": "needed"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Runs")
self.assertContains(response, "Review queued, running, completed")
self.assertContains(response, f"Run {failed.id}")
self.assertContains(response, "web-01")
self.assertContains(response, "needed")
self.assertNotContains(response, f"Run {success.id}")
def test_snapshots_list_filters_by_host_and_kind(self) -> None:
self.client.force_login(self.staff_user)
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
manual = self._snapshot(web, "20260519-021500Z__MANUAL01", kind=SnapshotRecord.Kind.MANUAL)
scheduled = self._snapshot(db, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
response = self.client.get(reverse("snapshots_list"), {"host": web.host, "kind": SnapshotRecord.Kind.MANUAL})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Snapshots")
self.assertContains(response, "Browse discovered scheduled, manual, and incomplete snapshots")
self.assertContains(response, manual.dirname)
self.assertContains(response, "web-01")
self.assertNotContains(response, scheduled.dirname)
def test_schedules_list_filters_by_enabled_and_prune(self) -> None:
self.client.force_login(self.staff_user)
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True, last_status="success")
ScheduleConfig.objects.create(host=db, cron_expr="30 3 * * *", enabled=False, prune=False, last_status="failed")
response = self.client.get(reverse("schedules_list"), {"enabled": "yes", "prune": "yes"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Schedules")
self.assertContains(response, "Review configured backup schedules")
self.assertContains(response, "web-01")
self.assertContains(response, "15 2 * * *")
self.assertContains(response, "success")
self.assertContains(response, "UTC")
self.assertNotContains(response, "30 3 * * *")
def test_dashboard_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(
@@ -293,6 +363,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Self Check")
self.assertContains(response, "Runtime, filesystem, service, and configuration checks")
self.assertContains(response, "Django debug")
self.assertContains(response, "Database connection")
self.assertContains(response, "State root")
@@ -327,6 +398,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Logs")
self.assertContains(response, "Filter pobsync service logs")
self.assertContains(response, "web-01 failed backup run 12")
self.assertNotContains(response, "web-02 failed backup run 12")
self.assertNotContains(response, "started")
@@ -356,6 +428,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Purged Snapshots")
self.assertContains(response, "Audit trail for snapshots removed")
self.assertContains(response, "20260518-021500Z__OLDSNAP")
self.assertContains(response, "outside retention policy")
self.assertContains(response, "Scheduled")
@@ -410,6 +483,7 @@ class ViewTests(TestCase):
)
self.assertRedirects(response, reverse("ssh_credentials"))
self.assertContains(response, "Manage the key pairs pobsync uses")
self.assertContains(response, "SSH credential saved for backup-key.")
self.assertContains(response, "backup-key")
credential = SshCredential.objects.get(name="backup-key")
@@ -639,6 +713,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("edit_global_config"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Defaults used by hosts unless a host overrides them")
self.assertContains(response, f'value="{credential.id}" selected')
self.assertContains(response, "--archive")
self.assertContains(response, "/proc/***")
@@ -869,6 +944,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Host")
self.assertContains(response, "web-01.example.test")
self.assertContains(response, "Effective Config")
self.assertContains(response, "Backup source:")
self.assertNotContains(response, "Source root:")
@@ -1425,6 +1502,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Backup run")
self.assertContains(response, "web-01")
self.assertContains(response, "Failure")
self.assertContains(response, "transport")
self.assertContains(response, "Check network connectivity.")
@@ -1615,6 +1694,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("snapshot_detail", args=[base.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Snapshot")
self.assertContains(response, base.dirname)
self.assertContains(response, "BASESNAP")
self.assertContains(response, "Stats")
@@ -1697,7 +1777,9 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Retention Plan: web-01")
self.assertContains(response, "Retention")
self.assertContains(response, "Preview which snapshots stay")
self.assertContains(response, "web-01")
self.assertContains(response, old_snapshot.dirname)
self.assertContains(response, new_snapshot.dirname)
self.assertContains(response, "newest")
@@ -2022,6 +2104,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("edit_host_schedule", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Automatic backup timing and scheduled prune behavior")
self.assertContains(response, "Create Schedule")
self.assertContains(response, "Schedule expression")
self.assertContains(response, "evaluated by the pobsync scheduler service")
@@ -2208,13 +2291,19 @@ class ViewTests(TestCase):
self.assertEqual(host.excludes_add, [])
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
def _snapshot(
self,
host: HostConfig,
dirname: str,
*,
kind: str = SnapshotRecord.Kind.SCHEDULED,
) -> SnapshotRecord:
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
return SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.SCHEDULED,
kind=kind,
dirname=dirname,
path=f"/backups/{host.host}/scheduled/{dirname}",
path=f"/backups/{host.host}/{kind}/{dirname}",
status="success",
started_at=started_at,
)

View File

@@ -145,6 +145,102 @@ def logs(request):
return render(request, "pobsync_backend/logs.html", context)
@staff_member_required
def runs_list(request):
status = request.GET.get("status", "").strip()
run_type = request.GET.get("type", "").strip()
host = request.GET.get("host", "").strip()
review = request.GET.get("review", "").strip()
runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")
if status:
runs = runs.filter(status=status)
if run_type:
runs = runs.filter(run_type=run_type)
if host:
runs = runs.filter(host__host=host)
if review == "needed":
runs = runs.filter(status__in=[BackupRun.Status.FAILED, BackupRun.Status.WARNING], reviewed_at__isnull=True)
elif review == "reviewed":
runs = runs.filter(reviewed_at__isnull=False)
context = {
"runs": runs[:200],
"total_count": runs.count(),
"hosts": HostConfig.objects.order_by("host"),
"statuses": BackupRun.Status.choices,
"run_types": BackupRun.RunType.choices,
"selected_status": status,
"selected_type": run_type,
"selected_host": host,
"selected_review": review,
}
return render(request, "pobsync_backend/runs_list.html", context)
@staff_member_required
def snapshots_list(request):
kind = request.GET.get("kind", "").strip()
status = request.GET.get("status", "").strip()
host = request.GET.get("host", "").strip()
snapshots = SnapshotRecord.objects.select_related("host", "base").order_by("-started_at", "-discovered_at", "-id")
if kind:
snapshots = snapshots.filter(kind=kind)
if status:
snapshots = snapshots.filter(status=status)
if host:
snapshots = snapshots.filter(host__host=host)
context = {
"snapshots": snapshots[:200],
"total_count": snapshots.count(),
"hosts": HostConfig.objects.order_by("host"),
"kinds": SnapshotRecord.Kind.choices,
"statuses": SnapshotRecord.objects.exclude(status="").order_by("status").values_list("status", flat=True).distinct(),
"selected_kind": kind,
"selected_status": status,
"selected_host": host,
}
return render(request, "pobsync_backend/snapshots_list.html", context)
@staff_member_required
def schedules_list(request):
enabled = request.GET.get("enabled", "").strip()
prune = request.GET.get("prune", "").strip()
host = request.GET.get("host", "").strip()
schedules = ScheduleConfig.objects.select_related("host").order_by("host__host")
if enabled == "yes":
schedules = schedules.filter(enabled=True)
elif enabled == "no":
schedules = schedules.filter(enabled=False)
if prune == "yes":
schedules = schedules.filter(prune=True)
elif prune == "no":
schedules = schedules.filter(prune=False)
if host:
schedules = schedules.filter(host__host=host)
schedule_rows = []
for schedule in schedules[:200]:
schedule_rows.append(
{
"schedule": schedule,
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
}
)
context = {
"schedule_rows": schedule_rows,
"total_count": schedules.count(),
"hosts": HostConfig.objects.order_by("host"),
"selected_enabled": enabled,
"selected_prune": prune,
"selected_host": host,
"scheduler_timezone": timezone.get_current_timezone_name(),
}
return render(request, "pobsync_backend/schedules_list.html", context)
@staff_member_required
def purged_snapshots(request):
host = request.GET.get("host", "").strip()

View File

@@ -12,6 +12,7 @@ urlpatterns = [
path("self-check/", views.self_check, name="self_check"),
path("logs/", views.logs, name="logs"),
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
path("schedules/", views.schedules_list, name="schedules_list"),
path("config/global/", views.edit_global_config, name="edit_global_config"),
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
@@ -34,6 +35,7 @@ urlpatterns = [
name="cleanup_host_incomplete_snapshots",
),
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>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
@@ -43,6 +45,7 @@ urlpatterns = [
views.resolve_host_incomplete_reviews,
name="resolve_host_incomplete_reviews",
),
path("snapshots/", views.snapshots_list, name="snapshots_list"),
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
path("api/", api.api_index),
path("api/status/", api.status),