62 Commits

Author SHA1 Message Date
41ceab5a40 Merge pull request '(bugfix) Measure incomplete snapshot data from disk' (#70) from issue-69-data-overview-incomplete-sizes into master
Reviewed-on: #70
2026-05-28 21:34:22 +02:00
2ad119e214 (bugfix) Measure incomplete snapshot data from disk
Use filesystem usage for incomplete snapshots instead of trusting potentially
stale metadata, and expose unique non-hardlinked data totals for completed
snapshots.

Update dashboard and host storage summaries so incomplete data is visible and
complete snapshot totals distinguish allocated and unique data.
2026-05-28 21:33:26 +02:00
eb121453c8 Merge pull request '(feature) Add run completion notifications' (#68) from issue-50-run-notifications into master
Reviewed-on: #68
2026-05-28 21:20:59 +02:00
67ffd6101b (feature) Add run completion notifications
Add email and webhook notification targets with delivery tracking, and send
notifications when backup runs reach a terminal status.

Expose notification target management in the Django UI and keep delivery
failures recorded without failing the backup worker.
2026-05-28 21:20:38 +02:00
1f5c4e0756 Merge pull request '(feature) Require review before incomplete cleanup' (#67) from issue-47-reviewed-incomplete-cleanup into master
Reviewed-on: #67
2026-05-28 21:08:21 +02:00
b5e87abad2 (feature) Require review before incomplete cleanup
Require incomplete snapshots to be marked reviewed before the cleanup action
can delete them, and show review state in the retention plan UI.

Keep cleanup confirmation counts scoped to reviewed incomplete snapshots and
add coverage for blocked, reviewed, and deletion flows.
2026-05-28 21:07:59 +02:00
fc6df89370 Merge pull request '(bugfix) Make snapshot pruning robust to archived permissions' (#66) from issue-47-65-robust-prune-cleanup into master
Reviewed-on: #66
2026-05-28 20:59:23 +02:00
3893df4640 (bugfix) Make snapshot pruning robust to archived permissions
Repair user permissions inside snapshot trees before deleting them so
retention prune and incomplete cleanup can remove directories copied with
restrictive rsync archive modes.

Add path validation for scheduled/manual snapshot deletes and cover
non-traversable nested directories in retention tests.
2026-05-28 20:58:37 +02:00
f86c67aeee Merge pull request '## Summary' (#63) from bugfix/62-incomplete-backup-data-totals into master
Reviewed-on: #63
2026-05-23 01:36:18 +02:00
7dc4c1df84 ## Summary
- Fall back to filesystem measurement when snapshot storage metadata is missing.
- Prefer `data/` inside snapshot directories so incomplete snapshot metadata/log files are not counted as backup data.
- Add stats and dashboard rendering coverage for incomplete snapshots without recorded storage metadata.

## Tests
- .venv/bin/python manage.py test src.pobsync_backend.tests.test_stats_summary src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_host_cards_measure_incomplete_data_without_snapshot_metadata --verbosity 2
- .venv/bin/python manage.py check
- git diff --check
- .venv/bin/python manage.py test src.pobsync_backend --verbosity 2

Closes #62
2026-05-23 01:35:38 +02:00
10e0293559 Merge pull request '(feature) Show backup data totals by snapshot kind' (#61) from issue-53-host-backup-data-totals into master
Reviewed-on: #61
2026-05-23 01:28:18 +02:00
9dd690bb3b (feature) Show backup data totals by snapshot kind
Aggregate snapshot storage metadata by snapshot kind so operators can see
scheduled, manual, incomplete, and total backup data.

Surface the totals per host and across all hosts on the dashboard, using
allocated snapshot size from recorded backup metadata without rescanning
backup storage.
2026-05-23 01:27:51 +02:00
8740b75841 Merge pull request '## Summary' (#60) from issue-48-49-hosts-page-controls into master
Reviewed-on: #60
2026-05-23 01:14:48 +02:00
ce1cb9d157 ## Summary
- Add a dedicated `/hosts/` page with host cards and enabled/disabled filtering.
- Link the dashboard Hosts metric and top navigation to the new page.
- Add host enable/disable plus schedule and scheduled-retention pause/resume actions.

## Tests
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_views.ViewTests.test_base_navigation_groups_primary_and_system_links src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_renders_hosts_and_latest_runs src.pobsync_backend.tests.test_views.ViewTests.test_dashboard_hosts_live_returns_hosts_partial src.pobsync_backend.tests.test_views.ViewTests.test_hosts_list_renders_host_cards_and_controls src.pobsync_backend.tests.test_views.ViewTests.test_hosts_list_filters_by_enabled_state src.pobsync_backend.tests.test_views.ViewTests.test_update_host_state_toggles_host_schedule_and_retention --verbosity 2`
- `.venv/bin/python manage.py check`
- `.venv/bin/python manage.py test src.pobsync_backend --verbosity 2`

Closes #48
Closes #49
2026-05-23 01:13:32 +02:00
8e83fee7b5 Merge pull request '## Summary' (#59) from issue-55-writable-test-state into master
Reviewed-on: #59
2026-05-23 01:07:26 +02:00
a6d6468da8 ## Summary
- Override `POBSYNC_HOME` in the filesystem SSH credential test.
- Keep credential materialization tests self-contained and writable in local development.
- Leave production runtime defaults unchanged.

## Tests
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_django_config_source --verbosity 2`
- `.venv/bin/python manage.py test src.pobsync_backend --verbosity 2`

Closes #55
2026-05-23 01:06:22 +02:00
b87203c538 Merge pull request '## Summary' (#58) from issue-51-bandwidth-limit into master
Reviewed-on: #58
2026-05-23 01:02:28 +02:00
515330c436 ## Summary
- Add per-host rsync bandwidth limit overrides with inherit/unlimited semantics.
- Store the effective bwlimit in run metadata/results and show it in host/run detail views.
- Document recommended starting values for VPN and remote backups.

## Tests
- `.venv/bin/python manage.py makemigrations --check --dry-run`
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_returns_effective_config_from_database src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_host_can_disable_global_rsync_bandwidth_limit src.pobsync_backend.tests.test_configure_commands.ConfigureCommandsTests.test_configure_host_uses_global_retention_defaults src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_dry_run_applies_configured_bandwidth_limit src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_real_run_can_request_verbose_output_args --verbosity 2`
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_views.ViewTests.test_create_host_config_form_creates_host src.pobsync_backend.tests.test_views.ViewTests.test_host_detail_renders_effective_config_preview src.pobsync_backend.tests.test_views.ViewTests.test_run_detail_renders_result_payload src.pobsync_backend.tests.test_views.ViewTests.test_host_config_form_updates_host_config --verbosity 2`
- `.venv/bin/python manage.py check`

Closes #51
2026-05-23 00:59:55 +02:00
fdf401a0be Merge pull request '(refactor) Unify run progress panels' (#57) from issue-52-live-normal-runs into master
Reviewed-on: #57
2026-05-23 00:48:22 +02:00
3b77f2e5d0 (refactor) Unify run progress panels
Use a shared Run Progress presentation for dry-runs and normal backup
runs so live run feedback is consistent across run types.

Keep mode-specific metrics while aligning status, mode, log, and warning
layout.

Refs #52
2026-05-23 00:46:52 +02:00
df9ec5b04c Merge pull request 'issue-54-worker-rsync-state' (#56) from issue-54-worker-rsync-state into master
Reviewed-on: #56
2026-05-23 00:39:47 +02:00
5788f53854 (bugfix) Keep rsync runner callback optional
Only pass the process_started hook when live run state tracking is active,
so existing rsync call sites and tests without that hook remain compatible.

Refs #54
2026-05-23 00:31:24 +02:00
28da9c4096 (bugfix) Track rsync process state for running backups
Record rsync process pid and execution phase while normal backup runs are
active so the worker can reconcile stale running rows when rsync has
already disappeared.

Keep finalizing runs out of the missing-process path to avoid marking
slow post-rsync stats collection as a failed transfer.

Closes #54
2026-05-23 00:26:22 +02:00
6eb1b4add3 (bugfix) Reconcile real rsync failures from worker logs
Record live rsync log paths for normal backup runs so the worker can
recover stale running state after terminal rsync errors.

Treat rsync vanished-file exit code 24 as a warning and keep the
completed snapshot instead of failing the run into incomplete state.

Closes #54
2026-05-23 00:23:14 +02:00
8633cbea26 Merge pull request '(bugfix) Quote remote preflight shell commands' (#46) from issue-45-preflight-shell-quoting into master
Reviewed-on: #46
2026-05-21 15:45:46 +02:00
3fb8209aef (bugfix) Quote remote preflight shell commands
Pass remote rsync and source-root preflight checks as a single quoted
shell command to SSH so the remote shell evaluates command -v and test
expressions reliably.

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

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

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

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

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

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

Refs #37
2026-05-21 14:56:30 +02:00
a3a8fea071 Merge pull request 'issue-38-dashboard-responsive-layout' (#41) from issue-38-dashboard-responsive-layout into master
Reviewed-on: #41
2026-05-21 14:51:34 +02:00
0e2f48ab65 (ui) Remove dashboard panel overflow traps
Let dashboard-specific panels size naturally instead of inheriting generic
panel overflow behavior, and tighten activity and host card sizing so long
content wraps without creating internal scrollbars.

Refs #38
2026-05-21 14:50:05 +02:00
b55950e24a (ui) Add dashboard section responsive hooks
Give dashboard summary, trends, and host sections dedicated layout hooks
and tighten their responsive behavior so metrics and host cards remain
readable on narrower screens.

Refs #38
2026-05-21 14:46:23 +02:00
025cd0336c (ui) Harden dashboard responsive layout
Rework the dashboard priority grid to avoid cramped four-column layouts,
prevent stretched empty panels, and make long host and snapshot text wrap
safely across dashboard cards.

Refs #38
2026-05-21 14:43:24 +02:00
4c76ae9f52 Merge pull request 'Polish forms and action flows' (#35) from issue-25-forms-action-flows into master
Reviewed-on: #35
2026-05-21 14:28:20 +02:00
7a552715fe (ui) Clarify run action flows
Move run cancellation and review actions out of the page header into
dedicated action panels with clearer operator copy and consistent form
button styling.

Refs #25
2026-05-21 14:25:26 +02:00
0f0de5dc30 (ui) Standardize list filter actions
Give run, snapshot, schedule, purged snapshot, and log filters the same
responsive form layout with consistent Apply/Clear actions.

Refs #25
2026-05-21 14:22:11 +02:00
1604f0f6f4 (ui) Clarify destructive action flows
Make retention apply, incomplete cleanup, and SSH key deletion visibly
destructive with warning copy, danger styling, and consistent cancel actions
while keeping the existing confirmation requirements intact.

Refs #25
2026-05-21 14:17:07 +02:00
af548f11c4 (ui) Standardize primary form actions
Add shared form action styling and consistent Cancel links across config,
schedule, and SSH key forms so create/edit flows behave predictably.

Refs #25
2026-05-21 14:13:05 +02:00
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
56 changed files with 5290 additions and 981 deletions

View File

@@ -1,5 +1,27 @@
# Changelog
## 1.1.0 - 2026-05-21
UI-focused release for the Django control panel.
### Added
- Dedicated list pages for runs, snapshots, schedules, purged snapshots, and changelog navigation.
- Dashboard priority panels for required action, next scheduled work, recent activity, and storage pressure.
- Dashboard host cards with clearer backup activity, snapshot health, next run, and retention status.
- Lightweight live refresh for active run detail pages, including status, timing, controls, and rsync log output.
- Lightweight live refresh for dashboard priority and host status sections.
- Current-page navigation states for primary and system navigation.
- Responsive dashboard behavior for narrower screens.
### Changed
- Reworked the primary navigation around day-to-day operator workflows and moved admin/system links out of the main path.
- Simplified legacy-facing labels and removed source-of-truth wording that no longer applies to the Django-first model.
- Improved run and snapshot detail pages with clearer links between backup runs, snapshots, logs, and review actions.
- Improved dashboard spacing and card layouts to reduce cramped or overlapping text.
- Documented the Django-template-first partial refresh pattern for future UI work.
## 1.0.0 - 2026-05-21
Initial stable release of the Django-first pobsync control panel.

View File

@@ -154,9 +154,22 @@ The UI includes:
- `/self-check/` for runtime checks
- `/logs/` for filtered pobsync service logs
## Bandwidth Limits
Global config can set an rsync bandwidth limit in KB/s. The default `0` means unlimited. Each host can inherit the
global value, set `0` to explicitly run unlimited, or set its own limit for slower remote links.
For VPN-backed or remote backups, start conservatively and adjust after watching normal traffic:
- `2500` KB/s is roughly 20 Mbit/s
- `5000` KB/s is roughly 40 Mbit/s
- `10000` KB/s is roughly 80 Mbit/s
pobsync passes the effective value to rsync as `--bwlimit=<KB/s>` and shows it on the host detail and run detail pages.
## Restoring Data
pobsync 1.0 treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
detail page, but it does not run restore commands for you yet. That is deliberate: restores should be inspected and
tested before data is copied back into a live system.

View File

@@ -50,6 +50,16 @@ python3 manage.py showmigrations pobsync_backend
The short `pobsync` aliases are limited to operational actions that are useful while debugging a running install.
Configuration aliases are intentionally not public commands; use the Django UI or explicit management commands instead.
## UI Refresh Pattern
The control panel stays Django-template-first. Pages that need live status should expose a small server-rendered partial
view and opt into refresh with `data-refresh-url` and `data-refresh-interval` on the container that should be replaced.
The shared script in `base.html` polls only those explicit regions, skips refreshes while the browser tab is hidden, and
lets the partial response turn polling off with the `X-Pobsync-Refresh-Active: false` header.
Use this for operational status surfaces such as running backup details. Avoid refreshing form-heavy sections while an
operator might be typing.
Worker and scheduler commands are normally run by systemd services:
```

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pobsync"
version = "1.0.0"
version = "1.1.0"
description = "Pull-based rsync backup tool with hardlinked snapshots"
requires-python = ">=3.11"
dependencies = [

View File

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

View File

@@ -23,6 +23,7 @@ from ..util import ensure_dir, realpath_startswith, sanitize_host, write_yaml_at
DEFAULT_DRY_RUN_TIMEOUT_SECONDS = 900
RSYNC_PARTIAL_VANISHED_EXIT_CODE = 24
def dry_run_log_path(host: str, run_id: int | None = None) -> Path:
@@ -72,6 +73,24 @@ def classify_rsync_failure(exit_code: int | None, log_tail: list[str]) -> dict[s
}
def classify_rsync_warning(exit_code: int | None, log_tail: list[str]) -> dict[str, str] | None:
joined_tail = "\n".join(log_tail).lower()
if exit_code == RSYNC_PARTIAL_VANISHED_EXIT_CODE:
return {
"category": "vanished",
"message": "Some source files vanished during rsync.",
"hint": "This is common on live systems. The snapshot was kept, but review the rsync log if this happens often.",
}
if exit_code in (None, RSYNC_PARTIAL_VANISHED_EXIT_CODE) and (
"file has vanished" in joined_tail or "vanished before it could be transferred" in joined_tail
):
return {
"category": "vanished",
"message": "Some source files vanished during rsync.",
"hint": "This is common on live systems. The snapshot was kept, but review the rsync log if this happens often.",
}
return None
def _collect_run_stats(
*,
log_path: Path,
@@ -158,6 +177,7 @@ def run_scheduled(
run_id: int | None = None,
cancel_check: Callable[[], bool] | None = None,
verbose_output: bool = False,
state_callback: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
host = sanitize_host(host)
@@ -258,6 +278,7 @@ def run_scheduled(
"exit_code": result.exit_code,
"command": result.command,
"log_tail": log_tail,
"bwlimit_kbps": bwlimit_kbps,
},
}
if result.exit_code != 0:
@@ -316,20 +337,65 @@ def run_scheduled(
"ended_at": None,
"duration_seconds": None,
"base": _base_meta_from_path(base_dir, link_dest),
"rsync": {"exit_code": None, "command": cmd, "stats": {}},
"rsync": {"exit_code": None, "command": cmd, "stats": {}, "bwlimit_kbps": bwlimit_kbps},
"overrides": {"includes": [], "excludes": [], "base": None},
}
log_path.touch(exist_ok=True)
write_yaml_atomic(meta_path, meta)
if state_callback is not None:
state_callback(
{
"status": "running",
"phase": "preparing",
"snapshot": str(incomplete_dir),
"log": str(log_path),
"rsync": {"command": cmd, "exit_code": None, "bwlimit_kbps": bwlimit_kbps},
}
)
result = run_rsync(cmd, log_path=log_path, timeout_seconds=timeout_seconds, cancel_check=cancel_check)
def process_started(pid: int, pgid: int) -> None:
if state_callback is None:
return
state_callback(
{
"status": "running",
"phase": "rsync",
"snapshot": str(incomplete_dir),
"log": str(log_path),
"rsync": {"command": cmd, "exit_code": None, "pid": pid, "pgid": pgid, "bwlimit_kbps": bwlimit_kbps},
}
)
run_rsync_kwargs: dict[str, Any] = {
"log_path": log_path,
"timeout_seconds": timeout_seconds,
"cancel_check": cancel_check,
}
if state_callback is not None:
run_rsync_kwargs["process_started"] = process_started
result = run_rsync(cmd, **run_rsync_kwargs)
log_tail = _read_log_tail(log_path)
warning = classify_rsync_warning(result.exit_code, log_tail)
successful_or_warning = result.exit_code == 0 or warning is not None
if state_callback is not None:
state_callback(
{
"status": "running",
"phase": "finalizing",
"snapshot": str(incomplete_dir),
"log": str(log_path),
"rsync": {"command": cmd, "exit_code": result.exit_code, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps},
}
)
end_ts = utc_now()
meta["ended_at"] = format_iso_z(end_ts)
meta["duration_seconds"] = int((end_ts - ts).total_seconds())
meta["rsync"]["exit_code"] = result.exit_code
meta["status"] = "cancelled" if result.cancelled else ("success" if result.exit_code == 0 else "failed")
meta["status"] = "cancelled" if result.cancelled else ("warning" if warning else ("success" if result.exit_code == 0 else "failed"))
if warning is not None:
meta["warning"] = warning
meta["stats"] = _collect_run_stats(
log_path=log_path,
backup_root=Path(backup_root),
@@ -349,8 +415,7 @@ def run_scheduled(
"error": "rsync.log missing after execution",
}
if result.exit_code != 0:
log_tail = _read_log_tail(log_path)
if not successful_or_warning:
return {
"ok": False,
"dry_run": False,
@@ -366,6 +431,7 @@ def run_scheduled(
"exit_code": result.exit_code,
"command": result.command,
"log_tail": log_tail,
"bwlimit_kbps": bwlimit_kbps,
},
"failure": classify_rsync_failure(result.exit_code, log_tail),
}
@@ -404,7 +470,9 @@ def run_scheduled(
"snapshot": str(final_dir),
"base": str(base_dir) if base_dir else None,
"log": str(final_log_path),
"rsync": {"exit_code": result.exit_code},
"status": meta["status"],
"warning": warning,
"rsync": {"exit_code": result.exit_code, "command": result.command, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps},
"verbose_output": bool(verbose_output),
"duration_seconds": meta["duration_seconds"],
"stats": meta["stats"],

View File

@@ -110,6 +110,7 @@ GLOBAL_SCHEMA = Schema(
HOST_RSYNC_SCHEMA = Schema(
fields={
"bwlimit_kbps": FieldSpec(int, required=False, min_value=0),
"extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)),
},
allow_unknown=False,

View File

@@ -82,6 +82,7 @@ def run_rsync(
log_path: Path,
timeout_seconds: int,
cancel_check: Callable[[], bool] | None = None,
process_started: Callable[[int, int], None] | None = None,
) -> RsyncResult:
"""
Run rsync and always write stdout/stderr to log_path.
@@ -95,6 +96,8 @@ def run_rsync(
with log_path.open("ab") as f:
process = subprocess.Popen(command, stdout=f, stderr=subprocess.STDOUT, start_new_session=True)
if process_started is not None:
process_started(process.pid, os.getpgid(process.pid))
started = time.monotonic()
while True:
exit_code = process.poll()

View File

@@ -6,7 +6,17 @@ from django.urls import reverse
from django.utils.html import format_html
from django.utils.http import urlencode
from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
from .models import (
BackupRun,
GlobalConfig,
HostConfig,
NotificationDelivery,
NotificationTarget,
PurgedSnapshot,
ScheduleConfig,
SnapshotRecord,
SshCredential,
)
@admin.register(SshCredential)
@@ -73,7 +83,7 @@ class HostConfigAdmin(admin.ModelAdmin):
(None, {"fields": ("host", "address", "enabled")}),
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}),
("Rsync override", {"fields": ("rsync_extra_args",)}),
("Rsync override", {"fields": ("rsync_extra_args", "rsync_bwlimit_kbps")}),
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
("Runtime state", {"fields": ("config",), "classes": ("collapse",)}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
@@ -136,6 +146,38 @@ class BackupRunAdmin(admin.ModelAdmin):
return format_html('<a href="{}">{}</a>', url, obj.snapshot.dirname)
@admin.register(NotificationTarget)
class NotificationTargetAdmin(admin.ModelAdmin):
list_display = ("name", "channel", "enabled", "last_status", "last_sent_at", "updated_at")
list_filter = ("enabled", "channel", "last_status")
search_fields = ("name", "email_to", "webhook_url", "notes")
readonly_fields = ("created_at", "updated_at", "last_status", "last_error", "last_sent_at")
fieldsets = (
(None, {"fields": ("name", "enabled", "channel", "statuses")}),
("Email", {"fields": ("email_to",)}),
("Webhook", {"fields": ("webhook_url", "webhook_headers")}),
("State", {"fields": ("last_status", "last_error", "last_sent_at"), "classes": ("collapse",)}),
("Notes", {"fields": ("notes",), "classes": ("collapse",)}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
)
@admin.register(NotificationDelivery)
class NotificationDeliveryAdmin(admin.ModelAdmin):
list_display = ("target", "run", "status", "created_at")
list_filter = ("status", "target__channel", "created_at")
search_fields = ("target__name", "run__host__host", "error")
readonly_fields = ("target", "run", "status", "error", "payload", "created_at")
list_select_related = ("target", "run", "run__host")
date_hierarchy = "created_at"
def has_add_permission(self, request) -> bool:
return False
def has_change_permission(self, request, obj=None) -> bool:
return False
@admin.register(SnapshotRecord)
class SnapshotRecordAdmin(admin.ModelAdmin):
list_display = ("host", "kind", "dirname", "status", "base_link", "backup_run_count_link", "started_at", "discovered_at")

View File

@@ -8,9 +8,16 @@ from pathlib import Path
from django.db import transaction
from django.utils import timezone
from pobsync.commands.run_scheduled import DEFAULT_DRY_RUN_TIMEOUT_SECONDS, classify_rsync_failure, dry_run_log_path, run_scheduled
from pobsync.commands.run_scheduled import (
DEFAULT_DRY_RUN_TIMEOUT_SECONDS,
classify_rsync_failure,
classify_rsync_warning,
dry_run_log_path,
run_scheduled,
)
from pobsync_backend.config_source import DjangoConfigSource
from pobsync_backend.models import BackupRun, HostConfig
from pobsync_backend.notifications import notify_backup_run_completed
from pobsync_backend.retention import run_sql_retention_apply
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
@@ -66,6 +73,7 @@ def execute_backup_run(
run_id=run.id,
cancel_check=lambda: _run_cancel_requested(run.id),
verbose_output=bool(dry_run or verbose_output),
state_callback=lambda state: _record_running_state(run.id, state),
)
except Exception as exc:
run.refresh_from_db()
@@ -78,11 +86,14 @@ def execute_backup_run(
"type": type(exc).__name__,
}
run.save(update_fields=["status", "ended_at", "result"])
notify_backup_run_completed(run)
raise
run.refresh_from_db()
if result.get("cancelled") or run.status == BackupRun.Status.CANCELLED:
run.status = BackupRun.Status.CANCELLED
elif result.get("status") == BackupRun.Status.WARNING:
run.status = BackupRun.Status.WARNING
else:
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
run.ended_at = timezone.now()
@@ -142,6 +153,7 @@ def execute_backup_run(
"result",
],
)
notify_backup_run_completed(run)
return run
@@ -201,11 +213,100 @@ def _run_cancel_requested(run_id: int) -> bool:
return False
def _record_running_state(run_id: int, state: dict[str, object]) -> None:
try:
run = BackupRun.objects.only("id", "status", "result", "snapshot_path", "rsync_exit_code").get(id=run_id)
except BackupRun.DoesNotExist:
return
if run.status != BackupRun.Status.RUNNING:
return
result = run.result if isinstance(run.result, dict) else {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
incoming_rsync = state.get("rsync") if isinstance(state.get("rsync"), dict) else {}
log_path = state.get("log")
snapshot_path = state.get("snapshot")
phase = state.get("phase")
if isinstance(phase, str) and phase:
execution["phase"] = phase
if isinstance(log_path, str) and log_path:
execution["log"] = log_path
if isinstance(snapshot_path, str) and snapshot_path:
execution["snapshot"] = snapshot_path
run.snapshot_path = snapshot_path
if incoming_rsync:
result["rsync"] = {**rsync, **incoming_rsync}
exit_code = incoming_rsync.get("exit_code")
if isinstance(exit_code, int):
run.rsync_exit_code = exit_code
result["execution"] = {
**execution,
"worker_pid": os.getpid(),
"worker_host": socket.gethostname(),
"heartbeat_at": timezone.now().isoformat(),
}
run.result = result
run.save(update_fields=["snapshot_path", "rsync_exit_code", "result"])
def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_seconds: int) -> bool:
result = run.result if isinstance(run.result, dict) else {}
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
log_path = _execution_log_path(result)
log_tail = _read_log_tail(log_path) if log_path is not None else []
terminal_log = _terminal_rsync_log(log_tail)
exit_code = _exit_code_from_log(log_tail)
stale_worker = _running_worker_timed_out(run=run, stale_worker_seconds=stale_worker_seconds)
if not requested.get("dry_run"):
if terminal_log:
failure = classify_rsync_failure(exit_code or 255, log_tail)
result.update(
{
"ok": False,
"host": run.host.host,
"log": str(log_path) if log_path else "",
"failure": failure,
"rsync": {
**(result.get("rsync") if isinstance(result.get("rsync"), dict) else {}),
"exit_code": exit_code or 255,
"log_tail": log_tail,
},
}
)
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.rsync_exit_code = exit_code or 255
run.result = result
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
notify_backup_run_completed(run)
return True
if _running_rsync_process_missing(run=run, grace_seconds=grace_seconds):
result.update(
{
"ok": False,
"host": run.host.host,
"log": str(log_path) if log_path else "",
"failure": {
"category": "rsync_process",
"message": "The rsync process is no longer running while the backup is still marked running.",
"hint": "Check the rsync log and pobsync-worker.service logs before retrying the backup.",
},
"rsync": {
**(result.get("rsync") if isinstance(result.get("rsync"), dict) else {}),
"exit_code": exit_code or 255,
"log_tail": log_tail,
},
}
)
run.status = BackupRun.Status.FAILED
run.ended_at = timezone.now()
run.rsync_exit_code = exit_code or 255
run.result = result
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
notify_backup_run_completed(run)
return True
if stale_worker:
result.update(
{
@@ -222,17 +323,15 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_s
run.ended_at = timezone.now()
run.result = result
run.save(update_fields=["status", "ended_at", "result"])
notify_backup_run_completed(run)
return True
return False
log_path = _execution_log_path(result)
log_tail = _read_log_tail(log_path) if log_path is not None else []
terminal_log = _terminal_rsync_log(log_tail)
timed_out = _running_dry_run_timed_out(run=run, grace_seconds=grace_seconds)
if not terminal_log and not timed_out and not stale_worker:
return False
exit_code = _exit_code_from_log(log_tail) or (124 if timed_out or stale_worker else 255)
exit_code = exit_code or (124 if timed_out or stale_worker else 255)
failure = classify_rsync_failure(exit_code, log_tail)
if stale_worker and not terminal_log:
failure = {
@@ -260,6 +359,7 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_s
run.rsync_exit_code = exit_code
run.result = result
run.save(update_fields=["status", "ended_at", "rsync_exit_code", "result"])
notify_backup_run_completed(run)
return True
@@ -305,6 +405,9 @@ def _read_log_tail(log_path: Path | None, *, max_lines: int = 40) -> list[str]:
def _terminal_rsync_log(log_tail: list[str]) -> bool:
warning = classify_rsync_warning(_exit_code_from_log(log_tail), log_tail)
if warning is not None:
return False
return any(line.startswith("rsync error:") for line in log_tail)
@@ -312,6 +415,8 @@ def _exit_code_from_log(log_tail: list[str]) -> int | None:
for line in reversed(log_tail):
if "code 255" in line:
return 255
if "code 24" in line:
return 24
if "code 124" in line:
return 124
if "code 12" in line:
@@ -342,6 +447,33 @@ def _running_worker_timed_out(*, run: BackupRun, stale_worker_seconds: int) -> b
return timezone.now() >= heartbeat_at + timedelta(seconds=stale_worker_seconds)
def _running_rsync_process_missing(*, run: BackupRun, grace_seconds: int) -> bool:
if grace_seconds <= 0:
return False
result = run.result if isinstance(run.result, dict) else {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
if execution.get("phase") != "rsync":
return False
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
pid = rsync.get("pid")
if not isinstance(pid, int) or pid <= 0:
return False
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at")) or run.started_at
if heartbeat_at is None or timezone.now() < heartbeat_at + timedelta(seconds=grace_seconds):
return False
return not _process_exists(pid)
def _process_exists(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _parse_iso_datetime(value: object):
if not isinstance(value, str) or not value:
return None

View File

@@ -68,8 +68,12 @@ def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]:
data["excludes_replace"] = list(host_config.excludes_replace or [])
else:
data["excludes_add"] = list(host_config.excludes_add or [])
if host_config.rsync_extra_args or host_config.rsync_bwlimit_kbps is not None:
data["rsync"] = {}
if host_config.rsync_extra_args:
data["rsync"] = {"extra_args": list(host_config.rsync_extra_args or [])}
data["rsync"]["extra_args"] = list(host_config.rsync_extra_args or [])
if host_config.rsync_bwlimit_kbps is not None:
data["rsync"]["bwlimit_kbps"] = host_config.rsync_bwlimit_kbps
return validate_dict(data, HOST_SCHEMA, path="host")

View File

@@ -9,7 +9,7 @@ from tempfile import TemporaryDirectory
from django import forms
from django.conf import settings
from .models import GlobalConfig, HostConfig, ScheduleConfig, SshCredential
from .models import BackupRun, GlobalConfig, HostConfig, NotificationTarget, ScheduleConfig, SshCredential
from .scheduler import parse_cron_expr
@@ -60,6 +60,7 @@ class HostConfigForm(forms.ModelForm):
"excludes_add",
"excludes_replace",
"rsync_extra_args",
"rsync_bwlimit_kbps",
"retention_daily",
"retention_weekly",
"retention_monthly",
@@ -70,6 +71,7 @@ class HostConfigForm(forms.ModelForm):
"ssh_user": "Leave empty to use the global SSH user.",
"ssh_port": "Leave empty to use the global SSH port.",
"source_root": "Leave empty to use the global default source root.",
"rsync_bwlimit_kbps": "Leave empty to inherit the global limit. Use 0 for unlimited on this host.",
}
@@ -112,6 +114,7 @@ class GlobalConfigForm(forms.ModelForm):
help_texts = {
"name": "Usually 'default'. The backup engine currently reads the default config.",
"default_ssh_credential": "Optional. Used by hosts without their own SSH credential.",
"rsync_bwlimit_kbps": "Rsync bandwidth limit in KB/s. Use 0 for unlimited.",
"default_source_root": "Used by hosts without a custom source root.",
"default_destination_subdir": "Optional subdirectory below each snapshot.",
}
@@ -150,6 +153,62 @@ class ManualBackupForm(forms.Form):
)
class NotificationTargetForm(forms.ModelForm):
TERMINAL_STATUS_CHOICES = (
(BackupRun.Status.SUCCESS, BackupRun.Status.SUCCESS.label),
(BackupRun.Status.WARNING, BackupRun.Status.WARNING.label),
(BackupRun.Status.FAILED, BackupRun.Status.FAILED.label),
(BackupRun.Status.CANCELLED, BackupRun.Status.CANCELLED.label),
)
statuses = forms.MultipleChoiceField(
choices=TERMINAL_STATUS_CHOICES,
widget=forms.CheckboxSelectMultiple,
initial=[choice[0] for choice in TERMINAL_STATUS_CHOICES],
help_text="Send notifications for these terminal run statuses.",
)
email_to = forms.CharField(
widget=forms.Textarea,
required=False,
help_text="One recipient per line, or comma-separated.",
)
webhook_headers = forms.JSONField(
required=False,
widget=forms.Textarea(attrs={"rows": 4}),
help_text='Optional JSON object with extra headers, for example {"Authorization": "Bearer ..."}.',
)
class Meta:
model = NotificationTarget
fields = (
"name",
"enabled",
"channel",
"statuses",
"email_to",
"webhook_url",
"webhook_headers",
"notes",
)
widgets = {
"notes": forms.Textarea,
}
def clean(self):
cleaned_data = super().clean()
channel = cleaned_data.get("channel")
if channel == NotificationTarget.Channel.EMAIL and not cleaned_data.get("email_to", "").strip():
self.add_error("email_to", "Email targets need at least one recipient.")
if channel == NotificationTarget.Channel.WEBHOOK and not cleaned_data.get("webhook_url"):
self.add_error("webhook_url", "Webhook targets need a URL.")
return cleaned_data
def clean_email_to(self) -> str:
value = self.cleaned_data.get("email_to", "")
recipients = [line.strip() for line in value.replace(",", "\n").splitlines() if line.strip()]
return "\n".join(recipients)
class SshCredentialForm(forms.ModelForm):
private_key_file = forms.FileField(
required=False,

View File

@@ -22,6 +22,12 @@ class Command(BaseCommand):
parser.add_argument("--exclude-add", action="append", default=[])
parser.add_argument("--exclude-replace", action="append", default=None)
parser.add_argument("--rsync-extra-arg", action="append", default=[])
parser.add_argument(
"--rsync-bwlimit-kbps",
type=int,
default=None,
help="Host rsync bandwidth limit in KB/s. Omit to inherit global; set 0 for unlimited.",
)
parser.add_argument("--retention", default=None)
parser.add_argument("--disabled", action="store_true")
parser.add_argument("--force", action="store_true", help="Update existing host")
@@ -42,6 +48,7 @@ class Command(BaseCommand):
"excludes_add": [] if options["exclude_replace"] is not None else list(options["exclude_add"]),
"excludes_replace": options["exclude_replace"],
"rsync_extra_args": list(options["rsync_extra_arg"]),
"rsync_bwlimit_kbps": options["rsync_bwlimit_kbps"],
"retention_daily": retention["daily"],
"retention_weekly": retention["weekly"],
"retention_monthly": retention["monthly"],

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-22 22:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pobsync_backend', '0013_purgedsnapshot'),
]
operations = [
migrations.AddField(
model_name='hostconfig',
name='rsync_bwlimit_kbps',
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.2.14 on 2026-05-28 19:11
import django.db.models.deletion
import pobsync_backend.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pobsync_backend', '0014_host_bwlimit_override'),
]
operations = [
migrations.CreateModel(
name='NotificationTarget',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=128, unique=True)),
('enabled', models.BooleanField(default=True)),
('channel', models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook')], max_length=16)),
('statuses', models.JSONField(blank=True, default=pobsync_backend.models.default_notification_statuses)),
('email_to', models.TextField(blank=True)),
('webhook_url', models.URLField(blank=True, max_length=1024)),
('webhook_headers', models.JSONField(blank=True, default=dict)),
('notes', models.TextField(blank=True)),
('last_status', models.CharField(blank=True, max_length=16)),
('last_error', models.TextField(blank=True)),
('last_sent_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='NotificationDelivery',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('sent', 'Sent'), ('failed', 'Failed'), ('skipped', 'Skipped')], max_length=16)),
('error', models.TextField(blank=True)),
('payload', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_deliveries', to='pobsync_backend.backuprun')),
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='pobsync_backend.notificationtarget')),
],
options={
'verbose_name_plural': 'notification deliveries',
'ordering': ['-created_at', 'target__name'],
'constraints': [models.UniqueConstraint(fields=('target', 'run'), name='unique_notification_delivery_per_target_run')],
},
),
]

View File

@@ -63,6 +63,7 @@ class HostConfig(TimestampedModel):
excludes_add = models.JSONField(default=list, blank=True)
excludes_replace = models.JSONField(null=True, blank=True)
rsync_extra_args = models.JSONField(default=list, blank=True)
rsync_bwlimit_kbps = models.PositiveIntegerField(null=True, blank=True)
retention_daily = models.PositiveIntegerField(default=14)
retention_weekly = models.PositiveIntegerField(default=8)
retention_monthly = models.PositiveIntegerField(default=12)
@@ -134,6 +135,63 @@ class BackupRun(models.Model):
return f"{self.host} {self.run_type} {self.status}"
def default_notification_statuses() -> list[str]:
return [
BackupRun.Status.SUCCESS,
BackupRun.Status.WARNING,
BackupRun.Status.FAILED,
BackupRun.Status.CANCELLED,
]
class NotificationTarget(TimestampedModel):
class Channel(models.TextChoices):
EMAIL = "email", "Email"
WEBHOOK = "webhook", "Webhook"
name = models.CharField(max_length=128, unique=True)
enabled = models.BooleanField(default=True)
channel = models.CharField(max_length=16, choices=Channel.choices)
statuses = models.JSONField(default=default_notification_statuses, blank=True)
email_to = models.TextField(blank=True)
webhook_url = models.URLField(max_length=1024, blank=True)
webhook_headers = models.JSONField(default=dict, blank=True)
notes = models.TextField(blank=True)
last_status = models.CharField(max_length=16, blank=True)
last_error = models.TextField(blank=True)
last_sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class NotificationDelivery(models.Model):
class Status(models.TextChoices):
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
SKIPPED = "skipped", "Skipped"
target = models.ForeignKey(NotificationTarget, on_delete=models.CASCADE, related_name="deliveries")
run = models.ForeignKey(BackupRun, on_delete=models.CASCADE, related_name="notification_deliveries")
status = models.CharField(max_length=16, choices=Status.choices)
error = models.TextField(blank=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["target", "run"], name="unique_notification_delivery_per_target_run"),
]
ordering = ["-created_at", "target__name"]
verbose_name_plural = "notification deliveries"
def __str__(self) -> str:
return f"{self.target} run {self.run_id} {self.status}"
class SnapshotRecord(models.Model):
class Kind(models.TextChoices):
SCHEDULED = "scheduled", "Scheduled"

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
from django.conf import settings
from django.core.mail import send_mail
from django.utils import timezone
from .models import BackupRun, NotificationDelivery, NotificationTarget
TERMINAL_RUN_STATUSES = {
BackupRun.Status.SUCCESS,
BackupRun.Status.WARNING,
BackupRun.Status.FAILED,
BackupRun.Status.CANCELLED,
}
@dataclass(frozen=True)
class DeliveryResult:
target: NotificationTarget
delivery: NotificationDelivery
sent: bool
def notify_backup_run_completed(run: BackupRun) -> list[DeliveryResult]:
if run.status not in TERMINAL_RUN_STATUSES:
return []
targets = [target for target in NotificationTarget.objects.filter(enabled=True) if _target_wants_status(target, run.status)]
return [_notify_target(target=target, run=run) for target in targets]
def _target_wants_status(target: NotificationTarget, status: str) -> bool:
statuses = target.statuses
if not isinstance(statuses, list):
return False
return status in {str(item) for item in statuses}
def _notify_target(*, target: NotificationTarget, run: BackupRun) -> DeliveryResult:
payload = _run_payload(run)
delivery, created = NotificationDelivery.objects.get_or_create(
target=target,
run=run,
defaults={
"status": NotificationDelivery.Status.SKIPPED,
"payload": payload,
},
)
if not created:
return DeliveryResult(target=target, delivery=delivery, sent=False)
try:
if target.channel == NotificationTarget.Channel.EMAIL:
_send_email(target=target, run=run, payload=payload)
elif target.channel == NotificationTarget.Channel.WEBHOOK:
_send_webhook(target=target, payload=payload)
else:
raise ValueError(f"Unsupported notification channel: {target.channel}")
except Exception as exc:
delivery.status = NotificationDelivery.Status.FAILED
delivery.error = str(exc)
delivery.save(update_fields=["status", "error"])
target.last_status = NotificationDelivery.Status.FAILED
target.last_error = str(exc)
target.save(update_fields=["last_status", "last_error", "updated_at"])
return DeliveryResult(target=target, delivery=delivery, sent=False)
delivery.status = NotificationDelivery.Status.SENT
delivery.save(update_fields=["status"])
target.last_status = NotificationDelivery.Status.SENT
target.last_error = ""
target.last_sent_at = timezone.now()
target.save(update_fields=["last_status", "last_error", "last_sent_at", "updated_at"])
return DeliveryResult(target=target, delivery=delivery, sent=True)
def _send_email(*, target: NotificationTarget, run: BackupRun, payload: dict[str, Any]) -> None:
recipients = [line.strip() for line in target.email_to.replace(",", "\n").splitlines() if line.strip()]
if not recipients:
raise ValueError("Email notification target has no recipients.")
subject = f"pobsync {run.status}: {run.host.host} run {run.id}"
message = _email_message(payload)
from_email = getattr(settings, "DEFAULT_FROM_EMAIL", "") or "pobsync@localhost"
sent = send_mail(subject, message, from_email, recipients, fail_silently=False)
if sent == 0:
raise ValueError("Django email backend reported zero sent messages.")
def _send_webhook(*, target: NotificationTarget, payload: dict[str, Any]) -> None:
if not target.webhook_url:
raise ValueError("Webhook notification target has no URL.")
headers = {"Content-Type": "application/json", **_string_headers(target.webhook_headers)}
request = urllib.request.Request(
target.webhook_url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status >= 400:
raise ValueError(f"Webhook returned HTTP {response.status}.")
except urllib.error.HTTPError as exc:
raise ValueError(f"Webhook returned HTTP {exc.code}.") from exc
def _string_headers(headers: object) -> dict[str, str]:
if not isinstance(headers, dict):
return {}
return {str(key): str(value) for key, value in headers.items() if str(key).strip()}
def _run_payload(run: BackupRun) -> dict[str, Any]:
result = run.result if isinstance(run.result, dict) else {}
failure = result.get("failure") if isinstance(result.get("failure"), dict) else {}
prune = result.get("prune") if isinstance(result.get("prune"), dict) else {}
return {
"event": "backup_run.completed",
"run": {
"id": run.id,
"host": run.host.host,
"type": run.run_type,
"status": run.status,
"started_at": run.started_at.isoformat() if run.started_at else None,
"ended_at": run.ended_at.isoformat() if run.ended_at else None,
"snapshot": run.snapshot_path,
"rsync_exit_code": run.rsync_exit_code,
},
"failure": {
"category": failure.get("category"),
"message": failure.get("message") or result.get("error"),
"hint": failure.get("hint"),
},
"prune": {
"ok": prune.get("ok") if prune else None,
"error": prune.get("error") if prune else "",
},
}
def _email_message(payload: dict[str, Any]) -> str:
run = payload["run"]
lines = [
f"Host: {run['host']}",
f"Run: {run['id']}",
f"Type: {run['type']}",
f"Status: {run['status']}",
f"Started: {run['started_at'] or '-'}",
f"Ended: {run['ended_at'] or '-'}",
f"Snapshot: {run['snapshot'] or '-'}",
f"Rsync exit code: {run['rsync_exit_code'] if run['rsync_exit_code'] is not None else '-'}",
]
failure = payload.get("failure") if isinstance(payload.get("failure"), dict) else {}
if failure.get("message"):
lines.extend(["", f"Failure: {failure['message']}"])
prune = payload.get("prune") if isinstance(payload.get("prune"), dict) else {}
if prune.get("error"):
lines.extend(["", f"Retention: {prune['error']}"])
return "\n".join(lines)

View File

@@ -97,9 +97,7 @@ def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict
*ssh_cmd,
"-oBatchMode=yes",
target,
"sh",
"-lc",
f"command -v {shlex.quote(rsync_binary)} >/dev/null",
_remote_shell_command(f"command -v {shlex.quote(rsync_binary)} >/dev/null"),
],
timeout_seconds=timeout_seconds,
),
@@ -109,9 +107,7 @@ def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict
*ssh_cmd,
"-oBatchMode=yes",
target,
"sh",
"-lc",
f"test -e {shlex.quote(source_root)} && test -r {shlex.quote(source_root)}",
_remote_shell_command(f"test -e {shlex.quote(source_root)} && test -r {shlex.quote(source_root)}"),
],
timeout_seconds=timeout_seconds,
),
@@ -129,6 +125,10 @@ def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict
return result
def _remote_shell_command(script: str) -> str:
return f"sh -lc {shlex.quote(script)}"
def effective_host_config_preview(host: HostConfig, global_config: GlobalConfig) -> dict[str, Any]:
config = build_effective_config(global_config_object_data(global_config), host_config_object_data(host))
credential = host.ssh_credential or global_config.default_ssh_credential

View File

@@ -23,7 +23,7 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
host_config = _enabled_host_config(host)
retention = _retention_for_host(host_config)
snapshots = _snapshots_for_retention(host_config=host_config, kind=kind)
incomplete_snapshots = _incomplete_snapshots_for_host(host_config)
incomplete_items = _incomplete_snapshot_items_for_host(host_config)
plan = build_retention_plan(
snapshots=snapshots,
@@ -49,10 +49,9 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
"keep": sorted(keep),
"keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items],
"delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete],
"incomplete": [
_snapshot_to_item(snapshot, reasons=["incomplete snapshot; excluded from retention cleanup"])
for snapshot in incomplete_snapshots
],
"incomplete": incomplete_items,
"incomplete_reviewed_count": sum(1 for item in incomplete_items if item["reviewed"]),
"incomplete_unreviewed_count": sum(1 for item in incomplete_items if not item["reviewed"]),
"reasons": reasons,
}
@@ -103,6 +102,7 @@ def run_sql_retention_apply(
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
_validate_snapshot_delete_path(host=host, kind=snap_kind, path=path, dirname=dirname)
reason = str(item.get("reason") or "outside retention policy")
if not path.exists():
actions.append(f"skip missing {snap_kind}/{dirname}")
@@ -163,9 +163,15 @@ def run_incomplete_cleanup(
def _do_cleanup() -> dict[str, Any]:
host_config = _enabled_host_config(host)
unreviewed_count = _unreviewed_incomplete_count(host_config)
if unreviewed_count:
raise ConfigError(
f"Refusing to delete {unreviewed_count} incomplete snapshot(s) that have not been reviewed."
)
incomplete_list = [
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
for snapshot in _incomplete_snapshots_for_host(host_config)
for snapshot in _reviewed_incomplete_snapshots_for_host(host_config)
]
if max_delete == 0 and len(incomplete_list) > 0:
raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
@@ -252,15 +258,39 @@ def _snapshots_for_retention(*, host_config: HostConfig, kind: str) -> list[Snap
return [_snapshot_from_record(record) for record in records]
def _incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
def _incomplete_snapshot_items_for_host(host_config: HostConfig) -> list[dict[str, Any]]:
records = (
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
.select_related("base")
.order_by("-started_at", "dirname")
)
return [
_snapshot_record_to_item(record, reasons=["incomplete snapshot; excluded from retention cleanup"])
for record in records
]
def _reviewed_incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
records = (
SnapshotRecord.objects.filter(
host=host_config,
kind=SnapshotRecord.Kind.INCOMPLETE,
reviewed_at__isnull=False,
)
.select_related("base")
.order_by("-started_at", "dirname")
)
return [_snapshot_from_record(record) for record in records]
def _unreviewed_incomplete_count(host_config: HostConfig) -> int:
return SnapshotRecord.objects.filter(
host=host_config,
kind=SnapshotRecord.Kind.INCOMPLETE,
reviewed_at__isnull=True,
).count()
def _snapshot_from_record(record: SnapshotRecord) -> Snapshot:
return Snapshot(
kind=record.kind,
@@ -300,6 +330,14 @@ def _snapshot_to_item(snapshot: Snapshot, *, reasons: list[str]) -> dict[str, An
}
def _snapshot_record_to_item(record: SnapshotRecord, *, reasons: list[str]) -> dict[str, Any]:
item = _snapshot_to_item(_snapshot_from_record(record), reasons=reasons)
item["reviewed"] = record.reviewed_at is not None
item["reviewed_at"] = record.reviewed_at.isoformat() if record.reviewed_at else ""
item["reviewed_by"] = record.reviewed_by
return item
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
if path.name == "data" and path.parent.name == dirname:
return path.parent
@@ -339,14 +377,55 @@ def _validate_incomplete_delete_path(*, host: str, path: Path, dirname: str) ->
raise ConfigError(f"Refusing to delete incomplete snapshot outside host backup root: {path}")
def _validate_snapshot_delete_path(*, host: str, kind: str, path: Path, dirname: str) -> None:
if kind not in {SnapshotRecord.Kind.SCHEDULED, SnapshotRecord.Kind.MANUAL}:
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {kind!r}")
path_parts = path.parts
if path.name != dirname or kind not in path_parts or host not in path_parts:
raise ConfigError(f"Refusing to delete unexpected snapshot path: {path}")
kind_index = path_parts.index(kind)
if kind_index == 0 or path_parts[kind_index - 1] != host:
raise ConfigError(f"Refusing to delete snapshot outside host backup root: {path}")
def _remove_snapshot_tree(path: Path) -> None:
_make_directories_user_writable(path)
shutil.rmtree(path)
_make_snapshot_tree_user_removable(path)
shutil.rmtree(path, onexc=_retry_remove_with_user_permissions)
def _make_directories_user_writable(path: Path) -> None:
for directory in [path, *[child for child in path.rglob("*") if child.is_dir() and not child.is_symlink()]]:
mode = directory.stat().st_mode
if mode & stat.S_IWUSR:
def _make_snapshot_tree_user_removable(path: Path) -> None:
stack = [path]
while stack:
directory = stack.pop()
if directory.is_symlink():
continue
directory.chmod(mode | stat.S_IWUSR)
_make_path_user_removable(directory)
try:
children = list(directory.iterdir())
except OSError:
continue
for child in children:
if child.is_dir() and not child.is_symlink():
stack.append(child)
def _retry_remove_with_user_permissions(function: Any, path: str, excinfo: BaseException) -> None:
failed_path = Path(path)
_make_path_user_removable(failed_path)
function(path)
def _make_path_user_removable(path: Path) -> None:
try:
mode = path.stat().st_mode
except OSError:
return
wanted = stat.S_IRUSR | stat.S_IWUSR
if path.is_dir() and not path.is_symlink():
wanted |= stat.S_IXUSR
if mode & wanted == wanted:
return
try:
path.chmod(mode | wanted)
except OSError:
return

View File

@@ -5,12 +5,13 @@ from typing import Any, Iterable
from django.utils import timezone
from pobsync.run_stats import filesystem_capacity
from pobsync.run_stats import filesystem_capacity, tree_usage
from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: GlobalConfig | None) -> dict[str, Any]:
hosts = list(hosts)
runs = list(
BackupRun.objects.select_related("host", "snapshot")
.filter(status__in=_COMPLETED_BACKUP_STATUSES)
@@ -21,6 +22,7 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
for host in hosts:
host.stats_summary = collect_host_stats(host=host)
backup_data = _sum_backup_data_by_kind(host.stats_summary["backup_data"] for host in hosts)
literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in real_runs]
literal_values = [value for value in literal_values if value is not None]
@@ -51,6 +53,7 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
"estimated_runs_until_full": int(available / avg_literal) if available and avg_literal else None,
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
"capacity": capacity,
"backup_data": backup_data,
}
@@ -61,6 +64,7 @@ def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
trend_runs = [run for run in completed_real_runs if run["has_stats"]][:limit]
latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {}
backup_data = _backup_data_by_kind(host)
literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in trend_runs]
literal_values = [value for value in literal_values if value is not None]
@@ -75,6 +79,7 @@ def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
"latest_good_run": _first_run_with_status(real_runs, {BackupRun.Status.SUCCESS}),
"latest_problem_run": _first_run_with_status(real_runs, {BackupRun.Status.WARNING, BackupRun.Status.FAILED}),
"latest_snapshot": latest_snapshot_stats,
"backup_data": backup_data,
"avg_literal_data_bytes": _average(literal_values),
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
"total_literal_data_bytes": sum(literal_values),
@@ -102,6 +107,65 @@ def _run_summary(run: BackupRun) -> dict[str, Any]:
}
def _backup_data_by_kind(host: HostConfig) -> dict[str, Any]:
rows: dict[str, dict[str, int]] = {
SnapshotRecord.Kind.SCHEDULED: _empty_snapshot_data_row(),
SnapshotRecord.Kind.MANUAL: _empty_snapshot_data_row(),
SnapshotRecord.Kind.INCOMPLETE: _empty_snapshot_data_row(),
}
total = _empty_snapshot_data_row()
for snapshot in host.snapshots.all():
summary = _snapshot_summary(snapshot)
row = rows.setdefault(snapshot.kind, _empty_snapshot_data_row())
allocated = summary.get("allocated_size_bytes") or summary.get("apparent_size_bytes") or 0
apparent = summary.get("apparent_size_bytes") or 0
unique_apparent = summary.get("unique_apparent_size_bytes") or 0
row["count"] += 1
row["allocated_size_bytes"] += int(allocated)
row["apparent_size_bytes"] += int(apparent)
row["unique_apparent_size_bytes"] += int(unique_apparent)
total["count"] += 1
total["allocated_size_bytes"] += int(allocated)
total["apparent_size_bytes"] += int(apparent)
total["unique_apparent_size_bytes"] += int(unique_apparent)
return {
"scheduled": rows[SnapshotRecord.Kind.SCHEDULED],
"manual": rows[SnapshotRecord.Kind.MANUAL],
"incomplete": rows[SnapshotRecord.Kind.INCOMPLETE],
"total": total,
}
def _empty_snapshot_data_row() -> dict[str, int]:
return {
"count": 0,
"allocated_size_bytes": 0,
"apparent_size_bytes": 0,
"unique_apparent_size_bytes": 0,
}
def _sum_backup_data_by_kind(rows: Iterable[dict[str, dict[str, int]]]) -> dict[str, dict[str, int]]:
total_rows: dict[str, dict[str, int]] = {
"scheduled": _empty_snapshot_data_row(),
"manual": _empty_snapshot_data_row(),
"incomplete": _empty_snapshot_data_row(),
"total": _empty_snapshot_data_row(),
}
for row in rows:
for kind, values in row.items():
total_row = total_rows.setdefault(kind, _empty_snapshot_data_row())
total_row["count"] += values.get("count", 0)
total_row["allocated_size_bytes"] += values.get("allocated_size_bytes", 0)
total_row["apparent_size_bytes"] += values.get("apparent_size_bytes", 0)
total_row["unique_apparent_size_bytes"] += values.get("unique_apparent_size_bytes", 0)
return total_rows
def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
if snapshot is None:
return {}
@@ -109,18 +173,43 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
storage = stats.get("storage") if isinstance(stats.get("storage"), dict) else {}
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) else {}
if snapshot.kind == SnapshotRecord.Kind.INCOMPLETE:
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
else:
has_recorded_size = (
_int_at(snapshot_storage, "allocated_size_bytes") is not None
or _int_at(snapshot_storage, "apparent_size_bytes") is not None
)
if not has_recorded_size:
snapshot_storage = _snapshot_storage_from_filesystem(snapshot)
apparent_size = _int_at(snapshot_storage, "apparent_size_bytes")
hardlinked_apparent = _int_at(snapshot_storage, "hardlinked_apparent_size_bytes") or 0
return {
"id": snapshot.id,
"dirname": snapshot.dirname,
"kind": snapshot.kind,
"status": snapshot.status,
"started_at": snapshot.started_at,
"apparent_size_bytes": _int_at(snapshot_storage, "apparent_size_bytes"),
"apparent_size_bytes": apparent_size,
"allocated_size_bytes": _int_at(snapshot_storage, "allocated_size_bytes"),
"hardlinked_files": _int_at(snapshot_storage, "hardlinked_files"),
"hardlinked_apparent_size_bytes": hardlinked_apparent,
"unique_apparent_size_bytes": max((apparent_size or 0) - hardlinked_apparent, 0),
}
def _snapshot_storage_from_filesystem(snapshot: SnapshotRecord) -> dict[str, Any]:
if not snapshot.path:
return {}
snapshot_path = Path(snapshot.path)
data_path = snapshot_path / "data"
if snapshot_path.name == "data":
return tree_usage(snapshot_path)
if data_path.exists():
return tree_usage(data_path)
return tree_usage(snapshot_path)
def _is_real_run(run: BackupRun) -> bool:
result = run.result if isinstance(run.result, dict) else {}
if result.get("dry_run") is True:

View File

@@ -7,79 +7,262 @@
<style>
:root {
color-scheme: light;
--bg: #f5f7fa;
--bg: #f2f5f8;
--bg-soft: #f8fafc;
--panel: #ffffff;
--border: #d9e0e8;
--text: #17202a;
--muted: #657386;
--link: #0b5cad;
--success: #176b3a;
--failed: #a12828;
--panel-subtle: #fbfcfe;
--border: #d8e1eb;
--border-strong: #c7d2df;
--text: #121a24;
--muted: #65758a;
--muted-strong: #46566a;
--link: #075fae;
--link-strong: #064b89;
--success: #17633a;
--failed: #a73333;
--running: #8a5a00;
--queued: #075fae;
--shadow-sm: 0 1px 2px rgba(18, 26, 36, 0.05);
--shadow-md: 0 10px 28px rgba(18, 26, 36, 0.08);
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
a { color: var(--link); text-decoration: none; }
a { color: var(--link); font-weight: 560; text-decoration: none; }
a:hover { text-decoration: underline; }
header {
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(7, 95, 174, 0.24);
outline-offset: 2px;
}
body > header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
box-shadow: var(--shadow-sm);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 20;
}
nav {
display: flex;
align-items: center;
gap: 18px;
gap: 6px;
max-width: 1180px;
margin: 0 auto;
min-height: 48px;
}
nav strong {
font-size: 16px;
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 a[aria-current="page"] {
background: #eaf3fb;
color: var(--link-strong);
font-weight: 720;
}
.nav-primary,
.nav-secondary {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.nav-secondary {
justify-content: flex-end;
}
.nav-secondary a {
font-size: 13px;
}
.nav-user {
margin-left: 6px;
white-space: nowrap;
}
nav strong { font-size: 16px; }
nav .spacer { flex: 1; }
main {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
padding: 28px 24px 42px;
}
h1 {
font-size: clamp(28px, 3vw, 36px);
letter-spacing: 0;
line-height: 1.15;
margin: 0 0 18px;
}
h2 {
font-size: 18px;
letter-spacing: 0;
line-height: 1.25;
margin: 0 0 14px;
}
h3 {
font-size: 15px;
letter-spacing: 0;
margin: 16px 0 8px;
}
p { margin: 0 0 12px; }
p:last-child { margin-bottom: 0; }
.page-header {
align-items: end;
display: flex;
gap: 18px;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
display: grid;
gap: 5px;
min-width: 0;
}
.page-title h1 { margin-bottom: 0; overflow-wrap: anywhere; }
.page-kicker {
color: var(--muted);
font-size: 12px;
font-weight: 750;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.page-subtitle {
color: var(--muted);
max-width: 760px;
}
.page-header .actions {
flex: 0 0 auto;
justify-content: flex-end;
margin-bottom: 0;
}
h1 { font-size: 26px; margin: 0 0 18px; }
h2 { font-size: 18px; margin: 0 0 12px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
margin-bottom: 22px;
margin-bottom: 20px;
}
.metric, .panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.metric {
min-height: 88px;
padding: 14px 15px;
}
.metric .label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.metric .value {
font-size: 27px;
font-weight: 760;
line-height: 1.15;
margin-top: 6px;
overflow-wrap: anywhere;
}
.metric { padding: 14px; }
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
.metric.failed { border-color: #e8b4b4; background: #fff7f7; }
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
.metric-link {
color: inherit;
display: block;
text-decoration: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.metric-link:hover {
border-color: #9eb2c8;
box-shadow: var(--shadow);
transform: translateY(-1px);
}
.metric-link:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 2px;
}
.dashboard-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.dashboard-summary-grid .metric {
min-height: 78px;
}
.dashboard-summary-grid .metric .value {
font-size: 25px;
}
.dashboard-trends-panel,
.dashboard-hosts-panel {
overflow: visible;
}
.panel {
margin-bottom: 18px;
overflow: auto;
padding: 18px;
}
.panel > h2:first-child {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
}
.panel.highlight { border-left: 4px solid var(--border); }
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
.panel.highlight.warning { border-left-color: var(--running); background: #fffaf0; }
.panel.highlight.success { border-left-color: var(--success); background: #f5fbf7; }
table { width: 100%; border-collapse: collapse; min-width: 640px; }
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; }
table {
border-collapse: collapse;
min-width: 640px;
width: 100%;
}
th, td {
border-bottom: 1px solid var(--border);
padding: 10px 9px;
text-align: left;
vertical-align: top;
}
th {
background: var(--panel-subtle);
color: var(--muted);
font-size: 11px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
tbody tr:hover td { background: #f9fbfd; }
tr:last-child td { border-bottom: 0; }
.muted { color: var(--muted); }
.status {
display: inline-block;
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
padding: 3px 8px;
white-space: nowrap;
}
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
@@ -88,33 +271,66 @@
.status.blocked { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.status.warning { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
.status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; }
.status.queued { color: var(--queued); border-color: #b5cdea; background: #eef6ff; }
.status.skipped { color: var(--muted); background: #f7f9fb; }
.stack { display: grid; gap: 4px; }
.stack { display: grid; gap: 5px; }
.stack.spaced { margin-bottom: 14px; }
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
.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; }
button, .button-link {
appearance: none;
background: #17202a;
border: 1px solid #17202a;
background: var(--text);
border: 1px solid var(--text);
border-radius: 6px;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 650;
padding: 8px 12px;
font-weight: 700;
line-height: 1.25;
padding: 8px 13px;
}
button:hover, .button-link:hover {
background: #273343;
text-decoration: none;
}
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
button.secondary,
.button-link.secondary {
background: #fff;
border-color: var(--border);
border-color: var(--border-strong);
color: var(--text);
}
button.secondary:hover,
.button-link.secondary:hover { background: #eef3f8; }
button.danger,
.button-link.danger {
background: var(--failed);
border-color: var(--failed);
color: #fff;
}
button.danger:hover,
.button-link.danger:hover {
background: #842828;
border-color: #842828;
}
button.compact,
.button-link.compact {
font-size: 12px;
padding: 5px 8px;
}
button:disabled {
background: #d8dee6;
border-color: #d8dee6;
@@ -122,23 +338,220 @@
cursor: not-allowed;
}
.inline-form { margin: 0; }
.status-overview {
.dashboard-priority-grid {
align-items: start;
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 20px;
}
.priority-panel {
display: grid;
gap: 12px;
margin-bottom: 0;
min-width: 0;
overflow: visible;
}
.priority-panel > h2:first-child {
flex-wrap: wrap;
margin-bottom: 0;
}
.action-list,
.activity-list,
.schedule-list {
display: grid;
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);
}
.activity-row .status {
justify-self: start;
}
.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;
}
.action-row strong,
.action-row .muted,
.activity-row strong,
.activity-row .muted,
.schedule-row strong,
.schedule-row .muted {
overflow-wrap: anywhere;
}
.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;
}
.storage-priority-facts strong {
text-align: right;
overflow-wrap: anywhere;
}
.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 {
align-items: center;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius);
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
padding: 11px 12px;
}
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
.status-summary.warning,
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
a.status-summary {
color: inherit;
text-decoration: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
a.status-summary:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.operator-state {
align-items: center;
display: flex;
@@ -146,6 +559,15 @@
gap: 8px;
margin-bottom: 14px;
}
.refresh-controls {
align-items: center;
display: flex;
gap: 14px;
justify-content: space-between;
}
.refresh-controls h2 {
margin-bottom: 4px;
}
.trend-bars {
display: grid;
gap: 5px;
@@ -191,7 +613,7 @@
.insight-main .value,
.insight-item .value {
font-size: 22px;
font-weight: 650;
font-weight: 760;
overflow-wrap: anywhere;
}
.storage-meter {
@@ -213,9 +635,15 @@
}
.host-card {
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
min-width: 0;
padding: 16px;
}
.host-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
}
.host-card-header {
align-items: start;
display: flex;
@@ -230,7 +658,7 @@
}
.host-card-title a {
font-size: 17px;
font-weight: 650;
font-weight: 750;
overflow-wrap: anywhere;
}
.host-card-status {
@@ -243,8 +671,8 @@
}
.host-card-layout {
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
gap: 18px;
grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.9fr);
}
.host-card-section {
align-content: start;
@@ -261,15 +689,17 @@
.host-card-timeline {
display: grid;
gap: 16px 22px;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.host-card-stats {
align-content: start;
display: grid;
border-top: 1px solid #e6edf4;
background: var(--bg-soft);
border: 1px solid #e6edf4;
border-radius: var(--radius);
gap: 12px 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: 12px;
padding: 12px;
}
.host-card-item {
display: grid;
@@ -285,6 +715,10 @@
.host-card-item .value {
overflow-wrap: anywhere;
}
.host-card-item .value a {
overflow-wrap: anywhere;
word-break: break-word;
}
.host-card-stat {
display: grid;
gap: 3px;
@@ -298,7 +732,7 @@
}
.host-card-stat .value {
font-size: 16px;
font-weight: 650;
font-weight: 750;
overflow-wrap: anywhere;
}
.host-card-stat.wide {
@@ -315,32 +749,74 @@
margin-top: 14px;
padding: 10px;
}
.host-card-warning > * {
min-width: 0;
}
.host-card-actions {
align-items: center;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
}
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
.message {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
padding: 10px 12px;
}
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
.message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); }
.form-grid { display: grid; gap: 14px; max-width: 680px; }
.field { display: grid; gap: 5px; }
.field label { font-weight: 650; }
.form-grid { display: grid; gap: 15px; max-width: 720px; }
.filter-form {
align-items: end;
display: grid;
gap: 15px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
max-width: none;
}
.form-actions {
align-items: center;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
padding-top: 15px;
}
.form-actions .button-link.secondary { margin-left: auto; }
.filter-form .form-actions {
border-top: 0;
justify-content: flex-end;
margin-top: 0;
padding-top: 0;
}
.filter-form .form-actions .button-link.secondary { margin-left: 0; }
.field { display: grid; gap: 6px; }
.field label { font-weight: 700; }
.field input[type="text"], .field input[type="number"], .field select, .field textarea {
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
padding: 8px 10px;
padding: 9px 10px;
width: 100%;
}
.field input[type="text"]:focus,
.field input[type="number"]:focus,
.field select:focus,
.field textarea:focus {
border-color: #8bb9e3;
}
.field textarea { min-height: 92px; resize: vertical; }
.field .helptext { color: var(--muted); font-size: 12px; }
.field input[type="checkbox"] { justify-self: start; }
pre {
background: #101820;
border-radius: 6px;
border-radius: var(--radius);
color: #edf4fb;
line-height: 1.5;
margin: 0;
@@ -356,30 +832,106 @@
padding: 0;
}
@media (max-width: 800px) {
main { padding: 16px; }
nav { padding: 0; }
.two-col { grid-template-columns: 1fr; }
body > header { padding: 0 14px; position: static; }
main { padding: 18px 14px 32px; }
nav {
align-items: flex-start;
flex-wrap: wrap;
gap: 4px;
padding: 8px 0;
}
nav strong { flex-basis: 100%; margin-right: 0; }
.nav-secondary {
justify-content: flex-start;
}
.nav-user {
margin-left: 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; }
.refresh-controls {
align-items: stretch;
display: grid;
}
.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; }
.form-actions .button-link.secondary { margin-left: 0; }
.host-card-header { display: grid; }
.host-card-status { justify-content: flex-start; max-width: none; }
.host-card-layout { grid-template-columns: 1fr; }
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.insight-grid { grid-template-columns: 1fr; }
}
@media (max-width: 1100px) {
.dashboard-priority-grid {
grid-template-columns: 1fr;
}
.dashboard-summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.host-card-layout {
grid-template-columns: 1fr;
}
.host-card-status {
justify-content: flex-start;
max-width: none;
}
.schedule-row {
grid-template-columns: 1fr;
}
.schedule-time {
justify-items: start;
text-align: left;
}
}
@media (max-width: 560px) {
.dashboard-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric {
min-height: 76px;
padding: 12px;
}
.metric .value {
font-size: 24px;
}
.host-card {
padding: 13px;
}
.host-card-stats {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<nav>
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
<a href="{% url 'admin:index' %}">Admin</a>
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
<a href="{% url 'self_check' %}">Self Check</a>
<a href="{% url 'logs' %}">Logs</a>
<a href="{% url 'purged_snapshots' %}">Purged</a>
<a href="{% url 'changelog' %}">Changelog</a>
<a href="/api/status/">Status API</a>
<span class="nav-primary" aria-label="Primary navigation">
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{% url 'hosts_list' %}" {% if request.resolver_match.url_name == "hosts_list" or request.resolver_match.url_name == "host_detail" or request.resolver_match.url_name == "create_host_config" or request.resolver_match.url_name == "edit_host_config" or request.resolver_match.url_name == "edit_host_schedule" %}aria-current="page"{% endif %}>Hosts</a>
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
<a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a>
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
</span>
<span class="spacer"></span>
<span class="muted">{{ request.user.username }}</span>
<span class="nav-secondary" aria-label="System navigation">
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
<a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a>
<a href="/api/status/">Status API</a>
<a href="{% url 'admin:index' %}">Admin</a>
</span>
<span class="muted nav-user">{{ request.user.username }}</span>
</nav>
</header>
<main>
@@ -392,5 +944,54 @@
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
(() => {
const updateRefreshControls = (region) => {
const toggle = document.querySelector(`[data-refresh-toggle][data-refresh-target="${region.id}"]`);
const state = document.querySelector(`[data-refresh-state="${region.id}"]`);
const paused = region.dataset.refreshPaused === "true";
const active = region.dataset.refreshActive === "true";
if (state) state.textContent = paused ? "paused" : (active ? "on" : "off");
if (toggle) {
toggle.textContent = paused ? "Resume refresh" : "Pause refresh";
toggle.disabled = !active && !paused;
}
};
const refreshRegion = async (region) => {
if (region.dataset.refreshActive !== "true" || region.dataset.refreshPaused === "true" || document.hidden) return;
try {
const response = await fetch(region.dataset.refreshUrl, {
credentials: "same-origin",
headers: { "X-Requested-With": "XMLHttpRequest" },
});
if (!response.ok) return;
region.innerHTML = await response.text();
const refreshActive = response.headers.get("X-Pobsync-Refresh-Active");
if (refreshActive) region.dataset.refreshActive = refreshActive;
updateRefreshControls(region);
} catch (error) {
// Keep the current server-rendered content visible if a refresh fails.
}
};
document.addEventListener("click", (event) => {
const toggle = event.target.closest("[data-refresh-toggle]");
if (!toggle) return;
const region = document.getElementById(toggle.dataset.refreshTarget);
if (!region) return;
const paused = region.dataset.refreshPaused === "true";
region.dataset.refreshPaused = paused ? "false" : "true";
if (paused && region.dataset.refreshActive === "true") refreshRegion(region);
updateRefreshControls(region);
});
document.querySelectorAll("[data-refresh-url]").forEach((region) => {
const interval = Number.parseInt(region.dataset.refreshInterval || "5000", 10);
updateRefreshControls(region);
window.setInterval(() => refreshRegion(region), Number.isFinite(interval) ? interval : 5000);
});
})();
</script>
</body>
</html>

View File

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

View File

@@ -3,12 +3,17 @@
{% block title %}pobsync dashboard{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Control panel</div>
<h1>Dashboard</h1>
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</div>
<section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section>
</header>
{% if not global_config or not counts.hosts %}
<section class="panel">
@@ -27,75 +32,28 @@
</section>
{% endif %}
<section class="grid" aria-label="Summary">
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
<div
data-refresh-url="{% url 'dashboard_priority_live' %}"
data-refresh-interval="10000"
data-refresh-active="true"
aria-live="polite"
>
{% include "pobsync_backend/partials/dashboard_priority.html" %}
</div>
<section class="grid dashboard-summary-grid" aria-label="Summary">
<a class="metric metric-link" href="{% url 'hosts_list' %}"><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>Operational Status</h2>
{% if counts.failed_runs or counts.warning_runs or counts.running_runs or counts.queued_runs %}
<div class="status-overview">
{% if counts.failed_runs %}
<div class="status-summary failed">
<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>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, or unreviewed warning/failed runs.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %}
</section>
<section class="panel">
<section class="panel dashboard-trends-panel">
<h2>Backup Trends</h2>
{% if stats_summary.runs_sampled %}
<div class="insight-grid" aria-label="Backup trends">
<div class="insight-main">
<div class="label">Storage Used</div>
<div class="value">
{% if stats_summary.capacity.used_percent is not None %}
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
{% else %}
unknown
{% endif %}
</div>
{% if stats_summary.capacity.used_percent is not None %}
<div class="storage-meter" aria-label="Backup root storage usage">
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
</div>
{% endif %}
<div class="muted">
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root.
</div>
</div>
<div class="insight-item">
<div class="label">Runway</div>
<div class="value">
@@ -136,155 +94,13 @@
{% endif %}
</section>
<section class="panel">
<h2>Hosts</h2>
<div class="host-list">
{% for host in hosts %}
<article class="host-card">
<div class="host-card-header">
<div class="host-card-title">
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
<span class="muted">{{ host.address }}</span>
<div
data-refresh-url="{% url 'dashboard_hosts_live' %}"
data-refresh-interval="15000"
data-refresh-active="true"
aria-live="polite"
>
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
</div>
<div class="host-card-status">
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
{% if host.queued_run_count %}
<span class="status queued">queued {{ host.queued_run_count }}</span>
{% endif %}
{% if host.running_run_count %}
<span class="status running">running {{ host.running_run_count }}</span>
{% endif %}
{% if host.warning_run_count %}
<span class="status warning">warning {{ host.warning_run_count }}</span>
{% endif %}
{% if host.failed_run_count %}
<span class="status failed">failed {{ host.failed_run_count }}</span>
{% endif %}
</div>
</div>
<div class="host-card-layout">
<div class="host-card-section">
<div class="host-card-section-title">Backup activity</div>
<div class="host-card-timeline">
<div class="host-card-item">
<div class="label">Latest Snapshot</div>
<div class="value">
{% if host.latest_snapshot %}
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Last Good Backup</div>
<div class="value">
{% if host.stats_summary.latest_good_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Latest Issue</div>
<div class="value">
{% if host.stats_summary.latest_problem_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Next Run</div>
<div class="value">
{% if host.next_run_at %}
{{ host.next_run_at|date:"Y-m-d H:i T" }}
<div class="muted">{{ scheduler_timezone }}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="host-card-section">
<div class="host-card-section-title">Snapshot health</div>
<div class="host-card-stats">
<div class="host-card-stat">
<div class="label">Snapshots</div>
<div class="value">{{ host.snapshot_count }}</div>
</div>
<div class="host-card-stat">
<div class="label">Runs</div>
<div class="value">{{ host.run_count }}</div>
</div>
<div class="host-card-stat">
<div class="label">New Data</div>
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
</div>
<div class="host-card-stat">
<div class="label">Retention</div>
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
</div>
</div>
</div>
</div>
{% if host.retention_warning.has_warning %}
<div class="host-card-warning">
<span class="status warning">retention</span>
{% if host.retention_warning.prune_exceeded %}
Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}.
{% endif %}
{% if host.retention_warning.incomplete_count %}
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark reviewed</button>
</form>
{% endif %}
{% if host.retention_warning.error %}
{{ host.retention_warning.error }}
{% endif %}
</div>
{% endif %}
</article>
{% empty %}
<p class="muted">No hosts configured yet.</p>
{% endfor %}
</div>
</section>
<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 %}

View File

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

View File

@@ -3,70 +3,13 @@
{% block title %}{{ host.host }} | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Host</div>
<h1>{{ host.host }}</h1>
<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>Backup source:</strong> {{ host.source_root|default:"global default" }}</div>
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
</div>
</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 class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
</div>
</header>
{% if retention_warning.has_warning %}
<section class="panel highlight warning">
@@ -96,60 +39,167 @@
</section>
{% endif %}
{% if effective_config %}
<section class="panel">
<h2>Effective Config</h2>
<div class="two-col">
<div class="stack">
<div><strong>Backup source:</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>
<section class="host-control-grid" aria-label="Host control workspace">
<article class="panel host-control-panel">
<h2>Host Status</h2>
<div class="host-control-primary">
<div>
<strong>Retention:</strong>
d{{ effective_config.retention.daily }}
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>
{% if host.enabled %}
<span class="status ok">enabled</span>
{% else %}
<div class="muted">No include rules configured.</div>
<span class="status failed">disabled</span>
{% endif %}
<div><strong>Excludes:</strong> {{ effective_config.excludes|length }}</div>
{% if effective_config.excludes %}
<pre>{{ effective_config.excludes|join:"&#10;" }}</pre>
<span class="muted">{{ host.address }}</span>
</div>
{% 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 %}
<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 %}
</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>
</article>
<article class="panel host-control-panel">
<h2>Backup Control</h2>
<div class="operator-state">
{% 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 %}
</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 class="panel">
<h2>Snapshot Discovery</h2>
<div class="stack">
<div><strong>Backup root:</strong> {{ discovery.backup_root|default:"" }}</div>
<div><strong>Host root:</strong> {{ discovery.host_root|default:"" }}</div>
<div><strong>Status:</strong> {{ discovery.message }}</div>
{% if discovery.kind_counts %}
<div><strong>On disk:</strong>
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
manual {{ discovery.kind_counts.manual|default:0 }},
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
<h2>Backup Data</h2>
<section class="grid" aria-label="Host backup data totals">
<div class="metric">
<div class="label">Scheduled</div>
<div class="value">{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
{% endif %}
<div class="metric">
<div class="label">Manual</div>
<div class="value">{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
<div class="metric">
<div class="label">Incomplete</div>
<div class="value">{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">measured from disk</div>
</div>
<div class="metric">
<div class="label">Total</div>
<div class="value">{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
</section>
<p class="muted">
Main totals use allocated snapshot size. Unique values estimate non-hardlinked visible data; incomplete
snapshots are measured from disk because their metadata can be stale.
</p>
</section>
{% if stats_summary.runs %}
@@ -209,104 +259,200 @@
<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>
</section>
<table>
<thead>
<tr>
<th>Status</th>
<th>Check</th>
<th>Message</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
<div class="record-list">
{% for check in host_checks %}
<tr>
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
<td>{{ check.name }}</td>
<td>{{ check.message }}</td>
<td class="muted">{{ check.detail }}</td>
</tr>
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<strong>{{ check.name }}</strong>
<span class="muted">{{ check.message }}</span>
</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 %}
</tbody>
</table>
</div>
</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 %}
<section class="panel">
<h2>Connection Preflight</h2>
<div class="stack spaced">
<div><strong>Status:</strong> <span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">{% if last_preflight.ok %}ok{% else %}failed{% endif %}</span></div>
<div><strong>Target:</strong> {{ last_preflight.target }}</div>
<div><strong>Backup source:</strong> {{ last_preflight.source_root }}</div>
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</div>
<div class="host-control-meta">
<div>
<span class="label">Preflight</span>
<strong>
<span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">
{% if last_preflight.ok %}ok{% else %}failed{% endif %}
</span>
</strong>
</div>
<table>
<thead>
<tr>
<th>Status</th>
<th>Check</th>
<th>Message</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
<div><span class="label">Target</span><strong>{{ last_preflight.target }}</strong></div>
<div><span class="label">Backup source</span><strong>{{ last_preflight.source_root }}</strong></div>
<div><span class="label">Remote rsync</span><strong>{{ last_preflight.rsync_binary }}</strong></div>
</div>
{% else %}
<p class="muted">No connection preflight recorded yet.</p>
{% endif %}
<div class="actions inline">
<form method="post" action="{% url 'run_host_preflight' host.host %}">
{% 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 %}
<tr>
<td><span class="status {% if check.ok %}ok{% else %}failed{% endif %}">{% if check.ok %}ok{% else %}failed{% endif %}</span></td>
<td>{{ check.name }}</td>
<td>{{ check.message }}</td>
<td class="muted">{{ check.detail }}</td>
</tr>
<div class="activity-row">
<span class="status {% if check.ok %}ok{% else %}failed{% endif %}">
{% if check.ok %}ok{% else %}failed{% endif %}
</span>
<span>
<strong>{{ check.name }}</strong>
<span class="muted">{{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %}</span>
</span>
</div>
{% endfor %}
</tbody>
</table>
</div>
{% 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>{% if effective_config.rsync.bwlimit_kbps %}{{ effective_config.rsync.bwlimit_kbps }} KB/s{% else %}unlimited{% endif %}</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>
{% endif %}
<section class="panel">
<h2>Backup Control</h2>
<div class="operator-state">
{% 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>
<h2>Backup Options</h2>
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
{% csrf_token %}
{{ manual_backup_form.non_field_errors }}
@@ -320,65 +466,95 @@
</div>
{% endfor %}
<div class="actions">
<div class="form-actions">
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
</div>
</form>
</section>
<section class="panel">
<h2>Latest Runs</h2>
<table>
<thead>
<tr>
<th>Status</th>
<th>Started</th>
<th>Ended</th>
<th>Snapshot</th>
<th>Base</th>
</tr>
</thead>
<tbody>
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
<div class="record-list">
{% for run in latest_runs %}
<tr>
<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>
<td>{{ run.base_path|default:"" }}</td>
</tr>
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a>
<span class="muted">{{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %}</span>
</div>
<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 %}
<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 %}
</tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Snapshots</h2>
<table>
<thead>
<tr>
<th>Kind</th>
<th>Status</th>
<th>Started</th>
<th>Dirname</th>
<th>Base</th>
</tr>
</thead>
<tbody>
<h2>Snapshots <a class="button-link secondary compact" href="{% url 'snapshots_list' %}?host={{ host.host }}">View all</a></h2>
<div class="record-list">
{% for snapshot in snapshots %}
<tr>
<td>{{ snapshot.kind }}</td>
<td>{{ snapshot.status }}</td>
<td>{{ snapshot.started_at|default:"" }}</td>
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
</tr>
<article class="record-card">
<div class="record-card-header">
<div class="record-title">
<a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a>
<span class="muted">{{ snapshot.kind }}</span>
</div>
<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 %}
<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 %}
</tbody>
</table>
</div>
</section>
{% endblock %}

View File

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

View File

@@ -0,0 +1,43 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Hosts | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Inventory</div>
<h1>Hosts</h1>
<div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div>
</div>
<section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
</section>
</header>
<section class="grid dashboard-summary-grid" aria-label="Host summary">
<a class="metric metric-link" href="{% url 'hosts_list' %}"><div class="label">Showing</div><div class="value">{{ counts.hosts }}</div></a>
<a class="metric metric-link" href="{% url 'hosts_list' %}?enabled=yes"><div class="label">Enabled</div><div class="value">{{ counts.enabled_hosts }}</div></a>
<a class="metric metric-link" href="{% url 'hosts_list' %}?enabled=no"><div class="label">Disabled</div><div class="value">{{ counts.disabled_hosts }}</div></a>
<a class="metric metric-link" href="{% url 'dashboard' %}"><div class="label">Total</div><div class="value">{{ total_count }}</div></a>
</section>
<section class="panel">
<h2>Filters</h2>
<form class="filter-form" method="get">
<div class="field">
<label for="enabled">Host state</label>
<select id="enabled" name="enabled">
<option value="" {% if selected_enabled == "" %}selected{% endif %}>All hosts</option>
<option value="yes" {% if selected_enabled == "yes" %}selected{% endif %}>Enabled only</option>
<option value="no" {% if selected_enabled == "no" %}selected{% endif %}>Disabled only</option>
</select>
</div>
<div class="form-actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'hosts_list' %}">Reset</a>
</div>
</form>
</section>
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
{% endblock %}

View File

@@ -3,15 +3,20 @@
{% block title %}Logs | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Operations</div>
<h1>Logs</h1>
<div class="page-subtitle">Filter pobsync service logs by unit, priority, host, run, or message content.</div>
</div>
<section class="actions" aria-label="Log actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filter</h2>
<form method="get" class="form-grid">
<form method="get" class="filter-form">
<div class="field">
<label for="unit">Unit</label>
<select id="unit" name="unit">
@@ -49,8 +54,9 @@
<label for="q">Message contains</label>
<input id="q" name="q" value="{{ query }}">
</div>
<div class="actions">
<div class="form-actions">
<button type="submit">Filter logs</button>
<a class="button-link secondary" href="{% url 'logs' %}">Clear</a>
</div>
</form>
</section>

View File

@@ -0,0 +1,38 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}{{ title }} | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Reports</div>
<h1>{{ title }}</h1>
<div class="page-subtitle">Choose which completed backup statuses should trigger an email or webhook report.</div>
</div>
<section class="actions" aria-label="Notification target form actions">
<a class="button-link" href="{% url 'notification_targets' %}">Back to notifications</a>
</section>
</header>
<section class="panel">
<h2>{% if target %}Edit Target{% else %}Create Target{% endif %}</h2>
<form method="post" class="form-grid">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit">{{ submit_label }}</button>
<a class="button-link secondary" href="{% url 'notification_targets' %}">Cancel</a>
</div>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Notifications | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Reports</div>
<h1>Notifications</h1>
<div class="page-subtitle">Send email or webhook reports when backup runs finish.</div>
</div>
<section class="actions" aria-label="Notification actions">
<a class="button-link" href="{% url 'create_notification_target' %}">New target</a>
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Targets</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Channel</th>
<th>Status</th>
<th>Events</th>
<th>Destination</th>
<th>Last delivery</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for target in targets %}
<tr>
<td><a href="{% url 'edit_notification_target' target.id %}">{{ target.name }}</a></td>
<td>{{ target.get_channel_display }}</td>
<td><span class="status {% if target.enabled %}ok{% else %}skipped{% endif %}">{{ target.enabled|yesno:"enabled,disabled" }}</span></td>
<td>{{ target.statuses|join:", " }}</td>
<td>
{% if target.channel == "email" %}
{{ target.email_to|linebreaksbr }}
{% else %}
<code>{{ target.webhook_url|truncatechars:70 }}</code>
{% endif %}
</td>
<td>
{% if target.last_status %}
<span class="status {{ target.last_status }}">{{ target.last_status }}</span>
{% if target.last_error %}<div class="muted">{{ target.last_error|truncatechars:90 }}</div>{% endif %}
{% if target.last_sent_at %}<div class="muted">{{ target.last_sent_at }}</div>{% endif %}
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td><a class="button-link secondary" href="{% url 'edit_notification_target' target.id %}">Edit</a></td>
</tr>
{% empty %}
<tr><td colspan="7" class="muted">No notification targets configured yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Recent Deliveries</h2>
<table>
<thead>
<tr>
<th>Target</th>
<th>Run</th>
<th>Status</th>
<th>Created</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for delivery in deliveries %}
<tr>
<td>{{ delivery.target.name }}</td>
<td><a href="{% url 'run_detail' delivery.run.id %}">Run {{ delivery.run.id }}</a> {{ delivery.run.host.host }}</td>
<td><span class="status {{ delivery.status }}">{{ delivery.status }}</span></td>
<td>{{ delivery.created_at }}</td>
<td class="muted">{{ delivery.error|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="muted">No notification deliveries recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -0,0 +1,178 @@
<section class="panel dashboard-hosts-panel" id="hosts">
<h2>Hosts</h2>
<div class="host-list">
{% for host in hosts %}
<article class="host-card">
<div class="host-card-header">
<div class="host-card-title">
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
<span class="muted">{{ host.address }}</span>
</div>
<div class="host-card-status">
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
{% if host.queued_run_count %}
<span class="status queued">queued {{ host.queued_run_count }}</span>
{% endif %}
{% if host.running_run_count %}
<span class="status running">running {{ host.running_run_count }}</span>
{% endif %}
{% if host.warning_run_count %}
<span class="status warning">warning {{ host.warning_run_count }}</span>
{% endif %}
{% if host.failed_run_count %}
<span class="status failed">failed {{ host.failed_run_count }}</span>
{% endif %}
{% if show_host_controls %}
{% if host.schedule %}
<span class="status {% if host.schedule.enabled %}ok{% else %}skipped{% endif %}">schedule {{ host.schedule.enabled|yesno:"on,paused" }}</span>
<span class="status {% if host.schedule.prune %}ok{% else %}skipped{% endif %}">retention {{ host.schedule.prune|yesno:"on,paused" }}</span>
{% else %}
<span class="status skipped">no schedule</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="host-card-layout">
<div class="host-card-section">
<div class="host-card-section-title">Backup activity</div>
<div class="host-card-timeline">
<div class="host-card-item">
<div class="label">Latest Snapshot</div>
<div class="value">
{% if host.latest_snapshot %}
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Last Good Backup</div>
<div class="value">
{% if host.stats_summary.latest_good_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Latest Issue</div>
<div class="value">
{% if host.stats_summary.latest_problem_run.id %}
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
<div class="host-card-item">
<div class="label">Next Run</div>
<div class="value">
{% if host.next_run_at %}
{{ host.next_run_at|date:"Y-m-d H:i T" }}
<div class="muted">{{ scheduler_timezone }}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="host-card-section">
<div class="host-card-section-title">Snapshot health</div>
<div class="host-card-stats">
<div class="host-card-stat">
<div class="label">Snapshots</div>
<div class="value">{{ host.snapshot_count }}</div>
</div>
<div class="host-card-stat">
<div class="label">Runs</div>
<div class="value">{{ host.run_count }}</div>
</div>
<div class="host-card-stat">
<div class="label">New Data</div>
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
</div>
<div class="host-card-stat">
<div class="label">Retention</div>
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
</div>
<div class="host-card-stat">
<div class="label">Scheduled data</div>
<div class="value">{{ host.stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ host.stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
<div class="host-card-stat">
<div class="label">Manual data</div>
<div class="value">{{ host.stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ host.stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
<div class="host-card-stat">
<div class="label">Incomplete data</div>
<div class="value">{{ host.stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">measured from disk</div>
</div>
<div class="host-card-stat">
<div class="label">Total data</div>
<div class="value">{{ host.stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
<div class="muted">unique {{ host.stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</div>
</div>
</div>
</div>
</div>
{% if host.retention_warning.has_warning %}
<div class="host-card-warning">
<span class="status warning">retention</span>
{% if host.retention_warning.prune_exceeded %}
Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}.
{% endif %}
{% if host.retention_warning.incomplete_count %}
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark reviewed</button>
</form>
{% endif %}
{% if host.retention_warning.error %}
{{ host.retention_warning.error }}
{% endif %}
</div>
{% endif %}
{% if show_host_controls %}
<div class="host-card-actions">
<a class="button-link compact secondary" href="{% url 'host_detail' host.host %}">Open</a>
<a class="button-link compact secondary" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link compact secondary" href="{% url 'edit_host_schedule' host.host %}">{% if host.schedule %}Edit schedule{% else %}Create schedule{% endif %}</a>
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.enabled %}disable_host{% else %}enable_host{% endif %}">
<button class="compact {% if host.enabled %}secondary{% endif %}" type="submit">{{ host.enabled|yesno:"Disable host,Enable host" }}</button>
</form>
{% if host.schedule %}
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.schedule.enabled %}disable_schedule{% else %}enable_schedule{% endif %}">
<button class="compact secondary" type="submit">{{ host.schedule.enabled|yesno:"Pause schedule,Resume schedule" }}</button>
</form>
<form class="inline-form" method="post" action="{% url 'update_host_state' host.host %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="action" value="{% if host.schedule.prune %}disable_prune{% else %}enable_prune{% endif %}">
<button class="compact secondary" type="submit">{{ host.schedule.prune|yesno:"Pause retention,Resume retention" }}</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% empty %}
<p class="muted">No hosts configured yet.</p>
{% endfor %}
</div>
</section>

View File

@@ -0,0 +1,152 @@
<section class="dashboard-priority-grid" aria-label="Operator priorities">
<article class="panel priority-panel dashboard-panel-required">
<h2>Required Action</h2>
{% if action_items %}
<div class="action-list">
{% for item in action_items %}
<a class="action-row {{ item.status }}" href="{{ item.url }}">
<span class="status {{ item.status }}">{{ item.label }}</span>
<span>
<strong>{{ item.host.host }}</strong>
<span class="muted">{{ item.message }}</span>
</span>
</a>
{% endfor %}
</div>
{% elif counts.hosts %}
<p><span class="status ok">ok</span> No queued, running, unreviewed warning/failed runs, or retention warnings.</p>
{% else %}
<p class="muted">Add a host to start tracking backup status here.</p>
{% endif %}
{% if counts.running_runs or counts.queued_runs %}
<div class="operator-state">
{% if counts.running_runs %}
<a class="status-summary running" href="{% url 'runs_list' %}?status=running">
<span class="status running">running</span>
<strong>{{ counts.running_runs }} backup run{{ counts.running_runs|pluralize }} in progress.</strong>
</a>
{% endif %}
{% if counts.queued_runs %}
<a class="status-summary queued" href="{% url 'runs_list' %}?status=queued">
<span class="status queued">queued</span>
<strong>{{ counts.queued_runs }} backup run{{ counts.queued_runs|pluralize }} waiting.</strong>
</a>
{% endif %}
</div>
{% endif %}
</article>
<article class="panel priority-panel dashboard-panel-schedules">
<h2>Next Scheduled Work <a class="button-link secondary compact" href="{% url 'schedules_list' %}">View all</a></h2>
{% if next_schedule_rows %}
<div class="schedule-list">
{% for row in next_schedule_rows %}
<a class="schedule-row" href="{% url 'host_detail' row.schedule.host.host %}">
<span>
<strong>{{ row.schedule.host.host }}</strong>
<span class="muted">{{ row.schedule.cron_expr }}</span>
</span>
<span class="schedule-time">
{% if row.next_run_at %}
{{ row.next_run_at|date:"Y-m-d H:i T" }}
<span class="muted">{{ scheduler_timezone }}</span>
{% else %}
<span class="muted">not due</span>
{% endif %}
</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="muted">No enabled schedules yet.</p>
{% endif %}
</article>
<article class="panel priority-panel dashboard-panel-activity">
<h2>Recent Activity <a class="button-link secondary compact" href="{% url 'runs_list' %}">View all</a></h2>
{% if recent_runs %}
<div class="activity-list">
{% for run in recent_runs %}
<a class="activity-row" href="{% url 'run_detail' run.id %}">
<span class="status {{ run.status }}">{{ run.status }}</span>
<span>
<strong>Run {{ run.id }}</strong>
<span class="muted">{{ run.host.host }} · {{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="muted">No backup runs recorded yet.</p>
{% endif %}
</article>
<article class="panel priority-panel dashboard-panel-storage">
<h2>Storage Pressure</h2>
{% if stats_summary.runs_sampled %}
<div class="storage-priority">
<div>
<div class="label">Backup root used</div>
<div class="value">
{% if stats_summary.capacity.used_percent is not None %}
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
{% else %}
unknown
{% endif %}
</div>
{% if stats_summary.capacity.used_percent is not None %}
<div class="storage-meter" aria-label="Backup root storage usage">
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
</div>
{% endif %}
</div>
<div class="storage-priority-facts">
<div>
<span class="label">Runway</span>
<strong>
{% if stats_summary.estimated_days_until_full %}
{{ stats_summary.estimated_days_until_full }} days
{% elif stats_summary.estimated_runs_until_full %}
{{ stats_summary.estimated_runs_until_full }} runs
{% else %}
unknown
{% endif %}
</strong>
</div>
<div>
<span class="label">New data</span>
<strong>{{ stats_summary.avg_daily_literal_data_bytes|filesizeformat }}/day</strong>
</div>
<div>
<span class="label">Available</span>
<strong>{{ stats_summary.capacity.available_bytes|filesizeformat }}</strong>
</div>
</div>
</div>
{% else %}
<p class="muted">Storage pressure appears after the first completed backup with stats.</p>
{% endif %}
<div class="storage-priority-facts">
<div>
<span class="label">Scheduled data</span>
<strong>{{ stats_summary.backup_data.scheduled.allocated_size_bytes|filesizeformat }}</strong>
<span class="muted">unique {{ stats_summary.backup_data.scheduled.unique_apparent_size_bytes|filesizeformat }}</span>
</div>
<div>
<span class="label">Manual data</span>
<strong>{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</strong>
<span class="muted">unique {{ stats_summary.backup_data.manual.unique_apparent_size_bytes|filesizeformat }}</span>
</div>
<div>
<span class="label">Incomplete data</span>
<strong>{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</strong>
<span class="muted">measured from disk</span>
</div>
<div>
<span class="label">Total snapshot data</span>
<strong>{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</strong>
<span class="muted">unique {{ stats_summary.backup_data.total.unique_apparent_size_bytes|filesizeformat }}</span>
</div>
</div>
</article>
</section>

View File

@@ -0,0 +1,222 @@
<section class="grid" aria-label="Run summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
</section>
{% if can_cancel %}
<section class="panel highlight warning">
<h2>Run Control</h2>
<p>
Cancelling a queued run stops it immediately. Cancelling a running run asks the worker to stop
and records the cancellation request on this run.
</p>
<form method="post" action="{% url 'cancel_run' run.id %}">
{% csrf_token %}
<div class="form-actions">
<button type="submit" class="danger">Cancel run</button>
</div>
</form>
</section>
{% endif %}
{% if failure %}
<section class="panel highlight failed">
<h2>Failure</h2>
<div class="stack">
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
<div><strong>Summary:</strong> {{ failure_summary }}</div>
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
</div>
</section>
{% endif %}
{% if run.status == "failed" or run.status == "warning" %}
{% if not run.reviewed_at %}
<section class="panel highlight warning">
<h2>Review Required</h2>
<p>Mark this run as reviewed after you have checked the failure or warning and no longer need it in the action queue.</p>
<form method="post" action="{% url 'resolve_run_review' run.id %}">
{% csrf_token %}
<div class="form-actions">
<button type="submit" class="secondary">Mark reviewed</button>
</div>
</form>
</section>
{% endif %}
{% endif %}
{% if run.reviewed_at %}
<section class="panel highlight success">
<h2>Review</h2>
<div class="stack">
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
</div>
</section>
{% endif %}
{% if dry_run_summary %}
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
<h2>Run Progress</h2>
<section class="grid" aria-label="Run progress">
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
<div class="metric">
<div class="label">Mode</div>
<div class="value">dry run</div>
</div>
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
</section>
<div class="stack">
{% if dry_run_summary.duration_seconds is not None %}
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
{% endif %}
<div>
<strong>Log:</strong>
{% if dry_run_summary.log_available %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span>
{% else %}
<span class="muted">not recorded yet</span>
{% endif %}
</div>
{% if dry_run_summary.warnings %}
<div><strong>Warnings:</strong></div>
<ul>
{% for warning in dry_run_summary.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
{% else %}
<div><strong>Warnings:</strong> none recorded</div>
{% endif %}
</div>
</section>
{% endif %}
{% if live_progress %}
<section class="panel highlight running">
<h2>Run Progress</h2>
<section class="grid" aria-label="Run progress">
<div class="metric">
<div class="label">Status</div>
<div class="value">{{ run.status }}</div>
</div>
<div class="metric">
<div class="label">Mode</div>
<div class="value">backup</div>
</div>
<div class="metric">
<div class="label">Phase</div>
<div class="value">{{ live_progress.phase }}</div>
</div>
<div class="metric">
<div class="label">Rsync PID</div>
<div class="value">{{ live_progress.rsync_pid|default:"" }}</div>
</div>
<div class="metric">
<div class="label">Log Updated</div>
<div class="value">
{% if live_progress.log.exists %}
{{ live_progress.log.seconds_since_modified }}s ago
{% else %}
missing
{% endif %}
</div>
</div>
<div class="metric">
<div class="label">Log Size</div>
<div class="value">{{ live_progress.log.size_bytes|filesizeformat }}</div>
</div>
{% if live_progress.snapshot.exists %}
<div class="metric">
<div class="label">Data Files</div>
<div class="value">{% if live_progress.snapshot.scan_limited %}at least {% endif %}{{ live_progress.snapshot.files }}</div>
</div>
<div class="metric">
<div class="label">Data Size</div>
<div class="value">{% if live_progress.snapshot.scan_limited %}at least {% endif %}{{ live_progress.snapshot.apparent_size_bytes|filesizeformat }}</div>
</div>
{% endif %}
</section>
<div class="stack">
{% if live_progress.snapshot.path %}
<div><strong>Snapshot path:</strong> {{ live_progress.snapshot.path }}</div>
{% endif %}
{% if live_progress.snapshot.scan_limited %}
<div class="muted">Progress scan was capped to keep the UI responsive.</div>
{% endif %}
{% if live_progress.log.path %}
<div>
<strong>Log:</strong>
{% if live_progress.log.exists %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% else %}
<span class="muted">{{ live_progress.log.path }} (missing)</span>
{% endif %}
</div>
<div><strong>Log path:</strong> {{ live_progress.log.path }}</div>
{% endif %}
<div><strong>Warnings:</strong> none recorded</div>
</div>
</section>
{% endif %}
<div class="two-col">
<section class="panel">
<h2>Timing</h2>
<div class="stack">
<div><strong>Created:</strong> {{ run.created_at }}</div>
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
{% if execution %}
<div><strong>Worker:</strong> {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}</div>
<div><strong>Worker heartbeat:</strong> {{ execution.heartbeat_at|default:"" }}</div>
{% endif %}
</div>
</section>
<section class="panel">
<h2>Snapshot</h2>
<div class="stack">
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
<div>
<strong>Rsync log:</strong>
{% if rsync_log_exists %}
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
{% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
</section>
</div>
<section class="panel">
<h2>Rsync Log</h2>
<div class="stack spaced">
{% if rsync_log_exists %}
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
<div class="muted">{{ rsync_log_path }}</div>
{% elif rsync_log_path %}
<div class="muted">{{ rsync_log_path }} (missing)</div>
{% else %}
<div class="muted">No rsync log path recorded yet.</div>
{% endif %}
</div>
{% if rsync_log_tail %}
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
{% endif %}{% endfor %}</pre>
{% else %}
<p class="muted">No recent rsync log output recorded yet.</p>
{% endif %}
</section>

View File

@@ -3,15 +3,20 @@
{% block title %}Purged Snapshots | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Retention</div>
<h1>Purged Snapshots</h1>
<div class="page-subtitle">Audit trail for snapshots removed by retention or manual purge actions.</div>
</div>
<section class="actions" aria-label="Purged snapshot actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel">
<h2>Filters</h2>
<form method="get" class="form-grid">
<form method="get" class="filter-form">
<div class="field">
<label for="host">Host</label>
<select id="host" name="host">
@@ -30,7 +35,7 @@
{% endfor %}
</select>
</div>
<div class="actions">
<div class="form-actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'purged_snapshots' %}">Clear</a>
</div>

View File

@@ -3,8 +3,12 @@
{% block title %}Retention plan | {{ host.host }}{% endblock %}
{% block content %}
<h1>Retention Plan: {{ host.host }}</h1>
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Retention</div>
<h1>{{ host.host }}</h1>
<div class="page-subtitle">Preview which snapshots stay, which would be deleted, and whether incomplete cleanup is needed.</div>
</div>
<section class="actions" aria-label="Retention filters">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
@@ -12,6 +16,7 @@
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
</section>
</header>
<section class="grid" aria-label="Retention plan summary">
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>
@@ -40,8 +45,9 @@
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
</p>
<p>
After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their
tracking records. Successful scheduled and manual snapshots are not touched by this cleanup.
{{ incomplete_unreviewed_count }} still need review. After inspection, mark them reviewed and use the dedicated
cleanup form below to delete only incomplete snapshot directories and their tracking records. Successful
scheduled and manual snapshots are not touched by this cleanup.
</p>
</section>
{% endif %}
@@ -99,8 +105,12 @@
</section>
{% if plan.delete %}
<section class="panel">
<section class="panel highlight warning">
<h2>Apply Retention</h2>
<p class="muted">
This permanently deletes the snapshot directories listed in Would Delete. Confirm the host and delete count
before applying the plan.
</p>
<form method="post" action="{% url 'apply_host_retention' host.host %}" class="form-grid">
{% csrf_token %}
{{ apply_form.non_field_errors }}
@@ -133,8 +143,9 @@
<div class="helptext">{{ apply_form.confirm_delete_count.help_text }}</div>
</div>
<div class="actions">
<button type="submit">Apply retention</button>
<div class="form-actions">
<button type="submit" class="danger">Apply retention</button>
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
</div>
</form>
</section>
@@ -177,6 +188,7 @@
<th>Dirname</th>
<th>Started</th>
<th>Status</th>
<th>Review</th>
<th>Reason</th>
<th>Path</th>
</tr>
@@ -187,6 +199,14 @@
<td>{{ snapshot.dirname }}</td>
<td>{{ snapshot.dt }}</td>
<td>{{ snapshot.status|default:"" }}</td>
<td>
{% if snapshot.reviewed %}
<span class="status ok">reviewed</span>
<span class="muted">{{ snapshot.reviewed_by|default:"unknown" }}</span>
{% else %}
<span class="status warning">needs review</span>
{% endif %}
</td>
<td>{{ snapshot.reason }}</td>
<td class="muted">{{ snapshot.path }}</td>
</tr>
@@ -195,6 +215,20 @@
</table>
<h3>Cleanup Incomplete Snapshots</h3>
{% if incomplete_unreviewed_count %}
<p class="muted">
Cleanup is blocked until all incomplete snapshots are reviewed. This extra step makes it explicit that the
interrupted backup was inspected before deletion.
</p>
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}" class="actions inline">
{% csrf_token %}
<button type="submit" class="secondary">Mark incomplete snapshots reviewed</button>
</form>
{% else %}
<p class="muted">
This deletes only reviewed incomplete snapshot directories and their tracking records. Successful manual and
scheduled snapshots are not touched.
</p>
<form method="post" action="{% url 'cleanup_host_incomplete_snapshots' host.host %}" class="form-grid">
{% csrf_token %}
{{ incomplete_cleanup_form.non_field_errors }}
@@ -220,10 +254,12 @@
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
</div>
<div class="actions">
<button type="submit">Delete incomplete snapshots</button>
<div class="form-actions">
<button type="submit" class="danger">Delete incomplete snapshots</button>
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
</div>
</form>
{% endif %}
</section>
{% endif %}
{% endblock %}

View File

@@ -3,124 +3,36 @@
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Backup run</div>
<h1>Run {{ run.id }}</h1>
<div class="page-subtitle">{{ run.host.host }} · {{ run.run_type }} · {{ run.status }}</div>
</div>
<section class="actions" aria-label="Run actions">
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
{% if can_cancel %}
<form method="post" action="{% url 'cancel_run' run.id %}">
{% csrf_token %}
<button type="submit" class="secondary">Cancel run</button>
</form>
{% endif %}
{% if run.status == "failed" or run.status == "warning" %}
{% if not run.reviewed_at %}
<form method="post" action="{% url 'resolve_run_review' run.id %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark reviewed</button>
</form>
{% endif %}
{% endif %}
</section>
</header>
<section class="grid" aria-label="Run summary">
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
</section>
{% if failure %}
<section class="panel highlight failed">
<h2>Failure</h2>
<div class="stack">
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
<div><strong>Summary:</strong> {{ failure_summary }}</div>
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
</div>
</section>
{% endif %}
{% if run.reviewed_at %}
<section class="panel highlight success">
<h2>Review</h2>
<div class="stack">
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
</div>
</section>
{% endif %}
{% if dry_run_summary %}
<section class="panel highlight {{ dry_run_summary.highlight_class }}">
<h2>Dry Run Summary</h2>
<section class="grid" aria-label="Dry run summary">
<div class="metric"><div class="label">Status</div><div class="value">{{ dry_run_summary.status }}</div></div>
<div class="metric"><div class="label">Files Seen</div><div class="value">{{ dry_run_summary.files_seen|default:"unknown" }}</div></div>
<div class="metric"><div class="label">Would Transfer</div><div class="value">{{ dry_run_summary.files_would_transfer|default:"unknown" }}</div></div>
<div class="metric"><div class="label">Transfer Estimate</div><div class="value">{{ dry_run_summary.transfer_estimate_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Total Size</div><div class="value">{{ dry_run_summary.total_file_size_bytes|filesizeformat }}</div></div>
<div class="metric"><div class="label">Link-Dest Saving</div><div class="value">{{ dry_run_summary.link_dest_estimated_savings_bytes|filesizeformat }}</div></div>
</section>
<div class="stack">
{% if dry_run_summary.duration_seconds is not None %}
<div><strong>Duration:</strong> {{ dry_run_summary.duration_seconds }}s</div>
{% endif %}
{% if can_auto_refresh %}
<section class="panel refresh-controls" aria-label="Live refresh controls">
<div>
<strong>Log:</strong>
{% if dry_run_summary.log_available %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span>
{% else %}
<span class="muted">not recorded yet</span>
{% endif %}
</div>
{% if dry_run_summary.warnings %}
<div><strong>Warnings:</strong></div>
<ul>
{% for warning in dry_run_summary.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
{% else %}
<div><strong>Warnings:</strong> none recorded</div>
{% endif %}
<h2>Live Updates</h2>
<p class="muted">Auto-refresh is <strong data-refresh-state="run-live-region">on</strong> while this run is active.</p>
</div>
<button type="button" class="secondary" data-refresh-toggle data-refresh-target="run-live-region">Pause refresh</button>
</section>
{% endif %}
<div class="two-col">
<section class="panel">
<h2>Timing</h2>
<div class="stack">
<div><strong>Created:</strong> {{ run.created_at }}</div>
<div><strong>Started:</strong> {{ run.started_at|default:"" }}</div>
<div><strong>Ended:</strong> {{ run.ended_at|default:"" }}</div>
{% if execution %}
<div><strong>Worker:</strong> {{ execution.worker_host|default:"unknown" }}{% if execution.worker_pid %} pid {{ execution.worker_pid }}{% endif %}</div>
<div><strong>Worker heartbeat:</strong> {{ execution.heartbeat_at|default:"" }}</div>
{% endif %}
</div>
</section>
<section class="panel">
<h2>Snapshot</h2>
<div class="stack">
<div><strong>Snapshot:</strong> {% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}{{ run.snapshot_path|default:"" }}{% endif %}</div>
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
<div>
<strong>Rsync log:</strong>
{% if rsync_log_exists %}
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
{% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span>
{% else %}
<span class="muted">none</span>
{% endif %}
</div>
</div>
</section>
<div
id="run-live-region"
data-refresh-url="{% url 'run_detail_live' run.id %}"
data-refresh-interval="5000"
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
data-refresh-paused="false"
aria-live="polite"
>
{% include "pobsync_backend/partials/run_detail_live.html" %}
</div>
{% if requested %}
@@ -138,6 +50,10 @@
<section class="panel">
<h2>Rsync Command</h2>
<p class="muted">
<strong>Bandwidth limit:</strong>
{% if rsync_bwlimit_kbps %}{{ rsync_bwlimit_kbps }} KB/s{% else %}unlimited{% endif %}
</p>
{% if rsync_command %}
<pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
{% endif %}{% endfor %}</pre>
@@ -146,26 +62,6 @@
{% endif %}
</section>
<section class="panel">
<h2>Rsync Log</h2>
<div class="stack spaced">
{% if rsync_log_exists %}
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
<div class="muted">{{ rsync_log_path }}</div>
{% elif rsync_log_path %}
<div class="muted">{{ rsync_log_path }} (missing)</div>
{% else %}
<div class="muted">No rsync log path recorded yet.</div>
{% endif %}
</div>
{% if rsync_log_tail %}
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
{% endif %}{% endfor %}</pre>
{% else %}
<p class="muted">No recent rsync log output recorded yet.</p>
{% endif %}
</section>
{% if stats %}
<section class="panel">
<h2>Stats</h2>

View File

@@ -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="filter-form">
<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="form-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 content %}
<h1>Schedule: {{ host.host }}</h1>
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Schedule</div>
<h1>{{ host.host }}</h1>
<div class="page-subtitle">Automatic backup timing and scheduled prune behavior for this host.</div>
</div>
<section class="actions" aria-label="Schedule actions">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
</section>
</header>
<section class="panel">
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2>
@@ -25,8 +30,9 @@
</div>
{% endfor %}
<div class="actions">
<div class="form-actions">
<button type="submit">Save schedule</button>
<a class="button-link secondary" href="{% url 'host_detail' host.host %}">Cancel</a>
</div>
</form>
</section>

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="filter-form">
<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="form-actions">
<button type="submit">Apply filters</button>
<a class="button-link secondary" href="{% url 'schedules_list' %}">Clear</a>
</div>
</form>
</section>
<section class="panel">
<h2>Configured Schedules</h2>
<p class="muted">Showing up to 200 of {{ total_count }} schedule{{ total_count|pluralize }}. Times use {{ scheduler_timezone }}.</p>
<table>
<thead>
<tr>
<th>Host</th>
<th>Expression</th>
<th>Enabled</th>
<th>Next Run</th>
<th>Prune</th>
<th>Last Status</th>
<th>Last Started</th>
<th>Last Finished</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for row in schedule_rows %}
{% with schedule=row.schedule %}
<tr>
<td><a href="{% url 'host_detail' schedule.host.host %}">{{ schedule.host.host }}</a></td>
<td><code>{{ schedule.cron_expr }}</code></td>
<td><span class="status {% if schedule.enabled %}ok{% else %}skipped{% endif %}">{{ schedule.enabled|yesno:"enabled,disabled" }}</span></td>
<td>
{% if row.next_run_at %}
{{ row.next_run_at|date:"Y-m-d H:i T" }}
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td>
<span class="status {% if schedule.prune %}ok{% else %}skipped{% endif %}">{{ schedule.prune|yesno:"enabled,disabled" }}</span>
{% if schedule.prune %}
<div class="muted">max {{ schedule.prune_max_delete }}{% if schedule.prune_protect_bases %}, protects bases{% endif %}</div>
{% endif %}
</td>
<td>{% if schedule.last_status %}<span class="status {{ schedule.last_status }}">{{ schedule.last_status }}</span>{% else %}<span class="muted">none</span>{% endif %}</td>
<td>{{ schedule.last_started_at|default:"" }}</td>
<td>{{ schedule.last_finished_at|default:"" }}</td>
<td><a class="button-link secondary" href="{% url 'edit_host_schedule' schedule.host.host %}">Edit</a></td>
</tr>
{% endwith %}
{% empty %}
<tr><td colspan="9" class="muted">No schedules matched the current filter.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,16 @@
{% block title %}{% if credential %}SSH Key | {{ credential.name }}{% else %}New SSH Key{% endif %} | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Access</div>
<h1>{% if credential %}SSH Key: {{ credential.name }}{% else %}New SSH Key{% endif %}</h1>
<div class="page-subtitle">{% if credential %}Review key metadata, known hosts, and deletion safety for this credential.{% else %}Register an existing private key for use by pobsync backups.{% endif %}</div>
</div>
<section class="actions" aria-label="SSH key form actions">
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
</section>
</header>
<section class="panel">
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>
@@ -36,8 +41,9 @@
</div>
{% endfor %}
<div class="actions">
<div class="form-actions">
<button type="submit">{% if credential %}Save SSH key{% else %}Create SSH key{% endif %}</button>
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
</div>
</form>
</section>
@@ -59,7 +65,10 @@
<label for="confirm_name">Confirm key name</label>
<input id="confirm_name" name="confirm_name" type="text" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>
</div>
<div class="form-actions">
<button type="submit" class="danger" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>Delete SSH key</button>
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
</div>
</form>
</section>
{% endif %}

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ from django.utils import timezone
from pobsync.util import write_yaml_atomic
from pobsync_backend.backup_runner import queue_backup_run, reconcile_running_runs
from pobsync_backend.management.commands.run_pobsync_worker import Command
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, NotificationDelivery, NotificationTarget, SnapshotRecord
class BackupWorkerTests(TestCase):
@@ -85,6 +85,73 @@ class BackupWorkerTests(TestCase):
self.assertEqual(SnapshotRecord.objects.count(), 1)
self.assertEqual(run.snapshot, SnapshotRecord.objects.get())
def test_worker_records_warning_status_from_completed_run(self) -> None:
with TemporaryDirectory() as tmp:
backup_root = Path(tmp) / "backups"
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
meta_dir = snapshot_dir / "meta"
meta_dir.mkdir(parents=True)
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "warning", "started_at": "2026-05-19T02:15:00Z"})
run = queue_backup_run(host=host)
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
run_scheduled.return_value = {
"ok": True,
"status": "warning",
"dry_run": False,
"host": host.host,
"snapshot": str(snapshot_dir),
"base": None,
"warning": {"category": "vanished"},
"rsync": {"exit_code": 24},
}
count = Command()._run_once(prefix=Path(tmp) / "home")
self.assertEqual(count, 1)
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.WARNING)
self.assertEqual(run.rsync_exit_code, 24)
self.assertEqual(run.result["warning"]["category"], "vanished")
def test_worker_sends_notification_after_completed_run(self) -> None:
with TemporaryDirectory() as tmp:
backup_root = Path(tmp) / "backups"
GlobalConfig.objects.create(name="default", backup_root=str(backup_root))
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
NotificationTarget.objects.create(
name="ops",
channel=NotificationTarget.Channel.EMAIL,
email_to="ops@example.test",
)
snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH"
meta_dir = snapshot_dir / "meta"
meta_dir.mkdir(parents=True)
write_yaml_atomic(meta_dir / "meta.yaml", {"status": "success", "started_at": "2026-05-19T02:15:00Z"})
run = queue_backup_run(host=host)
with (
patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled,
patch("pobsync_backend.notifications.send_mail", return_value=1) as send_mail,
):
run_scheduled.return_value = {
"ok": True,
"dry_run": False,
"host": host.host,
"snapshot": str(snapshot_dir),
"base": None,
"rsync": {"exit_code": 0},
}
Command()._run_once(prefix=Path(tmp) / "home")
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.SUCCESS)
self.assertEqual(NotificationDelivery.objects.get(run=run).status, NotificationDelivery.Status.SENT)
send_mail.assert_called_once()
def test_worker_refreshes_heartbeat_while_run_is_active(self) -> None:
with TemporaryDirectory() as tmp:
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
@@ -116,6 +183,44 @@ class BackupWorkerTests(TestCase):
run_scheduled.side_effect = fake_run_scheduled
Command()._run_once(prefix=Path(tmp) / "home")
def test_worker_records_real_run_log_path_while_running(self) -> None:
with TemporaryDirectory() as tmp:
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(host=host)
snapshot_dir = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-021500Z__ABCDEFGH"
log_path = snapshot_dir / "meta" / "rsync.log"
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
def fake_run_scheduled(**kwargs):
kwargs["state_callback"](
{
"status": "running",
"phase": "rsync",
"snapshot": str(snapshot_dir),
"log": str(log_path),
"rsync": {"command": ["rsync"], "exit_code": None, "pid": 1234, "pgid": 1234},
}
)
run.refresh_from_db()
self.assertEqual(run.snapshot_path, str(snapshot_dir))
self.assertEqual(run.result["execution"]["phase"], "rsync")
self.assertEqual(run.result["execution"]["log"], str(log_path))
self.assertEqual(run.result["execution"]["snapshot"], str(snapshot_dir))
self.assertEqual(run.result["rsync"]["command"], ["rsync"])
self.assertEqual(run.result["rsync"]["pid"], 1234)
return {
"ok": True,
"dry_run": False,
"host": host.host,
"snapshot": "",
"base": None,
"rsync": {"exit_code": 0},
}
run_scheduled.side_effect = fake_run_scheduled
Command()._run_once(prefix=Path(tmp) / "home")
def test_worker_reconciles_stale_real_run_after_heartbeat_timeout(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(host=host)
@@ -136,6 +241,97 @@ class BackupWorkerTests(TestCase):
self.assertEqual(run.result["failure"]["category"], "worker")
self.assertIn("heartbeat stopped", run.result["failure"]["message"])
def test_worker_reconciles_real_run_with_terminal_broken_pipe_log(self) -> None:
with TemporaryDirectory() as tmp:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(host=host)
log_path = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-021500Z__ABCDEFGH" / "meta" / "rsync.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(
"rsync error: unexplained error (code 255) at rsync.c(716) [generator=3.4.1]\n"
"rsync error: received SIGUSR1 (code 19) at main.c(1600) [receiver=3.4.1]\n"
"rsync: [generator] write error: Broken pipe (32)\n",
encoding="utf-8",
)
run.status = BackupRun.Status.RUNNING
run.started_at = timezone.now()
run.result["execution"] = {"log": str(log_path)}
run.save(update_fields=["status", "started_at", "result"])
reconciled = reconcile_running_runs()
self.assertEqual(reconciled, 1)
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.FAILED)
self.assertEqual(run.rsync_exit_code, 255)
self.assertEqual(run.result["failure"]["category"], "transport")
self.assertIn("Broken pipe", "\n".join(run.result["rsync"]["log_tail"]))
def test_worker_reconciles_real_run_when_rsync_process_disappears(self) -> None:
with TemporaryDirectory() as tmp:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(host=host)
log_path = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-021500Z__ABCDEFGH" / "meta" / "rsync.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("sending incremental file list\n", encoding="utf-8")
run.status = BackupRun.Status.RUNNING
run.started_at = timezone.now() - timedelta(minutes=10)
run.result["execution"] = {
"phase": "rsync",
"log": str(log_path),
"heartbeat_at": (timezone.now() - timedelta(minutes=10)).isoformat(),
}
run.result["rsync"] = {"pid": 999999, "pgid": 999999, "command": ["rsync"]}
run.save(update_fields=["status", "started_at", "result"])
reconciled = reconcile_running_runs(grace_seconds=300, stale_worker_seconds=24 * 60 * 60)
self.assertEqual(reconciled, 1)
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.FAILED)
self.assertEqual(run.result["failure"]["category"], "rsync_process")
self.assertEqual(run.rsync_exit_code, 255)
def test_worker_does_not_reconcile_missing_rsync_process_during_finalizing_phase(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(host=host)
run.status = BackupRun.Status.RUNNING
run.started_at = timezone.now() - timedelta(minutes=10)
run.result["execution"] = {
"phase": "finalizing",
"heartbeat_at": (timezone.now() - timedelta(minutes=10)).isoformat(),
}
run.result["rsync"] = {"pid": 999999, "pgid": 999999, "command": ["rsync"], "exit_code": 0}
run.save(update_fields=["status", "started_at", "result"])
reconciled = reconcile_running_runs(grace_seconds=300, stale_worker_seconds=24 * 60 * 60)
self.assertEqual(reconciled, 0)
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.RUNNING)
def test_worker_does_not_fail_real_run_for_vanished_file_warning_log(self) -> None:
with TemporaryDirectory() as tmp:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = queue_backup_run(host=host)
log_path = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-021500Z__ABCDEFGH" / "meta" / "rsync.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(
"file has vanished: \"/var/lib/app/session\"\n"
"rsync warning: some files vanished before they could be transferred (code 24) at main.c(1338) [sender=3.4.1]\n",
encoding="utf-8",
)
run.status = BackupRun.Status.RUNNING
run.started_at = timezone.now()
run.result["execution"] = {"log": str(log_path)}
run.save(update_fields=["status", "started_at", "result"])
reconciled = reconcile_running_runs()
self.assertEqual(reconciled, 0)
run.refresh_from_db()
self.assertEqual(run.status, BackupRun.Status.RUNNING)
def test_worker_records_dry_run_log_path_while_running(self) -> None:
with TemporaryDirectory() as tmp:
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))

View File

@@ -42,6 +42,7 @@ class ConfigureCommandsTests(TestCase):
address="web-01.example.test",
exclude_add=["/tmp/***"],
rsync_extra_arg=["--delete"],
rsync_bwlimit_kbps=4096,
stdout=out,
)
@@ -49,10 +50,12 @@ class ConfigureCommandsTests(TestCase):
self.assertEqual(host.retention_daily, 5)
self.assertEqual(host.excludes_add, ["/tmp/***"])
self.assertEqual(host.rsync_extra_args, ["--delete"])
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
effective = DjangoConfigSource().effective_config_for_host("web-01")
self.assertEqual(effective["retention"]["yearly"], 2)
self.assertEqual(effective["excludes_effective"], ["/tmp/***"])
self.assertEqual(effective["rsync"]["bwlimit_kbps"], 4096)
def test_configure_schedule_creates_sql_schedule(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -15,7 +15,7 @@ class ConsoleEntrypointTests(SimpleTestCase):
exit_code = main(["--version"])
self.assertEqual(exit_code, 0)
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.0.0")
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.1.0")
def test_maps_backup_alias_to_django_command(self) -> None:
with patch("pobsync.cli.execute_from_command_line") as execute:

View File

@@ -17,6 +17,7 @@ class DjangoConfigSourceTests(TestCase):
backup_root="/backups",
rsync_args=["--archive"],
rsync_extra_args=["--numeric-ids"],
rsync_bwlimit_kbps=10000,
excludes_default=["/proc/***"],
retention_daily=7,
retention_weekly=4,
@@ -28,6 +29,7 @@ class DjangoConfigSourceTests(TestCase):
address="web-01.example.test",
excludes_add=["/tmp/***"],
rsync_extra_args=["--delete"],
rsync_bwlimit_kbps=2500,
retention_daily=7,
retention_weekly=4,
retention_monthly=3,
@@ -46,6 +48,24 @@ class DjangoConfigSourceTests(TestCase):
self.assertEqual(cfg["address"], "web-01.example.test")
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"])
self.assertEqual(cfg["rsync"]["bwlimit_kbps"], 2500)
def test_host_can_disable_global_rsync_bandwidth_limit(self) -> None:
GlobalConfig.objects.create(
name="default",
backup_root="/backups",
rsync_args=["--archive"],
rsync_bwlimit_kbps=5000,
)
HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
rsync_bwlimit_kbps=0,
)
cfg = DjangoConfigSource().effective_config_for_host("web-01")
self.assertEqual(cfg["rsync"]["bwlimit_kbps"], 0)
def test_materializes_global_ssh_credential_for_runtime_config(self) -> None:
credential = SshCredential.objects.create(
@@ -113,6 +133,7 @@ class DjangoConfigSourceTests(TestCase):
)
HostConfig.objects.create(host="web-01", address="web-01.example.test")
with override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
cfg = DjangoConfigSource().effective_config_for_host("web-01")
self.assertIn(f"-oIdentityFile={identity_file}", cfg["ssh"]["options"])

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from datetime import timedelta
from unittest.mock import Mock, patch
from django.test import TestCase
from django.utils import timezone
from pobsync_backend.models import BackupRun, HostConfig, NotificationDelivery, NotificationTarget
from pobsync_backend.notifications import notify_backup_run_completed
class NotificationTests(TestCase):
def test_email_notification_is_sent_for_matching_status(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
target = NotificationTarget.objects.create(
name="ops",
channel=NotificationTarget.Channel.EMAIL,
statuses=[BackupRun.Status.FAILED],
email_to="ops@example.test",
)
run = BackupRun.objects.create(
host=host,
status=BackupRun.Status.FAILED,
run_type=BackupRun.RunType.MANUAL,
started_at=timezone.now() - timedelta(minutes=5),
ended_at=timezone.now(),
rsync_exit_code=12,
result={"failure": {"message": "rsync failed"}},
)
with patch("pobsync_backend.notifications.send_mail", return_value=1) as send_mail:
results = notify_backup_run_completed(run)
self.assertEqual(len(results), 1)
self.assertTrue(results[0].sent)
send_mail.assert_called_once()
subject, message, _from_email, recipients = send_mail.call_args.args
self.assertEqual(subject, f"pobsync failed: web-01 run {run.id}")
self.assertIn("Failure: rsync failed", message)
self.assertEqual(recipients, ["ops@example.test"])
delivery = NotificationDelivery.objects.get(target=target, run=run)
self.assertEqual(delivery.status, NotificationDelivery.Status.SENT)
target.refresh_from_db()
self.assertEqual(target.last_status, NotificationDelivery.Status.SENT)
self.assertEqual(target.last_error, "")
self.assertIsNotNone(target.last_sent_at)
def test_webhook_notification_posts_payload(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
target = NotificationTarget.objects.create(
name="discord",
channel=NotificationTarget.Channel.WEBHOOK,
webhook_url="https://hooks.example.test/pobsync",
webhook_headers={"X-Token": "secret"},
)
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED)
response = Mock()
response.status = 204
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with patch("pobsync_backend.notifications.urllib.request.urlopen", return_value=response) as urlopen:
notify_backup_run_completed(run)
request = urlopen.call_args.args[0]
self.assertEqual(request.full_url, "https://hooks.example.test/pobsync")
self.assertEqual(request.get_method(), "POST")
self.assertEqual(request.headers["X-token"], "secret")
self.assertIn(f'"id": {run.id}', request.data.decode("utf-8"))
self.assertEqual(NotificationDelivery.objects.get(target=target, run=run).status, NotificationDelivery.Status.SENT)
def test_notification_filters_statuses(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
NotificationTarget.objects.create(
name="failures-only",
channel=NotificationTarget.Channel.EMAIL,
statuses=[BackupRun.Status.FAILED],
email_to="ops@example.test",
)
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS)
with patch("pobsync_backend.notifications.send_mail") as send_mail:
results = notify_backup_run_completed(run)
self.assertEqual(results, [])
send_mail.assert_not_called()
self.assertEqual(NotificationDelivery.objects.count(), 0)
def test_notification_delivery_is_idempotent_per_run_and_target(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
target = NotificationTarget.objects.create(
name="ops",
channel=NotificationTarget.Channel.EMAIL,
email_to="ops@example.test",
)
run = BackupRun.objects.create(host=host, status=BackupRun.Status.WARNING)
with patch("pobsync_backend.notifications.send_mail", return_value=1) as send_mail:
notify_backup_run_completed(run)
notify_backup_run_completed(run)
self.assertEqual(NotificationDelivery.objects.filter(target=target, run=run).count(), 1)
send_mail.assert_called_once()
def test_failed_delivery_is_recorded_without_raising(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
target = NotificationTarget.objects.create(
name="broken",
channel=NotificationTarget.Channel.EMAIL,
email_to="ops@example.test",
)
run = BackupRun.objects.create(host=host, status=BackupRun.Status.FAILED)
with patch("pobsync_backend.notifications.send_mail", side_effect=RuntimeError("smtp down")):
results = notify_backup_run_completed(run)
self.assertEqual(len(results), 1)
self.assertFalse(results[0].sent)
delivery = NotificationDelivery.objects.get(target=target, run=run)
self.assertEqual(delivery.status, NotificationDelivery.Status.FAILED)
self.assertEqual(delivery.error, "smtp down")
target.refresh_from_db()
self.assertEqual(target.last_status, NotificationDelivery.Status.FAILED)
self.assertEqual(target.last_error, "smtp down")

View File

@@ -12,8 +12,9 @@ from pobsync.rsync import RsyncResult
class FakeConfigSource:
def __init__(self, backup_root: str = "/tmp/pobsync-test-backups") -> None:
def __init__(self, backup_root: str = "/tmp/pobsync-test-backups", bwlimit_kbps: int = 0) -> None:
self.backup_root = backup_root
self.bwlimit_kbps = bwlimit_kbps
def effective_config_for_host(self, host: str) -> dict:
return {
@@ -25,7 +26,7 @@ class FakeConfigSource:
"binary": "rsync",
"args_effective": ["--archive"],
"timeout_seconds": 0,
"bwlimit_kbps": 0,
"bwlimit_kbps": self.bwlimit_kbps,
},
"source_root": "/",
"includes": [],
@@ -54,6 +55,21 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
self.assertEqual(result["host"], "web-01")
run_rsync.assert_called_once()
def test_dry_run_applies_configured_bandwidth_limit(self) -> None:
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--bwlimit=4096"])
result = run_scheduled(
prefix=Path("/missing-prefix"),
host="web-01",
dry_run=True,
config_source=FakeConfigSource(bwlimit_kbps=4096),
)
command = run_rsync.call_args.args[0]
self.assertIn("--bwlimit=4096", command)
self.assertEqual(result["rsync"]["bwlimit_kbps"], 4096)
def test_failed_dry_run_includes_log_tail(self) -> None:
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -186,11 +202,13 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
host="web-01",
dry_run=False,
verbose_output=True,
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")),
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups"), bwlimit_kbps=2048),
)
command = run_rsync.call_args.args[0]
self.assertTrue(result["ok"])
self.assertIn("--bwlimit=2048", command)
self.assertEqual(result["rsync"]["bwlimit_kbps"], 2048)
self.assertIn("--stats", command)
self.assertIn("--itemize-changes", command)
self.assertIn("--info=flist2,progress2,stats2", command)
@@ -256,6 +274,71 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
self.assertIn("stats:", meta_text)
self.assertIn("files_total: 10", meta_text)
def test_real_run_reports_running_state_callback_before_rsync_returns(self) -> None:
states = []
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None, process_started=None):
self.assertEqual(len(states), 1)
self.assertEqual(states[0]["status"], "running")
self.assertEqual(states[0]["phase"], "preparing")
self.assertEqual(states[0]["log"], str(log_path))
self.assertEqual(states[0]["rsync"]["command"], command)
self.assertIsNotNone(process_started)
process_started(1234, 1234)
self.assertEqual(len(states), 2)
self.assertEqual(states[1]["phase"], "rsync")
self.assertEqual(states[1]["rsync"]["pid"], 1234)
self.assertEqual(states[1]["rsync"]["pgid"], 1234)
log_path.write_text("Number of files: 1\n", encoding="utf-8")
return RsyncResult(exit_code=0, command=command)
with TemporaryDirectory() as tmp:
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
run_scheduled(
prefix=Path(tmp) / "home",
host="web-01",
dry_run=False,
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")),
state_callback=states.append,
)
self.assertEqual(len(states), 3)
self.assertIn("/.incomplete/", states[0]["snapshot"])
self.assertEqual(states[2]["phase"], "finalizing")
self.assertEqual(states[2]["rsync"]["exit_code"], 0)
def test_real_run_keeps_snapshot_with_warning_for_vanished_files(self) -> None:
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
log_path.write_text(
"file has vanished: \"/var/lib/app/session\"\n"
"rsync warning: some files vanished before they could be transferred (code 24) at main.c(1338) [sender=3.4.1]\n",
encoding="utf-8",
)
data_dir = log_path.parent.parent / "data"
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / "payload.txt").write_text("payload", encoding="utf-8")
return RsyncResult(exit_code=24, command=command)
with TemporaryDirectory() as tmp:
backup_root = Path(tmp) / "backups"
with patch("pobsync.commands.run_scheduled.run_rsync", side_effect=fake_run_rsync):
result = run_scheduled(
prefix=Path(tmp) / "home",
host="web-01",
dry_run=False,
config_source=FakeConfigSource(backup_root=str(backup_root)),
)
snapshot = Path(result["snapshot"])
self.assertTrue((snapshot / "data" / "payload.txt").exists())
self.assertTrue(result["ok"])
self.assertEqual(result["status"], "warning")
self.assertEqual(result["rsync"]["exit_code"], 24)
self.assertEqual(result["warning"]["category"], "vanished")
self.assertEqual(snapshot.parent.name, "scheduled")
self.assertIn("file has vanished", "\n".join(result["rsync"]["log_tail"]))
def test_dry_run_reports_cancelled_rsync(self) -> None:
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
self.assertTrue(cancel_check())

View File

@@ -154,6 +154,68 @@ class SqlRetentionTests(TestCase):
],
)
def test_apply_deletes_snapshot_with_non_traversable_nested_directory(self) -> None:
with TemporaryDirectory() as tmp:
prefix = Path(tmp) / "home"
host = HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
retention_daily=0,
retention_weekly=0,
retention_monthly=0,
retention_yearly=0,
)
old_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260518-021500Z__OLD"
restricted_dir = old_dir / "data" / "var" / "lib" / "snapd" / "void"
restricted_dir.mkdir(parents=True)
restricted_dir.joinpath("state").write_text("preserved permissions\n")
restricted_dir.chmod(0)
new_dir = Path(tmp) / "backups" / host.host / "scheduled" / "20260519-021500Z__NEW"
new_dir.mkdir(parents=True)
old = self._snapshot(host, old_dir.name, path=str(old_dir))
self._snapshot(host, new_dir.name, path=str(new_dir))
result = run_sql_retention_apply(
prefix=prefix,
host=host.host,
kind="scheduled",
protect_bases=False,
yes=True,
max_delete=1,
acquire_lock=False,
)
self.assertFalse(old_dir.exists())
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
self.assertEqual(result["deleted"][0]["dirname"], old.dirname)
def test_apply_rejects_scheduled_snapshot_path_outside_host_kind_directory(self) -> None:
host = HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
retention_daily=0,
retention_weekly=0,
retention_monthly=0,
retention_yearly=0,
)
self._snapshot(
host,
"20260518-021500Z__OLD",
path="/backups/web-01/manual/20260518-021500Z__OLD",
)
self._snapshot(host, "20260519-021500Z__NEW")
with self.assertRaisesRegex(ConfigError, "unexpected snapshot path"):
run_sql_retention_apply(
prefix=Path("/tmp/pobsync-test"),
host=host.host,
kind="scheduled",
protect_bases=False,
yes=True,
max_delete=1,
acquire_lock=False,
)
def test_apply_respects_max_delete(self) -> None:
host = HostConfig.objects.create(
host="web-01",
@@ -192,6 +254,8 @@ class SqlRetentionTests(TestCase):
path=str(incomplete_dir),
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
result = run_incomplete_cleanup(
@@ -213,6 +277,58 @@ class SqlRetentionTests(TestCase):
self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
self.assertEqual(purged.reason, "manual incomplete cleanup")
def test_incomplete_cleanup_deletes_non_traversable_nested_directory(self) -> None:
with TemporaryDirectory() as tmp:
prefix = Path(tmp) / "home"
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
incomplete_dir = Path(tmp) / "backups" / host.host / ".incomplete" / "20260519-031500Z__BROKEN01"
restricted_dir = incomplete_dir / "data" / "var" / "lib" / "snapd" / "void"
restricted_dir.mkdir(parents=True)
restricted_dir.joinpath("state").write_text("interrupted\n")
restricted_dir.chmod(0)
record = SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=incomplete_dir.name,
path=str(incomplete_dir),
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
result = run_incomplete_cleanup(
prefix=prefix,
host=host.host,
yes=True,
max_delete=1,
acquire_lock=False,
)
self.assertFalse(incomplete_dir.exists())
self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
self.assertEqual(result["deleted"][0]["dirname"], incomplete_dir.name)
def test_incomplete_cleanup_requires_reviewed_snapshots(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname="20260519-031500Z__BROKEN01",
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
)
with self.assertRaisesRegex(ConfigError, "have not been reviewed"):
run_incomplete_cleanup(
prefix=Path("/tmp/pobsync-test"),
host=host.host,
yes=True,
max_delete=1,
acquire_lock=False,
)
def test_incomplete_cleanup_respects_max_delete(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
SnapshotRecord.objects.create(
@@ -222,6 +338,8 @@ class SqlRetentionTests(TestCase):
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
with self.assertRaisesRegex(ConfigError, "blocked by --max-delete=0"):
@@ -242,6 +360,8 @@ class SqlRetentionTests(TestCase):
path=f"/backups/{host.host}/scheduled/20260519-031500Z__BROKEN01",
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
with self.assertRaisesRegex(ConfigError, "unexpected incomplete snapshot path"):

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from django.test import TestCase
from pobsync.run_stats import tree_usage
from pobsync_backend.models import HostConfig, SnapshotRecord
from pobsync_backend.stats_summary import collect_dashboard_stats, collect_host_stats
class StatsSummaryTests(TestCase):
def test_collect_dashboard_stats_sums_backup_data_across_hosts(self) -> None:
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
self._snapshot(web, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
self._snapshot(web, "20260519-031500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=200)
self._snapshot(db, "20260519-041500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=300)
with TemporaryDirectory() as tmp:
incomplete_usage = self._incomplete_snapshot_on_disk(
db,
Path(tmp),
"20260519-051500Z__BROKEN1",
)
stats = collect_dashboard_stats(hosts=[web, db], global_config=None)
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 400)
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 200)
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"])
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"])
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"])
def test_collect_host_stats_sums_backup_data_by_snapshot_kind(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
self._snapshot(host, "20260519-021500Z__SCHED01", SnapshotRecord.Kind.SCHEDULED, allocated=100)
self._snapshot(host, "20260519-031500Z__SCHED02", SnapshotRecord.Kind.SCHEDULED, allocated=200)
self._snapshot(host, "20260519-041500Z__MANUAL1", SnapshotRecord.Kind.MANUAL, allocated=300)
with TemporaryDirectory() as tmp:
incomplete_usage = self._incomplete_snapshot_on_disk(
host,
Path(tmp),
"20260519-051500Z__BROKEN1",
)
stats = collect_host_stats(host=host)
self.assertEqual(stats["backup_data"]["scheduled"]["count"], 2)
self.assertEqual(stats["backup_data"]["scheduled"]["allocated_size_bytes"], 300)
self.assertEqual(stats["backup_data"]["manual"]["allocated_size_bytes"], 300)
self.assertEqual(stats["backup_data"]["incomplete"]["allocated_size_bytes"], incomplete_usage["allocated_size_bytes"])
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 600 + incomplete_usage["allocated_size_bytes"])
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 1200 + incomplete_usage["apparent_size_bytes"])
def test_collect_host_stats_falls_back_to_filesystem_usage_for_snapshots_without_metadata(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with TemporaryDirectory() as tmp:
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-051500Z__BROKEN1"
data_dir = incomplete_dir / "data"
meta_dir = incomplete_dir / "meta"
data_dir.mkdir(parents=True)
meta_dir.mkdir()
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
meta_dir.joinpath("rsync.log").write_text("not part of the backup data total\n", encoding="utf-8")
expected_usage = tree_usage(data_dir)
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=incomplete_dir.name,
path=str(incomplete_dir),
status="failed",
metadata={},
)
stats = collect_host_stats(host=host)
self.assertEqual(stats["backup_data"]["incomplete"]["count"], 1)
self.assertEqual(
stats["backup_data"]["incomplete"]["allocated_size_bytes"],
expected_usage["allocated_size_bytes"],
)
self.assertEqual(
stats["backup_data"]["incomplete"]["apparent_size_bytes"],
expected_usage["apparent_size_bytes"],
)
self.assertEqual(
stats["backup_data"]["total"]["allocated_size_bytes"],
expected_usage["allocated_size_bytes"],
)
def test_collect_host_stats_measures_incomplete_data_from_disk_even_with_stale_metadata(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with TemporaryDirectory() as tmp:
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-051500Z__BROKEN1"
data_dir = incomplete_dir / "data"
data_dir.mkdir(parents=True)
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
expected_usage = tree_usage(data_dir)
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=incomplete_dir.name,
path=str(incomplete_dir),
status="failed",
metadata={
"stats": {
"storage": {
"snapshot": {
"apparent_size_bytes": 0,
"allocated_size_bytes": 0,
}
}
}
},
)
stats = collect_host_stats(host=host)
self.assertEqual(
stats["backup_data"]["incomplete"]["allocated_size_bytes"],
expected_usage["allocated_size_bytes"],
)
self.assertGreater(stats["backup_data"]["incomplete"]["apparent_size_bytes"], 0)
def test_collect_host_stats_reports_non_hardlinked_snapshot_data(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
self._snapshot_with_sizes(
host,
"20260519-021500Z__SCHED01",
SnapshotRecord.Kind.SCHEDULED,
allocated=1_200,
apparent=2_000,
hardlinked_apparent=1_500,
)
stats = collect_host_stats(host=host)
self.assertEqual(stats["backup_data"]["scheduled"]["apparent_size_bytes"], 2_000)
self.assertEqual(stats["backup_data"]["scheduled"]["unique_apparent_size_bytes"], 500)
self.assertEqual(stats["backup_data"]["total"]["unique_apparent_size_bytes"], 500)
def _snapshot(self, host: HostConfig, dirname: str, kind: str, *, allocated: int) -> SnapshotRecord:
return self._snapshot_with_sizes(host, dirname, kind, allocated=allocated)
def _incomplete_snapshot_on_disk(self, host: HostConfig, root: Path, dirname: str) -> dict:
incomplete_dir = root / host.host / ".incomplete" / dirname
data_dir = incomplete_dir / "data"
data_dir.mkdir(parents=True)
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
usage = tree_usage(data_dir)
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=dirname,
path=str(incomplete_dir),
status="failed",
)
return usage
def _snapshot_with_sizes(
self,
host: HostConfig,
dirname: str,
kind: str,
*,
allocated: int,
apparent: int | None = None,
hardlinked_apparent: int = 0,
) -> SnapshotRecord:
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
apparent_size = apparent if apparent is not None else allocated * 2
return SnapshotRecord.objects.create(
host=host,
kind=kind,
dirname=dirname,
path=f"/backups/{host.host}/{kind}/{dirname}",
status="success",
started_at=started_at,
metadata={
"stats": {
"storage": {
"snapshot": {
"apparent_size_bytes": apparent_size,
"allocated_size_bytes": allocated,
"hardlinked_apparent_size_bytes": hardlinked_apparent,
}
}
}
},
)

View File

@@ -8,14 +8,18 @@ from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.defaultfilters import filesizeformat
from django.test import TestCase, override_settings
from django.urls import reverse
from pobsync.run_stats import tree_usage
from pobsync.util import write_yaml_atomic
from pobsync_backend.models import (
BackupRun,
GlobalConfig,
HostConfig,
NotificationDelivery,
NotificationTarget,
PurgedSnapshot,
ScheduleConfig,
SnapshotRecord,
@@ -39,6 +43,34 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_base_navigation_groups_primary_and_system_links(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'aria-label="Primary navigation"', html=False)
self.assertContains(response, 'aria-label="System navigation"', html=False)
self.assertContains(response, reverse("dashboard"))
self.assertContains(response, reverse("hosts_list"))
self.assertContains(response, reverse("ssh_credentials"))
self.assertContains(response, reverse("notification_targets"))
self.assertContains(response, reverse("logs"))
self.assertContains(response, reverse("purged_snapshots"))
self.assertContains(response, reverse("self_check"))
self.assertContains(response, reverse("changelog"))
self.assertContains(response, "/api/status/")
self.assertContains(response, reverse("admin:index"))
self.assertContains(response, '<a href="/" aria-current="page">Dashboard</a>', html=False)
def test_base_navigation_marks_current_secondary_page(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.get(reverse("self_check"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, f'<a href="{reverse("self_check")}" aria-current="page">Self Check</a>', html=False)
def test_changelog_requires_staff_login(self) -> None:
response = self.client.get(reverse("changelog"))
@@ -65,6 +97,70 @@ class ViewTests(TestCase):
self.assertContains(response, "Django control panel")
self.assertContains(response, "Native systemd installer")
def test_notification_targets_view_renders_targets_and_deliveries(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS)
target = NotificationTarget.objects.create(
name="ops",
channel=NotificationTarget.Channel.EMAIL,
email_to="ops@example.test",
last_status=NotificationDelivery.Status.SENT,
)
NotificationDelivery.objects.create(target=target, run=run, status=NotificationDelivery.Status.SENT)
response = self.client.get(reverse("notification_targets"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Notifications")
self.assertContains(response, "ops")
self.assertContains(response, "ops@example.test")
self.assertContains(response, f"Run {run.id}")
def test_notification_target_form_creates_email_target(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.post(
reverse("create_notification_target"),
{
"name": "ops",
"enabled": "on",
"channel": NotificationTarget.Channel.EMAIL,
"statuses": [BackupRun.Status.FAILED, BackupRun.Status.WARNING],
"email_to": "ops@example.test, backup@example.test",
"webhook_headers": "{}",
"notes": "Notify ops",
},
follow=True,
)
self.assertRedirects(response, reverse("notification_targets"))
self.assertContains(response, "Notification target ops created.")
target = NotificationTarget.objects.get(name="ops")
self.assertEqual(target.channel, NotificationTarget.Channel.EMAIL)
self.assertEqual(target.statuses, [BackupRun.Status.FAILED, BackupRun.Status.WARNING])
self.assertEqual(target.email_to, "ops@example.test\nbackup@example.test")
def test_notification_target_form_requires_channel_destination(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.post(
reverse("create_notification_target"),
{
"name": "broken",
"enabled": "on",
"channel": NotificationTarget.Channel.WEBHOOK,
"statuses": [BackupRun.Status.FAILED],
"email_to": "",
"webhook_url": "",
"webhook_headers": "{}",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Webhook targets need a URL.")
self.assertFalse(NotificationTarget.objects.exists())
def test_dashboard_renders_hosts_and_latest_runs(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
@@ -101,6 +197,15 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Control panel")
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
self.assertContains(response, "dashboard-panel-required")
self.assertContains(response, "dashboard-panel-schedules")
self.assertContains(response, "dashboard-panel-activity")
self.assertContains(response, "dashboard-panel-storage")
self.assertContains(response, "dashboard-summary-grid")
self.assertContains(response, "dashboard-trends-panel")
self.assertContains(response, "dashboard-hosts-panel")
self.assertContains(response, "Dashboard")
self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
@@ -118,11 +223,211 @@ class ViewTests(TestCase):
self.assertContains(response, "running 1")
self.assertContains(response, "warning 1")
self.assertContains(response, "failed 1")
self.assertContains(response, "Operational Status")
self.assertContains(response, "1 failed run needs review.")
self.assertContains(response, "1 run completed with warnings.")
self.assertContains(response, "Required Action")
self.assertContains(response, "Failed runs")
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 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'data-refresh-url="{reverse("dashboard_priority_live")}"', html=False)
self.assertContains(response, f'data-refresh-url="{reverse("dashboard_hosts_live")}"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False)
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_priority_live_returns_status_partial(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING)
response = self.client.get(reverse("dashboard_priority_live"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Required Action")
self.assertContains(response, "Recent Activity")
self.assertContains(response, "running")
self.assertNotContains(response, "<html", html=False)
def test_dashboard_priority_live_renders_global_backup_data_totals(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")
scheduled = self._snapshot(web, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
manual = self._snapshot(web, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
self._set_snapshot_storage(scheduled, allocated=100)
self._set_snapshot_storage(manual, allocated=200)
with TemporaryDirectory() as tmp:
incomplete_dir = Path(tmp) / db.host / ".incomplete" / "20260519-041500Z__BROKEN1"
data_dir = incomplete_dir / "data"
data_dir.mkdir(parents=True)
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
expected_usage = tree_usage(data_dir)
SnapshotRecord.objects.create(
host=db,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=incomplete_dir.name,
path=str(incomplete_dir),
status="failed",
)
response = self.client.get(reverse("dashboard_priority_live"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Scheduled data")
self.assertContains(response, "Manual data")
self.assertContains(response, "Incomplete data")
self.assertContains(response, "Total snapshot data")
self.assertContains(response, "100&nbsp;bytes", html=True)
self.assertContains(response, "200&nbsp;bytes", html=True)
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"]))
def test_dashboard_hosts_live_returns_hosts_partial(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
BackupRun.objects.create(host=host, status=BackupRun.Status.QUEUED)
response = self.client.get(reverse("dashboard_hosts_live"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "web-01")
self.assertContains(response, "queued 1")
self.assertContains(response, "Snapshot health")
self.assertNotContains(response, "<html", html=False)
def test_dashboard_host_cards_render_backup_data_totals(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
scheduled = self._snapshot(host, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
manual = self._snapshot(host, "20260519-031500Z__MANUAL1", kind=SnapshotRecord.Kind.MANUAL)
self._set_snapshot_storage(scheduled, allocated=100)
self._set_snapshot_storage(manual, allocated=200)
with TemporaryDirectory() as tmp:
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-041500Z__BROKEN1"
data_dir = incomplete_dir / "data"
data_dir.mkdir(parents=True)
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
expected_usage = tree_usage(data_dir)
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=incomplete_dir.name,
path=str(incomplete_dir),
status="failed",
)
response = self.client.get(reverse("dashboard_hosts_live"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Scheduled data")
self.assertContains(response, "Manual data")
self.assertContains(response, "Incomplete data")
self.assertContains(response, "Total data")
self.assertContains(response, "100&nbsp;bytes", html=True)
self.assertContains(response, "200&nbsp;bytes", html=True)
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
self.assertContains(response, filesizeformat(300 + expected_usage["allocated_size_bytes"]))
def test_dashboard_host_cards_measure_incomplete_data_without_snapshot_metadata(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with TemporaryDirectory() as tmp:
incomplete_dir = Path(tmp) / host.host / ".incomplete" / "20260519-041500Z__BROKEN1"
data_dir = incomplete_dir / "data"
data_dir.mkdir(parents=True)
data_dir.joinpath("partial-file").write_text("interrupted backup data\n", encoding="utf-8")
expected_usage = tree_usage(data_dir)
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname=incomplete_dir.name,
path=str(incomplete_dir),
status="failed",
metadata={},
)
response = self.client.get(reverse("dashboard_hosts_live"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Incomplete data")
self.assertContains(response, filesizeformat(expected_usage["allocated_size_bytes"]))
def test_hosts_list_renders_host_cards_and_controls(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", enabled=False)
ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True)
BackupRun.objects.create(host=web, status=BackupRun.Status.RUNNING)
response = self.client.get(reverse("hosts_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Inventory")
self.assertContains(response, "Configured backup targets")
self.assertContains(response, "web-01")
self.assertContains(response, "db-01")
self.assertContains(response, "running 1")
self.assertContains(response, "schedule on")
self.assertContains(response, "retention on")
self.assertContains(response, "Disable host")
self.assertContains(response, "Enable host")
self.assertContains(response, "Pause schedule")
self.assertContains(response, "Pause retention")
self.assertContains(response, reverse("update_host_state", args=[web.host]))
self.assertContains(response, reverse("edit_host_config", args=[web.host]))
self.assertContains(response, reverse("edit_host_schedule", args=[web.host]))
def test_hosts_list_filters_by_enabled_state(self) -> None:
self.client.force_login(self.staff_user)
HostConfig.objects.create(host="web-01", address="web-01.example.test")
HostConfig.objects.create(host="db-01", address="db-01.example.test", enabled=False)
response = self.client.get(reverse("hosts_list"), {"enabled": "no"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "db-01")
self.assertNotContains(response, "web-01")
def test_update_host_state_toggles_host_schedule_and_retention(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
schedule = ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True)
response = self.client.post(
reverse("update_host_state", args=[host.host]),
{"action": "disable_host", "next": reverse("hosts_list")},
follow=True,
)
self.assertRedirects(response, reverse("hosts_list"))
host.refresh_from_db()
self.assertFalse(host.enabled)
self.assertContains(response, "Disabled host web-01.")
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "disable_schedule"})
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "disable_prune"})
schedule.refresh_from_db()
self.assertFalse(schedule.enabled)
self.assertFalse(schedule.prune)
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_host"})
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_schedule"})
self.client.post(reverse("update_host_state", args=[host.host]), {"action": "enable_prune"})
host.refresh_from_db()
schedule.refresh_from_db()
self.assertTrue(host.enabled)
self.assertTrue(schedule.enabled)
self.assertTrue(schedule.prune)
def test_dashboard_renders_backup_trend_summary(self) -> None:
self.client.force_login(self.staff_user)
@@ -160,14 +465,14 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
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, "New Data")
self.assertContains(response, "Link-Dest Savings")
self.assertContains(response, "80.0%")
self.assertContains(response, "10 days")
self.assertContains(response, "Warnings")
self.assertContains(response, "Queued")
self.assertContains(response, "Next Run")
self.assertContains(response, "UTC")
self.assertContains(response, "10")
@@ -195,8 +500,99 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Operational Status")
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
self.assertContains(response, "Required Action")
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, "Apply filters")
self.assertContains(response, reverse("runs_list"))
self.assertContains(response, "Clear")
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, "Apply filters")
self.assertContains(response, reverse("snapshots_list"))
self.assertContains(response, "Clear")
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, "Apply filters")
self.assertContains(response, reverse("schedules_list"))
self.assertContains(response, "Clear")
self.assertContains(response, "web-01")
self.assertContains(response, "15 2 * * *")
self.assertContains(response, "success")
self.assertContains(response, "UTC")
self.assertNotContains(response, "30 3 * * *")
def test_dashboard_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
@@ -247,7 +643,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("dashboard"))
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, "warning 1")
@@ -293,6 +689,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Self Check")
self.assertContains(response, "Runtime, filesystem, service, and configuration checks")
self.assertContains(response, "Django debug")
self.assertContains(response, "Database connection")
self.assertContains(response, "State root")
@@ -327,6 +724,10 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Logs")
self.assertContains(response, "Filter pobsync service logs")
self.assertContains(response, "Filter logs")
self.assertContains(response, reverse("logs"))
self.assertContains(response, "Clear")
self.assertContains(response, "web-01 failed backup run 12")
self.assertNotContains(response, "web-02 failed backup run 12")
self.assertNotContains(response, "started")
@@ -356,6 +757,10 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Purged Snapshots")
self.assertContains(response, "Audit trail for snapshots removed")
self.assertContains(response, "Apply filters")
self.assertContains(response, reverse("purged_snapshots"))
self.assertContains(response, "Clear")
self.assertContains(response, "20260518-021500Z__OLDSNAP")
self.assertContains(response, "outside retention policy")
self.assertContains(response, "Scheduled")
@@ -410,6 +815,7 @@ class ViewTests(TestCase):
)
self.assertRedirects(response, reverse("ssh_credentials"))
self.assertContains(response, "Manage the key pairs pobsync uses")
self.assertContains(response, "SSH credential saved for backup-key.")
self.assertContains(response, "backup-key")
credential = SshCredential.objects.get(name="backup-key")
@@ -439,6 +845,21 @@ class ViewTests(TestCase):
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
self.assertEqual(credential.public_key, "DERIVED PUBLIC KEY")
def test_ssh_credential_forms_render_cancel_actions(self) -> None:
self.client.force_login(self.staff_user)
credential = SshCredential.objects.create(name="backup-key")
create_response = self.client.get(reverse("create_ssh_credential"))
edit_response = self.client.get(reverse("edit_ssh_credential", args=[credential.id]))
generate_response = self.client.get(reverse("generate_ssh_credential"))
for response in (create_response, edit_response, generate_response):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Cancel")
self.assertContains(response, reverse("ssh_credentials"))
self.assertContains(edit_response, "Delete SSH key")
self.assertContains(edit_response, 'class="danger"', html=False)
def test_ssh_credentials_view_generates_filesystem_key(self) -> None:
self.client.force_login(self.staff_user)
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
@@ -639,9 +1060,12 @@ class ViewTests(TestCase):
response = self.client.get(reverse("edit_global_config"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Defaults used by hosts unless a host overrides them")
self.assertContains(response, f'value="{credential.id}" selected')
self.assertContains(response, "--archive")
self.assertContains(response, "/proc/***")
self.assertContains(response, "Cancel")
self.assertContains(response, reverse("dashboard"))
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
self.client.force_login(self.staff_user)
@@ -724,6 +1148,7 @@ class ViewTests(TestCase):
"excludes_add": "*.tmp",
"excludes_replace": "",
"rsync_extra_args": "--numeric-ids",
"rsync_bwlimit_kbps": "4096",
"retention_daily": "7",
"retention_weekly": "4",
"retention_monthly": "2",
@@ -741,6 +1166,7 @@ class ViewTests(TestCase):
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
self.assertEqual(host.excludes_add, ["*.tmp"])
self.assertEqual(host.rsync_extra_args, ["--numeric-ids"])
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
self.assertEqual(host.retention_weekly, 4)
def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None:
@@ -809,6 +1235,7 @@ class ViewTests(TestCase):
(backup_root / host.host / subdir).mkdir(parents=True)
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", prune=True, last_status="success")
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
self._set_snapshot_storage(snapshot, allocated=100)
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
response = self.client.get(reverse("host_detail", args=[host.host]))
@@ -819,7 +1246,7 @@ class ViewTests(TestCase):
self.assertContains(response, "15 2 * * *")
self.assertContains(response, "Schedule expression")
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, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "Discover snapshots")
@@ -832,10 +1259,14 @@ class ViewTests(TestCase):
self.assertContains(response, "Host Check")
self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
self.assertContains(response, "warning")
self.assertContains(response, "Snapshot Discovery")
self.assertContains(response, "Snapshot Storage")
self.assertContains(response, "Backup Data")
self.assertContains(response, "100&nbsp;bytes", html=True)
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("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:
self.client.force_login(self.staff_user)
@@ -869,6 +1300,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Host")
self.assertContains(response, "web-01.example.test")
self.assertContains(response, "Effective Config")
self.assertContains(response, "Backup source:")
self.assertNotContains(response, "Source root:")
@@ -876,6 +1309,7 @@ class ViewTests(TestCase):
self.assertContains(response, "default-key")
self.assertContains(response, "-oBatchMode=yes")
self.assertContains(response, "--archive --numeric-ids --delete --one-file-system")
self.assertContains(response, "2048 KB/s")
self.assertContains(response, "/srv/www/***")
self.assertContains(response, "/srv/www/cache/***")
self.assertContains(response, "d14")
@@ -899,6 +1333,10 @@ class ViewTests(TestCase):
self.assertContains(response, "Remote rsync")
self.assertContains(response, "Remote source root")
self.assertEqual(run.call_count, 3)
commands = [call.kwargs["args"] if "args" in call.kwargs else call.args[0] for call in run.call_args_list]
self.assertEqual(commands[1][-1], "sh -lc 'command -v rsync >/dev/null'")
self.assertEqual(commands[2][-1], "sh -lc 'test -e / && test -r /'")
self.assertNotIn("sh", commands[2][commands[2].index("root@web-01.example.test") + 1 : -1])
host.refresh_from_db()
self.assertTrue(host.config["last_preflight"]["ok"])
self.assertEqual(host.config["last_preflight"]["target"], "root@web-01.example.test")
@@ -1115,7 +1553,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_detail", args=[host.host]))
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, "scheduled 1")
self.assertContains(response, "incomplete 1")
@@ -1267,9 +1706,10 @@ class ViewTests(TestCase):
"ok": True,
"snapshot": snapshot.path,
"rsync": {
"command": ["rsync", "--archive", "root@web-01:/", snapshot.path],
"command": ["rsync", "--archive", "--bwlimit=2048", "root@web-01:/", snapshot.path],
"exit_code": 0,
"log_tail": ["sending incremental file list", "sent 500 bytes"],
"bwlimit_kbps": 2048,
},
"requested": {
"dry_run": True,
@@ -1304,10 +1744,13 @@ class ViewTests(TestCase):
self.assertContains(response, "Dry run:</strong> yes")
self.assertContains(response, "Verbose rsync output:</strong> yes")
self.assertContains(response, "Rsync Command")
self.assertContains(response, "Bandwidth limit:</strong>")
self.assertContains(response, "2048 KB/s")
self.assertContains(response, "--archive")
self.assertContains(response, "Rsync Log")
self.assertContains(response, "sending incremental file list")
self.assertContains(response, "Dry Run Summary")
self.assertContains(response, "Run Progress")
self.assertContains(response, "dry run")
self.assertContains(response, "Files Seen")
self.assertContains(response, "Would Transfer")
self.assertContains(response, "Transfer Estimate")
@@ -1357,7 +1800,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Dry Run Summary")
self.assertContains(response, "Run Progress")
self.assertContains(response, "dry run")
self.assertContains(response, "failed")
self.assertContains(response, "Files Seen")
self.assertContains(response, "25")
@@ -1425,6 +1869,8 @@ class ViewTests(TestCase):
response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Backup run")
self.assertContains(response, "web-01")
self.assertContains(response, "Failure")
self.assertContains(response, "transport")
self.assertContains(response, "Check network connectivity.")
@@ -1524,8 +1970,92 @@ class ViewTests(TestCase):
response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Run Control")
self.assertContains(response, "Cancelling a queued run stops it immediately")
self.assertContains(response, "Cancel run")
self.assertContains(response, reverse("cancel_run", args=[run.id]))
self.assertContains(response, 'class="danger"', html=False)
def test_run_detail_enables_live_refresh_for_active_run(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING)
response = self.client.get(reverse("run_detail", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, f'data-refresh-url="{reverse("run_detail_live", args=[run.id])}"', html=False)
self.assertContains(response, 'data-refresh-interval="5000"', html=False)
self.assertContains(response, 'data-refresh-active="true"', html=False)
self.assertContains(response, "Live Updates")
self.assertContains(response, "Pause refresh")
def test_run_detail_live_returns_partial_for_active_run(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = BackupRun.objects.create(
host=host,
status=BackupRun.Status.RUNNING,
result={"rsync": {"log_tail": ["sending incremental file list"]}},
)
response = self.client.get(reverse("run_detail_live", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["X-Pobsync-Refresh-Active"], "true")
self.assertContains(response, "Run Control")
self.assertContains(response, "sending incremental file list")
self.assertNotContains(response, "<html", html=False)
def test_run_detail_live_shows_progress_for_running_real_run(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with TemporaryDirectory() as tmp:
snapshot_path = Path(tmp) / "backups" / host.host / ".incomplete" / "20260523-010000Z__ABCDEFGH"
data_path = snapshot_path / "data"
log_path = snapshot_path / "meta" / "rsync.log"
data_path.mkdir(parents=True)
log_path.parent.mkdir(parents=True)
(data_path / "payload.txt").write_text("payload", encoding="utf-8")
log_path.write_text("sending incremental file list\npayload.txt\n", encoding="utf-8")
run = BackupRun.objects.create(
host=host,
status=BackupRun.Status.RUNNING,
snapshot_path=str(snapshot_path),
result={
"requested": {"dry_run": False},
"execution": {
"phase": "rsync",
"snapshot": str(snapshot_path),
"log": str(log_path),
"heartbeat_at": "2026-05-23T01:00:00+02:00",
},
"rsync": {"pid": 1234, "pgid": 1234, "command": ["rsync"]},
},
)
response = self.client.get(reverse("run_detail_live", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Run Progress")
self.assertContains(response, "backup")
self.assertContains(response, "rsync")
self.assertContains(response, "1234")
self.assertContains(response, "Data Files")
self.assertContains(response, "Open full rsync log")
self.assertContains(response, "payload.txt")
self.assertContains(response, "sending incremental file list")
def test_run_detail_live_stops_refresh_for_terminal_run(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS)
response = self.client.get(reverse("run_detail_live", args=[run.id]))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["X-Pobsync-Refresh-Active"], "false")
self.assertNotContains(response, "Run Control")
def test_run_detail_renders_worker_execution_metadata(self) -> None:
self.client.force_login(self.staff_user)
@@ -1615,6 +2145,7 @@ class ViewTests(TestCase):
response = self.client.get(reverse("snapshot_detail", args=[base.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Snapshot")
self.assertContains(response, base.dirname)
self.assertContains(response, "BASESNAP")
self.assertContains(response, "Stats")
@@ -1697,7 +2228,9 @@ class ViewTests(TestCase):
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Retention Plan: web-01")
self.assertContains(response, "Retention")
self.assertContains(response, "Preview which snapshots stay")
self.assertContains(response, "web-01")
self.assertContains(response, old_snapshot.dirname)
self.assertContains(response, new_snapshot.dirname)
self.assertContains(response, "newest")
@@ -1706,6 +2239,9 @@ class ViewTests(TestCase):
self.assertNotContains(response, "<div class=\"label\">Source</div>", html=True)
self.assertContains(response, "Confirm delete count")
self.assertContains(response, "Type 1 to confirm the current number of planned deletions.")
self.assertContains(response, "This permanently deletes the snapshot directories listed in Would Delete.")
self.assertContains(response, 'class="danger"', html=False)
self.assertContains(response, "Cancel")
def test_retention_plan_warns_when_scheduled_prune_limit_is_exceeded(self) -> None:
self.client.force_login(self.staff_user)
@@ -1779,8 +2315,34 @@ class ViewTests(TestCase):
self.assertContains(response, "Incomplete Snapshots")
self.assertContains(response, "20260519-031500Z__BROKEN01")
self.assertContains(response, "excluded from retention cleanup")
self.assertContains(response, "needs review")
self.assertContains(response, "Cleanup is blocked until all incomplete snapshots are reviewed.")
self.assertContains(response, "Mark incomplete snapshots reviewed")
self.assertContains(response, "delete only incomplete snapshot directories")
self.assertNotContains(response, "Delete incomplete snapshots")
def test_retention_plan_offers_incomplete_cleanup_after_review(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname="20260519-031500Z__BROKEN01",
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "reviewed")
self.assertContains(response, "Delete incomplete snapshots")
self.assertContains(response, "Type 1 to confirm the current number of incomplete snapshots.")
self.assertContains(response, "This deletes only reviewed incomplete snapshot directories")
self.assertContains(response, 'class="danger"', html=False)
def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None:
self.client.force_login(self.staff_user)
@@ -1797,6 +2359,8 @@ class ViewTests(TestCase):
path=str(incomplete_dir),
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
with override_settings(POBSYNC_HOME=str(home)):
@@ -1842,6 +2406,33 @@ class ViewTests(TestCase):
self.assertContains(response, "Incomplete cleanup confirmation is invalid.")
self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
def test_incomplete_cleanup_rejects_unreviewed_snapshots(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname="20260519-031500Z__BROKEN01",
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
)
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
response = self.client.post(
reverse("cleanup_host_incomplete_snapshots", args=[host.host]),
{
"max_delete": "0",
"confirm_host": host.host,
"confirm_delete_count": "0",
},
follow=True,
)
self.assertRedirects(response, reverse("host_retention_plan", args=[host.host]))
self.assertContains(response, "have not been reviewed")
self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
def test_host_detail_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(
@@ -2022,11 +2613,14 @@ class ViewTests(TestCase):
response = self.client.get(reverse("edit_host_schedule", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Automatic backup timing and scheduled prune behavior")
self.assertContains(response, "Create Schedule")
self.assertContains(response, "Schedule expression")
self.assertContains(response, "evaluated by the pobsync scheduler service")
self.assertContains(response, "15 2 * * *")
self.assertContains(response, "Save schedule")
self.assertContains(response, "Cancel")
self.assertContains(response, reverse("host_detail", args=[host.host]))
def test_schedule_form_creates_schedule(self) -> None:
self.client.force_login(self.staff_user)
@@ -2128,6 +2722,8 @@ class ViewTests(TestCase):
self.assertContains(response, "/srv")
self.assertContains(response, "*.tmp")
self.assertContains(response, "--numeric-ids")
self.assertContains(response, "Cancel")
self.assertContains(response, reverse("host_detail", args=[host.host]))
def test_host_config_form_renders_effective_config_check(self) -> None:
self.client.force_login(self.staff_user)
@@ -2157,6 +2753,7 @@ class ViewTests(TestCase):
"excludes_add": "*.tmp\ncache/",
"excludes_replace": "",
"rsync_extra_args": "--numeric-ids\n--delete",
"rsync_bwlimit_kbps": "8192",
"retention_daily": "7",
"retention_weekly": "4",
"retention_monthly": "2",
@@ -2176,6 +2773,7 @@ class ViewTests(TestCase):
self.assertEqual(host.excludes_add, ["*.tmp", "cache/"])
self.assertIsNone(host.excludes_replace)
self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"])
self.assertEqual(host.rsync_bwlimit_kbps, 8192)
self.assertEqual(host.retention_daily, 7)
self.assertEqual(host.retention_yearly, 1)
@@ -2208,13 +2806,32 @@ class ViewTests(TestCase):
self.assertEqual(host.excludes_add, [])
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
def _snapshot(
self,
host: HostConfig,
dirname: str,
*,
kind: str = SnapshotRecord.Kind.SCHEDULED,
) -> SnapshotRecord:
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
return SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.SCHEDULED,
kind=kind,
dirname=dirname,
path=f"/backups/{host.host}/scheduled/{dirname}",
path=f"/backups/{host.host}/{kind}/{dirname}",
status="success",
started_at=started_at,
)
def _set_snapshot_storage(self, snapshot: SnapshotRecord, *, allocated: int) -> None:
snapshot.metadata = {
"stats": {
"storage": {
"snapshot": {
"apparent_size_bytes": allocated * 2,
"allocated_size_bytes": allocated,
}
}
}
}
snapshot.save(update_fields=["metadata"])

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
import os
import json
import shlex
import shutil
import subprocess
from datetime import datetime, timezone as datetime_timezone
from pathlib import Path
from urllib.parse import urlencode
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
@@ -12,7 +15,9 @@ from django.conf import settings
from django.http import FileResponse, Http404
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from pobsync import __version__
@@ -27,13 +32,24 @@ from .forms import (
HostConfigForm,
IncompleteCleanupForm,
ManualBackupForm,
NotificationTargetForm,
RetentionApplyForm,
SshCredentialGenerateForm,
ScheduleConfigForm,
SshCredentialForm,
)
from .host_ops import ensure_host_directories
from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
from .models import (
BackupRun,
GlobalConfig,
HostConfig,
NotificationDelivery,
NotificationTarget,
PurgedSnapshot,
ScheduleConfig,
SnapshotRecord,
SshCredential,
)
from .preflight import collect_backup_gate, effective_host_config_preview, run_remote_preflight
from .retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
from .self_check import collect_self_checks, summarize_self_checks
@@ -45,7 +61,20 @@ from .stats_summary import collect_dashboard_stats, collect_host_stats
@staff_member_required
def dashboard(request):
global_config = GlobalConfig.objects.filter(name="default").first()
return render(request, "pobsync_backend/dashboard.html", _dashboard_context())
@staff_member_required
def dashboard_priority_live(request):
return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context())
@staff_member_required
def dashboard_hosts_live(request):
return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context())
def _host_cards_context(*, enabled: str = "") -> dict[str, object]:
hosts = list(
HostConfig.objects.select_related("schedule")
.annotate(
@@ -66,6 +95,11 @@ def dashboard(request):
)
.order_by("host")
)
if enabled == "yes":
hosts = [host for host in hosts if host.enabled]
elif enabled == "no":
hosts = [host for host in hosts if not host.enabled]
for host_config in hosts:
host_config.latest_snapshot = (
host_config.snapshots.select_related("base")
@@ -74,13 +108,34 @@ def dashboard(request):
)
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))
return {
"hosts": hosts,
"scheduler_timezone": timezone.get_current_timezone_name(),
"selected_enabled": enabled,
"counts": {
"hosts": len(hosts),
"enabled_hosts": sum(1 for host in hosts if host.enabled),
"disabled_hosts": sum(1 for host in hosts if not host.enabled),
},
}
def _dashboard_context() -> dict[str, object]:
global_config = GlobalConfig.objects.filter(name="default").first()
host_context = _host_cards_context()
hosts = host_context["hosts"]
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)
context = {
"hosts": hosts,
"global_config": global_config,
"stats_summary": stats_summary,
"scheduler_timezone": timezone.get_current_timezone_name(),
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
"scheduler_timezone": host_context["scheduler_timezone"],
"action_items": action_items,
"next_schedule_rows": next_schedule_rows,
"recent_runs": recent_runs,
"counts": {
"global_configs": GlobalConfig.objects.count(),
"hosts": HostConfig.objects.count(),
@@ -101,7 +156,138 @@ def dashboard(request):
).count(),
},
}
return render(request, "pobsync_backend/dashboard.html", context)
return context
@staff_member_required
def hosts_list(request):
enabled = request.GET.get("enabled", "").strip()
if enabled not in {"", "yes", "no"}:
enabled = ""
global_config = GlobalConfig.objects.filter(name="default").first()
context = _host_cards_context(enabled=enabled)
collect_dashboard_stats(hosts=context["hosts"], global_config=global_config)
return render(
request,
"pobsync_backend/hosts_list.html",
{
**context,
"global_config": global_config,
"show_host_controls": True,
"total_count": HostConfig.objects.count(),
},
)
@staff_member_required
@require_POST
def update_host_state(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
action = request.POST.get("action", "").strip()
next_url = request.POST.get("next") or reverse("hosts_list")
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
next_url = reverse("hosts_list")
if action == "enable_host":
host_config.enabled = True
host_config.save(update_fields=["enabled", "updated_at"])
messages.success(request, f"Enabled host {host_config.host}.")
elif action == "disable_host":
host_config.enabled = False
host_config.save(update_fields=["enabled", "updated_at"])
messages.success(request, f"Disabled host {host_config.host}.")
elif action in {"enable_schedule", "disable_schedule", "enable_prune", "disable_prune"}:
try:
schedule = host_config.schedule
except ScheduleConfig.DoesNotExist:
messages.warning(request, f"{host_config.host} does not have a schedule yet.")
else:
if action == "enable_schedule":
schedule.enabled = True
message = f"Enabled backup schedule for {host_config.host}."
elif action == "disable_schedule":
schedule.enabled = False
message = f"Paused backup schedule for {host_config.host}."
elif action == "enable_prune":
schedule.prune = True
message = f"Enabled scheduled retention for {host_config.host}."
else:
schedule.prune = False
message = f"Paused scheduled retention for {host_config.host}."
schedule.save(update_fields=["enabled", "prune", "updated_at"])
messages.success(request, message)
else:
messages.error(request, "Unknown host state action.")
return redirect(next_url)
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
@@ -145,6 +331,161 @@ def logs(request):
return render(request, "pobsync_backend/logs.html", context)
@staff_member_required
def notification_targets(request):
targets = NotificationTarget.objects.order_by("name")
deliveries = NotificationDelivery.objects.select_related("target", "run", "run__host").order_by("-created_at")[:12]
return render(
request,
"pobsync_backend/notification_targets.html",
{
"targets": targets,
"deliveries": deliveries,
},
)
@staff_member_required
def create_notification_target(request):
if request.method == "POST":
form = NotificationTargetForm(request.POST)
if form.is_valid():
target = form.save()
messages.success(request, f"Notification target {target.name} created.")
return redirect("notification_targets")
else:
form = NotificationTargetForm()
return render(
request,
"pobsync_backend/notification_target_form.html",
{
"form": form,
"target": None,
"title": "New notification target",
"submit_label": "Create target",
},
)
@staff_member_required
def edit_notification_target(request, target_id: int):
target = get_object_or_404(NotificationTarget, id=target_id)
if request.method == "POST":
form = NotificationTargetForm(request.POST, instance=target)
if form.is_valid():
target = form.save()
messages.success(request, f"Notification target {target.name} updated.")
return redirect("notification_targets")
else:
form = NotificationTargetForm(instance=target)
return render(
request,
"pobsync_backend/notification_target_form.html",
{
"form": form,
"target": target,
"title": f"Edit notification target: {target.name}",
"submit_label": "Save target",
},
)
@staff_member_required
def runs_list(request):
status = request.GET.get("status", "").strip()
run_type = request.GET.get("type", "").strip()
host = request.GET.get("host", "").strip()
review = request.GET.get("review", "").strip()
runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")
if status:
runs = runs.filter(status=status)
if run_type:
runs = runs.filter(run_type=run_type)
if host:
runs = runs.filter(host__host=host)
if review == "needed":
runs = runs.filter(status__in=[BackupRun.Status.FAILED, BackupRun.Status.WARNING], reviewed_at__isnull=True)
elif review == "reviewed":
runs = runs.filter(reviewed_at__isnull=False)
context = {
"runs": runs[:200],
"total_count": runs.count(),
"hosts": HostConfig.objects.order_by("host"),
"statuses": BackupRun.Status.choices,
"run_types": BackupRun.RunType.choices,
"selected_status": status,
"selected_type": run_type,
"selected_host": host,
"selected_review": review,
}
return render(request, "pobsync_backend/runs_list.html", context)
@staff_member_required
def snapshots_list(request):
kind = request.GET.get("kind", "").strip()
status = request.GET.get("status", "").strip()
host = request.GET.get("host", "").strip()
snapshots = SnapshotRecord.objects.select_related("host", "base").order_by("-started_at", "-discovered_at", "-id")
if kind:
snapshots = snapshots.filter(kind=kind)
if status:
snapshots = snapshots.filter(status=status)
if host:
snapshots = snapshots.filter(host__host=host)
context = {
"snapshots": snapshots[:200],
"total_count": snapshots.count(),
"hosts": HostConfig.objects.order_by("host"),
"kinds": SnapshotRecord.Kind.choices,
"statuses": SnapshotRecord.objects.exclude(status="").order_by("status").values_list("status", flat=True).distinct(),
"selected_kind": kind,
"selected_status": status,
"selected_host": host,
}
return render(request, "pobsync_backend/snapshots_list.html", context)
@staff_member_required
def schedules_list(request):
enabled = request.GET.get("enabled", "").strip()
prune = request.GET.get("prune", "").strip()
host = request.GET.get("host", "").strip()
schedules = ScheduleConfig.objects.select_related("host").order_by("host__host")
if enabled == "yes":
schedules = schedules.filter(enabled=True)
elif enabled == "no":
schedules = schedules.filter(enabled=False)
if prune == "yes":
schedules = schedules.filter(prune=True)
elif prune == "no":
schedules = schedules.filter(prune=False)
if host:
schedules = schedules.filter(host__host=host)
schedule_rows = []
for schedule in schedules[:200]:
schedule_rows.append(
{
"schedule": schedule,
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
}
)
context = {
"schedule_rows": schedule_rows,
"total_count": schedules.count(),
"hosts": HostConfig.objects.order_by("host"),
"selected_enabled": enabled,
"selected_prune": prune,
"selected_host": host,
"scheduler_timezone": timezone.get_current_timezone_name(),
}
return render(request, "pobsync_backend/schedules_list.html", context)
@staff_member_required
def purged_snapshots(request):
host = request.GET.get("host", "").strip()
@@ -485,6 +826,19 @@ def queue_manual_backup(request, host: str):
@staff_member_required
def run_detail(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run))
@staff_member_required
def run_detail_live(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
context = _run_detail_context(run)
response = render(request, "pobsync_backend/partials/run_detail_live.html", context)
response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false"
return response
def _run_detail_context(run: BackupRun) -> dict[str, object]:
result = run.result if isinstance(run.result, dict) else {}
run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
@@ -494,14 +848,17 @@ def run_detail(request, run_id: int):
rsync_log_path = _run_rsync_log_path(run)
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
context = {
can_cancel = run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}
return {
"run": run,
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
"can_cancel": can_cancel,
"can_auto_refresh": can_cancel,
"requested": requested,
"execution": execution,
"stats": run_stats if isinstance(run_stats, dict) else {},
"rsync": rsync_result,
"rsync_command": _run_rsync_command(rsync_result),
"rsync_bwlimit_kbps": _run_rsync_bwlimit_kbps(rsync_result),
"failure": failure,
"failure_summary": failure.get("message") or failure.get("summary") or "",
"prune_result": prune_result,
@@ -510,6 +867,7 @@ def run_detail(request, run_id: int):
"rsync_log_path": str(rsync_log_path) if rsync_log_path is not None else "",
"rsync_log_exists": bool(rsync_log_path and rsync_log_path.exists()),
"rsync_log_tail": rsync_log_tail,
"live_progress": _run_live_progress(run, rsync_log_path),
"dry_run_summary": _dry_run_summary(
result=result,
requested=requested,
@@ -520,7 +878,6 @@ def run_detail(request, run_id: int):
),
"result_json": _pretty_json(run.result),
}
return render(request, "pobsync_backend/run_detail.html", context)
@staff_member_required
@@ -565,13 +922,13 @@ def resolve_run_review(request, run_id: int):
return redirect("run_detail", run_id=run.id)
if run.reviewed_at:
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_by = request.user.get_username()
run.save(update_fields=["reviewed_at", "reviewed_by"])
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
@@ -646,6 +1003,8 @@ def host_retention_plan(request, host: str):
scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None
delete_count = len(plan["delete"])
incomplete_count = len(plan["incomplete"])
incomplete_reviewed_count = int(plan.get("incomplete_reviewed_count") or 0)
incomplete_unreviewed_count = int(plan.get("incomplete_unreviewed_count") or 0)
context = {
"host": host_config,
"kind": kind,
@@ -654,6 +1013,8 @@ def host_retention_plan(request, host: str):
"schedule": schedule,
"scheduled_prune_limit": scheduled_prune_limit,
"scheduled_prune_exceeded": scheduled_prune_limit is not None and delete_count > scheduled_prune_limit,
"incomplete_reviewed_count": incomplete_reviewed_count,
"incomplete_unreviewed_count": incomplete_unreviewed_count,
"apply_form": RetentionApplyForm(
host_name=host_config.host,
expected_delete_count=delete_count,
@@ -666,10 +1027,10 @@ def host_retention_plan(request, host: str):
),
"incomplete_cleanup_form": IncompleteCleanupForm(
host_name=host_config.host,
expected_delete_count=incomplete_count,
expected_delete_count=incomplete_reviewed_count,
initial={
"max_delete": incomplete_count,
"confirm_delete_count": incomplete_count,
"max_delete": incomplete_reviewed_count,
"confirm_delete_count": incomplete_reviewed_count,
},
),
}
@@ -740,7 +1101,7 @@ def cleanup_host_incomplete_snapshots(request, host: str):
messages.error(request, str(exc))
return redirect("host_retention_plan", host=host_config.host)
incomplete_count = len(plan.get("incomplete") or [])
incomplete_count = int(plan.get("incomplete_reviewed_count") or 0)
form = IncompleteCleanupForm(
request.POST,
host_name=host_config.host,
@@ -841,6 +1202,13 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon
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]:
incomplete_count = host_config.snapshots.filter(
kind=SnapshotRecord.Kind.INCOMPLETE,
@@ -1040,6 +1408,23 @@ def _run_rsync_command(rsync_result: dict) -> list[str]:
return [str(part) for part in command]
def _run_rsync_bwlimit_kbps(rsync_result: dict) -> int:
stored_limit = rsync_result.get("bwlimit_kbps")
if stored_limit is not None:
try:
return max(0, int(stored_limit))
except (TypeError, ValueError):
return 0
for part in _run_rsync_command(rsync_result):
if part.startswith("--bwlimit="):
try:
return max(0, int(part.split("=", 1)[1]))
except ValueError:
return 0
return 0
def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines: int = 30) -> list[str]:
log_tail = rsync_result.get("log_tail")
if isinstance(log_tail, list):
@@ -1053,6 +1438,97 @@ def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines:
return []
def _run_live_progress(run: BackupRun, log_path: Path | None) -> dict[str, object]:
if run.status not in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}:
return {}
result = run.result if isinstance(run.result, dict) else {}
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
if requested.get("dry_run") or result.get("dry_run"):
return {}
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
progress: dict[str, object] = {
"phase": execution.get("phase") or ("queued" if run.status == BackupRun.Status.QUEUED else "running"),
"worker_pid": execution.get("worker_pid"),
"rsync_pid": rsync.get("pid"),
"rsync_pgid": rsync.get("pgid"),
}
log_stats = _live_log_stats(log_path)
if log_stats:
progress["log"] = log_stats
snapshot_path = _run_progress_snapshot_path(run, execution)
if snapshot_path is not None:
progress["snapshot"] = {
"path": str(snapshot_path),
**_scan_snapshot_progress(snapshot_path / "data" if (snapshot_path / "data").exists() else snapshot_path),
}
return progress
def _run_progress_snapshot_path(run: BackupRun, execution: dict) -> Path | None:
snapshot = execution.get("snapshot")
if isinstance(snapshot, str) and snapshot:
return Path(snapshot)
if run.snapshot_path:
return Path(run.snapshot_path)
return None
def _live_log_stats(log_path: Path | None) -> dict[str, object]:
if log_path is None:
return {}
try:
stat = log_path.stat()
except OSError:
return {"path": str(log_path), "exists": False}
modified_at = timezone.datetime.fromtimestamp(stat.st_mtime, tz=timezone.get_current_timezone())
return {
"path": str(log_path),
"exists": True,
"size_bytes": stat.st_size,
"modified_at": modified_at,
"seconds_since_modified": max(0, int((timezone.now() - modified_at).total_seconds())),
}
def _scan_snapshot_progress(data_path: Path, *, max_entries: int = 20000) -> dict[str, object]:
progress: dict[str, object] = {
"data_path": str(data_path),
"exists": data_path.exists(),
"files": 0,
"directories": 0,
"apparent_size_bytes": 0,
"scan_limited": False,
}
if not data_path.exists():
return progress
entries_seen = 0
for root, dirnames, filenames in os.walk(data_path):
progress["directories"] = int(progress["directories"]) + len(dirnames)
entries_seen += len(dirnames)
for filename in filenames:
file_path = Path(root) / filename
try:
file_stat = file_path.lstat()
except OSError:
continue
progress["files"] = int(progress["files"]) + 1
progress["apparent_size_bytes"] = int(progress["apparent_size_bytes"]) + int(file_stat.st_size)
entries_seen += 1
if entries_seen >= max_entries:
progress["scan_limited"] = True
return progress
if entries_seen >= max_entries:
progress["scan_limited"] = True
return progress
return progress
def _dry_run_summary(
*,
result: dict,

View File

@@ -8,18 +8,26 @@ from pobsync_backend import api, views
urlpatterns = [
path("", views.dashboard, name="dashboard"),
path("dashboard/priority-live/", views.dashboard_priority_live, name="dashboard_priority_live"),
path("dashboard/hosts-live/", views.dashboard_hosts_live, name="dashboard_hosts_live"),
path("changelog/", views.changelog, name="changelog"),
path("self-check/", views.self_check, name="self_check"),
path("logs/", views.logs, name="logs"),
path("notifications/", views.notification_targets, name="notification_targets"),
path("notifications/new/", views.create_notification_target, name="create_notification_target"),
path("notifications/<int:target_id>/", views.edit_notification_target, name="edit_notification_target"),
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
path("schedules/", views.schedules_list, name="schedules_list"),
path("config/global/", views.edit_global_config, name="edit_global_config"),
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
path("ssh-credentials/generate/", views.generate_ssh_credential, name="generate_ssh_credential"),
path("ssh-credentials/<int:credential_id>/", views.edit_ssh_credential, name="edit_ssh_credential"),
path("ssh-credentials/<int:credential_id>/delete/", views.delete_ssh_credential, name="delete_ssh_credential"),
path("hosts/", views.hosts_list, name="hosts_list"),
path("hosts/new/", views.create_host_config, name="create_host_config"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("hosts/<str:host>/state/", views.update_host_state, name="update_host_state"),
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
path("hosts/<str:host>/prepare-directories/", views.prepare_host_directories, name="prepare_host_directories"),
path("hosts/<str:host>/scan-known-key/", views.scan_host_known_key, name="scan_host_known_key"),
@@ -34,7 +42,9 @@ urlpatterns = [
name="cleanup_host_incomplete_snapshots",
),
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
path("runs/", views.runs_list, name="runs_list"),
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
path("runs/<int:run_id>/live/", views.run_detail_live, name="run_detail_live"),
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
@@ -43,6 +53,7 @@ urlpatterns = [
views.resolve_host_incomplete_reviews,
name="resolve_host_incomplete_reviews",
),
path("snapshots/", views.snapshots_list, name="snapshots_list"),
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
path("api/", api.api_index),
path("api/status/", api.status),