Compare commits
14 Commits
v1.0.0
...
864a40e862
| Author | SHA1 | Date | |
|---|---|---|---|
| 864a40e862 | |||
| 9412feaa58 | |||
| 0fe2aa439f | |||
| fe4ae9d147 | |||
| 0a3a3448d6 | |||
| 01b779c862 | |||
| 67d1af0baa | |||
| 4e8e4f75fd | |||
| 2be2d11b4a | |||
| b67ae7ff8b | |||
| ad2cc5585e | |||
| 8aa3f1d1f5 | |||
| 30cf93df27 | |||
| 01c4ccb316 |
@@ -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)
|
||||||
|
|||||||
@@ -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}",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,79 +7,227 @@
|
|||||||
<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; }
|
.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 { 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,33 +236,48 @@
|
|||||||
.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,
|
||||||
.button-link.secondary:hover { background: #eef3f8; }
|
.button-link.secondary:hover { background: #eef3f8; }
|
||||||
|
button.compact,
|
||||||
|
.button-link.compact {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
button:disabled {
|
button:disabled {
|
||||||
background: #d8dee6;
|
background: #d8dee6;
|
||||||
border-color: #d8dee6;
|
border-color: #d8dee6;
|
||||||
@@ -122,23 +285,115 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.inline-form { margin: 0; }
|
.inline-form { margin: 0; }
|
||||||
.status-overview {
|
.dashboard-priority-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.priority-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.priority-panel > h2:first-child { margin-bottom: 0; }
|
||||||
|
.action-list,
|
||||||
|
.activity-list,
|
||||||
|
.schedule-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.action-row,
|
||||||
|
.activity-row,
|
||||||
|
.schedule-row {
|
||||||
|
align-items: start;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: inherit;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.action-row,
|
||||||
|
.activity-row {
|
||||||
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.schedule-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
|
}
|
||||||
|
.action-row:hover,
|
||||||
|
.activity-row:hover,
|
||||||
|
.schedule-row:hover {
|
||||||
|
background: var(--panel-subtle);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.action-row.failed { border-color: #e8b4b4; background: #fff7f7; }
|
||||||
|
.action-row.warning { border-color: #e7cf8a; background: #fffaf0; }
|
||||||
|
.action-row span:not(.status),
|
||||||
|
.activity-row span:not(.status),
|
||||||
|
.schedule-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.schedule-time {
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.storage-priority {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.storage-priority .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.storage-priority .value {
|
||||||
|
font-size: 27px;
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.storage-priority-facts {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.storage-priority-facts > div {
|
||||||
|
align-items: baseline;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
.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,
|
||||||
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
|
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
|
||||||
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
|
.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);
|
||||||
|
}
|
||||||
.operator-state {
|
.operator-state {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -191,7 +446,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 +468,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 +490,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 +526,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 +560,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 +581,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,9 +624,25 @@
|
|||||||
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; }
|
||||||
|
.dashboard-priority-grid { grid-template-columns: 1fr; }
|
||||||
|
.schedule-row { grid-template-columns: 1fr; }
|
||||||
|
.schedule-time { justify-items: start; text-align: left; }
|
||||||
.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; }
|
||||||
.host-card-layout { grid-template-columns: 1fr; }
|
.host-card-layout { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -27,59 +32,95 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="grid" aria-label="Summary">
|
<section class="dashboard-priority-grid" aria-label="Operator priorities">
|
||||||
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
|
<article class="panel priority-panel">
|
||||||
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
<h2>Required Action</h2>
|
||||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
{% if action_items %}
|
||||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
<div class="action-list">
|
||||||
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
{% for item in action_items %}
|
||||||
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
<a class="action-row {{ item.status }}" href="{{ item.url }}">
|
||||||
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
|
<span class="status {{ item.status }}">{{ item.label }}</span>
|
||||||
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
<span>
|
||||||
</section>
|
<strong>{{ item.host.host }}</strong>
|
||||||
|
<span class="muted">{{ item.message }}</span>
|
||||||
<section class="panel">
|
</span>
|
||||||
<h2>Operational Status</h2>
|
</a>
|
||||||
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
|
{% endfor %}
|
||||||
<div class="status-overview">
|
|
||||||
{% if counts.failed_runs %}
|
|
||||||
<div class="status-summary failed">
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
{% if counts.warning_runs %}
|
|
||||||
<div class="status-summary warning">
|
|
||||||
<span class="status warning">warning</span>
|
|
||||||
<strong>{{ counts.warning_runs }} run{{ counts.warning_runs|pluralize }} completed with warnings.</strong>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if counts.running_runs %}
|
|
||||||
<div class="status-summary running">
|
|
||||||
<span class="status running">running</span>
|
|
||||||
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if counts.queued_runs %}
|
|
||||||
<div class="status-summary queued">
|
|
||||||
<span class="status queued">queued</span>
|
|
||||||
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting for the worker.</strong>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% elif counts.hosts %}
|
{% elif counts.hosts %}
|
||||||
<p><span class="status ok">ok</span> No queued, running, or unreviewed warning/failed runs.</p>
|
<p><span class="status ok">ok</span> No queued, running, unreviewed warning/failed runs, or retention warnings.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Add a host to start tracking backup status here.</p>
|
<p class="muted">Add a host to start tracking backup status here.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
{% if counts.running_runs or counts.queued_runs %}
|
||||||
|
<div class="operator-state">
|
||||||
|
{% if counts.running_runs %}
|
||||||
|
<a class="status-summary running" href="{% url 'runs_list' %}?status=running">
|
||||||
|
<span class="status running">running</span>
|
||||||
|
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if counts.queued_runs %}
|
||||||
|
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
|
||||||
|
<span class="status queued">queued</span>
|
||||||
|
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting.</strong>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
<section class="panel">
|
<article class="panel priority-panel">
|
||||||
<h2>Backup Trends</h2>
|
<h2>Next Scheduled Work <a class="button-link secondary compact" href="{% url 'schedules_list' %}">View all</a></h2>
|
||||||
|
{% if next_schedule_rows %}
|
||||||
|
<div class="schedule-list">
|
||||||
|
{% for row in next_schedule_rows %}
|
||||||
|
<a class="schedule-row" href="{% url 'host_detail' row.schedule.host.host %}">
|
||||||
|
<span>
|
||||||
|
<strong>{{ row.schedule.host.host }}</strong>
|
||||||
|
<span class="muted">{{ row.schedule.cron_expr }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="schedule-time">
|
||||||
|
{% if row.next_run_at %}
|
||||||
|
{{ row.next_run_at|date:"Y-m-d H:i T" }}
|
||||||
|
<span class="muted">{{ scheduler_timezone }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">not due</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No enabled schedules yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel priority-panel">
|
||||||
|
<h2>Recent Activity <a class="button-link secondary compact" href="{% url 'runs_list' %}">View all</a></h2>
|
||||||
|
{% if recent_runs %}
|
||||||
|
<div class="activity-list">
|
||||||
|
{% for run in recent_runs %}
|
||||||
|
<a class="activity-row" href="{% url 'run_detail' run.id %}">
|
||||||
|
<span class="status {{ run.status }}">{{ run.status }}</span>
|
||||||
|
<span>
|
||||||
|
<strong>Run {{ run.id }}</strong>
|
||||||
|
<span class="muted">{{ run.host.host }} · {{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No backup runs recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel priority-panel">
|
||||||
|
<h2>Storage Pressure</h2>
|
||||||
{% if stats_summary.runs_sampled %}
|
{% if stats_summary.runs_sampled %}
|
||||||
<div class="insight-grid" aria-label="Backup trends">
|
<div class="storage-priority">
|
||||||
<div class="insight-main">
|
<div>
|
||||||
<div class="label">Storage Used</div>
|
<div class="label">Backup root used</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
{% if stats_summary.capacity.used_percent is not None %}
|
{% if stats_summary.capacity.used_percent is not None %}
|
||||||
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
||||||
@@ -92,10 +133,49 @@
|
|||||||
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
|
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="muted">
|
</div>
|
||||||
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root.
|
<div class="storage-priority-facts">
|
||||||
|
<div>
|
||||||
|
<span class="label">Runway</span>
|
||||||
|
<strong>
|
||||||
|
{% if stats_summary.estimated_days_until_full %}
|
||||||
|
{{ stats_summary.estimated_days_until_full }} days
|
||||||
|
{% elif stats_summary.estimated_runs_until_full %}
|
||||||
|
{{ stats_summary.estimated_runs_until_full }} runs
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">New data</span>
|
||||||
|
<strong>{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Available</span>
|
||||||
|
<strong>{{ stats_summary.capacity.available_bytes|filesizeformat }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Storage pressure appears after the first completed backup with stats.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Summary">
|
||||||
|
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
|
||||||
|
<a class="metric metric-link" href="{% 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.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&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&review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backup Trends</h2>
|
||||||
|
{% if stats_summary.runs_sampled %}
|
||||||
|
<div class="insight-grid" aria-label="Backup trends">
|
||||||
<div class="insight-item">
|
<div class="insight-item">
|
||||||
<div class="label">Runway</div>
|
<div class="label">Runway</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
@@ -136,7 +216,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="hosts">
|
||||||
<h2>Hosts</h2>
|
<h2>Hosts</h2>
|
||||||
<div class="host-list">
|
<div class="host-list">
|
||||||
{% for host in hosts %}
|
{% for host in hosts %}
|
||||||
@@ -260,31 +340,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Latest Runs</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Started</th>
|
|
||||||
<th>Ended</th>
|
|
||||||
<th>Snapshot</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for run in latest_runs %}
|
|
||||||
<tr>
|
|
||||||
<td><a href="{% url 'host_detail' run.host.host %}">{{ run.host.host }}</a></td>
|
|
||||||
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></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>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr><td colspan="5" class="muted">No backup runs recorded yet.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
121
src/pobsync_backend/templates/pobsync_backend/runs_list.html
Normal file
121
src/pobsync_backend/templates/pobsync_backend/runs_list.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{% 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" %}
|
||||||
|
<div class="stack">
|
||||||
|
<span class="status warning">needed</span>
|
||||||
|
<form class="inline-form" method="post" action="{% url 'resolve_run_review' run.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="secondary compact">Mark reviewed</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -116,11 +120,26 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "running 1")
|
self.assertContains(response, "running 1")
|
||||||
self.assertContains(response, "warning 1")
|
self.assertContains(response, "warning 1")
|
||||||
self.assertContains(response, "failed 1")
|
self.assertContains(response, "failed 1")
|
||||||
self.assertContains(response, "Operational Status")
|
self.assertContains(response, "Required Action")
|
||||||
self.assertContains(response, "1 failed run needs review.")
|
self.assertContains(response, "Failed runs")
|
||||||
self.assertContains(response, "1 run completed with warnings.")
|
self.assertContains(response, "1 failed run(s) need review.")
|
||||||
|
self.assertContains(response, "1 run(s) completed with warnings.")
|
||||||
self.assertContains(response, "1 backup run in progress.")
|
self.assertContains(response, "1 backup run in progress.")
|
||||||
self.assertContains(response, "1 backup run waiting for the worker.")
|
self.assertContains(response, "1 backup run waiting.")
|
||||||
|
self.assertContains(response, "Next Scheduled Work")
|
||||||
|
self.assertContains(response, "Recent Activity")
|
||||||
|
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&review=needed"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&review=needed"', html=False)
|
||||||
|
self.assertContains(
|
||||||
|
response,
|
||||||
|
f'href="{reverse("runs_list")}?host=web-01&status=failed&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:
|
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -158,14 +177,14 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Backup Trends")
|
self.assertContains(response, "Backup Trends")
|
||||||
self.assertContains(response, "Storage Used")
|
self.assertContains(response, "Storage Pressure")
|
||||||
|
self.assertContains(response, "Backup root used")
|
||||||
self.assertContains(response, "Runway")
|
self.assertContains(response, "Runway")
|
||||||
self.assertContains(response, "New Data")
|
self.assertContains(response, "New Data")
|
||||||
self.assertContains(response, "Link-Dest Savings")
|
self.assertContains(response, "Link-Dest Savings")
|
||||||
self.assertContains(response, "80.0%")
|
self.assertContains(response, "80.0%")
|
||||||
self.assertContains(response, "10 days")
|
self.assertContains(response, "10 days")
|
||||||
self.assertContains(response, "Warnings")
|
self.assertContains(response, "Warnings")
|
||||||
self.assertContains(response, "Queued")
|
|
||||||
self.assertContains(response, "Next Run")
|
self.assertContains(response, "Next Run")
|
||||||
self.assertContains(response, "UTC")
|
self.assertContains(response, "UTC")
|
||||||
self.assertContains(response, "10")
|
self.assertContains(response, "10")
|
||||||
@@ -193,8 +212,90 @@ 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, "Operational Status")
|
self.assertContains(response, "Required Action")
|
||||||
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||||
|
|
||||||
|
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_runs_list_can_mark_problem_run_reviewed(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
run = BackupRun.objects.create(host=host, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL)
|
||||||
|
list_url = f'{reverse("runs_list")}?status=failed&review=needed'
|
||||||
|
|
||||||
|
response = self.client.get(list_url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Mark reviewed")
|
||||||
|
self.assertContains(response, 'value="/runs/?status=failed&review=needed"', html=False)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("resolve_run_review", args=[run.id]),
|
||||||
|
{"next": list_url},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
run.refresh_from_db()
|
||||||
|
self.assertIsNotNone(run.reviewed_at)
|
||||||
|
self.assertEqual(run.reviewed_by, self.staff_user.username)
|
||||||
|
self.assertRedirects(response, list_url)
|
||||||
|
self.assertContains(response, f"Run {run.id} marked reviewed.")
|
||||||
|
self.assertNotContains(response, f"Run {run.id}</a>", html=False)
|
||||||
|
|
||||||
|
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:
|
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -245,7 +346,7 @@ 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, "No queued, running, or unreviewed warning/failed runs.")
|
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||||
self.assertNotContains(response, "failed 1")
|
self.assertNotContains(response, "failed 1")
|
||||||
self.assertNotContains(response, "warning 1")
|
self.assertNotContains(response, "warning 1")
|
||||||
|
|
||||||
@@ -291,6 +392,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 +427,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 +457,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 +512,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 +742,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 +973,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 +1531,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 +1723,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 +1806,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 +2133,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")
|
||||||
@@ -2198,13 +2320,19 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(host.excludes_add, [])
|
self.assertEqual(host.excludes_add, [])
|
||||||
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
|
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)
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
return SnapshotRecord.objects.create(
|
return SnapshotRecord.objects.create(
|
||||||
host=host,
|
host=host,
|
||||||
kind=SnapshotRecord.Kind.SCHEDULED,
|
kind=kind,
|
||||||
dirname=dirname,
|
dirname=dirname,
|
||||||
path=f"/backups/{host.host}/scheduled/{dirname}",
|
path=f"/backups/{host.host}/{kind}/{dirname}",
|
||||||
status="success",
|
status="success",
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import json
|
|||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from datetime import datetime, timezone as datetime_timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
@@ -12,6 +14,7 @@ from django.conf import settings
|
|||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
@@ -74,13 +77,18 @@ def dashboard(request):
|
|||||||
)
|
)
|
||||||
host_config.next_run_at = _next_run_for_host(host_config)
|
host_config.next_run_at = _next_run_for_host(host_config)
|
||||||
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
|
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
|
||||||
|
action_items = _dashboard_action_items(hosts)
|
||||||
|
next_schedule_rows = _dashboard_next_schedule_rows()
|
||||||
|
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
|
||||||
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
|
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
|
||||||
context = {
|
context = {
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
"global_config": global_config,
|
"global_config": global_config,
|
||||||
"stats_summary": stats_summary,
|
"stats_summary": stats_summary,
|
||||||
"scheduler_timezone": timezone.get_current_timezone_name(),
|
"scheduler_timezone": timezone.get_current_timezone_name(),
|
||||||
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
"action_items": action_items,
|
||||||
|
"next_schedule_rows": next_schedule_rows,
|
||||||
|
"recent_runs": recent_runs,
|
||||||
"counts": {
|
"counts": {
|
||||||
"global_configs": GlobalConfig.objects.count(),
|
"global_configs": GlobalConfig.objects.count(),
|
||||||
"hosts": HostConfig.objects.count(),
|
"hosts": HostConfig.objects.count(),
|
||||||
@@ -104,6 +112,74 @@ def dashboard(request):
|
|||||||
return render(request, "pobsync_backend/dashboard.html", context)
|
return render(request, "pobsync_backend/dashboard.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
|
||||||
|
action_items: list[dict[str, object]] = []
|
||||||
|
for host_config in hosts:
|
||||||
|
if host_config.failed_run_count:
|
||||||
|
action_items.append(
|
||||||
|
{
|
||||||
|
"host": host_config,
|
||||||
|
"status": BackupRun.Status.FAILED,
|
||||||
|
"label": "Failed runs",
|
||||||
|
"message": f"{host_config.failed_run_count} failed run(s) need review.",
|
||||||
|
"url": _runs_list_url(host=host_config.host, status="failed", review="needed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if host_config.warning_run_count:
|
||||||
|
action_items.append(
|
||||||
|
{
|
||||||
|
"host": host_config,
|
||||||
|
"status": BackupRun.Status.WARNING,
|
||||||
|
"label": "Warnings",
|
||||||
|
"message": f"{host_config.warning_run_count} run(s) completed with warnings.",
|
||||||
|
"url": _runs_list_url(host=host_config.host, status="warning", review="needed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if host_config.retention_warning.get("has_warning"):
|
||||||
|
action_items.append(
|
||||||
|
{
|
||||||
|
"host": host_config,
|
||||||
|
"status": BackupRun.Status.WARNING,
|
||||||
|
"label": "Retention",
|
||||||
|
"message": _retention_warning_summary(host_config.retention_warning),
|
||||||
|
"url": reverse("host_detail", args=[host_config.host]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return action_items
|
||||||
|
|
||||||
|
|
||||||
|
def _runs_list_url(**params: str) -> str:
|
||||||
|
return f"{reverse('runs_list')}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_next_schedule_rows() -> list[dict[str, object]]:
|
||||||
|
rows = []
|
||||||
|
schedules = ScheduleConfig.objects.select_related("host").filter(enabled=True).order_by("host__host")
|
||||||
|
for schedule in schedules[:200]:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"schedule": schedule,
|
||||||
|
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.sort(key=lambda row: row["next_run_at"] or datetime.max.replace(tzinfo=datetime_timezone.utc))
|
||||||
|
return rows[:6]
|
||||||
|
|
||||||
|
|
||||||
|
def _retention_warning_summary(retention_warning) -> str:
|
||||||
|
parts = []
|
||||||
|
if retention_warning.get("prune_exceeded"):
|
||||||
|
parts.append(
|
||||||
|
f"Scheduled prune would delete {retention_warning.get('delete_count')} snapshot(s), "
|
||||||
|
f"above max {retention_warning.get('max_delete')}."
|
||||||
|
)
|
||||||
|
if retention_warning.get("incomplete_count"):
|
||||||
|
parts.append(f"{retention_warning.get('incomplete_count')} incomplete snapshot(s) need review.")
|
||||||
|
if retention_warning.get("error"):
|
||||||
|
parts.append(str(retention_warning.get("error")))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def changelog(request):
|
def changelog(request):
|
||||||
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
|
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
|
||||||
@@ -145,6 +221,102 @@ def logs(request):
|
|||||||
return render(request, "pobsync_backend/logs.html", context)
|
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
|
@staff_member_required
|
||||||
def purged_snapshots(request):
|
def purged_snapshots(request):
|
||||||
host = request.GET.get("host", "").strip()
|
host = request.GET.get("host", "").strip()
|
||||||
@@ -565,13 +737,13 @@ def resolve_run_review(request, run_id: int):
|
|||||||
return redirect("run_detail", run_id=run.id)
|
return redirect("run_detail", run_id=run.id)
|
||||||
if run.reviewed_at:
|
if run.reviewed_at:
|
||||||
messages.info(request, f"Run {run.id} was already marked reviewed.")
|
messages.info(request, f"Run {run.id} was already marked reviewed.")
|
||||||
return redirect("run_detail", run_id=run.id)
|
return _redirect_after_run_review(request, run)
|
||||||
|
|
||||||
run.reviewed_at = timezone.now()
|
run.reviewed_at = timezone.now()
|
||||||
run.reviewed_by = request.user.get_username()
|
run.reviewed_by = request.user.get_username()
|
||||||
run.save(update_fields=["reviewed_at", "reviewed_by"])
|
run.save(update_fields=["reviewed_at", "reviewed_by"])
|
||||||
messages.success(request, f"Run {run.id} marked reviewed.")
|
messages.success(request, f"Run {run.id} marked reviewed.")
|
||||||
return redirect("run_detail", run_id=run.id)
|
return _redirect_after_run_review(request, run)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
@@ -841,6 +1013,13 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_after_run_review(request, run: BackupRun):
|
||||||
|
next_url = request.POST.get("next", "").strip()
|
||||||
|
if next_url.startswith("/"):
|
||||||
|
return redirect(next_url)
|
||||||
|
return redirect("run_detail", run_id=run.id)
|
||||||
|
|
||||||
|
|
||||||
def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]:
|
def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]:
|
||||||
incomplete_count = host_config.snapshots.filter(
|
incomplete_count = host_config.snapshots.filter(
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
|||||||
path("self-check/", views.self_check, name="self_check"),
|
path("self-check/", views.self_check, name="self_check"),
|
||||||
path("logs/", views.logs, name="logs"),
|
path("logs/", views.logs, name="logs"),
|
||||||
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
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("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||||
@@ -34,6 +35,7 @@ urlpatterns = [
|
|||||||
name="cleanup_host_incomplete_snapshots",
|
name="cleanup_host_incomplete_snapshots",
|
||||||
),
|
),
|
||||||
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||||
|
path("runs/", views.runs_list, name="runs_list"),
|
||||||
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
||||||
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
||||||
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
||||||
@@ -43,6 +45,7 @@ urlpatterns = [
|
|||||||
views.resolve_host_incomplete_reviews,
|
views.resolve_host_incomplete_reviews,
|
||||||
name="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("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
||||||
path("api/", api.api_index),
|
path("api/", api.api_index),
|
||||||
path("api/status/", api.status),
|
path("api/status/", api.status),
|
||||||
|
|||||||
Reference in New Issue
Block a user