5 Commits

Author SHA1 Message Date
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
01c4ccb316 (ui) Remove legacy-facing labels from operator pages
Replace refactor-era wording such as Source, Source root, SQL records,
database, runtime, and Django generation labels with operator-facing copy
around backup source, tracking records, changelog files, active config, and
pobsync-managed SSH keys.

Add view assertions so the old source/SQL labels do not quietly return.

Refs #24
2026-05-21 11:13:10 +02:00
19 changed files with 580 additions and 313 deletions

View File

@@ -192,7 +192,7 @@ class SshCredentialForm(forms.ModelForm):
if not raw_private_key.strip(): if not raw_private_key.strip():
if self.instance and self.instance.pk and self.instance.key_path: if self.instance and self.instance.pk and self.instance.key_path:
return self.instance.private_key return self.instance.private_key
raise forms.ValidationError("Paste a private key, upload a private key file, or generate a key from Django.") raise forms.ValidationError("Paste a private key, upload a private key file, or generate a key in pobsync.")
private_key = normalize_private_key(raw_private_key) private_key = normalize_private_key(raw_private_key)
public_key = validate_ssh_private_key(private_key) public_key = validate_ssh_private_key(private_key)

View File

@@ -266,13 +266,13 @@ def _config_checks() -> list[SelfCheck]:
message = "Default global config exists." message = "Default global config exists."
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT: if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
status = "warning" status = "warning"
message = "Global config backup root differs from the runtime backup root." message = "Saved backup root differs from the active backup root."
return [ return [
SelfCheck( SelfCheck(
"Global config", "Global config",
status, status,
message, message,
f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}", f"saved={global_config.backup_root} active={settings.POBSYNC_BACKUP_ROOT}",
) )
] ]

View File

@@ -7,79 +7,212 @@
<style> <style>
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f5f7fa; --bg: #f2f5f8;
--bg-soft: #f8fafc;
--panel: #ffffff; --panel: #ffffff;
--border: #d9e0e8; --panel-subtle: #fbfcfe;
--text: #17202a; --border: #d8e1eb;
--muted: #657386; --border-strong: #c7d2df;
--link: #0b5cad; --text: #121a24;
--success: #176b3a; --muted: #65758a;
--failed: #a12828; --muted-strong: #46566a;
--link: #075fae;
--link-strong: #064b89;
--success: #17633a;
--failed: #a73333;
--running: #8a5a00; --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; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--text); 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; } 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); background: var(--panel);
border-bottom: 1px solid var(--border); 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 { nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 18px; gap: 6px;
max-width: 1180px; max-width: 1180px;
margin: 0 auto; 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; } nav .spacer { flex: 1; }
main { main {
max-width: 1180px; max-width: 1180px;
margin: 0 auto; 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 { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px; gap: 12px;
margin-bottom: 22px; margin-bottom: 20px;
} }
.metric, .panel { .metric, .panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); 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.failed { border-color: #e8b4b4; background: #fff7f7; }
.metric.warning { border-color: #e7cf8a; background: #fffaf0; } .metric.warning { border-color: #e7cf8a; background: #fffaf0; }
.metric.running { border-color: #e7cf8a; background: #fffaf0; } .metric.running { border-color: #e7cf8a; background: #fffaf0; }
.metric.queued { border-color: #b5cdea; background: #eef6ff; } .metric.queued { border-color: #b5cdea; background: #eef6ff; }
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; } .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 { border-left: 4px solid var(--border); }
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; } .panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
.panel.highlight.warning { border-left-color: var(--running); background: #fffaf0; } .panel.highlight.warning { border-left-color: var(--running); background: #fffaf0; }
.panel.highlight.success { border-left-color: var(--success); background: #f5fbf7; } .panel.highlight.success { border-left-color: var(--success); background: #f5fbf7; }
table { width: 100%; border-collapse: collapse; min-width: 640px; } table {
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; } border-collapse: collapse;
th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; } 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; } tr:last-child td { border-bottom: 0; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.status { .status {
display: inline-block; display: inline-block;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 999px; border-radius: 999px;
padding: 2px 8px; font-size: 12px;
font-weight: 700;
line-height: 1.35;
padding: 3px 8px;
white-space: nowrap; white-space: nowrap;
} }
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; } .status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
@@ -88,29 +221,39 @@
.status.blocked { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; } .status.blocked { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; } .status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.status.warning { 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; } .status.skipped { color: var(--muted); background: #f7f9fb; }
.stack { display: grid; gap: 4px; } .stack { display: grid; gap: 5px; }
.stack.spaced { margin-bottom: 14px; } .stack.spaced { margin-bottom: 14px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .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; } .actions.inline { margin: 12px 0 0; }
button, .button-link { button, .button-link {
appearance: none; appearance: none;
background: #17202a; background: var(--text);
border: 1px solid #17202a; border: 1px solid var(--text);
border-radius: 6px; border-radius: 6px;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-weight: 650; font-weight: 700;
padding: 8px 12px; 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.secondary,
.button-link.secondary { .button-link.secondary {
background: #fff; background: #fff;
border-color: var(--border); border-color: var(--border-strong);
color: var(--text); color: var(--text);
} }
button.secondary:hover, button.secondary:hover,
@@ -129,11 +272,11 @@
.status-summary { .status-summary {
align-items: center; align-items: center;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 10px; padding: 11px 12px;
} }
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); } .status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
.status-summary.warning, .status-summary.warning,
@@ -191,7 +334,7 @@
.insight-main .value, .insight-main .value,
.insight-item .value { .insight-item .value {
font-size: 22px; font-size: 22px;
font-weight: 650; font-weight: 760;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.storage-meter { .storage-meter {
@@ -213,9 +356,14 @@
} }
.host-card { .host-card {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 16px; padding: 16px;
} }
.host-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
}
.host-card-header { .host-card-header {
align-items: start; align-items: start;
display: flex; display: flex;
@@ -230,7 +378,7 @@
} }
.host-card-title a { .host-card-title a {
font-size: 17px; font-size: 17px;
font-weight: 650; font-weight: 750;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.host-card-status { .host-card-status {
@@ -266,10 +414,12 @@
.host-card-stats { .host-card-stats {
align-content: start; align-content: start;
display: grid; display: grid;
border-top: 1px solid #e6edf4; background: var(--bg-soft);
border: 1px solid #e6edf4;
border-radius: var(--radius);
gap: 12px 18px; gap: 12px 18px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: 12px; padding: 12px;
} }
.host-card-item { .host-card-item {
display: grid; display: grid;
@@ -298,7 +448,7 @@
} }
.host-card-stat .value { .host-card-stat .value {
font-size: 16px; font-size: 16px;
font-weight: 650; font-weight: 750;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.host-card-stat.wide { .host-card-stat.wide {
@@ -319,28 +469,34 @@
.message { .message {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius);
padding: 10px 12px; padding: 10px 12px;
} }
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); } .message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); } .message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
.message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); } .message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); }
.form-grid { display: grid; gap: 14px; max-width: 680px; } .form-grid { display: grid; gap: 15px; max-width: 720px; }
.field { display: grid; gap: 5px; } .field { display: grid; gap: 6px; }
.field label { font-weight: 650; } .field label { font-weight: 700; }
.field input[type="text"], .field input[type="number"], .field select, .field textarea { .field input[type="text"], .field input[type="number"], .field select, .field textarea {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font: inherit; font: inherit;
padding: 8px 10px; padding: 9px 10px;
width: 100%; 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 textarea { min-height: 92px; resize: vertical; }
.field .helptext { color: var(--muted); font-size: 12px; } .field .helptext { color: var(--muted); font-size: 12px; }
.field input[type="checkbox"] { justify-self: start; } .field input[type="checkbox"] { justify-self: start; }
pre { pre {
background: #101820; background: #101820;
border-radius: 6px; border-radius: var(--radius);
color: #edf4fb; color: #edf4fb;
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
@@ -356,8 +512,21 @@
padding: 0; padding: 0;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
main { padding: 16px; } body > header { padding: 0 14px; position: static; }
nav { padding: 0; } 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; } .two-col { grid-template-columns: 1fr; }
.host-card-header { display: grid; } .host-card-header { display: grid; }
.host-card-status { justify-content: flex-start; max-width: none; } .host-card-status { justify-content: flex-start; max-width: none; }

View File

@@ -3,16 +3,21 @@
{% block title %}Changelog - pobsync{% endblock %} {% block title %}Changelog - pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Release notes</div>
<h1>Changelog</h1> <h1>Changelog</h1>
<div class="page-subtitle">Installed release notes rendered from the repository changelog.</div>
</div>
<section class="actions" aria-label="Changelog actions"> <section class="actions" aria-label="Changelog actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a> <a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<div class="stack spaced"> <div class="stack spaced">
<div><strong>Installed version:</strong> {{ app_version }}</div> <div><strong>Installed version:</strong> {{ app_version }}</div>
<div class="muted">Source: {{ changelog_path }}</div> <div class="muted">Changelog file: {{ changelog_path }}</div>
{% if missing %} {% if missing %}
<div class="status warning">missing</div> <div class="status warning">missing</div>
{% endif %} {% endif %}

View File

@@ -3,12 +3,17 @@
{% block title %}pobsync dashboard{% endblock %} {% block title %}pobsync dashboard{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Control panel</div>
<h1>Dashboard</h1> <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"> <section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a> <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> <a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section> </section>
</header>
{% if not global_config or not counts.hosts %} {% if not global_config or not counts.hosts %}
<section class="panel"> <section class="panel">

View File

@@ -3,17 +3,22 @@
{% block title %}Global Config{% endblock %} {% block title %}Global Config{% endblock %}
{% block content %} {% 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> <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"> <section class="actions" aria-label="Global config actions">
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a> <a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2> <h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
<div class="stack spaced"> <div class="stack spaced">
<div><strong>Backup root:</strong> {{ backup_root }}</div> <div><strong>Backup root:</strong> {{ backup_root }}</div>
<div class="muted">This path comes from the runtime environment and is written back when the config is saved.</div> <div class="muted">This path is managed by the service environment and is saved with the config.</div>
</div> </div>
<form method="post" class="form-grid"> <form method="post" class="form-grid">
{% csrf_token %} {% csrf_token %}

View File

@@ -3,8 +3,12 @@
{% block title %}{{ host.host }} | pobsync{% endblock %} {% block title %}{{ host.host }} | pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Host</div>
<h1>{{ host.host }}</h1> <h1>{{ host.host }}</h1>
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
</div>
<section class="actions" aria-label="Host actions"> <section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a> <a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<form method="post" action="{% url 'discover_host_snapshots' host.host %}"> <form method="post" action="{% url 'discover_host_snapshots' host.host %}">
@@ -26,6 +30,7 @@
<button type="submit" class="secondary">Run connection preflight</button> <button type="submit" class="secondary">Run connection preflight</button>
</form> </form>
</section> </section>
</header>
<section class="grid" aria-label="Host summary"> <section class="grid" aria-label="Host summary">
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div> <div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
@@ -44,7 +49,7 @@
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div> <div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
<div><strong>SSH key:</strong> {{ host.ssh_credential|default:"global default" }}</div> <div><strong>SSH key:</strong> {{ host.ssh_credential|default:"global default" }}</div>
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div> <div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
<div><strong>Source:</strong> {{ host.source_root|default:"global default" }}</div> <div><strong>Backup source:</strong> {{ host.source_root|default:"global default" }}</div>
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div> <div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
</div> </div>
</section> </section>
@@ -101,7 +106,7 @@
<h2>Effective Config</h2> <h2>Effective Config</h2>
<div class="two-col"> <div class="two-col">
<div class="stack"> <div class="stack">
<div><strong>Source root:</strong> {{ effective_config.source_root }}</div> <div><strong>Backup source:</strong> {{ effective_config.source_root }}</div>
<div><strong>Destination subdir:</strong> {{ effective_config.destination_subdir|default:"none" }}</div> <div><strong>Destination subdir:</strong> {{ effective_config.destination_subdir|default:"none" }}</div>
<div><strong>SSH:</strong> {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</div> <div><strong>SSH:</strong> {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</div>
<div><strong>SSH key:</strong> {{ effective_config.ssh.credential|default:"none selected" }}</div> <div><strong>SSH key:</strong> {{ effective_config.ssh.credential|default:"none selected" }}</div>
@@ -237,7 +242,7 @@
<div class="stack spaced"> <div class="stack spaced">
<div><strong>Status:</strong> <span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">{% if last_preflight.ok %}ok{% else %}failed{% endif %}</span></div> <div><strong>Status:</strong> <span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">{% if last_preflight.ok %}ok{% else %}failed{% endif %}</span></div>
<div><strong>Target:</strong> {{ last_preflight.target }}</div> <div><strong>Target:</strong> {{ last_preflight.target }}</div>
<div><strong>Source root:</strong> {{ last_preflight.source_root }}</div> <div><strong>Backup source:</strong> {{ last_preflight.source_root }}</div>
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</div> <div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</div>
</div> </div>
<table> <table>

View File

@@ -3,8 +3,12 @@
{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %} {% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
{% block content %} {% 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> <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"> <section class="actions" aria-label="Config actions">
{% if host %} {% if host %}
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a> <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> <a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
{% endif %} {% endif %}
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2> <h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>

View File

@@ -3,11 +3,16 @@
{% block title %}Logs | pobsync{% endblock %} {% block title %}Logs | pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Operations</div>
<h1>Logs</h1> <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"> <section class="actions" aria-label="Log actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a> <a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>Filter</h2> <h2>Filter</h2>

View File

@@ -3,11 +3,16 @@
{% block title %}Purged Snapshots | pobsync{% endblock %} {% block title %}Purged Snapshots | pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Retention</div>
<h1>Purged Snapshots</h1> <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"> <section class="actions" aria-label="Purged snapshot actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a> <a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>Filters</h2> <h2>Filters</h2>

View File

@@ -3,8 +3,12 @@
{% block title %}Retention plan | {{ host.host }}{% endblock %} {% block title %}Retention plan | {{ host.host }}{% endblock %}
{% block content %} {% 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"> <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_detail' host.host %}">Back to host</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a> <a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
@@ -12,9 +16,9 @@
<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=all">All</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a> <a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
</section> </section>
</header>
<section class="grid" aria-label="Retention plan summary"> <section class="grid" aria-label="Retention plan summary">
<div class="metric"><div class="label">Source</div><div class="value">{{ plan.source }}</div></div>
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div> <div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>
<div class="metric"><div class="label">Keep</div><div class="value">{{ plan.keep|length }}</div></div> <div class="metric"><div class="label">Keep</div><div class="value">{{ plan.keep|length }}</div></div>
<div class="metric"><div class="label">Would Delete</div><div class="value">{{ plan.delete|length }}</div></div> <div class="metric"><div class="label">Would Delete</div><div class="value">{{ plan.delete|length }}</div></div>
@@ -42,7 +46,7 @@
</p> </p>
<p> <p>
After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their
SQL records. Successful scheduled and manual snapshots are not touched by this cleanup. tracking records. Successful scheduled and manual snapshots are not touched by this cleanup.
</p> </p>
</section> </section>
{% endif %} {% endif %}

View File

@@ -3,8 +3,12 @@
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %} {% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Backup run</div>
<h1>Run {{ run.id }}</h1> <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"> <section class="actions" aria-label="Run actions">
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a> <a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
{% if can_cancel %} {% if can_cancel %}
@@ -22,6 +26,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</section> </section>
</header>
<section class="grid" aria-label="Run summary"> <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">Host</div><div class="value">{{ run.host.host }}</div></div>
@@ -193,7 +198,6 @@
<h2>Retention</h2> <h2>Retention</h2>
<div class="stack"> <div class="stack">
<div><strong>Status:</strong> {% if prune_result.ok %}ok{% else %}warning{% endif %}</div> <div><strong>Status:</strong> {% if prune_result.ok %}ok{% else %}warning{% endif %}</div>
{% if prune_result.source %}<div><strong>Source:</strong> {{ prune_result.source }}</div>{% endif %}
{% if prune_result.kind %}<div><strong>Kind:</strong> {{ prune_result.kind }}</div>{% endif %} {% if prune_result.kind %}<div><strong>Kind:</strong> {{ prune_result.kind }}</div>{% endif %}
{% if prune_result.planned_delete_count is not None %}<div><strong>Planned deletions:</strong> {{ prune_result.planned_delete_count }}</div>{% endif %} {% if prune_result.planned_delete_count is not None %}<div><strong>Planned deletions:</strong> {{ prune_result.planned_delete_count }}</div>{% endif %}
{% if prune_result.deleted %}<div><strong>Deleted:</strong> {{ prune_result.deleted|length }}</div>{% endif %} {% if prune_result.deleted %}<div><strong>Deleted:</strong> {{ prune_result.deleted|length }}</div>{% endif %}

View File

@@ -3,11 +3,16 @@
{% block title %}Schedule | {{ host.host }}{% endblock %} {% block title %}Schedule | {{ host.host }}{% endblock %}
{% block content %} {% 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"> <section class="actions" aria-label="Schedule actions">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a> <a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2> <h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2>

View File

@@ -3,11 +3,16 @@
{% block title %}Self Check | pobsync{% endblock %} {% block title %}Self Check | pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Operations</div>
<h1>Self Check</h1> <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"> <section class="actions" aria-label="Self check actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a> <a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section> </section>
</header>
<section class="grid" aria-label="Self check summary"> <section class="grid" aria-label="Self check summary">
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div> <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 title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Snapshot</div>
<h1>{{ snapshot.dirname }}</h1> <h1>{{ snapshot.dirname }}</h1>
<div class="page-subtitle">{{ snapshot.host.host }} · {{ snapshot.kind }} · {{ snapshot.status }}</div>
</div>
<section class="actions" aria-label="Snapshot actions"> <section class="actions" aria-label="Snapshot actions">
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a> <a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
</section> </section>
</header>
<section class="grid" aria-label="Snapshot summary"> <section class="grid" aria-label="Snapshot summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div> <div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>
@@ -63,7 +68,7 @@
<section class="panel"> <section class="panel">
<h2>Restore Guidance</h2> <h2>Restore Guidance</h2>
<div class="stack spaced"> <div class="stack spaced">
<div><strong>Snapshot data source:</strong> {{ restore.source_path }}</div> <div><strong>Snapshot data path:</strong> {{ restore.source_path }}</div>
<div><strong>Example staging destination:</strong> {{ restore.destination_path }}</div> <div><strong>Example staging destination:</strong> {{ restore.destination_path }}</div>
<div class="muted"> <div class="muted">
Restore from the snapshot's <code>data/</code> directory. Start with a dry run, restore to a staging path first, Restore from the snapshot's <code>data/</code> directory. Start with a dry run, restore to a staging path first,
@@ -93,7 +98,7 @@
<div class="muted">Replace <code>{{ restore.example_file_relative_path }}</code> with the file you want to restore.</div> <div class="muted">Replace <code>{{ restore.example_file_relative_path }}</code> with the file you want to restore.</div>
</div> </div>
<div class="stack spaced"> <div class="stack spaced">
<div><strong>Dry-run restore back to the source host:</strong></div> <div><strong>Dry-run restore back to the original host:</strong></div>
<pre>{{ restore.remote_dry_run_command }}</pre> <pre>{{ restore.remote_dry_run_command }}</pre>
</div> </div>
<p class="muted"> <p class="muted">

View File

@@ -3,11 +3,16 @@
{% block title %}{% if credential %}SSH Key | {{ credential.name }}{% else %}New SSH Key{% endif %} | pobsync{% endblock %} {% block title %}{% if credential %}SSH Key | {{ credential.name }}{% else %}New SSH Key{% endif %} | pobsync{% endblock %}
{% block content %} {% 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> <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"> <section class="actions" aria-label="SSH key form actions">
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a> <a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2> <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 title %}Generate SSH Key | pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Access</div>
<h1>Generate SSH Key</h1> <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"> <section class="actions" aria-label="SSH key form actions">
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a> <a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>Create Key Pair</h2> <h2>Create Key Pair</h2>

View File

@@ -3,13 +3,18 @@
{% block title %}SSH Keys | pobsync{% endblock %} {% block title %}SSH Keys | pobsync{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Access</div>
<h1>SSH Keys</h1> <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"> <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" 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 'create_ssh_credential' %}">Add existing key</a>
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a> <a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section> </section>
</header>
<section class="panel"> <section class="panel">
<h2>Credentials</h2> <h2>Credentials</h2>

View File

@@ -59,6 +59,8 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Installed version:") self.assertContains(response, "Installed version:")
self.assertContains(response, "Changelog file:")
self.assertNotContains(response, "Source:")
self.assertContains(response, "1.0.0 - 2026-05-21") self.assertContains(response, "1.0.0 - 2026-05-21")
self.assertContains(response, "Django control panel") self.assertContains(response, "Django control panel")
self.assertContains(response, "Native systemd installer") self.assertContains(response, "Native systemd installer")
@@ -99,6 +101,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) 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, "Dashboard")
self.assertContains(response, "web-01") self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
@@ -291,6 +295,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Self Check") self.assertContains(response, "Self Check")
self.assertContains(response, "Runtime, filesystem, service, and configuration checks")
self.assertContains(response, "Django debug") self.assertContains(response, "Django debug")
self.assertContains(response, "Database connection") self.assertContains(response, "Database connection")
self.assertContains(response, "State root") self.assertContains(response, "State root")
@@ -325,6 +330,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Logs") self.assertContains(response, "Logs")
self.assertContains(response, "Filter pobsync service logs")
self.assertContains(response, "web-01 failed backup run 12") self.assertContains(response, "web-01 failed backup run 12")
self.assertNotContains(response, "web-02 failed backup run 12") self.assertNotContains(response, "web-02 failed backup run 12")
self.assertNotContains(response, "started") self.assertNotContains(response, "started")
@@ -354,6 +360,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Purged Snapshots") self.assertContains(response, "Purged Snapshots")
self.assertContains(response, "Audit trail for snapshots removed")
self.assertContains(response, "20260518-021500Z__OLDSNAP") self.assertContains(response, "20260518-021500Z__OLDSNAP")
self.assertContains(response, "outside retention policy") self.assertContains(response, "outside retention policy")
self.assertContains(response, "Scheduled") self.assertContains(response, "Scheduled")
@@ -408,6 +415,7 @@ class ViewTests(TestCase):
) )
self.assertRedirects(response, reverse("ssh_credentials")) 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, "SSH credential saved for backup-key.")
self.assertContains(response, "backup-key") self.assertContains(response, "backup-key")
credential = SshCredential.objects.get(name="backup-key") credential = SshCredential.objects.get(name="backup-key")
@@ -637,6 +645,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("edit_global_config")) response = self.client.get(reverse("edit_global_config"))
self.assertEqual(response.status_code, 200) 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, f'value="{credential.id}" selected')
self.assertContains(response, "--archive") self.assertContains(response, "--archive")
self.assertContains(response, "/proc/***") self.assertContains(response, "/proc/***")
@@ -867,7 +876,11 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_detail", args=[host.host])) response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200) 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, "Effective Config")
self.assertContains(response, "Backup source:")
self.assertNotContains(response, "Source root:")
self.assertContains(response, "root@web-01.example.test:2222") self.assertContains(response, "root@web-01.example.test:2222")
self.assertContains(response, "default-key") self.assertContains(response, "default-key")
self.assertContains(response, "-oBatchMode=yes") self.assertContains(response, "-oBatchMode=yes")
@@ -1421,11 +1434,14 @@ class ViewTests(TestCase):
response = self.client.get(reverse("run_detail", args=[run.id])) response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Backup run")
self.assertContains(response, "web-01")
self.assertContains(response, "Failure") self.assertContains(response, "Failure")
self.assertContains(response, "transport") self.assertContains(response, "transport")
self.assertContains(response, "Check network connectivity.") self.assertContains(response, "Check network connectivity.")
self.assertContains(response, "Retention") self.assertContains(response, "Retention")
self.assertContains(response, "Planned deletions") self.assertContains(response, "Planned deletions")
self.assertNotContains(response, "Source:</strong> sql")
self.assertContains(response, "Max delete") self.assertContains(response, "Max delete")
self.assertContains(response, "Protect bases") self.assertContains(response, "Protect bases")
self.assertContains(response, "Incomplete ignored") self.assertContains(response, "Incomplete ignored")
@@ -1610,12 +1626,17 @@ class ViewTests(TestCase):
response = self.client.get(reverse("snapshot_detail", args=[base.id])) response = self.client.get(reverse("snapshot_detail", args=[base.id]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Snapshot")
self.assertContains(response, base.dirname) self.assertContains(response, base.dirname)
self.assertContains(response, "BASESNAP") self.assertContains(response, "BASESNAP")
self.assertContains(response, "Stats") self.assertContains(response, "Stats")
self.assertContains(response, "Files seen:</strong> 100") self.assertContains(response, "Files seen:</strong> 100")
self.assertContains(response, "Hardlinked files:</strong> 9") self.assertContains(response, "Hardlinked files:</strong> 9")
self.assertContains(response, "Restore Guidance") self.assertContains(response, "Restore Guidance")
self.assertContains(response, "Snapshot data path:")
self.assertNotContains(response, "Snapshot data source:")
self.assertContains(response, "Dry-run restore back to the original host:")
self.assertNotContains(response, "Dry-run restore back to the source host:")
self.assertContains(response, f"{base.path}/data") self.assertContains(response, f"{base.path}/data")
self.assertContains(response, f"/restore/{host.host}") self.assertContains(response, f"/restore/{host.host}")
self.assertContains(response, "rsync -aHAX --numeric-ids --info=progress2 --dry-run") self.assertContains(response, "rsync -aHAX --numeric-ids --info=progress2 --dry-run")
@@ -1688,12 +1709,15 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_retention_plan", args=[host.host])) response = self.client.get(reverse("host_retention_plan", args=[host.host]))
self.assertEqual(response.status_code, 200) 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, old_snapshot.dirname)
self.assertContains(response, new_snapshot.dirname) self.assertContains(response, new_snapshot.dirname)
self.assertContains(response, "newest") self.assertContains(response, "newest")
self.assertContains(response, "Would Delete") self.assertContains(response, "Would Delete")
self.assertContains(response, "outside retention policy") self.assertContains(response, "outside retention policy")
self.assertNotContains(response, "<div class=\"label\">Source</div>", html=True)
self.assertContains(response, "Confirm delete count") self.assertContains(response, "Confirm delete count")
self.assertContains(response, "Type 1 to confirm the current number of planned deletions.") self.assertContains(response, "Type 1 to confirm the current number of planned deletions.")
@@ -2012,6 +2036,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("edit_host_schedule", args=[host.host])) response = self.client.get(reverse("edit_host_schedule", args=[host.host]))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Automatic backup timing and scheduled prune behavior")
self.assertContains(response, "Create Schedule") self.assertContains(response, "Create Schedule")
self.assertContains(response, "Schedule expression") self.assertContains(response, "Schedule expression")
self.assertContains(response, "evaluated by the pobsync scheduler service") self.assertContains(response, "evaluated by the pobsync scheduler service")