Compare commits
59 Commits
v1.0.0
...
1f5c4e0756
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5c4e0756 | |||
| b5e87abad2 | |||
| fc6df89370 | |||
| 3893df4640 | |||
| f86c67aeee | |||
| 7dc4c1df84 | |||
| 10e0293559 | |||
| 9dd690bb3b | |||
| 8740b75841 | |||
| ce1cb9d157 | |||
| 8e83fee7b5 | |||
| a6d6468da8 | |||
| b87203c538 | |||
| 515330c436 | |||
| fdf401a0be | |||
| 3b77f2e5d0 | |||
| df9ec5b04c | |||
| 5788f53854 | |||
| 28da9c4096 | |||
| 6eb1b4add3 | |||
| 8633cbea26 | |||
| 3fb8209aef | |||
| 833edb2466 | |||
| c7e9e69345 | |||
| e79d871f36 | |||
| ad45fbe46e | |||
| 3cac7b61ac | |||
| 1d6c21764b | |||
| 6f392bef65 | |||
| 6035c547ae | |||
| a3a8fea071 | |||
| 0e2f48ab65 | |||
| b55950e24a | |||
| 025cd0336c | |||
| 4c76ae9f52 | |||
| 7a552715fe | |||
| 0f0de5dc30 | |||
| 1604f0f6f4 | |||
| af548f11c4 | |||
| c0eca3da55 | |||
| 212813e066 | |||
| ab5291b8d3 | |||
| 1929196287 | |||
| 9e75273fc5 | |||
| 5dd6ebb3db | |||
| 864a40e862 | |||
| 9412feaa58 | |||
| 0fe2aa439f | |||
| fe4ae9d147 | |||
| 0a3a3448d6 | |||
| 01b779c862 | |||
| 67d1af0baa | |||
| 4e8e4f75fd | |||
| 2be2d11b4a | |||
| b67ae7ff8b | |||
| ad2cc5585e | |||
| 8aa3f1d1f5 | |||
| 30cf93df27 | |||
| 01c4ccb316 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.0 - 2026-05-21
|
||||||
|
|
||||||
Initial stable release of the Django-first pobsync control panel.
|
Initial stable release of the Django-first pobsync control panel.
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -154,9 +154,22 @@ The UI includes:
|
|||||||
- `/self-check/` for runtime checks
|
- `/self-check/` for runtime checks
|
||||||
- `/logs/` for filtered pobsync service logs
|
- `/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
|
## 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
|
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.
|
tested before data is copied back into a live system.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
Worker and scheduler commands are normally run by systemd services:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pobsync"
|
name = "pobsync"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ..util import ensure_dir, realpath_startswith, sanitize_host, write_yaml_at
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_DRY_RUN_TIMEOUT_SECONDS = 900
|
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:
|
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(
|
def _collect_run_stats(
|
||||||
*,
|
*,
|
||||||
log_path: Path,
|
log_path: Path,
|
||||||
@@ -158,6 +177,7 @@ def run_scheduled(
|
|||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
verbose_output: bool = False,
|
verbose_output: bool = False,
|
||||||
|
state_callback: Callable[[dict[str, Any]], None] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
||||||
host = sanitize_host(host)
|
host = sanitize_host(host)
|
||||||
@@ -258,6 +278,7 @@ def run_scheduled(
|
|||||||
"exit_code": result.exit_code,
|
"exit_code": result.exit_code,
|
||||||
"command": result.command,
|
"command": result.command,
|
||||||
"log_tail": log_tail,
|
"log_tail": log_tail,
|
||||||
|
"bwlimit_kbps": bwlimit_kbps,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if result.exit_code != 0:
|
if result.exit_code != 0:
|
||||||
@@ -316,20 +337,65 @@ def run_scheduled(
|
|||||||
"ended_at": None,
|
"ended_at": None,
|
||||||
"duration_seconds": None,
|
"duration_seconds": None,
|
||||||
"base": _base_meta_from_path(base_dir, link_dest),
|
"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},
|
"overrides": {"includes": [], "excludes": [], "base": None},
|
||||||
}
|
}
|
||||||
|
|
||||||
log_path.touch(exist_ok=True)
|
log_path.touch(exist_ok=True)
|
||||||
write_yaml_atomic(meta_path, meta)
|
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()
|
end_ts = utc_now()
|
||||||
meta["ended_at"] = format_iso_z(end_ts)
|
meta["ended_at"] = format_iso_z(end_ts)
|
||||||
meta["duration_seconds"] = int((end_ts - ts).total_seconds())
|
meta["duration_seconds"] = int((end_ts - ts).total_seconds())
|
||||||
meta["rsync"]["exit_code"] = result.exit_code
|
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(
|
meta["stats"] = _collect_run_stats(
|
||||||
log_path=log_path,
|
log_path=log_path,
|
||||||
backup_root=Path(backup_root),
|
backup_root=Path(backup_root),
|
||||||
@@ -349,8 +415,7 @@ def run_scheduled(
|
|||||||
"error": "rsync.log missing after execution",
|
"error": "rsync.log missing after execution",
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.exit_code != 0:
|
if not successful_or_warning:
|
||||||
log_tail = _read_log_tail(log_path)
|
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
@@ -366,6 +431,7 @@ def run_scheduled(
|
|||||||
"exit_code": result.exit_code,
|
"exit_code": result.exit_code,
|
||||||
"command": result.command,
|
"command": result.command,
|
||||||
"log_tail": log_tail,
|
"log_tail": log_tail,
|
||||||
|
"bwlimit_kbps": bwlimit_kbps,
|
||||||
},
|
},
|
||||||
"failure": classify_rsync_failure(result.exit_code, log_tail),
|
"failure": classify_rsync_failure(result.exit_code, log_tail),
|
||||||
}
|
}
|
||||||
@@ -404,7 +470,9 @@ def run_scheduled(
|
|||||||
"snapshot": str(final_dir),
|
"snapshot": str(final_dir),
|
||||||
"base": str(base_dir) if base_dir else None,
|
"base": str(base_dir) if base_dir else None,
|
||||||
"log": str(final_log_path),
|
"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),
|
"verbose_output": bool(verbose_output),
|
||||||
"duration_seconds": meta["duration_seconds"],
|
"duration_seconds": meta["duration_seconds"],
|
||||||
"stats": meta["stats"],
|
"stats": meta["stats"],
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ GLOBAL_SCHEMA = Schema(
|
|||||||
|
|
||||||
HOST_RSYNC_SCHEMA = Schema(
|
HOST_RSYNC_SCHEMA = Schema(
|
||||||
fields={
|
fields={
|
||||||
|
"bwlimit_kbps": FieldSpec(int, required=False, min_value=0),
|
||||||
"extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)),
|
"extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)),
|
||||||
},
|
},
|
||||||
allow_unknown=False,
|
allow_unknown=False,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def run_rsync(
|
|||||||
log_path: Path,
|
log_path: Path,
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
process_started: Callable[[int, int], None] | None = None,
|
||||||
) -> RsyncResult:
|
) -> RsyncResult:
|
||||||
"""
|
"""
|
||||||
Run rsync and always write stdout/stderr to log_path.
|
Run rsync and always write stdout/stderr to log_path.
|
||||||
@@ -95,6 +96,8 @@ def run_rsync(
|
|||||||
|
|
||||||
with log_path.open("ab") as f:
|
with log_path.open("ab") as f:
|
||||||
process = subprocess.Popen(command, stdout=f, stderr=subprocess.STDOUT, start_new_session=True)
|
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()
|
started = time.monotonic()
|
||||||
while True:
|
while True:
|
||||||
exit_code = process.poll()
|
exit_code = process.poll()
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class HostConfigAdmin(admin.ModelAdmin):
|
|||||||
(None, {"fields": ("host", "address", "enabled")}),
|
(None, {"fields": ("host", "address", "enabled")}),
|
||||||
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
|
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
|
||||||
("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}),
|
("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")}),
|
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
|
||||||
("Runtime state", {"fields": ("config",), "classes": ("collapse",)}),
|
("Runtime state", {"fields": ("config",), "classes": ("collapse",)}),
|
||||||
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ from pathlib import Path
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
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.config_source import DjangoConfigSource
|
||||||
from pobsync_backend.models import BackupRun, HostConfig
|
from pobsync_backend.models import BackupRun, HostConfig
|
||||||
from pobsync_backend.retention import run_sql_retention_apply
|
from pobsync_backend.retention import run_sql_retention_apply
|
||||||
@@ -66,6 +72,7 @@ def execute_backup_run(
|
|||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
cancel_check=lambda: _run_cancel_requested(run.id),
|
cancel_check=lambda: _run_cancel_requested(run.id),
|
||||||
verbose_output=bool(dry_run or verbose_output),
|
verbose_output=bool(dry_run or verbose_output),
|
||||||
|
state_callback=lambda state: _record_running_state(run.id, state),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
run.refresh_from_db()
|
run.refresh_from_db()
|
||||||
@@ -83,6 +90,8 @@ def execute_backup_run(
|
|||||||
run.refresh_from_db()
|
run.refresh_from_db()
|
||||||
if result.get("cancelled") or run.status == BackupRun.Status.CANCELLED:
|
if result.get("cancelled") or run.status == BackupRun.Status.CANCELLED:
|
||||||
run.status = BackupRun.Status.CANCELLED
|
run.status = BackupRun.Status.CANCELLED
|
||||||
|
elif result.get("status") == BackupRun.Status.WARNING:
|
||||||
|
run.status = BackupRun.Status.WARNING
|
||||||
else:
|
else:
|
||||||
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
|
run.status = BackupRun.Status.SUCCESS if result.get("ok") else BackupRun.Status.FAILED
|
||||||
run.ended_at = timezone.now()
|
run.ended_at = timezone.now()
|
||||||
@@ -201,11 +210,98 @@ def _run_cancel_requested(run_id: int) -> bool:
|
|||||||
return False
|
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:
|
def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_seconds: int) -> bool:
|
||||||
result = run.result if isinstance(run.result, dict) else {}
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
requested = result.get("requested") if isinstance(result.get("requested"), 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)
|
stale_worker = _running_worker_timed_out(run=run, stale_worker_seconds=stale_worker_seconds)
|
||||||
if not requested.get("dry_run"):
|
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"])
|
||||||
|
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"])
|
||||||
|
return True
|
||||||
if stale_worker:
|
if stale_worker:
|
||||||
result.update(
|
result.update(
|
||||||
{
|
{
|
||||||
@@ -225,14 +321,11 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int, stale_worker_s
|
|||||||
return True
|
return True
|
||||||
return False
|
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)
|
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:
|
if not terminal_log and not timed_out and not stale_worker:
|
||||||
return False
|
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)
|
failure = classify_rsync_failure(exit_code, log_tail)
|
||||||
if stale_worker and not terminal_log:
|
if stale_worker and not terminal_log:
|
||||||
failure = {
|
failure = {
|
||||||
@@ -305,6 +398,9 @@ def _read_log_tail(log_path: Path | None, *, max_lines: int = 40) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _terminal_rsync_log(log_tail: list[str]) -> bool:
|
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)
|
return any(line.startswith("rsync error:") for line in log_tail)
|
||||||
|
|
||||||
|
|
||||||
@@ -312,6 +408,8 @@ def _exit_code_from_log(log_tail: list[str]) -> int | None:
|
|||||||
for line in reversed(log_tail):
|
for line in reversed(log_tail):
|
||||||
if "code 255" in line:
|
if "code 255" in line:
|
||||||
return 255
|
return 255
|
||||||
|
if "code 24" in line:
|
||||||
|
return 24
|
||||||
if "code 124" in line:
|
if "code 124" in line:
|
||||||
return 124
|
return 124
|
||||||
if "code 12" in line:
|
if "code 12" in line:
|
||||||
@@ -342,6 +440,33 @@ def _running_worker_timed_out(*, run: BackupRun, stale_worker_seconds: int) -> b
|
|||||||
return timezone.now() >= heartbeat_at + timedelta(seconds=stale_worker_seconds)
|
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):
|
def _parse_iso_datetime(value: object):
|
||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or not value:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]:
|
|||||||
data["excludes_replace"] = list(host_config.excludes_replace or [])
|
data["excludes_replace"] = list(host_config.excludes_replace or [])
|
||||||
else:
|
else:
|
||||||
data["excludes_add"] = list(host_config.excludes_add or [])
|
data["excludes_add"] = list(host_config.excludes_add or [])
|
||||||
if host_config.rsync_extra_args:
|
if host_config.rsync_extra_args or host_config.rsync_bwlimit_kbps is not None:
|
||||||
data["rsync"] = {"extra_args": list(host_config.rsync_extra_args or [])}
|
data["rsync"] = {}
|
||||||
|
if host_config.rsync_extra_args:
|
||||||
|
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")
|
return validate_dict(data, HOST_SCHEMA, path="host")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class HostConfigForm(forms.ModelForm):
|
|||||||
"excludes_add",
|
"excludes_add",
|
||||||
"excludes_replace",
|
"excludes_replace",
|
||||||
"rsync_extra_args",
|
"rsync_extra_args",
|
||||||
|
"rsync_bwlimit_kbps",
|
||||||
"retention_daily",
|
"retention_daily",
|
||||||
"retention_weekly",
|
"retention_weekly",
|
||||||
"retention_monthly",
|
"retention_monthly",
|
||||||
@@ -70,6 +71,7 @@ class HostConfigForm(forms.ModelForm):
|
|||||||
"ssh_user": "Leave empty to use the global SSH user.",
|
"ssh_user": "Leave empty to use the global SSH user.",
|
||||||
"ssh_port": "Leave empty to use the global SSH port.",
|
"ssh_port": "Leave empty to use the global SSH port.",
|
||||||
"source_root": "Leave empty to use the global default source root.",
|
"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 = {
|
help_texts = {
|
||||||
"name": "Usually 'default'. The backup engine currently reads the default config.",
|
"name": "Usually 'default'. The backup engine currently reads the default config.",
|
||||||
"default_ssh_credential": "Optional. Used by hosts without their own SSH credential.",
|
"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_source_root": "Used by hosts without a custom source root.",
|
||||||
"default_destination_subdir": "Optional subdirectory below each snapshot.",
|
"default_destination_subdir": "Optional subdirectory below each snapshot.",
|
||||||
}
|
}
|
||||||
@@ -192,7 +195,7 @@ class SshCredentialForm(forms.ModelForm):
|
|||||||
if not raw_private_key.strip():
|
if not raw_private_key.strip():
|
||||||
if self.instance and self.instance.pk and self.instance.key_path:
|
if self.instance and self.instance.pk and self.instance.key_path:
|
||||||
return self.instance.private_key
|
return self.instance.private_key
|
||||||
raise forms.ValidationError("Paste a private key, upload a private key file, or generate a key from Django.")
|
raise forms.ValidationError("Paste a private key, upload a private key file, or generate a key in pobsync.")
|
||||||
|
|
||||||
private_key = normalize_private_key(raw_private_key)
|
private_key = normalize_private_key(raw_private_key)
|
||||||
public_key = validate_ssh_private_key(private_key)
|
public_key = validate_ssh_private_key(private_key)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument("--exclude-add", action="append", default=[])
|
parser.add_argument("--exclude-add", action="append", default=[])
|
||||||
parser.add_argument("--exclude-replace", action="append", default=None)
|
parser.add_argument("--exclude-replace", action="append", default=None)
|
||||||
parser.add_argument("--rsync-extra-arg", action="append", default=[])
|
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("--retention", default=None)
|
||||||
parser.add_argument("--disabled", action="store_true")
|
parser.add_argument("--disabled", action="store_true")
|
||||||
parser.add_argument("--force", action="store_true", help="Update existing host")
|
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_add": [] if options["exclude_replace"] is not None else list(options["exclude_add"]),
|
||||||
"excludes_replace": options["exclude_replace"],
|
"excludes_replace": options["exclude_replace"],
|
||||||
"rsync_extra_args": list(options["rsync_extra_arg"]),
|
"rsync_extra_args": list(options["rsync_extra_arg"]),
|
||||||
|
"rsync_bwlimit_kbps": options["rsync_bwlimit_kbps"],
|
||||||
"retention_daily": retention["daily"],
|
"retention_daily": retention["daily"],
|
||||||
"retention_weekly": retention["weekly"],
|
"retention_weekly": retention["weekly"],
|
||||||
"retention_monthly": retention["monthly"],
|
"retention_monthly": retention["monthly"],
|
||||||
|
|||||||
18
src/pobsync_backend/migrations/0014_host_bwlimit_override.py
Normal file
18
src/pobsync_backend/migrations/0014_host_bwlimit_override.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -63,6 +63,7 @@ class HostConfig(TimestampedModel):
|
|||||||
excludes_add = models.JSONField(default=list, blank=True)
|
excludes_add = models.JSONField(default=list, blank=True)
|
||||||
excludes_replace = models.JSONField(null=True, blank=True)
|
excludes_replace = models.JSONField(null=True, blank=True)
|
||||||
rsync_extra_args = models.JSONField(default=list, 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_daily = models.PositiveIntegerField(default=14)
|
||||||
retention_weekly = models.PositiveIntegerField(default=8)
|
retention_weekly = models.PositiveIntegerField(default=8)
|
||||||
retention_monthly = models.PositiveIntegerField(default=12)
|
retention_monthly = models.PositiveIntegerField(default=12)
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict
|
|||||||
*ssh_cmd,
|
*ssh_cmd,
|
||||||
"-oBatchMode=yes",
|
"-oBatchMode=yes",
|
||||||
target,
|
target,
|
||||||
"sh",
|
_remote_shell_command(f"command -v {shlex.quote(rsync_binary)} >/dev/null"),
|
||||||
"-lc",
|
|
||||||
f"command -v {shlex.quote(rsync_binary)} >/dev/null",
|
|
||||||
],
|
],
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
),
|
),
|
||||||
@@ -109,9 +107,7 @@ def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict
|
|||||||
*ssh_cmd,
|
*ssh_cmd,
|
||||||
"-oBatchMode=yes",
|
"-oBatchMode=yes",
|
||||||
target,
|
target,
|
||||||
"sh",
|
_remote_shell_command(f"test -e {shlex.quote(source_root)} && test -r {shlex.quote(source_root)}"),
|
||||||
"-lc",
|
|
||||||
f"test -e {shlex.quote(source_root)} && test -r {shlex.quote(source_root)}",
|
|
||||||
],
|
],
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
),
|
),
|
||||||
@@ -129,6 +125,10 @@ def run_remote_preflight(host: HostConfig, *, timeout_seconds: int = 20) -> dict
|
|||||||
return result
|
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]:
|
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))
|
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
|
credential = host.ssh_credential or global_config.default_ssh_credential
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
|
|||||||
host_config = _enabled_host_config(host)
|
host_config = _enabled_host_config(host)
|
||||||
retention = _retention_for_host(host_config)
|
retention = _retention_for_host(host_config)
|
||||||
snapshots = _snapshots_for_retention(host_config=host_config, kind=kind)
|
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(
|
plan = build_retention_plan(
|
||||||
snapshots=snapshots,
|
snapshots=snapshots,
|
||||||
@@ -49,10 +49,9 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
|
|||||||
"keep": sorted(keep),
|
"keep": sorted(keep),
|
||||||
"keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items],
|
"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],
|
"delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete],
|
||||||
"incomplete": [
|
"incomplete": incomplete_items,
|
||||||
_snapshot_to_item(snapshot, reasons=["incomplete snapshot; excluded from retention cleanup"])
|
"incomplete_reviewed_count": sum(1 for item in incomplete_items if item["reviewed"]),
|
||||||
for snapshot in incomplete_snapshots
|
"incomplete_unreviewed_count": sum(1 for item in incomplete_items if not item["reviewed"]),
|
||||||
],
|
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +102,7 @@ def run_sql_retention_apply(
|
|||||||
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
|
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
|
||||||
|
|
||||||
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
|
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")
|
reason = str(item.get("reason") or "outside retention policy")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
actions.append(f"skip missing {snap_kind}/{dirname}")
|
actions.append(f"skip missing {snap_kind}/{dirname}")
|
||||||
@@ -163,9 +163,15 @@ def run_incomplete_cleanup(
|
|||||||
|
|
||||||
def _do_cleanup() -> dict[str, Any]:
|
def _do_cleanup() -> dict[str, Any]:
|
||||||
host_config = _enabled_host_config(host)
|
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 = [
|
incomplete_list = [
|
||||||
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
|
_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:
|
if max_delete == 0 and len(incomplete_list) > 0:
|
||||||
raise ConfigError("Incomplete cleanup blocked by --max-delete=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]
|
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 = (
|
records = (
|
||||||
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
|
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
.select_related("base")
|
.select_related("base")
|
||||||
.order_by("-started_at", "dirname")
|
.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]
|
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:
|
def _snapshot_from_record(record: SnapshotRecord) -> Snapshot:
|
||||||
return Snapshot(
|
return Snapshot(
|
||||||
kind=record.kind,
|
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:
|
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
||||||
if path.name == "data" and path.parent.name == dirname:
|
if path.name == "data" and path.parent.name == dirname:
|
||||||
return path.parent
|
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}")
|
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:
|
def _remove_snapshot_tree(path: Path) -> None:
|
||||||
_make_directories_user_writable(path)
|
_make_snapshot_tree_user_removable(path)
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path, onexc=_retry_remove_with_user_permissions)
|
||||||
|
|
||||||
|
|
||||||
def _make_directories_user_writable(path: Path) -> None:
|
def _make_snapshot_tree_user_removable(path: Path) -> None:
|
||||||
for directory in [path, *[child for child in path.rglob("*") if child.is_dir() and not child.is_symlink()]]:
|
stack = [path]
|
||||||
mode = directory.stat().st_mode
|
while stack:
|
||||||
if mode & stat.S_IWUSR:
|
directory = stack.pop()
|
||||||
|
if directory.is_symlink():
|
||||||
continue
|
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
|
||||||
|
|||||||
@@ -266,13 +266,13 @@ def _config_checks() -> list[SelfCheck]:
|
|||||||
message = "Default global config exists."
|
message = "Default global config exists."
|
||||||
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
|
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
|
||||||
status = "warning"
|
status = "warning"
|
||||||
message = "Global config backup root differs from the runtime backup root."
|
message = "Saved backup root differs from the active backup root."
|
||||||
return [
|
return [
|
||||||
SelfCheck(
|
SelfCheck(
|
||||||
"Global config",
|
"Global config",
|
||||||
status,
|
status,
|
||||||
message,
|
message,
|
||||||
f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}",
|
f"saved={global_config.backup_root} active={settings.POBSYNC_BACKUP_ROOT}",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ from typing import Any, Iterable
|
|||||||
|
|
||||||
from django.utils import timezone
|
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
|
from .models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord
|
||||||
|
|
||||||
|
|
||||||
def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: GlobalConfig | None) -> dict[str, Any]:
|
def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: GlobalConfig | None) -> dict[str, Any]:
|
||||||
|
hosts = list(hosts)
|
||||||
runs = list(
|
runs = list(
|
||||||
BackupRun.objects.select_related("host", "snapshot")
|
BackupRun.objects.select_related("host", "snapshot")
|
||||||
.filter(status__in=_COMPLETED_BACKUP_STATUSES)
|
.filter(status__in=_COMPLETED_BACKUP_STATUSES)
|
||||||
@@ -21,6 +22,7 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
|
|||||||
|
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
host.stats_summary = collect_host_stats(host=host)
|
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 = [_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]
|
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_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,
|
"estimated_days_until_full": int(available / daily_literal) if available and daily_literal else None,
|
||||||
"capacity": capacity,
|
"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]
|
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 = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
|
||||||
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {}
|
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 = [_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]
|
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_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_problem_run": _first_run_with_status(real_runs, {BackupRun.Status.WARNING, BackupRun.Status.FAILED}),
|
||||||
"latest_snapshot": latest_snapshot_stats,
|
"latest_snapshot": latest_snapshot_stats,
|
||||||
|
"backup_data": backup_data,
|
||||||
"avg_literal_data_bytes": _average(literal_values),
|
"avg_literal_data_bytes": _average(literal_values),
|
||||||
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
|
"avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
|
||||||
"total_literal_data_bytes": sum(literal_values),
|
"total_literal_data_bytes": sum(literal_values),
|
||||||
@@ -102,6 +107,60 @@ 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
|
||||||
|
row["count"] += 1
|
||||||
|
row["allocated_size_bytes"] += int(allocated)
|
||||||
|
row["apparent_size_bytes"] += int(apparent)
|
||||||
|
total["count"] += 1
|
||||||
|
total["allocated_size_bytes"] += int(allocated)
|
||||||
|
total["apparent_size_bytes"] += int(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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return total_rows
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -109,6 +168,12 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
|||||||
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
|
stats = metadata.get("stats") if isinstance(metadata.get("stats"), dict) else {}
|
||||||
storage = stats.get("storage") if isinstance(stats.get("storage"), 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 {}
|
snapshot_storage = storage.get("snapshot") if isinstance(storage.get("snapshot"), dict) 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)
|
||||||
return {
|
return {
|
||||||
"id": snapshot.id,
|
"id": snapshot.id,
|
||||||
"dirname": snapshot.dirname,
|
"dirname": snapshot.dirname,
|
||||||
@@ -121,6 +186,18 @@ def _snapshot_summary(snapshot: SnapshotRecord | None) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _is_real_run(run: BackupRun) -> bool:
|
||||||
result = run.result if isinstance(run.result, dict) else {}
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
if result.get("dry_run") is True:
|
if result.get("dry_run") is True:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,21 @@
|
|||||||
{% block title %}Changelog - pobsync{% endblock %}
|
{% block title %}Changelog - pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Changelog</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Changelog actions">
|
<div class="page-kicker">Release notes</div>
|
||||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
<h1>Changelog</h1>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<div class="stack spaced">
|
<div class="stack spaced">
|
||||||
<div><strong>Installed version:</strong> {{ app_version }}</div>
|
<div><strong>Installed version:</strong> {{ app_version }}</div>
|
||||||
<div class="muted">Source: {{ changelog_path }}</div>
|
<div class="muted">Changelog file: {{ changelog_path }}</div>
|
||||||
{% if missing %}
|
{% if missing %}
|
||||||
<div class="status warning">missing</div>
|
<div class="status warning">missing</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
{% block title %}pobsync dashboard{% endblock %}
|
{% block title %}pobsync dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Dashboard</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Dashboard actions">
|
<div class="page-kicker">Control panel</div>
|
||||||
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
|
<h1>Dashboard</h1>
|
||||||
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
|
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
|
||||||
</section>
|
</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 %}
|
{% if not global_config or not counts.hosts %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -27,75 +32,28 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="grid" aria-label="Summary">
|
<div
|
||||||
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
|
data-refresh-url="{% url 'dashboard_priority_live' %}"
|
||||||
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
data-refresh-interval="10000"
|
||||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
data-refresh-active="true"
|
||||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
aria-live="polite"
|
||||||
<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>
|
{% include "pobsync_backend/partials/dashboard_priority.html" %}
|
||||||
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
|
</div>
|
||||||
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></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&review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
|
||||||
|
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel dashboard-trends-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">
|
|
||||||
<h2>Backup Trends</h2>
|
<h2>Backup Trends</h2>
|
||||||
{% if stats_summary.runs_sampled %}
|
{% if stats_summary.runs_sampled %}
|
||||||
<div class="insight-grid" aria-label="Backup trends">
|
<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="insight-item">
|
||||||
<div class="label">Runway</div>
|
<div class="label">Runway</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
@@ -136,155 +94,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<div
|
||||||
<h2>Hosts</h2>
|
data-refresh-url="{% url 'dashboard_hosts_live' %}"
|
||||||
<div class="host-list">
|
data-refresh-interval="15000"
|
||||||
{% for host in hosts %}
|
data-refresh-active="true"
|
||||||
<article class="host-card">
|
aria-live="polite"
|
||||||
<div class="host-card-header">
|
>
|
||||||
<div class="host-card-title">
|
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
|
||||||
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
|
</div>
|
||||||
<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 %}
|
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,17 +3,22 @@
|
|||||||
{% block title %}Global Config{% endblock %}
|
{% block title %}Global Config{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Global config actions">
|
<div class="page-kicker">Configuration</div>
|
||||||
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
|
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
|
||||||
<div class="stack spaced">
|
<div class="stack spaced">
|
||||||
<div><strong>Backup root:</strong> {{ backup_root }}</div>
|
<div><strong>Backup root:</strong> {{ backup_root }}</div>
|
||||||
<div class="muted">This path comes from the runtime environment and is written back when the config is saved.</div>
|
<div class="muted">This path is managed by the service environment and is saved with the config.</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" class="form-grid">
|
<form method="post" class="form-grid">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -28,8 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Save global config</button>
|
<button type="submit">Save global config</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,70 +3,13 @@
|
|||||||
{% block title %}{{ host.host }} | pobsync{% endblock %}
|
{% block title %}{{ host.host }} | pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ host.host }}</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Host actions">
|
<div class="page-kicker">Host</div>
|
||||||
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
<h1>{{ host.host }}</h1>
|
||||||
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
|
||||||
{% csrf_token %}
|
</div>
|
||||||
<button type="submit">Discover snapshots</button>
|
</header>
|
||||||
</form>
|
|
||||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
|
||||||
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
|
|
||||||
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="secondary">Prepare directories</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="secondary">Scan SSH host key</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="{% url 'run_host_preflight' host.host %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="secondary">Run connection preflight</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid" aria-label="Host summary">
|
|
||||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="two-col">
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Config</h2>
|
|
||||||
<div class="stack">
|
|
||||||
<div><strong>Address:</strong> {{ host.address }}</div>
|
|
||||||
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
|
|
||||||
<div><strong>SSH key:</strong> {{ host.ssh_credential|default:"global default" }}</div>
|
|
||||||
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
|
|
||||||
<div><strong>Source:</strong> {{ host.source_root|default:"global default" }}</div>
|
|
||||||
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<h2>Schedule</h2>
|
|
||||||
{% if schedule %}
|
|
||||||
<div class="stack">
|
|
||||||
<div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div>
|
|
||||||
<div class="muted">Evaluated by the pobsync scheduler service.</div>
|
|
||||||
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
|
|
||||||
<div><strong>Next run:</strong> {% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }} <span class="muted">{{ scheduler_timezone }}</span>{% endif %}</div>
|
|
||||||
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
|
|
||||||
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
|
|
||||||
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
|
|
||||||
<div><strong>Last finished:</strong> {{ schedule.last_finished_at|default:"" }}</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">No schedule configured.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if retention_warning.has_warning %}
|
{% if retention_warning.has_warning %}
|
||||||
<section class="panel highlight warning">
|
<section class="panel highlight warning">
|
||||||
@@ -96,60 +39,160 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if effective_config %}
|
<section class="host-control-grid" aria-label="Host control workspace">
|
||||||
<section class="panel">
|
<article class="panel host-control-panel">
|
||||||
<h2>Effective Config</h2>
|
<h2>Host Status</h2>
|
||||||
<div class="two-col">
|
<div class="host-control-primary">
|
||||||
<div class="stack">
|
<div>
|
||||||
<div><strong>Source root:</strong> {{ effective_config.source_root }}</div>
|
{% if host.enabled %}
|
||||||
<div><strong>Destination subdir:</strong> {{ effective_config.destination_subdir|default:"none" }}</div>
|
<span class="status ok">enabled</span>
|
||||||
<div><strong>SSH:</strong> {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</div>
|
|
||||||
<div><strong>SSH key:</strong> {{ effective_config.ssh.credential|default:"none selected" }}</div>
|
|
||||||
<div><strong>SSH options:</strong> {{ effective_config.ssh.options|join:" " }}</div>
|
|
||||||
<div><strong>Rsync binary:</strong> {{ effective_config.rsync.binary }}</div>
|
|
||||||
<div><strong>Rsync args:</strong> {{ effective_config.rsync.args|join:" " }}</div>
|
|
||||||
<div><strong>Timeout:</strong> {{ effective_config.rsync.timeout_seconds }}s</div>
|
|
||||||
<div><strong>Bandwidth limit:</strong> {{ effective_config.rsync.bwlimit_kbps }} KB/s</div>
|
|
||||||
<div>
|
|
||||||
<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:" " }}</pre>
|
|
||||||
{% else %}
|
{% 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:" " }}</pre>
|
|
||||||
{% else %}
|
|
||||||
<div class="muted">No exclude rules configured.</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="muted">{{ host.address }}</span>
|
||||||
</div>
|
</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 }}&status=failed&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 %}
|
||||||
|
<span class="status-summary success">
|
||||||
|
<span class="status ok">ok</span>
|
||||||
|
<strong>No active blockers for this host.</strong>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="host-control-meta">
|
||||||
{% endif %}
|
<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>
|
||||||
|
|
||||||
<section class="panel">
|
<article class="panel host-control-panel">
|
||||||
<h2>Snapshot Discovery</h2>
|
<h2>Backup Control</h2>
|
||||||
<div class="stack">
|
<div class="operator-state">
|
||||||
<div><strong>Backup root:</strong> {{ discovery.backup_root|default:"" }}</div>
|
{% if active_run %}
|
||||||
<div><strong>Host root:</strong> {{ discovery.host_root|default:"" }}</div>
|
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||||
<div><strong>Status:</strong> {{ discovery.message }}</div>
|
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
||||||
{% if discovery.kind_counts %}
|
{% elif has_global_config and host.enabled %}
|
||||||
<div><strong>On disk:</strong>
|
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
|
||||||
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
|
<span class="muted">{{ backup_gate.message }}</span>
|
||||||
manual {{ discovery.kind_counts.manual|default:0 }},
|
{% elif not host.enabled %}
|
||||||
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
|
<span class="status failed">disabled</span>
|
||||||
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>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>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">Manual</div>
|
||||||
|
<div class="value">{{ stats_summary.backup_data.manual.allocated_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>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="label">Total</div>
|
||||||
|
<div class="value">{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p class="muted">Totals use the allocated snapshot size recorded in backup metadata, grouped by snapshot kind.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if stats_summary.runs %}
|
{% if stats_summary.runs %}
|
||||||
@@ -209,104 +252,200 @@
|
|||||||
<div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div>
|
<div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div>
|
||||||
<div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
|
<div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
|
||||||
</section>
|
</section>
|
||||||
<table>
|
<div class="record-list">
|
||||||
<thead>
|
{% for check in host_checks %}
|
||||||
<tr>
|
<article class="record-card">
|
||||||
<th>Status</th>
|
<div class="record-card-header">
|
||||||
<th>Check</th>
|
<div class="record-title">
|
||||||
<th>Message</th>
|
<strong>{{ check.name }}</strong>
|
||||||
<th>Detail</th>
|
<span class="muted">{{ check.message }}</span>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<span class="status {{ check.status }}">{{ check.status }}</span>
|
||||||
<tbody>
|
</div>
|
||||||
{% for check in host_checks %}
|
{% if check.detail %}
|
||||||
<tr>
|
<div class="record-fact">
|
||||||
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
|
<span class="label">Detail</span>
|
||||||
<td>{{ check.name }}</td>
|
<span class="muted">{{ check.detail }}</span>
|
||||||
<td>{{ check.message }}</td>
|
</div>
|
||||||
<td class="muted">{{ check.detail }}</td>
|
{% endif %}
|
||||||
</tr>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if last_preflight %}
|
<div class="panel-grid">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Connection Preflight</h2>
|
<h2>Configuration</h2>
|
||||||
<div class="stack spaced">
|
<div class="host-control-meta">
|
||||||
<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><span class="label">Address</span><strong>{{ host.address }}</strong></div>
|
||||||
<div><strong>Target:</strong> {{ last_preflight.target }}</div>
|
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
|
||||||
<div><strong>Source root:</strong> {{ last_preflight.source_root }}</div>
|
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
|
||||||
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</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>
|
||||||
<table>
|
<div class="actions inline">
|
||||||
<thead>
|
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||||
<tr>
|
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||||
<th>Status</th>
|
</div>
|
||||||
<th>Check</th>
|
</section>
|
||||||
<th>Message</th>
|
|
||||||
<th>Detail</th>
|
<section class="panel">
|
||||||
</tr>
|
<h2>Connection Preflight & SSH</h2>
|
||||||
</thead>
|
{% if last_preflight %}
|
||||||
<tbody>
|
<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>
|
||||||
|
<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 %}
|
{% for check in last_preflight.checks %}
|
||||||
<tr>
|
<div class="activity-row">
|
||||||
<td><span class="status {% if check.ok %}ok{% else %}failed{% endif %}">{% if check.ok %}ok{% else %}failed{% endif %}</span></td>
|
<span class="status {% if check.ok %}ok{% else %}failed{% endif %}">
|
||||||
<td>{{ check.name }}</td>
|
{% if check.ok %}ok{% else %}failed{% endif %}
|
||||||
<td>{{ check.message }}</td>
|
</span>
|
||||||
<td class="muted">{{ check.detail }}</td>
|
<span>
|
||||||
</tr>
|
<strong>{{ check.name }}</strong>
|
||||||
|
<span class="muted">{{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Snapshot Storage</h2>
|
||||||
|
<div class="host-control-meta">
|
||||||
|
<div><span class="label">Backup root</span><strong>{{ discovery.backup_root|default:"" }}</strong></div>
|
||||||
|
<div><span class="label">Host root</span><strong>{{ discovery.host_root|default:"" }}</strong></div>
|
||||||
|
<div><span class="label">Status</span><strong>{{ discovery.message }}</strong></div>
|
||||||
|
{% if discovery.kind_counts %}
|
||||||
|
<div>
|
||||||
|
<span class="label">On disk</span>
|
||||||
|
<strong>
|
||||||
|
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
|
||||||
|
manual {{ discovery.kind_counts.manual|default:0 }},
|
||||||
|
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="actions inline">
|
||||||
|
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary compact">Discover snapshots</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="secondary compact">Prepare directories</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if effective_config %}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Effective Config</h2>
|
||||||
|
<p class="muted">Runtime settings after global defaults and host overrides are combined.</p>
|
||||||
|
<div class="record-list">
|
||||||
|
<article class="record-card">
|
||||||
|
<div class="record-card-header">
|
||||||
|
<div class="record-title">
|
||||||
|
<strong>Backup target</strong>
|
||||||
|
<span class="muted">Source and destination used by rsync.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="record-facts">
|
||||||
|
<div class="record-fact"><span class="label">Backup source:</span><strong>{{ effective_config.source_root }}</strong></div>
|
||||||
|
<div class="record-fact"><span class="label">Destination subdir:</span><strong>{{ effective_config.destination_subdir|default:"none" }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="record-card">
|
||||||
|
<div class="record-card-header">
|
||||||
|
<div class="record-title">
|
||||||
|
<strong>Connection</strong>
|
||||||
|
<span class="muted">SSH and rsync execution settings.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="record-facts">
|
||||||
|
<div class="record-fact"><span class="label">SSH:</span><strong>{{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</strong></div>
|
||||||
|
<div class="record-fact"><span class="label">SSH key:</span><strong>{{ effective_config.ssh.credential|default:"none selected" }}</strong></div>
|
||||||
|
<div class="record-fact"><span class="label">SSH options:</span><span>{{ effective_config.ssh.options|join:" " }}</span></div>
|
||||||
|
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
|
||||||
|
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
|
||||||
|
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
|
||||||
|
<div class="record-fact">
|
||||||
|
<span class="label">Bandwidth limit:</span>
|
||||||
|
<strong>{% 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 & 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:" " }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No include rules configured.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
{% if effective_config.excludes %}
|
||||||
|
<pre>{{ effective_config.excludes|join:" " }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<div class="muted">No exclude rules configured.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Backup Control</h2>
|
<h2>Backup Options</h2>
|
||||||
<div class="operator-state">
|
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
|
||||||
{% if active_run %}
|
|
||||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
|
||||||
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
|
||||||
{% elif has_global_config and host.enabled %}
|
|
||||||
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
|
|
||||||
<span class="muted">{{ backup_gate.message }}</span>
|
|
||||||
{% elif not host.enabled %}
|
|
||||||
<span class="status failed">disabled</span>
|
|
||||||
{% elif not has_global_config %}
|
|
||||||
<span class="status failed">missing global config</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="actions inline" aria-label="Quick backup actions">
|
|
||||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="dry_run" value="on">
|
|
||||||
<input type="hidden" name="verbose_output" value="on">
|
|
||||||
<input type="hidden" name="prune_max_delete" value="10">
|
|
||||||
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
|
|
||||||
</form>
|
|
||||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="prune_max_delete" value="10">
|
|
||||||
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if active_run %}
|
|
||||||
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
|
|
||||||
{% elif not can_queue_dry_run or not can_queue_real_backup %}
|
|
||||||
{% if not has_global_config %}
|
|
||||||
<p class="muted">Create the default global config before queueing backups.</p>
|
|
||||||
{% elif not host.enabled %}
|
|
||||||
<p class="muted">Enable this host before queueing backups.</p>
|
|
||||||
{% elif backup_gate.real_blockers %}
|
|
||||||
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h3>Advanced Options</h3>
|
|
||||||
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ manual_backup_form.non_field_errors }}
|
{{ manual_backup_form.non_field_errors }}
|
||||||
@@ -320,65 +459,95 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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>
|
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Latest Runs</h2>
|
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
|
||||||
<table>
|
<div class="record-list">
|
||||||
<thead>
|
{% for run in latest_runs %}
|
||||||
<tr>
|
<article class="record-card">
|
||||||
<th>Status</th>
|
<div class="record-card-header">
|
||||||
<th>Started</th>
|
<div class="record-title">
|
||||||
<th>Ended</th>
|
<a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a>
|
||||||
<th>Snapshot</th>
|
<span class="muted">{{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %}</span>
|
||||||
<th>Base</th>
|
</div>
|
||||||
</tr>
|
<span class="status {{ run.status }}">{{ run.status }}</span>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div class="record-facts">
|
||||||
{% for run in latest_runs %}
|
<div class="record-fact">
|
||||||
<tr>
|
<span class="label">Started</span>
|
||||||
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
<strong>{{ run.started_at|default:run.created_at }}</strong>
|
||||||
<td>{{ run.started_at|default:"" }}</td>
|
</div>
|
||||||
<td>{{ run.ended_at|default:"" }}</td>
|
<div class="record-fact">
|
||||||
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
<span class="label">Ended</span>
|
||||||
<td>{{ run.base_path|default:"" }}</td>
|
<strong>{{ run.ended_at|default:"running or queued" }}</strong>
|
||||||
</tr>
|
</div>
|
||||||
{% empty %}
|
<div class="record-fact">
|
||||||
<tr><td colspan="5" class="muted">No backup runs recorded for this host.</td></tr>
|
<span class="label">Snapshot</span>
|
||||||
{% endfor %}
|
{% if run.snapshot %}
|
||||||
</tbody>
|
<strong><a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a></strong>
|
||||||
</table>
|
{% 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 %}
|
||||||
|
<p class="muted">No backup runs recorded for this host.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Snapshots</h2>
|
<h2>Snapshots <a class="button-link secondary compact" href="{% url 'snapshots_list' %}?host={{ host.host }}">View all</a></h2>
|
||||||
<table>
|
<div class="record-list">
|
||||||
<thead>
|
{% for snapshot in snapshots %}
|
||||||
<tr>
|
<article class="record-card">
|
||||||
<th>Kind</th>
|
<div class="record-card-header">
|
||||||
<th>Status</th>
|
<div class="record-title">
|
||||||
<th>Started</th>
|
<a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a>
|
||||||
<th>Dirname</th>
|
<span class="muted">{{ snapshot.kind }}</span>
|
||||||
<th>Base</th>
|
</div>
|
||||||
</tr>
|
<span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div class="record-facts">
|
||||||
{% for snapshot in snapshots %}
|
<div class="record-fact">
|
||||||
<tr>
|
<span class="label">Started</span>
|
||||||
<td>{{ snapshot.kind }}</td>
|
<strong>{{ snapshot.started_at|default:"unknown" }}</strong>
|
||||||
<td>{{ snapshot.status }}</td>
|
</div>
|
||||||
<td>{{ snapshot.started_at|default:"" }}</td>
|
<div class="record-fact">
|
||||||
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
|
<span class="label">Ended</span>
|
||||||
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
<strong>{{ snapshot.ended_at|default:"unknown" }}</strong>
|
||||||
</tr>
|
</div>
|
||||||
{% empty %}
|
<div class="record-fact">
|
||||||
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
<span class="label">Base</span>
|
||||||
{% endfor %}
|
{% if snapshot.base %}
|
||||||
</tbody>
|
<strong><a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a></strong>
|
||||||
</table>
|
{% 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 %}
|
||||||
|
<p class="muted">No snapshots discovered for this host.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,15 +3,20 @@
|
|||||||
{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
|
{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Config actions">
|
<div class="page-kicker">Configuration</div>
|
||||||
{% if host %}
|
<h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
|
||||||
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
<div class="page-subtitle">Host-specific backup, retention, SSH, include, and exclude settings.</div>
|
||||||
{% else %}
|
</div>
|
||||||
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
<section class="actions" aria-label="Config actions">
|
||||||
{% endif %}
|
{% if host %}
|
||||||
</section>
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>
|
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>
|
||||||
@@ -28,8 +33,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">{% if host %}Save config{% else %}Create host{% endif %}</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -3,15 +3,20 @@
|
|||||||
{% block title %}Logs | pobsync{% endblock %}
|
{% block title %}Logs | pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Logs</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Log actions">
|
<div class="page-kicker">Operations</div>
|
||||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
<h1>Logs</h1>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<h2>Filter</h2>
|
<h2>Filter</h2>
|
||||||
<form method="get" class="form-grid">
|
<form method="get" class="filter-form">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="unit">Unit</label>
|
<label for="unit">Unit</label>
|
||||||
<select id="unit" name="unit">
|
<select id="unit" name="unit">
|
||||||
@@ -49,8 +54,9 @@
|
|||||||
<label for="q">Message contains</label>
|
<label for="q">Message contains</label>
|
||||||
<input id="q" name="q" value="{{ query }}">
|
<input id="q" name="q" value="{{ query }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Filter logs</button>
|
<button type="submit">Filter logs</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'logs' %}">Clear</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Manual data</span>
|
||||||
|
<strong>{{ stats_summary.backup_data.manual.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Incomplete data</span>
|
||||||
|
<strong>{{ stats_summary.backup_data.incomplete.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Total snapshot data</span>
|
||||||
|
<strong>{{ stats_summary.backup_data.total.allocated_size_bytes|filesizeformat }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
@@ -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>
|
||||||
@@ -3,15 +3,20 @@
|
|||||||
{% block title %}Purged Snapshots | pobsync{% endblock %}
|
{% block title %}Purged Snapshots | pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Purged Snapshots</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Purged snapshot actions">
|
<div class="page-kicker">Retention</div>
|
||||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
<h1>Purged Snapshots</h1>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<h2>Filters</h2>
|
<h2>Filters</h2>
|
||||||
<form method="get" class="form-grid">
|
<form method="get" class="filter-form">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="host">Host</label>
|
<label for="host">Host</label>
|
||||||
<select id="host" name="host">
|
<select id="host" name="host">
|
||||||
@@ -30,7 +35,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Apply filters</button>
|
<button type="submit">Apply filters</button>
|
||||||
<a class="button-link secondary" href="{% url 'purged_snapshots' %}">Clear</a>
|
<a class="button-link secondary" href="{% url 'purged_snapshots' %}">Clear</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,18 +3,22 @@
|
|||||||
{% block title %}Retention plan | {{ host.host }}{% endblock %}
|
{% block title %}Retention plan | {{ host.host }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Retention Plan: {{ host.host }}</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Retention filters">
|
<div class="page-kicker">Retention</div>
|
||||||
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
<h1>{{ host.host }}</h1>
|
||||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
|
<div class="page-subtitle">Preview which snapshots stay, which would be deleted, and whether incomplete cleanup is needed.</div>
|
||||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=manual">Manual</a>
|
</div>
|
||||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
|
<section class="actions" aria-label="Retention filters">
|
||||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
</section>
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=manual">Manual</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
|
||||||
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section class="grid" aria-label="Retention plan summary">
|
<section class="grid" aria-label="Retention plan summary">
|
||||||
<div class="metric"><div class="label">Source</div><div class="value">{{ plan.source }}</div></div>
|
|
||||||
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>
|
<div class="metric"><div class="label">Kind</div><div class="value">{{ plan.kind }}</div></div>
|
||||||
<div class="metric"><div class="label">Keep</div><div class="value">{{ plan.keep|length }}</div></div>
|
<div class="metric"><div class="label">Keep</div><div class="value">{{ plan.keep|length }}</div></div>
|
||||||
<div class="metric"><div class="label">Would Delete</div><div class="value">{{ plan.delete|length }}</div></div>
|
<div class="metric"><div class="label">Would Delete</div><div class="value">{{ plan.delete|length }}</div></div>
|
||||||
@@ -41,8 +45,9 @@
|
|||||||
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
|
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their
|
{{ incomplete_unreviewed_count }} still need review. After inspection, mark them reviewed and use the dedicated
|
||||||
SQL records. Successful scheduled and manual snapshots are not touched by this cleanup.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -100,8 +105,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if plan.delete %}
|
{% if plan.delete %}
|
||||||
<section class="panel">
|
<section class="panel highlight warning">
|
||||||
<h2>Apply Retention</h2>
|
<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">
|
<form method="post" action="{% url 'apply_host_retention' host.host %}" class="form-grid">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ apply_form.non_field_errors }}
|
{{ apply_form.non_field_errors }}
|
||||||
@@ -134,8 +143,9 @@
|
|||||||
<div class="helptext">{{ apply_form.confirm_delete_count.help_text }}</div>
|
<div class="helptext">{{ apply_form.confirm_delete_count.help_text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Apply retention</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -178,6 +188,7 @@
|
|||||||
<th>Dirname</th>
|
<th>Dirname</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Review</th>
|
||||||
<th>Reason</th>
|
<th>Reason</th>
|
||||||
<th>Path</th>
|
<th>Path</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -188,6 +199,14 @@
|
|||||||
<td>{{ snapshot.dirname }}</td>
|
<td>{{ snapshot.dirname }}</td>
|
||||||
<td>{{ snapshot.dt }}</td>
|
<td>{{ snapshot.dt }}</td>
|
||||||
<td>{{ snapshot.status|default:"" }}</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>{{ snapshot.reason }}</td>
|
||||||
<td class="muted">{{ snapshot.path }}</td>
|
<td class="muted">{{ snapshot.path }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -196,35 +215,51 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Cleanup Incomplete Snapshots</h3>
|
<h3>Cleanup Incomplete Snapshots</h3>
|
||||||
<form method="post" action="{% url 'cleanup_host_incomplete_snapshots' host.host %}" class="form-grid">
|
{% if incomplete_unreviewed_count %}
|
||||||
{% csrf_token %}
|
<p class="muted">
|
||||||
{{ incomplete_cleanup_form.non_field_errors }}
|
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 }}
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{{ incomplete_cleanup_form.max_delete.errors }}
|
{{ incomplete_cleanup_form.max_delete.errors }}
|
||||||
<label for="{{ incomplete_cleanup_form.max_delete.id_for_label }}">Max delete</label>
|
<label for="{{ incomplete_cleanup_form.max_delete.id_for_label }}">Max delete</label>
|
||||||
{{ incomplete_cleanup_form.max_delete }}
|
{{ incomplete_cleanup_form.max_delete }}
|
||||||
<div class="helptext">{{ incomplete_cleanup_form.max_delete.help_text }}</div>
|
<div class="helptext">{{ incomplete_cleanup_form.max_delete.help_text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{{ incomplete_cleanup_form.confirm_host.errors }}
|
{{ incomplete_cleanup_form.confirm_host.errors }}
|
||||||
<label for="{{ incomplete_cleanup_form.confirm_host.id_for_label }}">Confirm host</label>
|
<label for="{{ incomplete_cleanup_form.confirm_host.id_for_label }}">Confirm host</label>
|
||||||
{{ incomplete_cleanup_form.confirm_host }}
|
{{ incomplete_cleanup_form.confirm_host }}
|
||||||
<div class="helptext">{{ incomplete_cleanup_form.confirm_host.help_text }}</div>
|
<div class="helptext">{{ incomplete_cleanup_form.confirm_host.help_text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{{ incomplete_cleanup_form.confirm_delete_count.errors }}
|
{{ incomplete_cleanup_form.confirm_delete_count.errors }}
|
||||||
<label for="{{ incomplete_cleanup_form.confirm_delete_count.id_for_label }}">Confirm incomplete count</label>
|
<label for="{{ incomplete_cleanup_form.confirm_delete_count.id_for_label }}">Confirm incomplete count</label>
|
||||||
{{ incomplete_cleanup_form.confirm_delete_count }}
|
{{ incomplete_cleanup_form.confirm_delete_count }}
|
||||||
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
|
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Delete incomplete snapshots</button>
|
<button type="submit" class="danger">Delete incomplete snapshots</button>
|
||||||
</div>
|
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,124 +3,36 @@
|
|||||||
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
|
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Run {{ run.id }}</h1>
|
<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>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section class="actions" aria-label="Run actions">
|
{% if can_auto_refresh %}
|
||||||
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
|
<section class="panel refresh-controls" aria-label="Live refresh controls">
|
||||||
{% if can_cancel %}
|
<div>
|
||||||
<form method="post" action="{% url 'cancel_run' run.id %}">
|
<h2>Live Updates</h2>
|
||||||
{% csrf_token %}
|
<p class="muted">Auto-refresh is <strong data-refresh-state="run-live-region">on</strong> while this run is active.</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
<button type="button" class="secondary" data-refresh-toggle data-refresh-target="run-live-region">Pause refresh</button>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if run.reviewed_at %}
|
<div
|
||||||
<section class="panel highlight success">
|
id="run-live-region"
|
||||||
<h2>Review</h2>
|
data-refresh-url="{% url 'run_detail_live' run.id %}"
|
||||||
<div class="stack">
|
data-refresh-interval="5000"
|
||||||
<div><strong>Reviewed:</strong> {{ run.reviewed_at }}</div>
|
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
|
||||||
<div><strong>Reviewed by:</strong> {{ run.reviewed_by|default:"unknown" }}</div>
|
data-refresh-paused="false"
|
||||||
</div>
|
aria-live="polite"
|
||||||
</section>
|
>
|
||||||
{% endif %}
|
{% include "pobsync_backend/partials/run_detail_live.html" %}
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
<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 %}
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{% if requested %}
|
{% if requested %}
|
||||||
@@ -138,6 +50,10 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Rsync Command</h2>
|
<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 %}
|
{% if rsync_command %}
|
||||||
<pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
|
<pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
|
||||||
{% endif %}{% endfor %}</pre>
|
{% endif %}{% endfor %}</pre>
|
||||||
@@ -146,26 +62,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</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 %}
|
{% if stats %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Stats</h2>
|
<h2>Stats</h2>
|
||||||
@@ -193,7 +89,6 @@
|
|||||||
<h2>Retention</h2>
|
<h2>Retention</h2>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div><strong>Status:</strong> {% if prune_result.ok %}ok{% else %}warning{% endif %}</div>
|
<div><strong>Status:</strong> {% if prune_result.ok %}ok{% else %}warning{% endif %}</div>
|
||||||
{% if prune_result.source %}<div><strong>Source:</strong> {{ prune_result.source }}</div>{% endif %}
|
|
||||||
{% if prune_result.kind %}<div><strong>Kind:</strong> {{ prune_result.kind }}</div>{% endif %}
|
{% if prune_result.kind %}<div><strong>Kind:</strong> {{ prune_result.kind }}</div>{% endif %}
|
||||||
{% if prune_result.planned_delete_count is not None %}<div><strong>Planned deletions:</strong> {{ prune_result.planned_delete_count }}</div>{% endif %}
|
{% if prune_result.planned_delete_count is not None %}<div><strong>Planned deletions:</strong> {{ prune_result.planned_delete_count }}</div>{% endif %}
|
||||||
{% if prune_result.deleted %}<div><strong>Deleted:</strong> {{ prune_result.deleted|length }}</div>{% endif %}
|
{% if prune_result.deleted %}<div><strong>Deleted:</strong> {{ prune_result.deleted|length }}</div>{% endif %}
|
||||||
|
|||||||
121
src/pobsync_backend/templates/pobsync_backend/runs_list.html
Normal file
121
src/pobsync_backend/templates/pobsync_backend/runs_list.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Runs | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Activity</div>
|
||||||
|
<h1>Runs</h1>
|
||||||
|
<div class="page-subtitle">Review queued, running, completed, warning, failed, and cancelled backup runs.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Run list actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<form method="get" class="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 %}
|
||||||
@@ -3,11 +3,16 @@
|
|||||||
{% block title %}Schedule | {{ host.host }}{% endblock %}
|
{% block title %}Schedule | {{ host.host }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Schedule: {{ host.host }}</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Schedule actions">
|
<div class="page-kicker">Schedule</div>
|
||||||
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
<h1>{{ host.host }}</h1>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2>
|
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2>
|
||||||
@@ -25,8 +30,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Save schedule</button>
|
<button type="submit">Save schedule</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'host_detail' host.host %}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -3,11 +3,16 @@
|
|||||||
{% block title %}Self Check | pobsync{% endblock %}
|
{% block title %}Self Check | pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Self Check</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Self check actions">
|
<div class="page-kicker">Operations</div>
|
||||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
<h1>Self Check</h1>
|
||||||
</section>
|
<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">
|
<section class="grid" aria-label="Self check summary">
|
||||||
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div>
|
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div>
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
|
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ snapshot.dirname }}</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="Snapshot actions">
|
<div class="page-kicker">Snapshot</div>
|
||||||
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
|
<h1>{{ snapshot.dirname }}</h1>
|
||||||
</section>
|
<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">
|
<section class="grid" aria-label="Snapshot summary">
|
||||||
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>
|
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>
|
||||||
@@ -63,7 +68,7 @@
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Restore Guidance</h2>
|
<h2>Restore Guidance</h2>
|
||||||
<div class="stack spaced">
|
<div class="stack spaced">
|
||||||
<div><strong>Snapshot data source:</strong> {{ restore.source_path }}</div>
|
<div><strong>Snapshot data path:</strong> {{ restore.source_path }}</div>
|
||||||
<div><strong>Example staging destination:</strong> {{ restore.destination_path }}</div>
|
<div><strong>Example staging destination:</strong> {{ restore.destination_path }}</div>
|
||||||
<div class="muted">
|
<div class="muted">
|
||||||
Restore from the snapshot's <code>data/</code> directory. Start with a dry run, restore to a staging path first,
|
Restore from the snapshot's <code>data/</code> directory. Start with a dry run, restore to a staging path first,
|
||||||
@@ -93,7 +98,7 @@
|
|||||||
<div class="muted">Replace <code>{{ restore.example_file_relative_path }}</code> with the file you want to restore.</div>
|
<div class="muted">Replace <code>{{ restore.example_file_relative_path }}</code> with the file you want to restore.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack spaced">
|
<div class="stack spaced">
|
||||||
<div><strong>Dry-run restore back to the source host:</strong></div>
|
<div><strong>Dry-run restore back to the original host:</strong></div>
|
||||||
<pre>{{ restore.remote_dry_run_command }}</pre>
|
<pre>{{ restore.remote_dry_run_command }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Snapshots | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<div class="page-kicker">Snapshots</div>
|
||||||
|
<h1>Snapshots</h1>
|
||||||
|
<div class="page-subtitle">Browse discovered scheduled, manual, and incomplete snapshots across all hosts.</div>
|
||||||
|
</div>
|
||||||
|
<section class="actions" aria-label="Snapshot list actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Filters</h2>
|
||||||
|
<form method="get" class="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 %}
|
||||||
@@ -3,11 +3,16 @@
|
|||||||
{% block title %}{% if credential %}SSH Key | {{ credential.name }}{% else %}New SSH Key{% endif %} | pobsync{% endblock %}
|
{% block title %}{% if credential %}SSH Key | {{ credential.name }}{% else %}New SSH Key{% endif %} | pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{% if credential %}SSH Key: {{ credential.name }}{% else %}New SSH Key{% endif %}</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="SSH key form actions">
|
<div class="page-kicker">Access</div>
|
||||||
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
|
<h1>{% if credential %}SSH Key: {{ credential.name }}{% else %}New SSH Key{% endif %}</h1>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>
|
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>
|
||||||
@@ -36,8 +41,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">{% if credential %}Save SSH key{% else %}Create SSH key{% endif %}</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -59,7 +65,10 @@
|
|||||||
<label for="confirm_name">Confirm key name</label>
|
<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 %}>
|
<input id="confirm_name" name="confirm_name" type="text" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="danger" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>Delete SSH key</button>
|
<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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
{% block title %}Generate SSH Key | pobsync{% endblock %}
|
{% block title %}Generate SSH Key | pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Generate SSH Key</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="SSH key form actions">
|
<div class="page-kicker">Access</div>
|
||||||
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
|
<h1>Generate SSH Key</h1>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<h2>Create Key Pair</h2>
|
<h2>Create Key Pair</h2>
|
||||||
@@ -24,8 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Generate SSH key</button>
|
<button type="submit">Generate SSH key</button>
|
||||||
|
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
{% block title %}SSH Keys | pobsync{% endblock %}
|
{% block title %}SSH Keys | pobsync{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>SSH Keys</h1>
|
<header class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
<section class="actions" aria-label="SSH key actions">
|
<div class="page-kicker">Access</div>
|
||||||
<a class="button-link" href="{% url 'generate_ssh_credential' %}">Generate SSH key</a>
|
<h1>SSH Keys</h1>
|
||||||
<a class="button-link secondary" href="{% url 'create_ssh_credential' %}">Add existing key</a>
|
<div class="page-subtitle">Manage the key pairs pobsync uses to reach backup targets.</div>
|
||||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
</div>
|
||||||
</section>
|
<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">
|
<section class="panel">
|
||||||
<h2>Credentials</h2>
|
<h2>Credentials</h2>
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ class BackupWorkerTests(TestCase):
|
|||||||
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
||||||
self.assertEqual(run.snapshot, SnapshotRecord.objects.get())
|
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_refreshes_heartbeat_while_run_is_active(self) -> None:
|
def test_worker_refreshes_heartbeat_while_run_is_active(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
@@ -116,6 +147,44 @@ class BackupWorkerTests(TestCase):
|
|||||||
run_scheduled.side_effect = fake_run_scheduled
|
run_scheduled.side_effect = fake_run_scheduled
|
||||||
Command()._run_once(prefix=Path(tmp) / "home")
|
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:
|
def test_worker_reconciles_stale_real_run_after_heartbeat_timeout(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
run = queue_backup_run(host=host)
|
run = queue_backup_run(host=host)
|
||||||
@@ -136,6 +205,97 @@ class BackupWorkerTests(TestCase):
|
|||||||
self.assertEqual(run.result["failure"]["category"], "worker")
|
self.assertEqual(run.result["failure"]["category"], "worker")
|
||||||
self.assertIn("heartbeat stopped", run.result["failure"]["message"])
|
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:
|
def test_worker_records_dry_run_log_path_while_running(self) -> None:
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ConfigureCommandsTests(TestCase):
|
|||||||
address="web-01.example.test",
|
address="web-01.example.test",
|
||||||
exclude_add=["/tmp/***"],
|
exclude_add=["/tmp/***"],
|
||||||
rsync_extra_arg=["--delete"],
|
rsync_extra_arg=["--delete"],
|
||||||
|
rsync_bwlimit_kbps=4096,
|
||||||
stdout=out,
|
stdout=out,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,10 +50,12 @@ class ConfigureCommandsTests(TestCase):
|
|||||||
self.assertEqual(host.retention_daily, 5)
|
self.assertEqual(host.retention_daily, 5)
|
||||||
self.assertEqual(host.excludes_add, ["/tmp/***"])
|
self.assertEqual(host.excludes_add, ["/tmp/***"])
|
||||||
self.assertEqual(host.rsync_extra_args, ["--delete"])
|
self.assertEqual(host.rsync_extra_args, ["--delete"])
|
||||||
|
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
|
||||||
|
|
||||||
effective = DjangoConfigSource().effective_config_for_host("web-01")
|
effective = DjangoConfigSource().effective_config_for_host("web-01")
|
||||||
self.assertEqual(effective["retention"]["yearly"], 2)
|
self.assertEqual(effective["retention"]["yearly"], 2)
|
||||||
self.assertEqual(effective["excludes_effective"], ["/tmp/***"])
|
self.assertEqual(effective["excludes_effective"], ["/tmp/***"])
|
||||||
|
self.assertEqual(effective["rsync"]["bwlimit_kbps"], 4096)
|
||||||
|
|
||||||
def test_configure_schedule_creates_sql_schedule(self) -> None:
|
def test_configure_schedule_creates_sql_schedule(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ConsoleEntrypointTests(SimpleTestCase):
|
|||||||
exit_code = main(["--version"])
|
exit_code = main(["--version"])
|
||||||
|
|
||||||
self.assertEqual(exit_code, 0)
|
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:
|
def test_maps_backup_alias_to_django_command(self) -> None:
|
||||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
backup_root="/backups",
|
backup_root="/backups",
|
||||||
rsync_args=["--archive"],
|
rsync_args=["--archive"],
|
||||||
rsync_extra_args=["--numeric-ids"],
|
rsync_extra_args=["--numeric-ids"],
|
||||||
|
rsync_bwlimit_kbps=10000,
|
||||||
excludes_default=["/proc/***"],
|
excludes_default=["/proc/***"],
|
||||||
retention_daily=7,
|
retention_daily=7,
|
||||||
retention_weekly=4,
|
retention_weekly=4,
|
||||||
@@ -28,6 +29,7 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
address="web-01.example.test",
|
address="web-01.example.test",
|
||||||
excludes_add=["/tmp/***"],
|
excludes_add=["/tmp/***"],
|
||||||
rsync_extra_args=["--delete"],
|
rsync_extra_args=["--delete"],
|
||||||
|
rsync_bwlimit_kbps=2500,
|
||||||
retention_daily=7,
|
retention_daily=7,
|
||||||
retention_weekly=4,
|
retention_weekly=4,
|
||||||
retention_monthly=3,
|
retention_monthly=3,
|
||||||
@@ -46,6 +48,24 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
self.assertEqual(cfg["address"], "web-01.example.test")
|
self.assertEqual(cfg["address"], "web-01.example.test")
|
||||||
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
|
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
|
||||||
self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"])
|
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:
|
def test_materializes_global_ssh_credential_for_runtime_config(self) -> None:
|
||||||
credential = SshCredential.objects.create(
|
credential = SshCredential.objects.create(
|
||||||
@@ -113,7 +133,8 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
)
|
)
|
||||||
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
cfg = DjangoConfigSource().effective_config_for_host("web-01")
|
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"])
|
self.assertIn(f"-oIdentityFile={identity_file}", cfg["ssh"]["options"])
|
||||||
self.assertEqual(cfg["ssh_credential"]["storage"], "filesystem")
|
self.assertEqual(cfg["ssh_credential"]["storage"], "filesystem")
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ from pobsync.rsync import RsyncResult
|
|||||||
|
|
||||||
|
|
||||||
class FakeConfigSource:
|
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.backup_root = backup_root
|
||||||
|
self.bwlimit_kbps = bwlimit_kbps
|
||||||
|
|
||||||
def effective_config_for_host(self, host: str) -> dict:
|
def effective_config_for_host(self, host: str) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -25,7 +26,7 @@ class FakeConfigSource:
|
|||||||
"binary": "rsync",
|
"binary": "rsync",
|
||||||
"args_effective": ["--archive"],
|
"args_effective": ["--archive"],
|
||||||
"timeout_seconds": 0,
|
"timeout_seconds": 0,
|
||||||
"bwlimit_kbps": 0,
|
"bwlimit_kbps": self.bwlimit_kbps,
|
||||||
},
|
},
|
||||||
"source_root": "/",
|
"source_root": "/",
|
||||||
"includes": [],
|
"includes": [],
|
||||||
@@ -54,6 +55,21 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
|
|||||||
self.assertEqual(result["host"], "web-01")
|
self.assertEqual(result["host"], "web-01")
|
||||||
run_rsync.assert_called_once()
|
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 test_failed_dry_run_includes_log_tail(self) -> None:
|
||||||
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -186,11 +202,13 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
|
|||||||
host="web-01",
|
host="web-01",
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
verbose_output=True,
|
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]
|
command = run_rsync.call_args.args[0]
|
||||||
self.assertTrue(result["ok"])
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertIn("--bwlimit=2048", command)
|
||||||
|
self.assertEqual(result["rsync"]["bwlimit_kbps"], 2048)
|
||||||
self.assertIn("--stats", command)
|
self.assertIn("--stats", command)
|
||||||
self.assertIn("--itemize-changes", command)
|
self.assertIn("--itemize-changes", command)
|
||||||
self.assertIn("--info=flist2,progress2,stats2", command)
|
self.assertIn("--info=flist2,progress2,stats2", command)
|
||||||
@@ -256,6 +274,71 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
|
|||||||
self.assertIn("stats:", meta_text)
|
self.assertIn("stats:", meta_text)
|
||||||
self.assertIn("files_total: 10", 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 test_dry_run_reports_cancelled_rsync(self) -> None:
|
||||||
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
|
||||||
self.assertTrue(cancel_check())
|
self.assertTrue(cancel_check())
|
||||||
|
|||||||
@@ -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:
|
def test_apply_respects_max_delete(self) -> None:
|
||||||
host = HostConfig.objects.create(
|
host = HostConfig.objects.create(
|
||||||
host="web-01",
|
host="web-01",
|
||||||
@@ -192,6 +254,8 @@ class SqlRetentionTests(TestCase):
|
|||||||
path=str(incomplete_dir),
|
path=str(incomplete_dir),
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
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(
|
result = run_incomplete_cleanup(
|
||||||
@@ -213,6 +277,58 @@ class SqlRetentionTests(TestCase):
|
|||||||
self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
|
self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
|
||||||
self.assertEqual(purged.reason, "manual 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:
|
def test_incomplete_cleanup_respects_max_delete(self) -> None:
|
||||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
SnapshotRecord.objects.create(
|
SnapshotRecord.objects.create(
|
||||||
@@ -222,6 +338,8 @@ class SqlRetentionTests(TestCase):
|
|||||||
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
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"):
|
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",
|
path=f"/backups/{host.host}/scheduled/20260519-031500Z__BROKEN01",
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
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"):
|
with self.assertRaisesRegex(ConfigError, "unexpected incomplete snapshot path"):
|
||||||
|
|||||||
103
src/pobsync_backend/tests/test_stats_summary.py
Normal file
103
src/pobsync_backend/tests/test_stats_summary.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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)
|
||||||
|
self._snapshot(db, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400)
|
||||||
|
|
||||||
|
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"], 400)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000)
|
||||||
|
|
||||||
|
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)
|
||||||
|
self._snapshot(host, "20260519-051500Z__BROKEN1", SnapshotRecord.Kind.INCOMPLETE, allocated=400)
|
||||||
|
|
||||||
|
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"], 400)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["count"], 4)
|
||||||
|
self.assertEqual(stats["backup_data"]["total"]["allocated_size_bytes"], 1000)
|
||||||
|
|
||||||
|
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 _snapshot(self, host: HostConfig, dirname: str, kind: str, *, allocated: int) -> 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=kind,
|
||||||
|
dirname=dirname,
|
||||||
|
path=f"/backups/{host.host}/{kind}/{dirname}",
|
||||||
|
status="success",
|
||||||
|
started_at=started_at,
|
||||||
|
metadata={
|
||||||
|
"stats": {
|
||||||
|
"storage": {
|
||||||
|
"snapshot": {
|
||||||
|
"apparent_size_bytes": allocated * 2,
|
||||||
|
"allocated_size_bytes": allocated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -8,9 +8,11 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.template.defaultfilters import filesizeformat
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from pobsync.run_stats import tree_usage
|
||||||
from pobsync.util import write_yaml_atomic
|
from pobsync.util import write_yaml_atomic
|
||||||
from pobsync_backend.models import (
|
from pobsync_backend.models import (
|
||||||
BackupRun,
|
BackupRun,
|
||||||
@@ -39,6 +41,33 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn("/admin/login/", response["Location"])
|
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("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:
|
def test_changelog_requires_staff_login(self) -> None:
|
||||||
response = self.client.get(reverse("changelog"))
|
response = self.client.get(reverse("changelog"))
|
||||||
|
|
||||||
@@ -59,6 +88,8 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Installed version:")
|
self.assertContains(response, "Installed version:")
|
||||||
|
self.assertContains(response, "Changelog file:")
|
||||||
|
self.assertNotContains(response, "Source:")
|
||||||
self.assertContains(response, "1.0.0 - 2026-05-21")
|
self.assertContains(response, "1.0.0 - 2026-05-21")
|
||||||
self.assertContains(response, "Django control panel")
|
self.assertContains(response, "Django control panel")
|
||||||
self.assertContains(response, "Native systemd installer")
|
self.assertContains(response, "Native systemd installer")
|
||||||
@@ -99,6 +130,15 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("dashboard"))
|
response = self.client.get(reverse("dashboard"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Control panel")
|
||||||
|
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
|
||||||
|
self.assertContains(response, "dashboard-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, "Dashboard")
|
||||||
self.assertContains(response, "web-01")
|
self.assertContains(response, "web-01")
|
||||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||||
@@ -116,11 +156,189 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "running 1")
|
self.assertContains(response, "running 1")
|
||||||
self.assertContains(response, "warning 1")
|
self.assertContains(response, "warning 1")
|
||||||
self.assertContains(response, "failed 1")
|
self.assertContains(response, "failed 1")
|
||||||
self.assertContains(response, "Operational Status")
|
self.assertContains(response, "Required Action")
|
||||||
self.assertContains(response, "1 failed run needs review.")
|
self.assertContains(response, "Failed runs")
|
||||||
self.assertContains(response, "1 run completed with warnings.")
|
self.assertContains(response, "1 failed run(s) need review.")
|
||||||
|
self.assertContains(response, "1 run(s) completed with warnings.")
|
||||||
self.assertContains(response, "1 backup run in progress.")
|
self.assertContains(response, "1 backup run in progress.")
|
||||||
self.assertContains(response, "1 backup run waiting for the worker.")
|
self.assertContains(response, "1 backup run waiting.")
|
||||||
|
self.assertContains(response, "Next Scheduled Work")
|
||||||
|
self.assertContains(response, "Recent Activity")
|
||||||
|
self.assertContains(response, f'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&review=needed"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("runs_list")}?status=failed&review=needed"', html=False)
|
||||||
|
self.assertContains(
|
||||||
|
response,
|
||||||
|
f'href="{reverse("runs_list")}?host=web-01&status=failed&review=needed"',
|
||||||
|
html=False,
|
||||||
|
)
|
||||||
|
self.assertContains(response, f'href="{reverse("snapshots_list")}"', html=False)
|
||||||
|
self.assertContains(response, f'href="{reverse("schedules_list")}"', html=False)
|
||||||
|
|
||||||
|
def test_dashboard_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)
|
||||||
|
incomplete = self._snapshot(db, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
|
self._set_snapshot_storage(scheduled, allocated=100)
|
||||||
|
self._set_snapshot_storage(manual, allocated=200)
|
||||||
|
self._set_snapshot_storage(incomplete, allocated=300)
|
||||||
|
|
||||||
|
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 bytes", html=True)
|
||||||
|
self.assertContains(response, "200 bytes", html=True)
|
||||||
|
self.assertContains(response, "300 bytes", html=True)
|
||||||
|
self.assertContains(response, "600 bytes", html=True)
|
||||||
|
|
||||||
|
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)
|
||||||
|
incomplete = self._snapshot(host, "20260519-041500Z__BROKEN1", kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||||
|
self._set_snapshot_storage(scheduled, allocated=100)
|
||||||
|
self._set_snapshot_storage(manual, allocated=200)
|
||||||
|
self._set_snapshot_storage(incomplete, allocated=300)
|
||||||
|
|
||||||
|
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 bytes", html=True)
|
||||||
|
self.assertContains(response, "200 bytes", html=True)
|
||||||
|
self.assertContains(response, "300 bytes", html=True)
|
||||||
|
self.assertContains(response, "600 bytes", html=True)
|
||||||
|
|
||||||
|
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:
|
def test_dashboard_renders_backup_trend_summary(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -158,14 +376,14 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Backup Trends")
|
self.assertContains(response, "Backup Trends")
|
||||||
self.assertContains(response, "Storage Used")
|
self.assertContains(response, "Storage Pressure")
|
||||||
|
self.assertContains(response, "Backup root used")
|
||||||
self.assertContains(response, "Runway")
|
self.assertContains(response, "Runway")
|
||||||
self.assertContains(response, "New Data")
|
self.assertContains(response, "New Data")
|
||||||
self.assertContains(response, "Link-Dest Savings")
|
self.assertContains(response, "Link-Dest Savings")
|
||||||
self.assertContains(response, "80.0%")
|
self.assertContains(response, "80.0%")
|
||||||
self.assertContains(response, "10 days")
|
self.assertContains(response, "10 days")
|
||||||
self.assertContains(response, "Warnings")
|
self.assertContains(response, "Warnings")
|
||||||
self.assertContains(response, "Queued")
|
|
||||||
self.assertContains(response, "Next Run")
|
self.assertContains(response, "Next Run")
|
||||||
self.assertContains(response, "UTC")
|
self.assertContains(response, "UTC")
|
||||||
self.assertContains(response, "10")
|
self.assertContains(response, "10")
|
||||||
@@ -193,8 +411,99 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("dashboard"))
|
response = self.client.get(reverse("dashboard"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Operational Status")
|
self.assertContains(response, "Required Action")
|
||||||
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||||
|
|
||||||
|
def test_runs_list_filters_by_status_and_review(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||||
|
failed = BackupRun.objects.create(host=web, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL)
|
||||||
|
success = BackupRun.objects.create(host=db, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED)
|
||||||
|
BackupRun.objects.create(
|
||||||
|
host=web,
|
||||||
|
status=BackupRun.Status.WARNING,
|
||||||
|
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||||
|
reviewed_by="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("runs_list"), {"status": "failed", "review": "needed"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Runs")
|
||||||
|
self.assertContains(response, "Review queued, running, completed")
|
||||||
|
self.assertContains(response, "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&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:
|
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -245,7 +554,7 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("dashboard"))
|
response = self.client.get(reverse("dashboard"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
|
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||||
self.assertNotContains(response, "failed 1")
|
self.assertNotContains(response, "failed 1")
|
||||||
self.assertNotContains(response, "warning 1")
|
self.assertNotContains(response, "warning 1")
|
||||||
|
|
||||||
@@ -291,6 +600,7 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Self Check")
|
self.assertContains(response, "Self Check")
|
||||||
|
self.assertContains(response, "Runtime, filesystem, service, and configuration checks")
|
||||||
self.assertContains(response, "Django debug")
|
self.assertContains(response, "Django debug")
|
||||||
self.assertContains(response, "Database connection")
|
self.assertContains(response, "Database connection")
|
||||||
self.assertContains(response, "State root")
|
self.assertContains(response, "State root")
|
||||||
@@ -325,6 +635,10 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Logs")
|
self.assertContains(response, "Logs")
|
||||||
|
self.assertContains(response, "Filter pobsync service logs")
|
||||||
|
self.assertContains(response, "Filter logs")
|
||||||
|
self.assertContains(response, reverse("logs"))
|
||||||
|
self.assertContains(response, "Clear")
|
||||||
self.assertContains(response, "web-01 failed backup run 12")
|
self.assertContains(response, "web-01 failed backup run 12")
|
||||||
self.assertNotContains(response, "web-02 failed backup run 12")
|
self.assertNotContains(response, "web-02 failed backup run 12")
|
||||||
self.assertNotContains(response, "started")
|
self.assertNotContains(response, "started")
|
||||||
@@ -354,6 +668,10 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Purged Snapshots")
|
self.assertContains(response, "Purged Snapshots")
|
||||||
|
self.assertContains(response, "Audit trail for snapshots removed")
|
||||||
|
self.assertContains(response, "Apply filters")
|
||||||
|
self.assertContains(response, reverse("purged_snapshots"))
|
||||||
|
self.assertContains(response, "Clear")
|
||||||
self.assertContains(response, "20260518-021500Z__OLDSNAP")
|
self.assertContains(response, "20260518-021500Z__OLDSNAP")
|
||||||
self.assertContains(response, "outside retention policy")
|
self.assertContains(response, "outside retention policy")
|
||||||
self.assertContains(response, "Scheduled")
|
self.assertContains(response, "Scheduled")
|
||||||
@@ -408,6 +726,7 @@ class ViewTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("ssh_credentials"))
|
self.assertRedirects(response, reverse("ssh_credentials"))
|
||||||
|
self.assertContains(response, "Manage the key pairs pobsync uses")
|
||||||
self.assertContains(response, "SSH credential saved for backup-key.")
|
self.assertContains(response, "SSH credential saved for backup-key.")
|
||||||
self.assertContains(response, "backup-key")
|
self.assertContains(response, "backup-key")
|
||||||
credential = SshCredential.objects.get(name="backup-key")
|
credential = SshCredential.objects.get(name="backup-key")
|
||||||
@@ -437,6 +756,21 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
|
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
|
||||||
self.assertEqual(credential.public_key, "DERIVED PUBLIC KEY")
|
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:
|
def test_ssh_credentials_view_generates_filesystem_key(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||||
@@ -637,9 +971,12 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("edit_global_config"))
|
response = self.client.get(reverse("edit_global_config"))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Defaults used by hosts unless a host overrides them")
|
||||||
self.assertContains(response, f'value="{credential.id}" selected')
|
self.assertContains(response, f'value="{credential.id}" selected')
|
||||||
self.assertContains(response, "--archive")
|
self.assertContains(response, "--archive")
|
||||||
self.assertContains(response, "/proc/***")
|
self.assertContains(response, "/proc/***")
|
||||||
|
self.assertContains(response, "Cancel")
|
||||||
|
self.assertContains(response, reverse("dashboard"))
|
||||||
|
|
||||||
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
|
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -722,6 +1059,7 @@ class ViewTests(TestCase):
|
|||||||
"excludes_add": "*.tmp",
|
"excludes_add": "*.tmp",
|
||||||
"excludes_replace": "",
|
"excludes_replace": "",
|
||||||
"rsync_extra_args": "--numeric-ids",
|
"rsync_extra_args": "--numeric-ids",
|
||||||
|
"rsync_bwlimit_kbps": "4096",
|
||||||
"retention_daily": "7",
|
"retention_daily": "7",
|
||||||
"retention_weekly": "4",
|
"retention_weekly": "4",
|
||||||
"retention_monthly": "2",
|
"retention_monthly": "2",
|
||||||
@@ -739,6 +1077,7 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
|
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
|
||||||
self.assertEqual(host.excludes_add, ["*.tmp"])
|
self.assertEqual(host.excludes_add, ["*.tmp"])
|
||||||
self.assertEqual(host.rsync_extra_args, ["--numeric-ids"])
|
self.assertEqual(host.rsync_extra_args, ["--numeric-ids"])
|
||||||
|
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
|
||||||
self.assertEqual(host.retention_weekly, 4)
|
self.assertEqual(host.retention_weekly, 4)
|
||||||
|
|
||||||
def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None:
|
def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None:
|
||||||
@@ -807,6 +1146,7 @@ class ViewTests(TestCase):
|
|||||||
(backup_root / host.host / subdir).mkdir(parents=True)
|
(backup_root / host.host / subdir).mkdir(parents=True)
|
||||||
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", prune=True, last_status="success")
|
ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", prune=True, last_status="success")
|
||||||
snapshot = self._snapshot(host, "20260519-021500Z__ABCDEFGH")
|
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)
|
BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
|
||||||
|
|
||||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||||
@@ -817,7 +1157,7 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "15 2 * * *")
|
self.assertContains(response, "15 2 * * *")
|
||||||
self.assertContains(response, "Schedule expression")
|
self.assertContains(response, "Schedule expression")
|
||||||
self.assertContains(response, "Evaluated by the pobsync scheduler service.")
|
self.assertContains(response, "Evaluated by the pobsync scheduler service.")
|
||||||
self.assertContains(response, "Next run:")
|
self.assertContains(response, "Next run")
|
||||||
self.assertContains(response, "UTC")
|
self.assertContains(response, "UTC")
|
||||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||||
self.assertContains(response, "Discover snapshots")
|
self.assertContains(response, "Discover snapshots")
|
||||||
@@ -830,10 +1170,14 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Host Check")
|
self.assertContains(response, "Host Check")
|
||||||
self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
|
self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
|
||||||
self.assertContains(response, "warning")
|
self.assertContains(response, "warning")
|
||||||
self.assertContains(response, "Snapshot Discovery")
|
self.assertContains(response, "Snapshot Storage")
|
||||||
|
self.assertContains(response, "Backup Data")
|
||||||
|
self.assertContains(response, "100 bytes", html=True)
|
||||||
self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
|
self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
|
||||||
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
|
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
|
||||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||||
|
self.assertContains(response, f'{reverse("runs_list")}?host={host.host}', html=False)
|
||||||
|
self.assertContains(response, f'{reverse("snapshots_list")}?host={host.host}', html=False)
|
||||||
|
|
||||||
def test_host_detail_renders_effective_config_preview(self) -> None:
|
def test_host_detail_renders_effective_config_preview(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -867,11 +1211,16 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Host")
|
||||||
|
self.assertContains(response, "web-01.example.test")
|
||||||
self.assertContains(response, "Effective Config")
|
self.assertContains(response, "Effective Config")
|
||||||
|
self.assertContains(response, "Backup source:")
|
||||||
|
self.assertNotContains(response, "Source root:")
|
||||||
self.assertContains(response, "root@web-01.example.test:2222")
|
self.assertContains(response, "root@web-01.example.test:2222")
|
||||||
self.assertContains(response, "default-key")
|
self.assertContains(response, "default-key")
|
||||||
self.assertContains(response, "-oBatchMode=yes")
|
self.assertContains(response, "-oBatchMode=yes")
|
||||||
self.assertContains(response, "--archive --numeric-ids --delete --one-file-system")
|
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/***")
|
||||||
self.assertContains(response, "/srv/www/cache/***")
|
self.assertContains(response, "/srv/www/cache/***")
|
||||||
self.assertContains(response, "d14")
|
self.assertContains(response, "d14")
|
||||||
@@ -895,6 +1244,10 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Remote rsync")
|
self.assertContains(response, "Remote rsync")
|
||||||
self.assertContains(response, "Remote source root")
|
self.assertContains(response, "Remote source root")
|
||||||
self.assertEqual(run.call_count, 3)
|
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()
|
host.refresh_from_db()
|
||||||
self.assertTrue(host.config["last_preflight"]["ok"])
|
self.assertTrue(host.config["last_preflight"]["ok"])
|
||||||
self.assertEqual(host.config["last_preflight"]["target"], "root@web-01.example.test")
|
self.assertEqual(host.config["last_preflight"]["target"], "root@web-01.example.test")
|
||||||
@@ -1111,7 +1464,8 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, f"Host root:</strong> {backup_root / host.host}")
|
self.assertContains(response, "Host root")
|
||||||
|
self.assertContains(response, str(backup_root / host.host))
|
||||||
self.assertContains(response, "Found 2 snapshot directories")
|
self.assertContains(response, "Found 2 snapshot directories")
|
||||||
self.assertContains(response, "scheduled 1")
|
self.assertContains(response, "scheduled 1")
|
||||||
self.assertContains(response, "incomplete 1")
|
self.assertContains(response, "incomplete 1")
|
||||||
@@ -1263,9 +1617,10 @@ class ViewTests(TestCase):
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"snapshot": snapshot.path,
|
"snapshot": snapshot.path,
|
||||||
"rsync": {
|
"rsync": {
|
||||||
"command": ["rsync", "--archive", "root@web-01:/", snapshot.path],
|
"command": ["rsync", "--archive", "--bwlimit=2048", "root@web-01:/", snapshot.path],
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
"log_tail": ["sending incremental file list", "sent 500 bytes"],
|
"log_tail": ["sending incremental file list", "sent 500 bytes"],
|
||||||
|
"bwlimit_kbps": 2048,
|
||||||
},
|
},
|
||||||
"requested": {
|
"requested": {
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
@@ -1300,10 +1655,13 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Dry run:</strong> yes")
|
self.assertContains(response, "Dry run:</strong> yes")
|
||||||
self.assertContains(response, "Verbose rsync output:</strong> yes")
|
self.assertContains(response, "Verbose rsync output:</strong> yes")
|
||||||
self.assertContains(response, "Rsync Command")
|
self.assertContains(response, "Rsync Command")
|
||||||
|
self.assertContains(response, "Bandwidth limit:</strong>")
|
||||||
|
self.assertContains(response, "2048 KB/s")
|
||||||
self.assertContains(response, "--archive")
|
self.assertContains(response, "--archive")
|
||||||
self.assertContains(response, "Rsync Log")
|
self.assertContains(response, "Rsync Log")
|
||||||
self.assertContains(response, "sending incremental file list")
|
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, "Files Seen")
|
||||||
self.assertContains(response, "Would Transfer")
|
self.assertContains(response, "Would Transfer")
|
||||||
self.assertContains(response, "Transfer Estimate")
|
self.assertContains(response, "Transfer Estimate")
|
||||||
@@ -1353,7 +1711,8 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Dry Run Summary")
|
self.assertContains(response, "Run Progress")
|
||||||
|
self.assertContains(response, "dry run")
|
||||||
self.assertContains(response, "failed")
|
self.assertContains(response, "failed")
|
||||||
self.assertContains(response, "Files Seen")
|
self.assertContains(response, "Files Seen")
|
||||||
self.assertContains(response, "25")
|
self.assertContains(response, "25")
|
||||||
@@ -1421,11 +1780,14 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Backup run")
|
||||||
|
self.assertContains(response, "web-01")
|
||||||
self.assertContains(response, "Failure")
|
self.assertContains(response, "Failure")
|
||||||
self.assertContains(response, "transport")
|
self.assertContains(response, "transport")
|
||||||
self.assertContains(response, "Check network connectivity.")
|
self.assertContains(response, "Check network connectivity.")
|
||||||
self.assertContains(response, "Retention")
|
self.assertContains(response, "Retention")
|
||||||
self.assertContains(response, "Planned deletions")
|
self.assertContains(response, "Planned deletions")
|
||||||
|
self.assertNotContains(response, "Source:</strong> sql")
|
||||||
self.assertContains(response, "Max delete")
|
self.assertContains(response, "Max delete")
|
||||||
self.assertContains(response, "Protect bases")
|
self.assertContains(response, "Protect bases")
|
||||||
self.assertContains(response, "Incomplete ignored")
|
self.assertContains(response, "Incomplete ignored")
|
||||||
@@ -1519,8 +1881,92 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Run Control")
|
||||||
|
self.assertContains(response, "Cancelling a queued run stops it immediately")
|
||||||
self.assertContains(response, "Cancel run")
|
self.assertContains(response, "Cancel run")
|
||||||
self.assertContains(response, reverse("cancel_run", args=[run.id]))
|
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:
|
def test_run_detail_renders_worker_execution_metadata(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -1610,12 +2056,17 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("snapshot_detail", args=[base.id]))
|
response = self.client.get(reverse("snapshot_detail", args=[base.id]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Snapshot")
|
||||||
self.assertContains(response, base.dirname)
|
self.assertContains(response, base.dirname)
|
||||||
self.assertContains(response, "BASESNAP")
|
self.assertContains(response, "BASESNAP")
|
||||||
self.assertContains(response, "Stats")
|
self.assertContains(response, "Stats")
|
||||||
self.assertContains(response, "Files seen:</strong> 100")
|
self.assertContains(response, "Files seen:</strong> 100")
|
||||||
self.assertContains(response, "Hardlinked files:</strong> 9")
|
self.assertContains(response, "Hardlinked files:</strong> 9")
|
||||||
self.assertContains(response, "Restore Guidance")
|
self.assertContains(response, "Restore Guidance")
|
||||||
|
self.assertContains(response, "Snapshot data path:")
|
||||||
|
self.assertNotContains(response, "Snapshot data source:")
|
||||||
|
self.assertContains(response, "Dry-run restore back to the original host:")
|
||||||
|
self.assertNotContains(response, "Dry-run restore back to the source host:")
|
||||||
self.assertContains(response, f"{base.path}/data")
|
self.assertContains(response, f"{base.path}/data")
|
||||||
self.assertContains(response, f"/restore/{host.host}")
|
self.assertContains(response, f"/restore/{host.host}")
|
||||||
self.assertContains(response, "rsync -aHAX --numeric-ids --info=progress2 --dry-run")
|
self.assertContains(response, "rsync -aHAX --numeric-ids --info=progress2 --dry-run")
|
||||||
@@ -1688,14 +2139,20 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
|
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Retention Plan: web-01")
|
self.assertContains(response, "Retention")
|
||||||
|
self.assertContains(response, "Preview which snapshots stay")
|
||||||
|
self.assertContains(response, "web-01")
|
||||||
self.assertContains(response, old_snapshot.dirname)
|
self.assertContains(response, old_snapshot.dirname)
|
||||||
self.assertContains(response, new_snapshot.dirname)
|
self.assertContains(response, new_snapshot.dirname)
|
||||||
self.assertContains(response, "newest")
|
self.assertContains(response, "newest")
|
||||||
self.assertContains(response, "Would Delete")
|
self.assertContains(response, "Would Delete")
|
||||||
self.assertContains(response, "outside retention policy")
|
self.assertContains(response, "outside retention policy")
|
||||||
|
self.assertNotContains(response, "<div class=\"label\">Source</div>", html=True)
|
||||||
self.assertContains(response, "Confirm delete count")
|
self.assertContains(response, "Confirm delete count")
|
||||||
self.assertContains(response, "Type 1 to confirm the current number of planned deletions.")
|
self.assertContains(response, "Type 1 to confirm the current number of planned deletions.")
|
||||||
|
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:
|
def test_retention_plan_warns_when_scheduled_prune_limit_is_exceeded(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -1769,8 +2226,34 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Incomplete Snapshots")
|
self.assertContains(response, "Incomplete Snapshots")
|
||||||
self.assertContains(response, "20260519-031500Z__BROKEN01")
|
self.assertContains(response, "20260519-031500Z__BROKEN01")
|
||||||
self.assertContains(response, "excluded from retention cleanup")
|
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, "Delete incomplete snapshots")
|
||||||
self.assertContains(response, "Type 1 to confirm the current number of 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:
|
def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -1787,6 +2270,8 @@ class ViewTests(TestCase):
|
|||||||
path=str(incomplete_dir),
|
path=str(incomplete_dir),
|
||||||
status="failed",
|
status="failed",
|
||||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
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)):
|
with override_settings(POBSYNC_HOME=str(home)):
|
||||||
@@ -1832,6 +2317,33 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "Incomplete cleanup confirmation is invalid.")
|
self.assertContains(response, "Incomplete cleanup confirmation is invalid.")
|
||||||
self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
|
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:
|
def test_host_detail_surfaces_retention_warnings(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(
|
host = HostConfig.objects.create(
|
||||||
@@ -2012,11 +2524,14 @@ class ViewTests(TestCase):
|
|||||||
response = self.client.get(reverse("edit_host_schedule", args=[host.host]))
|
response = self.client.get(reverse("edit_host_schedule", args=[host.host]))
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Automatic backup timing and scheduled prune behavior")
|
||||||
self.assertContains(response, "Create Schedule")
|
self.assertContains(response, "Create Schedule")
|
||||||
self.assertContains(response, "Schedule expression")
|
self.assertContains(response, "Schedule expression")
|
||||||
self.assertContains(response, "evaluated by the pobsync scheduler service")
|
self.assertContains(response, "evaluated by the pobsync scheduler service")
|
||||||
self.assertContains(response, "15 2 * * *")
|
self.assertContains(response, "15 2 * * *")
|
||||||
self.assertContains(response, "Save schedule")
|
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:
|
def test_schedule_form_creates_schedule(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -2118,6 +2633,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "/srv")
|
self.assertContains(response, "/srv")
|
||||||
self.assertContains(response, "*.tmp")
|
self.assertContains(response, "*.tmp")
|
||||||
self.assertContains(response, "--numeric-ids")
|
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:
|
def test_host_config_form_renders_effective_config_check(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -2147,6 +2664,7 @@ class ViewTests(TestCase):
|
|||||||
"excludes_add": "*.tmp\ncache/",
|
"excludes_add": "*.tmp\ncache/",
|
||||||
"excludes_replace": "",
|
"excludes_replace": "",
|
||||||
"rsync_extra_args": "--numeric-ids\n--delete",
|
"rsync_extra_args": "--numeric-ids\n--delete",
|
||||||
|
"rsync_bwlimit_kbps": "8192",
|
||||||
"retention_daily": "7",
|
"retention_daily": "7",
|
||||||
"retention_weekly": "4",
|
"retention_weekly": "4",
|
||||||
"retention_monthly": "2",
|
"retention_monthly": "2",
|
||||||
@@ -2166,6 +2684,7 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(host.excludes_add, ["*.tmp", "cache/"])
|
self.assertEqual(host.excludes_add, ["*.tmp", "cache/"])
|
||||||
self.assertIsNone(host.excludes_replace)
|
self.assertIsNone(host.excludes_replace)
|
||||||
self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"])
|
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_daily, 7)
|
||||||
self.assertEqual(host.retention_yearly, 1)
|
self.assertEqual(host.retention_yearly, 1)
|
||||||
|
|
||||||
@@ -2198,13 +2717,32 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(host.excludes_add, [])
|
self.assertEqual(host.excludes_add, [])
|
||||||
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
|
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
|
||||||
|
|
||||||
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
|
def _snapshot(
|
||||||
|
self,
|
||||||
|
host: HostConfig,
|
||||||
|
dirname: str,
|
||||||
|
*,
|
||||||
|
kind: str = SnapshotRecord.Kind.SCHEDULED,
|
||||||
|
) -> SnapshotRecord:
|
||||||
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
return SnapshotRecord.objects.create(
|
return SnapshotRecord.objects.create(
|
||||||
host=host,
|
host=host,
|
||||||
kind=SnapshotRecord.Kind.SCHEDULED,
|
kind=kind,
|
||||||
dirname=dirname,
|
dirname=dirname,
|
||||||
path=f"/backups/{host.host}/scheduled/{dirname}",
|
path=f"/backups/{host.host}/{kind}/{dirname}",
|
||||||
status="success",
|
status="success",
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from datetime import datetime, timezone as datetime_timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
@@ -12,7 +15,9 @@ from django.conf import settings
|
|||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from pobsync import __version__
|
from pobsync import __version__
|
||||||
@@ -45,7 +50,20 @@ from .stats_summary import collect_dashboard_stats, collect_host_stats
|
|||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def dashboard(request):
|
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(
|
hosts = list(
|
||||||
HostConfig.objects.select_related("schedule")
|
HostConfig.objects.select_related("schedule")
|
||||||
.annotate(
|
.annotate(
|
||||||
@@ -66,6 +84,11 @@ def dashboard(request):
|
|||||||
)
|
)
|
||||||
.order_by("host")
|
.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:
|
for host_config in hosts:
|
||||||
host_config.latest_snapshot = (
|
host_config.latest_snapshot = (
|
||||||
host_config.snapshots.select_related("base")
|
host_config.snapshots.select_related("base")
|
||||||
@@ -74,13 +97,34 @@ def dashboard(request):
|
|||||||
)
|
)
|
||||||
host_config.next_run_at = _next_run_for_host(host_config)
|
host_config.next_run_at = _next_run_for_host(host_config)
|
||||||
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
|
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
|
||||||
|
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)
|
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
|
||||||
context = {
|
context = {
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
"global_config": global_config,
|
"global_config": global_config,
|
||||||
"stats_summary": stats_summary,
|
"stats_summary": stats_summary,
|
||||||
"scheduler_timezone": timezone.get_current_timezone_name(),
|
"scheduler_timezone": host_context["scheduler_timezone"],
|
||||||
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
"action_items": action_items,
|
||||||
|
"next_schedule_rows": next_schedule_rows,
|
||||||
|
"recent_runs": recent_runs,
|
||||||
"counts": {
|
"counts": {
|
||||||
"global_configs": GlobalConfig.objects.count(),
|
"global_configs": GlobalConfig.objects.count(),
|
||||||
"hosts": HostConfig.objects.count(),
|
"hosts": HostConfig.objects.count(),
|
||||||
@@ -101,7 +145,138 @@ def dashboard(request):
|
|||||||
).count(),
|
).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
|
@staff_member_required
|
||||||
@@ -145,6 +320,102 @@ def logs(request):
|
|||||||
return render(request, "pobsync_backend/logs.html", context)
|
return render(request, "pobsync_backend/logs.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def runs_list(request):
|
||||||
|
status = request.GET.get("status", "").strip()
|
||||||
|
run_type = request.GET.get("type", "").strip()
|
||||||
|
host = request.GET.get("host", "").strip()
|
||||||
|
review = request.GET.get("review", "").strip()
|
||||||
|
runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")
|
||||||
|
if status:
|
||||||
|
runs = runs.filter(status=status)
|
||||||
|
if run_type:
|
||||||
|
runs = runs.filter(run_type=run_type)
|
||||||
|
if host:
|
||||||
|
runs = runs.filter(host__host=host)
|
||||||
|
if review == "needed":
|
||||||
|
runs = runs.filter(status__in=[BackupRun.Status.FAILED, BackupRun.Status.WARNING], reviewed_at__isnull=True)
|
||||||
|
elif review == "reviewed":
|
||||||
|
runs = runs.filter(reviewed_at__isnull=False)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"runs": runs[:200],
|
||||||
|
"total_count": runs.count(),
|
||||||
|
"hosts": HostConfig.objects.order_by("host"),
|
||||||
|
"statuses": BackupRun.Status.choices,
|
||||||
|
"run_types": BackupRun.RunType.choices,
|
||||||
|
"selected_status": status,
|
||||||
|
"selected_type": run_type,
|
||||||
|
"selected_host": host,
|
||||||
|
"selected_review": review,
|
||||||
|
}
|
||||||
|
return render(request, "pobsync_backend/runs_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def snapshots_list(request):
|
||||||
|
kind = request.GET.get("kind", "").strip()
|
||||||
|
status = request.GET.get("status", "").strip()
|
||||||
|
host = request.GET.get("host", "").strip()
|
||||||
|
snapshots = SnapshotRecord.objects.select_related("host", "base").order_by("-started_at", "-discovered_at", "-id")
|
||||||
|
if kind:
|
||||||
|
snapshots = snapshots.filter(kind=kind)
|
||||||
|
if status:
|
||||||
|
snapshots = snapshots.filter(status=status)
|
||||||
|
if host:
|
||||||
|
snapshots = snapshots.filter(host__host=host)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"snapshots": snapshots[:200],
|
||||||
|
"total_count": snapshots.count(),
|
||||||
|
"hosts": HostConfig.objects.order_by("host"),
|
||||||
|
"kinds": SnapshotRecord.Kind.choices,
|
||||||
|
"statuses": SnapshotRecord.objects.exclude(status="").order_by("status").values_list("status", flat=True).distinct(),
|
||||||
|
"selected_kind": kind,
|
||||||
|
"selected_status": status,
|
||||||
|
"selected_host": host,
|
||||||
|
}
|
||||||
|
return render(request, "pobsync_backend/snapshots_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def schedules_list(request):
|
||||||
|
enabled = request.GET.get("enabled", "").strip()
|
||||||
|
prune = request.GET.get("prune", "").strip()
|
||||||
|
host = request.GET.get("host", "").strip()
|
||||||
|
schedules = ScheduleConfig.objects.select_related("host").order_by("host__host")
|
||||||
|
if enabled == "yes":
|
||||||
|
schedules = schedules.filter(enabled=True)
|
||||||
|
elif enabled == "no":
|
||||||
|
schedules = schedules.filter(enabled=False)
|
||||||
|
if prune == "yes":
|
||||||
|
schedules = schedules.filter(prune=True)
|
||||||
|
elif prune == "no":
|
||||||
|
schedules = schedules.filter(prune=False)
|
||||||
|
if host:
|
||||||
|
schedules = schedules.filter(host__host=host)
|
||||||
|
|
||||||
|
schedule_rows = []
|
||||||
|
for schedule in schedules[:200]:
|
||||||
|
schedule_rows.append(
|
||||||
|
{
|
||||||
|
"schedule": schedule,
|
||||||
|
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"schedule_rows": schedule_rows,
|
||||||
|
"total_count": schedules.count(),
|
||||||
|
"hosts": HostConfig.objects.order_by("host"),
|
||||||
|
"selected_enabled": enabled,
|
||||||
|
"selected_prune": prune,
|
||||||
|
"selected_host": host,
|
||||||
|
"scheduler_timezone": timezone.get_current_timezone_name(),
|
||||||
|
}
|
||||||
|
return render(request, "pobsync_backend/schedules_list.html", context)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def purged_snapshots(request):
|
def purged_snapshots(request):
|
||||||
host = request.GET.get("host", "").strip()
|
host = request.GET.get("host", "").strip()
|
||||||
@@ -485,6 +756,19 @@ def queue_manual_backup(request, host: str):
|
|||||||
@staff_member_required
|
@staff_member_required
|
||||||
def run_detail(request, run_id: int):
|
def run_detail(request, run_id: int):
|
||||||
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
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 {}
|
result = run.result if isinstance(run.result, dict) else {}
|
||||||
run_stats = result.get("stats") if isinstance(result.get("stats"), 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 {}
|
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
|
||||||
@@ -494,14 +778,17 @@ def run_detail(request, run_id: int):
|
|||||||
rsync_log_path = _run_rsync_log_path(run)
|
rsync_log_path = _run_rsync_log_path(run)
|
||||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
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,
|
"run": run,
|
||||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
"can_cancel": can_cancel,
|
||||||
|
"can_auto_refresh": can_cancel,
|
||||||
"requested": requested,
|
"requested": requested,
|
||||||
"execution": execution,
|
"execution": execution,
|
||||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
"stats": run_stats if isinstance(run_stats, dict) else {},
|
||||||
"rsync": rsync_result,
|
"rsync": rsync_result,
|
||||||
"rsync_command": _run_rsync_command(rsync_result),
|
"rsync_command": _run_rsync_command(rsync_result),
|
||||||
|
"rsync_bwlimit_kbps": _run_rsync_bwlimit_kbps(rsync_result),
|
||||||
"failure": failure,
|
"failure": failure,
|
||||||
"failure_summary": failure.get("message") or failure.get("summary") or "",
|
"failure_summary": failure.get("message") or failure.get("summary") or "",
|
||||||
"prune_result": prune_result,
|
"prune_result": prune_result,
|
||||||
@@ -510,6 +797,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_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_exists": bool(rsync_log_path and rsync_log_path.exists()),
|
||||||
"rsync_log_tail": rsync_log_tail,
|
"rsync_log_tail": rsync_log_tail,
|
||||||
|
"live_progress": _run_live_progress(run, rsync_log_path),
|
||||||
"dry_run_summary": _dry_run_summary(
|
"dry_run_summary": _dry_run_summary(
|
||||||
result=result,
|
result=result,
|
||||||
requested=requested,
|
requested=requested,
|
||||||
@@ -520,7 +808,6 @@ def run_detail(request, run_id: int):
|
|||||||
),
|
),
|
||||||
"result_json": _pretty_json(run.result),
|
"result_json": _pretty_json(run.result),
|
||||||
}
|
}
|
||||||
return render(request, "pobsync_backend/run_detail.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
@@ -565,13 +852,13 @@ def resolve_run_review(request, run_id: int):
|
|||||||
return redirect("run_detail", run_id=run.id)
|
return redirect("run_detail", run_id=run.id)
|
||||||
if run.reviewed_at:
|
if run.reviewed_at:
|
||||||
messages.info(request, f"Run {run.id} was already marked reviewed.")
|
messages.info(request, f"Run {run.id} was already marked reviewed.")
|
||||||
return redirect("run_detail", run_id=run.id)
|
return _redirect_after_run_review(request, run)
|
||||||
|
|
||||||
run.reviewed_at = timezone.now()
|
run.reviewed_at = timezone.now()
|
||||||
run.reviewed_by = request.user.get_username()
|
run.reviewed_by = request.user.get_username()
|
||||||
run.save(update_fields=["reviewed_at", "reviewed_by"])
|
run.save(update_fields=["reviewed_at", "reviewed_by"])
|
||||||
messages.success(request, f"Run {run.id} marked reviewed.")
|
messages.success(request, f"Run {run.id} marked reviewed.")
|
||||||
return redirect("run_detail", run_id=run.id)
|
return _redirect_after_run_review(request, run)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
@@ -646,6 +933,8 @@ def host_retention_plan(request, host: str):
|
|||||||
scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None
|
scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None
|
||||||
delete_count = len(plan["delete"])
|
delete_count = len(plan["delete"])
|
||||||
incomplete_count = len(plan["incomplete"])
|
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 = {
|
context = {
|
||||||
"host": host_config,
|
"host": host_config,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
@@ -654,6 +943,8 @@ def host_retention_plan(request, host: str):
|
|||||||
"schedule": schedule,
|
"schedule": schedule,
|
||||||
"scheduled_prune_limit": scheduled_prune_limit,
|
"scheduled_prune_limit": scheduled_prune_limit,
|
||||||
"scheduled_prune_exceeded": scheduled_prune_limit is not None and delete_count > 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(
|
"apply_form": RetentionApplyForm(
|
||||||
host_name=host_config.host,
|
host_name=host_config.host,
|
||||||
expected_delete_count=delete_count,
|
expected_delete_count=delete_count,
|
||||||
@@ -666,10 +957,10 @@ def host_retention_plan(request, host: str):
|
|||||||
),
|
),
|
||||||
"incomplete_cleanup_form": IncompleteCleanupForm(
|
"incomplete_cleanup_form": IncompleteCleanupForm(
|
||||||
host_name=host_config.host,
|
host_name=host_config.host,
|
||||||
expected_delete_count=incomplete_count,
|
expected_delete_count=incomplete_reviewed_count,
|
||||||
initial={
|
initial={
|
||||||
"max_delete": incomplete_count,
|
"max_delete": incomplete_reviewed_count,
|
||||||
"confirm_delete_count": incomplete_count,
|
"confirm_delete_count": incomplete_reviewed_count,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -740,7 +1031,7 @@ def cleanup_host_incomplete_snapshots(request, host: str):
|
|||||||
messages.error(request, str(exc))
|
messages.error(request, str(exc))
|
||||||
return redirect("host_retention_plan", host=host_config.host)
|
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(
|
form = IncompleteCleanupForm(
|
||||||
request.POST,
|
request.POST,
|
||||||
host_name=host_config.host,
|
host_name=host_config.host,
|
||||||
@@ -841,6 +1132,13 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_after_run_review(request, run: BackupRun):
|
||||||
|
next_url = request.POST.get("next", "").strip()
|
||||||
|
if next_url.startswith("/"):
|
||||||
|
return redirect(next_url)
|
||||||
|
return redirect("run_detail", run_id=run.id)
|
||||||
|
|
||||||
|
|
||||||
def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]:
|
def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]:
|
||||||
incomplete_count = host_config.snapshots.filter(
|
incomplete_count = host_config.snapshots.filter(
|
||||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||||
@@ -1040,6 +1338,23 @@ def _run_rsync_command(rsync_result: dict) -> list[str]:
|
|||||||
return [str(part) for part in command]
|
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]:
|
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")
|
log_tail = rsync_result.get("log_tail")
|
||||||
if isinstance(log_tail, list):
|
if isinstance(log_tail, list):
|
||||||
@@ -1053,6 +1368,97 @@ def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines:
|
|||||||
return []
|
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(
|
def _dry_run_summary(
|
||||||
*,
|
*,
|
||||||
result: dict,
|
result: dict,
|
||||||
|
|||||||
@@ -8,18 +8,23 @@ from pobsync_backend import api, views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
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("changelog/", views.changelog, name="changelog"),
|
||||||
path("self-check/", views.self_check, name="self_check"),
|
path("self-check/", views.self_check, name="self_check"),
|
||||||
path("logs/", views.logs, name="logs"),
|
path("logs/", views.logs, name="logs"),
|
||||||
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
||||||
|
path("schedules/", views.schedules_list, name="schedules_list"),
|
||||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||||
path("ssh-credentials/generate/", views.generate_ssh_credential, name="generate_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>/", views.edit_ssh_credential, name="edit_ssh_credential"),
|
||||||
path("ssh-credentials/<int:credential_id>/delete/", views.delete_ssh_credential, name="delete_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/new/", views.create_host_config, name="create_host_config"),
|
||||||
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
|
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>/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>/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"),
|
path("hosts/<str:host>/scan-known-key/", views.scan_host_known_key, name="scan_host_known_key"),
|
||||||
@@ -34,7 +39,9 @@ urlpatterns = [
|
|||||||
name="cleanup_host_incomplete_snapshots",
|
name="cleanup_host_incomplete_snapshots",
|
||||||
),
|
),
|
||||||
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||||
|
path("runs/", views.runs_list, name="runs_list"),
|
||||||
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
||||||
|
path("runs/<int:run_id>/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>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
||||||
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
||||||
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
||||||
@@ -43,6 +50,7 @@ urlpatterns = [
|
|||||||
views.resolve_host_incomplete_reviews,
|
views.resolve_host_incomplete_reviews,
|
||||||
name="resolve_host_incomplete_reviews",
|
name="resolve_host_incomplete_reviews",
|
||||||
),
|
),
|
||||||
|
path("snapshots/", views.snapshots_list, name="snapshots_list"),
|
||||||
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
||||||
path("api/", api.api_index),
|
path("api/", api.api_index),
|
||||||
path("api/status/", api.status),
|
path("api/status/", api.status),
|
||||||
|
|||||||
Reference in New Issue
Block a user