20 Commits

Author SHA1 Message Date
c0eca3da55 Merge pull request 'issue-26-host-detail-control-page' (#34) from issue-26-host-detail-control-page into master
Reviewed-on: #34
2026-05-21 14:05:51 +02:00
212813e066 (ui) Make host diagnostics easier to scan
Replace the raw host check table with diagnostic cards and group effective
runtime config into operator-focused sections for backup target, connection,
and selection/retention details.

Refs #26
2026-05-21 14:03:43 +02:00
ab5291b8d3 (ui) Show host runs and snapshots as record cards
Replace the database-style Latest Runs and Snapshots tables on the host
detail page with scannable record cards and host-filtered View all links.

Refs #26
2026-05-21 13:59:16 +02:00
1929196287 (ui) Group host detail actions by context
Move host-level actions out of the page header and into the panels where
operators expect them: configuration, connection preflight, and snapshot
storage. This keeps the host control page calmer while preserving the same
actions.

Refs #26
2026-05-21 13:53:10 +02:00
9e75273fc5 (ui) Promote host detail operator controls
Add a first-screen host control workspace with status, backup actions,
schedule state, and current activity so the host detail page behaves as the
primary operator page instead of starting with raw configuration blocks.

Refs #26
2026-05-21 13:40:37 +02:00
5dd6ebb3db Merge pull request 'issue-27-dashboard-information-architecture' (#33) from issue-27-dashboard-information-architecture into master
Reviewed-on: #33
2026-05-21 13:29:18 +02:00
864a40e862 (ui) Surface storage pressure in dashboard priorities
Move backup root usage, runway, daily new data, and available capacity into
the top dashboard priority area so storage risk is visible before deeper trend
details.

Refs #27
2026-05-21 13:27:39 +02:00
9412feaa58 (ui) Rework dashboard around operator priorities
Move required actions, upcoming scheduled work, and recent run activity to the
top of the dashboard so the first screen answers what needs attention next.
Keep summary metrics, trends, and host cards as supporting drill-down content.

Refs #27
2026-05-21 13:21:09 +02:00
0fe2aa439f Merge pull request '(ui) Add review actions to filtered run lists' (#32) from issue-22-operational-review-actions into master
Reviewed-on: #32
2026-05-21 13:12:20 +02:00
fe4ae9d147 (ui) Add review actions to filtered run lists
Add inline Mark reviewed actions for failed and warning runs on the run list,
preserving active filters after review so Operational Status drill-downs can
be cleared without opening every run detail page.

Refs #22
2026-05-21 13:07:45 +02:00
0a3a3448d6 Merge pull request 'Make dashboard cards link to operational lists' (#31) from issue-23-dashboard-list-pages into master
Reviewed-on: #31
2026-05-21 12:49:15 +02:00
01b779c862 (ui) Add schedule overview for dashboard drill-down
Add a staff-only schedules page with filters for host, enabled state, and
prune state, including next run and last scheduler state.

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

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

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

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

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

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

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

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

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

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

Refs #28
2026-05-21 11:31:49 +02:00
30cf93df27 Merge pull request 'Remove legacy-facing UI labels' (#29)
Reviewed-on: #29
2026-05-21 11:19:48 +02:00
01c4ccb316 (ui) Remove legacy-facing labels from operator pages
Replace refactor-era wording such as Source, Source root, SQL records,
database, runtime, and Django generation labels with operator-facing copy
around backup source, tracking records, changelog files, active config, and
pobsync-managed SSH keys.

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

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

View File

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

View File

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

View File

@@ -7,79 +7,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,55 @@
.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; } .panel-grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
margin-bottom: 18px;
}
.panel-grid .panel { margin-bottom: 0; }
.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 +292,199 @@
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;
}
.record-list {
display: grid;
gap: 10px;
}
.record-card {
border: 1px solid var(--border);
border-radius: var(--radius);
display: grid;
gap: 10px;
padding: 12px;
}
.record-card-header {
align-items: start;
display: flex;
gap: 12px;
justify-content: space-between;
}
.record-title {
display: grid;
gap: 3px;
min-width: 0;
}
.record-title a {
font-weight: 750;
overflow-wrap: anywhere;
}
.record-facts {
display: grid;
gap: 8px 16px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.record-fact {
display: grid;
gap: 2px;
min-width: 0;
}
.record-fact .label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.record-fact strong,
.record-fact span {
overflow-wrap: anywhere;
}
.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;
}
.host-control-grid {
display: grid;
gap: 14px;
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
margin-bottom: 20px;
}
.host-control-panel {
display: grid;
gap: 12px;
margin-bottom: 0;
}
.host-control-panel > h2:first-child { margin-bottom: 0; }
.host-control-primary {
display: grid;
gap: 8px;
}
.host-control-meta {
display: grid;
gap: 6px;
}
.host-control-meta > div {
align-items: baseline;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
justify-content: space-between;
padding-top: 7px;
}
.host-control-meta .label {
color: var(--muted);
font-size: 12px;
font-weight: 650;
text-transform: uppercase;
}
.host-control-meta strong {
text-align: right;
} }
.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 +537,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 +559,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 +581,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 +617,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 +651,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 +672,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 +715,27 @@
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; }
.two-col { grid-template-columns: 1fr; } 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,
.panel-grid { grid-template-columns: 1fr; }
.dashboard-priority-grid { grid-template-columns: 1fr; }
.host-control-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; }

View File

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

View File

@@ -3,12 +3,17 @@
{% block title %}pobsync dashboard{% endblock %} {% block title %}pobsync dashboard{% endblock %}
{% block content %} {% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Control panel</div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</div>
<section class="actions" aria-label="Dashboard actions"> <section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a> <a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a> <a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section> </section>
</header>
{% if not global_config or not counts.hosts %} {% if not global_config or not counts.hosts %}
<section class="panel"> <section class="panel">
@@ -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&amp;review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&amp;review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
</section>
<section class="panel">
<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 %}

View File

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

View File

@@ -3,70 +3,13 @@
{% 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>
<section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
{% csrf_token %}
<button type="submit">Discover snapshots</button>
</form>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Prepare directories</button>
</form>
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Scan SSH host key</button>
</form>
<form method="post" action="{% url 'run_host_preflight' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Run connection preflight</button>
</form>
</section>
<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">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
</section>
<div class="two-col">
<section class="panel">
<h2>Config</h2>
<div class="stack">
<div><strong>Address:</strong> {{ host.address }}</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:</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>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
</div>
</section>
<section class="panel">
<h2>Schedule</h2>
{% if schedule %}
<div class="stack">
<div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div>
<div class="muted">Evaluated by the pobsync scheduler service.</div>
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
<div><strong>Next run:</strong> {% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }} <span class="muted">{{ scheduler_timezone }}</span>{% endif %}</div>
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
<div><strong>Last finished:</strong> {{ schedule.last_finished_at|default:"" }}</div>
</div>
{% else %}
<p class="muted">No schedule configured.</p>
{% endif %}
</section>
</div> </div>
</header>
{% if retention_warning.has_warning %} {% if retention_warning.has_warning %}
<section class="panel highlight warning"> <section class="panel highlight warning">
@@ -96,60 +39,137 @@
</section> </section>
{% endif %} {% endif %}
{% if effective_config %} <section class="host-control-grid" aria-label="Host control workspace">
<section class="panel"> <article class="panel host-control-panel">
<h2>Effective Config</h2> <h2>Host Status</h2>
<div class="two-col"> <div class="host-control-primary">
<div class="stack">
<div><strong>Source root:</strong> {{ effective_config.source_root }}</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 key:</strong> {{ effective_config.ssh.credential|default:"none selected" }}</div>
<div><strong>SSH options:</strong> {{ effective_config.ssh.options|join:" " }}</div>
<div><strong>Rsync binary:</strong> {{ effective_config.rsync.binary }}</div>
<div><strong>Rsync args:</strong> {{ effective_config.rsync.args|join:" " }}</div>
<div><strong>Timeout:</strong> {{ effective_config.rsync.timeout_seconds }}s</div>
<div><strong>Bandwidth limit:</strong> {{ effective_config.rsync.bwlimit_kbps }} KB/s</div>
<div> <div>
<strong>Retention:</strong> {% if host.enabled %}
d{{ effective_config.retention.daily }} <span class="status ok">enabled</span>
w{{ effective_config.retention.weekly }}
m{{ effective_config.retention.monthly }}
y{{ effective_config.retention.yearly }}
</div>
</div>
<div class="stack">
<div><strong>Includes:</strong> {{ effective_config.includes|length }}</div>
{% if effective_config.includes %}
<pre>{{ effective_config.includes|join:"&#10;" }}</pre>
{% else %} {% else %}
<div class="muted">No include rules configured.</div> <span class="status failed">disabled</span>
{% endif %} {% endif %}
<div><strong>Excludes:</strong> {{ effective_config.excludes|length }}</div> <span class="muted">{{ host.address }}</span>
{% if effective_config.excludes %} </div>
<pre>{{ effective_config.excludes|join:"&#10;" }}</pre> {% if active_run %}
<a class="status-summary {{ active_run.status }}" href="{% url 'run_detail' active_run.id %}">
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
<strong>Run {{ active_run.id }} is active.</strong>
</a>
{% elif counts.failed_runs %}
<a class="status-summary failed" href="{% url 'runs_list' %}?host={{ host.host }}&amp;status=failed&amp;review=needed">
<span class="status failed">failed</span>
<strong>{{ counts.failed_runs }} failed run{{ counts.failed_runs|pluralize }} need review.</strong>
</a>
{% elif retention_warning.has_warning %}
<span class="status-summary warning">
<span class="status warning">warning</span>
<strong>Retention needs attention.</strong>
</span>
{% else %} {% else %}
<div class="muted">No exclude rules configured.</div> <span class="status-summary success">
<span class="status ok">ok</span>
<strong>No active blockers for this host.</strong>
</span>
{% endif %} {% endif %}
</div> </div>
<div class="host-control-meta">
<div><span class="label">Snapshots</span><strong>{{ counts.snapshots }}</strong></div>
<div><span class="label">Runs</span><strong>{{ counts.runs }}</strong></div>
<div><span class="label">Incomplete</span><strong>{{ counts.incomplete_snapshots }}</strong></div>
</div> </div>
</section> </article>
{% endif %}
<section class="panel"> <article class="panel host-control-panel">
<h2>Snapshot Discovery</h2> <h2>Backup Control</h2>
<div class="stack"> <div class="operator-state">
<div><strong>Backup root:</strong> {{ discovery.backup_root|default:"" }}</div> {% if active_run %}
<div><strong>Host root:</strong> {{ discovery.host_root|default:"" }}</div> <span class="status {{ active_run.status }}">{{ active_run.status }}</span>
<div><strong>Status:</strong> {{ discovery.message }}</div> <a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
{% if discovery.kind_counts %} {% elif has_global_config and host.enabled %}
<div><strong>On disk:</strong> <span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
scheduled {{ discovery.kind_counts.scheduled|default:0 }}, <span class="muted">{{ backup_gate.message }}</span>
manual {{ discovery.kind_counts.manual|default:0 }}, {% elif not host.enabled %}
incomplete {{ discovery.kind_counts.incomplete|default:0 }} <span class="status failed">disabled</span>
</div> {% elif not has_global_config %}
<span class="status failed">missing global config</span>
{% endif %} {% endif %}
</div> </div>
<section class="actions inline" aria-label="Quick backup actions">
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
{% csrf_token %}
<input type="hidden" name="dry_run" value="on">
<input type="hidden" name="verbose_output" value="on">
<input type="hidden" name="prune_max_delete" value="10">
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
</form>
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
{% csrf_token %}
<input type="hidden" name="prune_max_delete" value="10">
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
</form>
</section>
{% if active_run %}
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
{% elif not can_queue_dry_run or not can_queue_real_backup %}
{% if not has_global_config %}
<p class="muted">Create the default global config before queueing backups.</p>
{% elif not host.enabled %}
<p class="muted">Enable this host before queueing backups.</p>
{% elif backup_gate.real_blockers %}
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
{% endif %}
{% endif %}
</article>
<article class="panel host-control-panel">
<h2>Schedule <a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a></h2>
{% if schedule %}
<div class="host-control-meta">
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
<div><span class="label">Next run</span><strong>{% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }}{% else %}none{% endif %}</strong></div>
<div><span class="label">Timezone</span><strong>{{ scheduler_timezone }}</strong></div>
<div><span class="label">Prune</span><strong>{{ schedule.prune|yesno:"yes,no" }}</strong></div>
<div><span class="label">Last status</span><strong>{{ schedule.last_status|default:"none" }}</strong></div>
</div>
<p class="muted">Evaluated by the pobsync scheduler service.</p>
{% else %}
<p class="muted">No schedule configured.</p>
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
{% endif %}
</article>
<article class="panel host-control-panel">
<h2>Current Activity</h2>
{% if latest_runs %}
{% with run=latest_runs.0 %}
<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.run_type }} · {{ run.started_at|default:run.created_at }}</span>
</span>
</a>
{% endwith %}
{% else %}
<p class="muted">No backup runs recorded for this host.</p>
{% endif %}
{% if stats_summary.latest_run.duration_seconds is not None %}
<div class="host-control-meta">
<div><span class="label">Latest duration</span><strong>{{ stats_summary.latest_run.duration_seconds }}s</strong></div>
<div><span class="label">New data</span><strong>{{ stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</strong></div>
</div>
{% endif %}
</article>
</section>
<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">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
</section> </section>
{% if stats_summary.runs %} {% if stats_summary.runs %}
@@ -209,104 +229,197 @@
<div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div> <div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div>
<div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div> <div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
</section> </section>
<table> <div class="record-list">
<thead>
<tr>
<th>Status</th>
<th>Check</th>
<th>Message</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for check in host_checks %} {% for check in host_checks %}
<tr> <article class="record-card">
<td><span class="status {{ check.status }}">{{ check.status }}</span></td> <div class="record-card-header">
<td>{{ check.name }}</td> <div class="record-title">
<td>{{ check.message }}</td> <strong>{{ check.name }}</strong>
<td class="muted">{{ check.detail }}</td> <span class="muted">{{ check.message }}</span>
</tr> </div>
<span class="status {{ check.status }}">{{ check.status }}</span>
</div>
{% if check.detail %}
<div class="record-fact">
<span class="label">Detail</span>
<span class="muted">{{ check.detail }}</span>
</div>
{% endif %}
</article>
{% endfor %} {% endfor %}
</tbody> </div>
</table>
</section> </section>
<div class="panel-grid">
<section class="panel">
<h2>Configuration</h2>
<div class="host-control-meta">
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
</div>
<div class="actions inline">
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</div>
</section>
<section class="panel">
<h2>Connection Preflight &amp; SSH</h2>
{% if last_preflight %} {% if last_preflight %}
<section class="panel"> <div class="host-control-meta">
<h2>Connection Preflight</h2> <div>
<div class="stack spaced"> <span class="label">Preflight</span>
<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> <strong>
<div><strong>Target:</strong> {{ last_preflight.target }}</div> <span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">
<div><strong>Source root:</strong> {{ last_preflight.source_root }}</div> {% if last_preflight.ok %}ok{% else %}failed{% endif %}
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</div> </span>
</strong>
</div> </div>
<table> <div><span class="label">Target</span><strong>{{ last_preflight.target }}</strong></div>
<thead> <div><span class="label">Backup source</span><strong>{{ last_preflight.source_root }}</strong></div>
<tr> <div><span class="label">Remote rsync</span><strong>{{ last_preflight.rsync_binary }}</strong></div>
<th>Status</th> </div>
<th>Check</th> {% else %}
<th>Message</th> <p class="muted">No connection preflight recorded yet.</p>
<th>Detail</th> {% endif %}
</tr> <div class="actions inline">
</thead> <form method="post" action="{% url 'run_host_preflight' host.host %}">
<tbody> {% csrf_token %}
<button type="submit" class="secondary compact">Run connection preflight</button>
</form>
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Scan SSH host key</button>
</form>
</div>
{% if last_preflight.checks %}
<div class="activity-list">
{% for check in last_preflight.checks %} {% for check in last_preflight.checks %}
<tr> <div class="activity-row">
<td><span class="status {% if check.ok %}ok{% else %}failed{% endif %}">{% if check.ok %}ok{% else %}failed{% endif %}</span></td> <span class="status {% if check.ok %}ok{% else %}failed{% endif %}">
<td>{{ check.name }}</td> {% if check.ok %}ok{% else %}failed{% endif %}
<td>{{ check.message }}</td> </span>
<td class="muted">{{ check.detail }}</td> <span>
</tr> <strong>{{ check.name }}</strong>
<span class="muted">{{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %}</span>
</span>
</div>
{% endfor %} {% endfor %}
</tbody> </div>
</table> {% endif %}
</section>
<section class="panel">
<h2>Snapshot Storage</h2>
<div class="host-control-meta">
<div><span class="label">Backup root</span><strong>{{ discovery.backup_root|default:"" }}</strong></div>
<div><span class="label">Host root</span><strong>{{ discovery.host_root|default:"" }}</strong></div>
<div><span class="label">Status</span><strong>{{ discovery.message }}</strong></div>
{% if discovery.kind_counts %}
<div>
<span class="label">On disk</span>
<strong>
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
manual {{ discovery.kind_counts.manual|default:0 }},
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
</strong>
</div>
{% endif %}
</div>
<div class="actions inline">
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Discover snapshots</button>
</form>
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Prepare directories</button>
</form>
</div>
</section>
</div>
{% if effective_config %}
<section class="panel">
<h2>Effective Config</h2>
<p class="muted">Runtime settings after global defaults and host overrides are combined.</p>
<div class="record-list">
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>Backup target</strong>
<span class="muted">Source and destination used by rsync.</span>
</div>
</div>
<div class="record-facts">
<div class="record-fact"><span class="label">Backup source:</span><strong>{{ effective_config.source_root }}</strong></div>
<div class="record-fact"><span class="label">Destination subdir:</span><strong>{{ effective_config.destination_subdir|default:"none" }}</strong></div>
</div>
</article>
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>Connection</strong>
<span class="muted">SSH and rsync execution settings.</span>
</div>
</div>
<div class="record-facts">
<div class="record-fact"><span class="label">SSH:</span><strong>{{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</strong></div>
<div class="record-fact"><span class="label">SSH key:</span><strong>{{ effective_config.ssh.credential|default:"none selected" }}</strong></div>
<div class="record-fact"><span class="label">SSH options:</span><span>{{ effective_config.ssh.options|join:" " }}</span></div>
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
<div class="record-fact"><span class="label">Bandwidth limit:</span><strong>{{ effective_config.rsync.bwlimit_kbps }} KB/s</strong></div>
</div>
</article>
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>Selection &amp; retention</strong>
<span class="muted">Include/exclude rules and retention counts.</span>
</div>
</div>
<div class="record-facts">
<div class="record-fact">
<span class="label">Retention:</span>
<strong>
d{{ effective_config.retention.daily }}
w{{ effective_config.retention.weekly }}
m{{ effective_config.retention.monthly }}
y{{ effective_config.retention.yearly }}
</strong>
</div>
<div class="record-fact"><span class="label">Includes:</span><strong>{{ effective_config.includes|length }}</strong></div>
<div class="record-fact"><span class="label">Excludes:</span><strong>{{ effective_config.excludes|length }}</strong></div>
</div>
<div class="two-col">
<div class="stack">
{% if effective_config.includes %}
<pre>{{ effective_config.includes|join:"&#10;" }}</pre>
{% else %}
<div class="muted">No include rules configured.</div>
{% endif %}
</div>
<div class="stack">
{% if effective_config.excludes %}
<pre>{{ effective_config.excludes|join:"&#10;" }}</pre>
{% else %}
<div class="muted">No exclude rules configured.</div>
{% endif %}
</div>
</div>
</article>
</div>
</section> </section>
{% endif %} {% endif %}
<section class="panel"> <section class="panel">
<h2>Backup Control</h2> <h2>Backup Options</h2>
<div class="operator-state"> <p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
{% if active_run %}
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
{% elif has_global_config and host.enabled %}
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
<span class="muted">{{ backup_gate.message }}</span>
{% elif not host.enabled %}
<span class="status failed">disabled</span>
{% elif not has_global_config %}
<span class="status failed">missing global config</span>
{% endif %}
</div>
<section class="actions inline" aria-label="Quick backup actions">
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
{% csrf_token %}
<input type="hidden" name="dry_run" value="on">
<input type="hidden" name="verbose_output" value="on">
<input type="hidden" name="prune_max_delete" value="10">
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
</form>
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
{% csrf_token %}
<input type="hidden" name="prune_max_delete" value="10">
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
</form>
</section>
{% if active_run %}
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
{% elif not can_queue_dry_run or not can_queue_real_backup %}
{% if not has_global_config %}
<p class="muted">Create the default global config before queueing backups.</p>
{% elif not host.enabled %}
<p class="muted">Enable this host before queueing backups.</p>
{% elif backup_gate.real_blockers %}
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
{% endif %}
{% endif %}
<h3>Advanced Options</h3>
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid"> <form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
{% csrf_token %} {% csrf_token %}
{{ manual_backup_form.non_field_errors }} {{ manual_backup_form.non_field_errors }}
@@ -327,58 +440,88 @@
</section> </section>
<section class="panel"> <section class="panel">
<h2>Latest Runs</h2> <h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
<table> <div class="record-list">
<thead>
<tr>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
<th>Base</th>
</tr>
</thead>
<tbody>
{% for run in latest_runs %} {% for run in latest_runs %}
<tr> <article class="record-card">
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td> <div class="record-card-header">
<td>{{ run.started_at|default:"" }}</td> <div class="record-title">
<td>{{ run.ended_at|default:"" }}</td> <a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a>
<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> <span class="muted">{{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %}</span>
<td>{{ run.base_path|default:"" }}</td> </div>
</tr> <span class="status {{ run.status }}">{{ run.status }}</span>
</div>
<div class="record-facts">
<div class="record-fact">
<span class="label">Started</span>
<strong>{{ run.started_at|default:run.created_at }}</strong>
</div>
<div class="record-fact">
<span class="label">Ended</span>
<strong>{{ run.ended_at|default:"running or queued" }}</strong>
</div>
<div class="record-fact">
<span class="label">Snapshot</span>
{% if run.snapshot %}
<strong><a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a></strong>
{% elif run.snapshot_path %}
<span class="muted">{{ run.snapshot_path }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
<div class="record-fact">
<span class="label">Base</span>
<span class="muted">{{ run.base_path|default:"none" }}</span>
</div>
</div>
</article>
{% empty %} {% empty %}
<tr><td colspan="5" class="muted">No backup runs recorded for this host.</td></tr> <p class="muted">No backup runs recorded for this host.</p>
{% endfor %} {% endfor %}
</tbody> </div>
</table>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Snapshots</h2> <h2>Snapshots <a class="button-link secondary compact" href="{% url 'snapshots_list' %}?host={{ host.host }}">View all</a></h2>
<table> <div class="record-list">
<thead>
<tr>
<th>Kind</th>
<th>Status</th>
<th>Started</th>
<th>Dirname</th>
<th>Base</th>
</tr>
</thead>
<tbody>
{% for snapshot in snapshots %} {% for snapshot in snapshots %}
<tr> <article class="record-card">
<td>{{ snapshot.kind }}</td> <div class="record-card-header">
<td>{{ snapshot.status }}</td> <div class="record-title">
<td>{{ snapshot.started_at|default:"" }}</td> <a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a>
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td> <span class="muted">{{ snapshot.kind }}</span>
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td> </div>
</tr> <span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>
</div>
<div class="record-facts">
<div class="record-fact">
<span class="label">Started</span>
<strong>{{ snapshot.started_at|default:"unknown" }}</strong>
</div>
<div class="record-fact">
<span class="label">Ended</span>
<strong>{{ snapshot.ended_at|default:"unknown" }}</strong>
</div>
<div class="record-fact">
<span class="label">Base</span>
{% if snapshot.base %}
<strong><a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a></strong>
{% elif snapshot.base_dirname %}
<span class="muted">{{ snapshot.base_dirname }}</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
<div class="record-fact">
<span class="label">Path</span>
<span class="muted">{{ snapshot.path|default:"not recorded" }}</span>
</div>
</div>
</article>
{% empty %} {% empty %}
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr> <p class="muted">No snapshots discovered for this host.</p>
{% endfor %} {% endfor %}
</tbody> </div>
</table>
</section> </section>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,8 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Installed version:") self.assertContains(response, "Installed version:")
self.assertContains(response, "Changelog file:")
self.assertNotContains(response, "Source:")
self.assertContains(response, "1.0.0 - 2026-05-21") self.assertContains(response, "1.0.0 - 2026-05-21")
self.assertContains(response, "Django control panel") self.assertContains(response, "Django control panel")
self.assertContains(response, "Native systemd installer") self.assertContains(response, "Native systemd installer")
@@ -99,6 +101,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Control panel")
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
self.assertContains(response, "Dashboard") self.assertContains(response, "Dashboard")
self.assertContains(response, "web-01") self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
@@ -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&amp;review=needed"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&amp;review=needed"', html=False)
self.assertContains(
response,
f'href="{reverse("runs_list")}?host=web-01&amp;status=failed&amp;review=needed"',
html=False,
)
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
def test_dashboard_renders_backup_trend_summary(self) -> None: 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&amp;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/***")
@@ -817,7 +923,7 @@ class ViewTests(TestCase):
self.assertContains(response, "15 2 * * *") self.assertContains(response, "15 2 * * *")
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.")
self.assertContains(response, "Next run:") self.assertContains(response, "Next run")
self.assertContains(response, "UTC") self.assertContains(response, "UTC")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "Discover snapshots") self.assertContains(response, "Discover snapshots")
@@ -830,10 +936,12 @@ class ViewTests(TestCase):
self.assertContains(response, "Host Check") self.assertContains(response, "Host Check")
self.assertContains(response, reverse("prepare_host_directories", args=[host.host])) self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
self.assertContains(response, "warning") self.assertContains(response, "warning")
self.assertContains(response, "Snapshot Discovery") self.assertContains(response, "Snapshot Storage")
self.assertContains(response, reverse("queue_manual_backup", args=[host.host])) self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id])) self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id])) self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
self.assertContains(response, f'{reverse("runs_list")}?host={host.host}', html=False)
self.assertContains(response, f'{reverse("snapshots_list")}?host={host.host}', html=False)
def test_host_detail_renders_effective_config_preview(self) -> None: def test_host_detail_renders_effective_config_preview(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -867,7 +975,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")
@@ -1111,7 +1223,8 @@ 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, f"Host root:</strong> {backup_root / host.host}") self.assertContains(response, "Host root")
self.assertContains(response, str(backup_root / host.host))
self.assertContains(response, "Found 2 snapshot directories") self.assertContains(response, "Found 2 snapshot directories")
self.assertContains(response, "scheduled 1") self.assertContains(response, "scheduled 1")
self.assertContains(response, "incomplete 1") self.assertContains(response, "incomplete 1")
@@ -1421,11 +1534,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 +1726,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 +1809,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 +2136,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 +2323,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,
) )

View File

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

View File

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