Compare commits
46 Commits
362a9dde62
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 00d4f2a70b | |||
| f8215a0c9a | |||
| ea9e3e41e3 | |||
| 5b5a5bc637 | |||
| c2e5a534aa | |||
| d0c23deb72 | |||
| 4c8ed24561 | |||
| 404b7f7500 | |||
| beca073ddc |
59
CHANGELOG.md
Normal file
59
CHANGELOG.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.0 - 2026-05-21
|
||||
|
||||
UI-focused release for the Django control panel.
|
||||
|
||||
### Added
|
||||
|
||||
- Dedicated list pages for runs, snapshots, schedules, purged snapshots, and changelog navigation.
|
||||
- Dashboard priority panels for required action, next scheduled work, recent activity, and storage pressure.
|
||||
- Dashboard host cards with clearer backup activity, snapshot health, next run, and retention status.
|
||||
- Lightweight live refresh for active run detail pages, including status, timing, controls, and rsync log output.
|
||||
- Lightweight live refresh for dashboard priority and host status sections.
|
||||
- Current-page navigation states for primary and system navigation.
|
||||
- Responsive dashboard behavior for narrower screens.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the primary navigation around day-to-day operator workflows and moved admin/system links out of the main path.
|
||||
- Simplified legacy-facing labels and removed source-of-truth wording that no longer applies to the Django-first model.
|
||||
- Improved run and snapshot detail pages with clearer links between backup runs, snapshots, logs, and review actions.
|
||||
- Improved dashboard spacing and card layouts to reduce cramped or overlapping text.
|
||||
- Documented the Django-template-first partial refresh pattern for future UI work.
|
||||
|
||||
## 1.0.0 - 2026-05-21
|
||||
|
||||
Initial stable release of the Django-first pobsync control panel.
|
||||
|
||||
### Added
|
||||
|
||||
- Django control panel for hosts, global settings, schedules, SSH credentials, snapshots, runs, self-checks, and logs.
|
||||
- Native systemd installer and updater for production backup servers.
|
||||
- SQLite by default, with optional MariaDB support.
|
||||
- Scheduler and worker services for queued manual backups and scheduled backups.
|
||||
- Manual backup, dry-run, cancellation, verbose rsync logging, and run detail views.
|
||||
- Snapshot discovery for existing backup directories and SQL-backed snapshot records.
|
||||
- SQL retention planning and apply flow with base snapshot protection and incomplete snapshot visibility.
|
||||
- Explicit cleanup flow for incomplete snapshots, separate from normal retention pruning.
|
||||
- Purged snapshot audit overview with reason, action source, operator, host, kind, path, and timestamp.
|
||||
- Dashboard and host pages with backup health, latest run/snapshot, next run, and storage/stat summaries.
|
||||
- Review resolution for failed/warning runs and incomplete snapshot tasks so operational warnings can be acknowledged.
|
||||
- Worker heartbeat metadata and stale running-run reconciliation for queued backup workers.
|
||||
- SSH key generation, upload, edit, guarded delete, known_hosts management, and per-host key selection.
|
||||
- In-app changelog page sourced from this changelog.
|
||||
- Restore guidance on snapshot detail pages.
|
||||
|
||||
### Changed
|
||||
|
||||
- Django and the database are now the source of truth for configuration.
|
||||
- Docker Compose is documented as development and disposable test tooling rather than the primary production path.
|
||||
- The `pobsync` console entrypoint is now a maintainer layer around Django management commands.
|
||||
- Scheduled pruning is evaluated by the pobsync scheduler service and recorded through Django, not host cron.
|
||||
- Retention and incomplete cleanup now preserve audit history even after source snapshot records are removed.
|
||||
|
||||
### Removed
|
||||
|
||||
- Legacy YAML config import/export workflow.
|
||||
- Public short aliases for configuration commands.
|
||||
- Obsolete global config storage fields.
|
||||
@@ -10,7 +10,7 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY pyproject.toml README.md CHANGELOG.md ./
|
||||
COPY src ./src
|
||||
COPY manage.py ./
|
||||
COPY scripts/docker-entrypoint ./scripts/docker-entrypoint
|
||||
|
||||
@@ -156,7 +156,7 @@ The UI includes:
|
||||
|
||||
## Restoring Data
|
||||
|
||||
pobsync 1.0 treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
|
||||
pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot
|
||||
detail page, but it does not run restore commands for you yet. That is deliberate: restores should be inspected and
|
||||
tested before data is copied back into a live system.
|
||||
|
||||
|
||||
@@ -50,6 +50,16 @@ python3 manage.py showmigrations pobsync_backend
|
||||
The short `pobsync` aliases are limited to operational actions that are useful while debugging a running install.
|
||||
Configuration aliases are intentionally not public commands; use the Django UI or explicit management commands instead.
|
||||
|
||||
## UI Refresh Pattern
|
||||
|
||||
The control panel stays Django-template-first. Pages that need live status should expose a small server-rendered partial
|
||||
view and opt into refresh with `data-refresh-url` and `data-refresh-interval` on the container that should be replaced.
|
||||
The shared script in `base.html` polls only those explicit regions, skips refreshes while the browser tab is hidden, and
|
||||
lets the partial response turn polling off with the `X-Pobsync-Refresh-Active: false` header.
|
||||
|
||||
Use this for operational status surfaces such as running backup details. Avoid refreshing form-heavy sections while an
|
||||
operator might be typing.
|
||||
|
||||
Worker and scheduler commands are normally run by systemd services:
|
||||
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pobsync"
|
||||
version = "0.1.0"
|
||||
version = "1.1.0"
|
||||
description = "Pull-based rsync backup tool with hardlinked snapshots"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__version__ = "1.1.0"
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import Sequence
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
from pobsync import __version__
|
||||
|
||||
|
||||
COMMAND_ALIASES = {
|
||||
"backup": "run_pobsync_backup",
|
||||
@@ -34,6 +36,9 @@ Configuration is managed from the Django control panel. Use
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if args and args[0] in {"--version", "version"}:
|
||||
print(f"pobsync {__version__}")
|
||||
return 0
|
||||
if not args or args[0] in {"-h", "--help", "help"}:
|
||||
print(_usage())
|
||||
return 0
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
|
||||
from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
|
||||
|
||||
|
||||
@admin.register(SshCredential)
|
||||
@@ -173,6 +173,16 @@ class SnapshotRecordAdmin(admin.ModelAdmin):
|
||||
return format_html('<a href="{}">{}</a>', url, count)
|
||||
|
||||
|
||||
@admin.register(PurgedSnapshot)
|
||||
class PurgedSnapshotAdmin(admin.ModelAdmin):
|
||||
list_display = ("host_name", "kind", "dirname", "action", "reason", "triggered_by", "purged_at")
|
||||
list_filter = ("action", "kind", "purged_at")
|
||||
search_fields = ("host_name", "dirname", "path", "reason", "triggered_by")
|
||||
list_select_related = ("host",)
|
||||
readonly_fields = ("purged_at",)
|
||||
date_hierarchy = "purged_at"
|
||||
|
||||
|
||||
@admin.register(ScheduleConfig)
|
||||
class ScheduleConfigAdmin(admin.ModelAdmin):
|
||||
list_display = ("host", "cron_expr", "enabled", "prune", "last_status", "last_started_at", "updated_at")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import os
|
||||
import socket
|
||||
from datetime import timedelta, timezone as datetime_timezone
|
||||
from pathlib import Path
|
||||
|
||||
from django.db import transaction
|
||||
@@ -107,6 +109,7 @@ def execute_backup_run(
|
||||
protect_bases=bool(prune_protect_bases),
|
||||
yes=True,
|
||||
max_delete=int(prune_max_delete),
|
||||
action=run.run_type,
|
||||
acquire_lock=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -158,10 +161,10 @@ def claim_next_queued_run() -> BackupRun | None:
|
||||
return run
|
||||
|
||||
|
||||
def reconcile_running_runs(*, grace_seconds: int = 300) -> int:
|
||||
def reconcile_running_runs(*, grace_seconds: int = 300, stale_worker_seconds: int = 24 * 60 * 60) -> int:
|
||||
reconciled = 0
|
||||
for run in BackupRun.objects.select_related("host").filter(status=BackupRun.Status.RUNNING).order_by("started_at", "id"):
|
||||
if _reconcile_running_run(run=run, grace_seconds=grace_seconds):
|
||||
if _reconcile_running_run(run=run, grace_seconds=grace_seconds, stale_worker_seconds=stale_worker_seconds):
|
||||
reconciled += 1
|
||||
return reconciled
|
||||
|
||||
@@ -176,7 +179,9 @@ def requested_options(run: BackupRun) -> dict[str, object]:
|
||||
def _running_result(*, run: BackupRun, dry_run: bool) -> dict[str, object]:
|
||||
result = dict(run.result) if isinstance(run.result, dict) else {}
|
||||
execution = {
|
||||
**_worker_execution_details(),
|
||||
"started_at": (run.started_at or timezone.now()).isoformat(),
|
||||
"heartbeat_at": timezone.now().isoformat(),
|
||||
}
|
||||
if dry_run:
|
||||
execution["log"] = str(dry_run_log_path(run.host.host, run_id=run.id))
|
||||
@@ -185,24 +190,56 @@ def _running_result(*, run: BackupRun, dry_run: bool) -> dict[str, object]:
|
||||
|
||||
|
||||
def _run_cancel_requested(run_id: int) -> bool:
|
||||
return BackupRun.objects.filter(id=run_id, status=BackupRun.Status.CANCELLED).exists()
|
||||
try:
|
||||
run = BackupRun.objects.only("id", "status", "result").get(id=run_id)
|
||||
except BackupRun.DoesNotExist:
|
||||
return True
|
||||
if run.status == BackupRun.Status.CANCELLED:
|
||||
return True
|
||||
if run.status == BackupRun.Status.RUNNING:
|
||||
_refresh_run_heartbeat(run)
|
||||
return False
|
||||
|
||||
|
||||
def _reconcile_running_run(*, run: BackupRun, grace_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 {}
|
||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||
stale_worker = _running_worker_timed_out(run=run, stale_worker_seconds=stale_worker_seconds)
|
||||
if not requested.get("dry_run"):
|
||||
if stale_worker:
|
||||
result.update(
|
||||
{
|
||||
"ok": False,
|
||||
"host": run.host.host,
|
||||
"failure": {
|
||||
"category": "worker",
|
||||
"message": "The worker heartbeat stopped before the run finished.",
|
||||
"hint": "Check pobsync-worker.service logs before retrying the backup.",
|
||||
},
|
||||
}
|
||||
)
|
||||
run.status = BackupRun.Status.FAILED
|
||||
run.ended_at = timezone.now()
|
||||
run.result = result
|
||||
run.save(update_fields=["status", "ended_at", "result"])
|
||||
return True
|
||||
return False
|
||||
|
||||
log_path = _execution_log_path(result)
|
||||
log_tail = _read_log_tail(log_path) if log_path is not None else []
|
||||
terminal_log = _terminal_rsync_log(log_tail)
|
||||
timed_out = _running_dry_run_timed_out(run=run, grace_seconds=grace_seconds)
|
||||
if not terminal_log and not timed_out:
|
||||
if not terminal_log and not timed_out and not stale_worker:
|
||||
return False
|
||||
|
||||
exit_code = _exit_code_from_log(log_tail) or (124 if timed_out else 255)
|
||||
exit_code = _exit_code_from_log(log_tail) or (124 if timed_out or stale_worker else 255)
|
||||
failure = classify_rsync_failure(exit_code, log_tail)
|
||||
if stale_worker and not terminal_log:
|
||||
failure = {
|
||||
"category": "worker",
|
||||
"message": "The worker heartbeat stopped before the dry-run finished.",
|
||||
"hint": "Check pobsync-worker.service logs before retrying the dry-run.",
|
||||
}
|
||||
result.update(
|
||||
{
|
||||
"ok": False,
|
||||
@@ -226,6 +263,30 @@ def _reconcile_running_run(*, run: BackupRun, grace_seconds: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _worker_execution_details() -> dict[str, object]:
|
||||
return {
|
||||
"worker_pid": os.getpid(),
|
||||
"worker_host": socket.gethostname(),
|
||||
"claimed_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _refresh_run_heartbeat(run: BackupRun, *, interval_seconds: int = 30) -> None:
|
||||
result = run.result if isinstance(run.result, dict) else {}
|
||||
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
|
||||
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
|
||||
if heartbeat_at is not None and timezone.now() < heartbeat_at + timedelta(seconds=interval_seconds):
|
||||
return
|
||||
result["execution"] = {
|
||||
**execution,
|
||||
"worker_pid": os.getpid(),
|
||||
"worker_host": socket.gethostname(),
|
||||
"heartbeat_at": timezone.now().isoformat(),
|
||||
}
|
||||
run.result = result
|
||||
run.save(update_fields=["result"])
|
||||
|
||||
|
||||
def _execution_log_path(result: dict[str, object]) -> Path | None:
|
||||
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
|
||||
log = execution.get("log") or result.get("log")
|
||||
@@ -266,3 +327,28 @@ def _running_dry_run_timed_out(*, run: BackupRun, grace_seconds: int) -> bool:
|
||||
if not isinstance(timeout_seconds, int) or timeout_seconds <= 0:
|
||||
timeout_seconds = DEFAULT_DRY_RUN_TIMEOUT_SECONDS
|
||||
return timezone.now() >= run.started_at + timedelta(seconds=timeout_seconds + grace_seconds)
|
||||
|
||||
|
||||
def _running_worker_timed_out(*, run: BackupRun, stale_worker_seconds: int) -> bool:
|
||||
if stale_worker_seconds <= 0:
|
||||
return False
|
||||
result = run.result if isinstance(run.result, dict) else {}
|
||||
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
|
||||
heartbeat_at = _parse_iso_datetime(execution.get("heartbeat_at"))
|
||||
if heartbeat_at is None:
|
||||
heartbeat_at = run.started_at
|
||||
if heartbeat_at is None:
|
||||
return False
|
||||
return timezone.now() >= heartbeat_at + timedelta(seconds=stale_worker_seconds)
|
||||
|
||||
|
||||
def _parse_iso_datetime(value: object):
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = timezone.datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if timezone.is_naive(parsed):
|
||||
return timezone.make_aware(parsed, timezone=datetime_timezone.utc)
|
||||
return parsed
|
||||
|
||||
@@ -192,7 +192,7 @@ class SshCredentialForm(forms.ModelForm):
|
||||
if not raw_private_key.strip():
|
||||
if self.instance and self.instance.pk and self.instance.key_path:
|
||||
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)
|
||||
public_key = validate_ssh_private_key(private_key)
|
||||
@@ -274,6 +274,36 @@ class RetentionApplyForm(forms.Form):
|
||||
return value
|
||||
|
||||
|
||||
class IncompleteCleanupForm(forms.Form):
|
||||
max_delete = forms.IntegerField(min_value=0, initial=0)
|
||||
confirm_delete_count = forms.IntegerField(min_value=0)
|
||||
confirm_host = forms.CharField()
|
||||
|
||||
def __init__(self, *args, host_name: str, expected_delete_count: int, **kwargs) -> None:
|
||||
self.host_name = host_name
|
||||
self.expected_delete_count = expected_delete_count
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["confirm_host"].help_text = f"Type {host_name} to confirm incomplete snapshot cleanup."
|
||||
self.fields["confirm_delete_count"].help_text = (
|
||||
f"Type {expected_delete_count} to confirm the current number of incomplete snapshots."
|
||||
)
|
||||
self.fields["max_delete"].help_text = (
|
||||
f"Must be at least {expected_delete_count} for the incomplete snapshots shown here."
|
||||
)
|
||||
|
||||
def clean_confirm_host(self) -> str:
|
||||
value = self.cleaned_data["confirm_host"].strip()
|
||||
if value != self.host_name:
|
||||
raise forms.ValidationError(f"Type {self.host_name} to confirm.")
|
||||
return value
|
||||
|
||||
def clean_confirm_delete_count(self) -> int:
|
||||
value = self.cleaned_data["confirm_delete_count"]
|
||||
if value != self.expected_delete_count:
|
||||
raise forms.ValidationError(f"Type {self.expected_delete_count} to confirm the incomplete count.")
|
||||
return value
|
||||
|
||||
|
||||
class ScheduleConfigForm(forms.ModelForm):
|
||||
cron_expr = forms.CharField(
|
||||
label="Schedule expression",
|
||||
|
||||
@@ -36,6 +36,7 @@ class Command(BaseCommand):
|
||||
protect_bases=bool(options["protect_bases"]),
|
||||
yes=True,
|
||||
max_delete=int(options["max_delete"]),
|
||||
action="cli",
|
||||
)
|
||||
else:
|
||||
result = run_sql_retention_plan(
|
||||
|
||||
@@ -19,6 +19,12 @@ class Command(BaseCommand):
|
||||
parser.add_argument("--once", action="store_true", help="Process one queued run and exit")
|
||||
parser.add_argument("--loop", action="store_true", help="Keep checking for queued runs")
|
||||
parser.add_argument("--interval", type=int, default=15, help="Loop interval in seconds")
|
||||
parser.add_argument(
|
||||
"--stale-running-seconds",
|
||||
type=int,
|
||||
default=24 * 60 * 60,
|
||||
help="Mark running runs failed after this many seconds without a worker heartbeat; use 0 to disable",
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
if not options["once"] and not options["loop"]:
|
||||
@@ -26,14 +32,14 @@ class Command(BaseCommand):
|
||||
|
||||
paths = PobsyncPaths(home=Path(options["prefix"]))
|
||||
while True:
|
||||
count = self._run_once(prefix=paths.home)
|
||||
count = self._run_once(prefix=paths.home, stale_running_seconds=int(options["stale_running_seconds"]))
|
||||
self.stdout.write(f"Ran {count} queued backup run(s).")
|
||||
if options["once"]:
|
||||
return
|
||||
time.sleep(max(1, int(options["interval"])))
|
||||
|
||||
def _run_once(self, *, prefix: Path) -> int:
|
||||
reconciled = reconcile_running_runs()
|
||||
def _run_once(self, *, prefix: Path, stale_running_seconds: int = 24 * 60 * 60) -> int:
|
||||
reconciled = reconcile_running_runs(stale_worker_seconds=stale_running_seconds)
|
||||
run = claim_next_queued_run()
|
||||
if run is None:
|
||||
return reconciled
|
||||
|
||||
30
src/pobsync_backend/migrations/0012_review_state.py
Normal file
30
src/pobsync_backend/migrations/0012_review_state.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pobsync_backend", "0011_remove_globalconfig_data"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="backuprun",
|
||||
name="reviewed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="backuprun",
|
||||
name="reviewed_by",
|
||||
field=models.CharField(blank=True, max_length=150),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="snapshotrecord",
|
||||
name="reviewed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="snapshotrecord",
|
||||
name="reviewed_by",
|
||||
field=models.CharField(blank=True, max_length=150),
|
||||
),
|
||||
]
|
||||
50
src/pobsync_backend/migrations/0013_purgedsnapshot.py
Normal file
50
src/pobsync_backend/migrations/0013_purgedsnapshot.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pobsync_backend", "0012_review_state"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PurgedSnapshot",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("host_name", models.CharField(max_length=255)),
|
||||
("kind", models.CharField(max_length=16)),
|
||||
("dirname", models.CharField(max_length=255)),
|
||||
("path", models.CharField(max_length=1024)),
|
||||
("reason", models.CharField(blank=True, max_length=512)),
|
||||
(
|
||||
"action",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("manual", "Manual"),
|
||||
("scheduled", "Scheduled"),
|
||||
("cli", "CLI"),
|
||||
("incomplete_cleanup", "Incomplete cleanup"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("triggered_by", models.CharField(blank=True, max_length=150)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("purged_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"host",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="purged_snapshots",
|
||||
to="pobsync_backend.hostconfig",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-purged_at", "host_name", "dirname"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -124,6 +124,8 @@ class BackupRun(models.Model):
|
||||
rsync_exit_code = models.IntegerField(null=True, blank=True)
|
||||
result = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_by = models.CharField(max_length=150, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -158,6 +160,8 @@ class SnapshotRecord(models.Model):
|
||||
ended_at = models.DateTimeField(null=True, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
discovered_at = models.DateTimeField(auto_now_add=True)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_by = models.CharField(max_length=150, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
@@ -169,6 +173,31 @@ class SnapshotRecord(models.Model):
|
||||
return f"{self.host}/{self.kind}/{self.dirname}"
|
||||
|
||||
|
||||
class PurgedSnapshot(models.Model):
|
||||
class Action(models.TextChoices):
|
||||
MANUAL = "manual", "Manual"
|
||||
SCHEDULED = "scheduled", "Scheduled"
|
||||
CLI = "cli", "CLI"
|
||||
INCOMPLETE_CLEANUP = "incomplete_cleanup", "Incomplete cleanup"
|
||||
|
||||
host = models.ForeignKey(HostConfig, on_delete=models.SET_NULL, null=True, blank=True, related_name="purged_snapshots")
|
||||
host_name = models.CharField(max_length=255)
|
||||
kind = models.CharField(max_length=16)
|
||||
dirname = models.CharField(max_length=255)
|
||||
path = models.CharField(max_length=1024)
|
||||
reason = models.CharField(max_length=512, blank=True)
|
||||
action = models.CharField(max_length=32, choices=Action.choices)
|
||||
triggered_by = models.CharField(max_length=150, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
purged_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-purged_at", "host_name", "dirname"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.host_name}/{self.kind}/{self.dirname}"
|
||||
|
||||
|
||||
class ScheduleConfig(TimestampedModel):
|
||||
host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule")
|
||||
cron_expr = models.CharField(max_length=128)
|
||||
|
||||
@@ -12,7 +12,7 @@ from pobsync.paths import PobsyncPaths
|
||||
from pobsync.retention import Snapshot, apply_base_protection, build_retention_plan
|
||||
from pobsync.util import sanitize_host
|
||||
|
||||
from .models import HostConfig, SnapshotRecord
|
||||
from .models import HostConfig, PurgedSnapshot, SnapshotRecord
|
||||
|
||||
|
||||
def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict[str, Any]:
|
||||
@@ -65,6 +65,8 @@ def run_sql_retention_apply(
|
||||
protect_bases: bool,
|
||||
yes: bool,
|
||||
max_delete: int,
|
||||
action: str = PurgedSnapshot.Action.MANUAL,
|
||||
triggered_by: str = "",
|
||||
acquire_lock: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
host = sanitize_host(host)
|
||||
@@ -101,6 +103,7 @@ def run_sql_retention_apply(
|
||||
raise ConfigError(f"Refusing to delete unsupported snapshot kind: {snap_kind!r}")
|
||||
|
||||
path = _snapshot_delete_path(path=Path(snap_path), dirname=dirname)
|
||||
reason = str(item.get("reason") or "outside retention policy")
|
||||
if not path.exists():
|
||||
actions.append(f"skip missing {snap_kind}/{dirname}")
|
||||
continue
|
||||
@@ -108,9 +111,19 @@ def run_sql_retention_apply(
|
||||
raise ConfigError(f"Refusing to delete non-directory path: {path}")
|
||||
|
||||
_remove_snapshot_tree(path)
|
||||
_record_purged_snapshot(
|
||||
host_config=_enabled_host_config(host),
|
||||
kind=snap_kind,
|
||||
dirname=dirname,
|
||||
path=path,
|
||||
reason=reason,
|
||||
action=action,
|
||||
triggered_by=triggered_by,
|
||||
metadata={"source": "retention", "protect_bases": bool(protect_bases), "retention_kind": kind},
|
||||
)
|
||||
SnapshotRecord.objects.filter(host__host=host, kind=snap_kind, dirname=dirname).delete()
|
||||
actions.append(f"deleted {snap_kind} {dirname}")
|
||||
deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path)})
|
||||
deleted.append({"dirname": dirname, "kind": snap_kind, "path": str(path), "reason": reason})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -131,6 +144,87 @@ def run_sql_retention_apply(
|
||||
return _do_apply()
|
||||
|
||||
|
||||
def run_incomplete_cleanup(
|
||||
*,
|
||||
prefix: Path,
|
||||
host: str,
|
||||
yes: bool,
|
||||
max_delete: int,
|
||||
triggered_by: str = "",
|
||||
acquire_lock: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
host = sanitize_host(host)
|
||||
if not yes:
|
||||
raise ConfigError("Refusing to delete incomplete snapshots without --yes")
|
||||
if max_delete < 0:
|
||||
raise ConfigError("--max-delete must be >= 0")
|
||||
|
||||
paths = PobsyncPaths(home=prefix)
|
||||
|
||||
def _do_cleanup() -> dict[str, Any]:
|
||||
host_config = _enabled_host_config(host)
|
||||
incomplete_list = [
|
||||
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
|
||||
for snapshot in _incomplete_snapshots_for_host(host_config)
|
||||
]
|
||||
if max_delete == 0 and len(incomplete_list) > 0:
|
||||
raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
|
||||
if len(incomplete_list) > max_delete:
|
||||
raise ConfigError(
|
||||
f"Refusing to delete {len(incomplete_list)} incomplete snapshots (exceeds --max-delete={max_delete})"
|
||||
)
|
||||
|
||||
actions: list[str] = []
|
||||
deleted: list[dict[str, Any]] = []
|
||||
|
||||
for item in incomplete_list:
|
||||
dirname = item["dirname"]
|
||||
snap_path = Path(item["path"])
|
||||
path = _snapshot_delete_path(path=snap_path, dirname=dirname)
|
||||
_validate_incomplete_delete_path(host=host, path=path, dirname=dirname)
|
||||
|
||||
if not path.exists():
|
||||
actions.append(f"skip missing incomplete/{dirname}")
|
||||
elif not path.is_dir():
|
||||
raise ConfigError(f"Refusing to delete non-directory path: {path}")
|
||||
else:
|
||||
_remove_snapshot_tree(path)
|
||||
actions.append(f"deleted incomplete {dirname}")
|
||||
|
||||
_record_purged_snapshot(
|
||||
host_config=host_config,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
dirname=dirname,
|
||||
path=path,
|
||||
reason="manual incomplete cleanup",
|
||||
action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP,
|
||||
triggered_by=triggered_by,
|
||||
metadata={"source": "incomplete_cleanup"},
|
||||
)
|
||||
SnapshotRecord.objects.filter(
|
||||
host__host=host,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
dirname=dirname,
|
||||
).delete()
|
||||
deleted.append({"dirname": dirname, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(path)})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"host": host,
|
||||
"kind": SnapshotRecord.Kind.INCOMPLETE,
|
||||
"max_delete": max_delete,
|
||||
"source": "sql",
|
||||
"planned_delete_count": len(incomplete_list),
|
||||
"deleted": deleted,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
if acquire_lock:
|
||||
with acquire_host_lock(paths.locks_dir, host, command="incomplete-cleanup"):
|
||||
return _do_cleanup()
|
||||
return _do_cleanup()
|
||||
|
||||
|
||||
def _enabled_host_config(host: str) -> HostConfig:
|
||||
try:
|
||||
return HostConfig.objects.get(host=host, enabled=True)
|
||||
@@ -212,6 +306,39 @@ def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _record_purged_snapshot(
|
||||
*,
|
||||
host_config: HostConfig,
|
||||
kind: str,
|
||||
dirname: str,
|
||||
path: Path,
|
||||
reason: str,
|
||||
action: str,
|
||||
triggered_by: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
PurgedSnapshot.objects.create(
|
||||
host=host_config,
|
||||
host_name=host_config.host,
|
||||
kind=kind,
|
||||
dirname=dirname,
|
||||
path=str(path),
|
||||
reason=reason,
|
||||
action=action,
|
||||
triggered_by=triggered_by,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _validate_incomplete_delete_path(*, host: str, path: Path, dirname: str) -> None:
|
||||
path_parts = path.parts
|
||||
if path.name != dirname or ".incomplete" not in path_parts or host not in path_parts:
|
||||
raise ConfigError(f"Refusing to delete unexpected incomplete snapshot path: {path}")
|
||||
incomplete_index = path_parts.index(".incomplete")
|
||||
if incomplete_index == 0 or path_parts[incomplete_index - 1] != host:
|
||||
raise ConfigError(f"Refusing to delete incomplete snapshot outside host backup root: {path}")
|
||||
|
||||
|
||||
def _remove_snapshot_tree(path: Path) -> None:
|
||||
_make_directories_user_writable(path)
|
||||
shutil.rmtree(path)
|
||||
|
||||
@@ -266,13 +266,13 @@ def _config_checks() -> list[SelfCheck]:
|
||||
message = "Default global config exists."
|
||||
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
|
||||
status = "warning"
|
||||
message = "Global config backup root differs from the runtime backup root."
|
||||
message = "Saved backup root differs from the active backup root."
|
||||
return [
|
||||
SelfCheck(
|
||||
"Global config",
|
||||
status,
|
||||
message,
|
||||
f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}",
|
||||
f"saved={global_config.backup_root} active={settings.POBSYNC_BACKUP_ROOT}",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ def _run_summary(run: BackupRun) -> dict[str, Any]:
|
||||
"snapshot": run.snapshot,
|
||||
"snapshot_path": run.snapshot_path,
|
||||
"status": run.status,
|
||||
"reviewed_at": run.reviewed_at,
|
||||
"has_stats": bool(stats),
|
||||
"duration_seconds": _int_at(stats, "duration_seconds"),
|
||||
"rsync": stats.get("rsync") if isinstance(stats.get("rsync"), dict) else {},
|
||||
@@ -130,7 +131,7 @@ def _is_real_run(run: BackupRun) -> bool:
|
||||
|
||||
def _first_run_with_status(runs: list[dict[str, Any]], statuses: set[str]) -> dict[str, Any]:
|
||||
for run in runs:
|
||||
if run["status"] in statuses:
|
||||
if run["status"] in statuses and run.get("reviewed_at") is None:
|
||||
return run
|
||||
return {}
|
||||
|
||||
|
||||
@@ -1,85 +1,268 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}pobsync{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fa;
|
||||
--bg: #f2f5f8;
|
||||
--bg-soft: #f8fafc;
|
||||
--panel: #ffffff;
|
||||
--border: #d9e0e8;
|
||||
--text: #17202a;
|
||||
--muted: #657386;
|
||||
--link: #0b5cad;
|
||||
--success: #176b3a;
|
||||
--failed: #a12828;
|
||||
--panel-subtle: #fbfcfe;
|
||||
--border: #d8e1eb;
|
||||
--border-strong: #c7d2df;
|
||||
--text: #121a24;
|
||||
--muted: #65758a;
|
||||
--muted-strong: #46566a;
|
||||
--link: #075fae;
|
||||
--link-strong: #064b89;
|
||||
--success: #17633a;
|
||||
--failed: #a73333;
|
||||
--running: #8a5a00;
|
||||
--queued: #075fae;
|
||||
--shadow-sm: 0 1px 2px rgba(18, 26, 36, 0.05);
|
||||
--shadow-md: 0 10px 28px rgba(18, 26, 36, 0.08);
|
||||
--radius: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a { color: var(--link); font-weight: 560; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header {
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid rgba(7, 95, 174, 0.24);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
body > header {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
gap: 6px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
min-height: 48px;
|
||||
}
|
||||
nav strong {
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
nav a {
|
||||
border-radius: 6px;
|
||||
color: var(--muted-strong);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
nav a:hover {
|
||||
background: var(--bg-soft);
|
||||
color: var(--link-strong);
|
||||
text-decoration: none;
|
||||
}
|
||||
nav strong a {
|
||||
color: var(--link-strong);
|
||||
font-weight: 750;
|
||||
padding-left: 0;
|
||||
}
|
||||
nav strong a:hover { background: transparent; }
|
||||
nav a[aria-current="page"] {
|
||||
background: #eaf3fb;
|
||||
color: var(--link-strong);
|
||||
font-weight: 720;
|
||||
}
|
||||
.nav-primary,
|
||||
.nav-secondary {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-secondary {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.nav-secondary a {
|
||||
font-size: 13px;
|
||||
}
|
||||
.nav-user {
|
||||
margin-left: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
nav strong { font-size: 16px; }
|
||||
nav .spacer { flex: 1; }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
padding: 28px 24px 42px;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(28px, 3vw, 36px);
|
||||
letter-spacing: 0;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
letter-spacing: 0;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
p { margin: 0 0 12px; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
.page-header {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.page-title h1 { margin-bottom: 0; overflow-wrap: anywhere; }
|
||||
.page-kicker {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.page-subtitle {
|
||||
color: var(--muted);
|
||||
max-width: 760px;
|
||||
}
|
||||
.page-header .actions {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
h1 { font-size: 26px; margin: 0 0 18px; }
|
||||
h2 { font-size: 18px; margin: 0 0 12px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.metric, .panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.metric {
|
||||
min-height: 88px;
|
||||
padding: 14px 15px;
|
||||
}
|
||||
.metric .label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric .value {
|
||||
font-size: 27px;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
margin-top: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.metric { padding: 14px; }
|
||||
.metric .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||
.metric .value { font-size: 26px; font-weight: 650; margin-top: 4px; }
|
||||
.metric.failed { border-color: #e8b4b4; background: #fff7f7; }
|
||||
.metric.warning { border-color: #e7cf8a; background: #fffaf0; }
|
||||
.metric.running { border-color: #e7cf8a; background: #fffaf0; }
|
||||
.metric.queued { border-color: #b5cdea; background: #eef6ff; }
|
||||
.panel { padding: 16px; margin-bottom: 18px; overflow: auto; }
|
||||
.metric-link {
|
||||
color: inherit;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.metric-link:hover {
|
||||
border-color: #9eb2c8;
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.metric-link:focus-visible {
|
||||
outline: 3px solid #93c5fd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
.dashboard-summary-grid .metric {
|
||||
min-height: 78px;
|
||||
}
|
||||
.dashboard-summary-grid .metric .value {
|
||||
font-size: 25px;
|
||||
}
|
||||
.dashboard-trends-panel,
|
||||
.dashboard-hosts-panel {
|
||||
overflow: visible;
|
||||
}
|
||||
.panel {
|
||||
margin-bottom: 18px;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
}
|
||||
.panel > h2:first-child {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.panel.highlight { border-left: 4px solid var(--border); }
|
||||
.panel.highlight.failed { border-left-color: var(--failed); background: #fff7f7; }
|
||||
.panel.highlight.warning { border-left-color: var(--running); background: #fffaf0; }
|
||||
.panel.highlight.success { border-left-color: var(--success); background: #f5fbf7; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 640px; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 12px; font-weight: 650; text-transform: uppercase; }
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
width: 100%;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 9px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: var(--panel-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
tbody tr:hover td { background: #f9fbfd; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.status {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
padding: 3px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
|
||||
@@ -88,33 +271,66 @@
|
||||
.status.blocked { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
|
||||
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
||||
.status.warning { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
||||
.status.queued { color: var(--link); border-color: #b5cdea; background: #eef6ff; }
|
||||
.status.queued { color: var(--queued); border-color: #b5cdea; background: #eef6ff; }
|
||||
.status.skipped { color: var(--muted); background: #f7f9fb; }
|
||||
.stack { display: grid; gap: 4px; }
|
||||
.stack { display: grid; gap: 5px; }
|
||||
.stack.spaced { margin-bottom: 14px; }
|
||||
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.panel-grid .panel { margin-bottom: 0; }
|
||||
.actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.actions.inline { margin: 12px 0 0; }
|
||||
button, .button-link {
|
||||
appearance: none;
|
||||
background: #17202a;
|
||||
border: 1px solid #17202a;
|
||||
background: var(--text);
|
||||
border: 1px solid var(--text);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
padding: 8px 13px;
|
||||
}
|
||||
button:hover, .button-link:hover {
|
||||
background: #273343;
|
||||
text-decoration: none;
|
||||
}
|
||||
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
|
||||
button.secondary,
|
||||
.button-link.secondary {
|
||||
background: #fff;
|
||||
border-color: var(--border);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
button.secondary:hover,
|
||||
.button-link.secondary:hover { background: #eef3f8; }
|
||||
button.danger,
|
||||
.button-link.danger {
|
||||
background: var(--failed);
|
||||
border-color: var(--failed);
|
||||
color: #fff;
|
||||
}
|
||||
button.danger:hover,
|
||||
.button-link.danger:hover {
|
||||
background: #842828;
|
||||
border-color: #842828;
|
||||
}
|
||||
button.compact,
|
||||
.button-link.compact {
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
button:disabled {
|
||||
background: #d8dee6;
|
||||
border-color: #d8dee6;
|
||||
@@ -122,23 +338,220 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.inline-form { margin: 0; }
|
||||
.status-overview {
|
||||
.dashboard-priority-grid {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.priority-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.priority-panel > h2:first-child {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.action-list,
|
||||
.activity-list,
|
||||
.schedule-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.record-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.record-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.record-card-header {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.record-title {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.record-title a {
|
||||
font-weight: 750;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.record-facts {
|
||||
display: grid;
|
||||
gap: 8px 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
.record-fact {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.record-fact .label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.record-fact strong,
|
||||
.record-fact span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.action-row,
|
||||
.activity-row,
|
||||
.schedule-row {
|
||||
align-items: start;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.action-row,
|
||||
.activity-row {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
}
|
||||
.activity-row .status {
|
||||
justify-self: start;
|
||||
}
|
||||
.schedule-row {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
}
|
||||
.action-row:hover,
|
||||
.activity-row:hover,
|
||||
.schedule-row:hover {
|
||||
background: var(--panel-subtle);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.action-row.failed { border-color: #e8b4b4; background: #fff7f7; }
|
||||
.action-row.warning { border-color: #e7cf8a; background: #fffaf0; }
|
||||
.action-row span:not(.status),
|
||||
.activity-row span:not(.status),
|
||||
.schedule-row span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.action-row strong,
|
||||
.action-row .muted,
|
||||
.activity-row strong,
|
||||
.activity-row .muted,
|
||||
.schedule-row strong,
|
||||
.schedule-row .muted {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.schedule-time {
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
.storage-priority {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.storage-priority .label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.storage-priority .value {
|
||||
font-size: 27px;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.storage-priority-facts {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.storage-priority-facts > div {
|
||||
align-items: baseline;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.storage-priority-facts strong {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-control-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(280px, 1.25fr) repeat(3, minmax(220px, 1fr));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.host-control-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.host-control-panel > h2:first-child { margin-bottom: 0; }
|
||||
.host-control-primary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-control-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.host-control-meta > div {
|
||||
align-items: baseline;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
padding-top: 7px;
|
||||
}
|
||||
.host-control-meta .label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.host-control-meta strong {
|
||||
text-align: right;
|
||||
}
|
||||
.status-summary {
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
.status-summary.failed { border-color: #e8b4b4; background: #fff7f7; color: var(--failed); }
|
||||
.status-summary.warning,
|
||||
.status-summary.running { border-color: #e7cf8a; background: #fffaf0; color: var(--running); }
|
||||
.status-summary.queued { border-color: #b5cdea; background: #eef6ff; color: var(--link); }
|
||||
a.status-summary {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
a.status-summary:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.operator-state {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -191,7 +604,7 @@
|
||||
.insight-main .value,
|
||||
.insight-item .value {
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
font-weight: 760;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.storage-meter {
|
||||
@@ -213,9 +626,15 @@
|
||||
}
|
||||
.host-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
.host-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.host-card-header {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
@@ -230,7 +649,7 @@
|
||||
}
|
||||
.host-card-title a {
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
font-weight: 750;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-card-status {
|
||||
@@ -243,8 +662,8 @@
|
||||
}
|
||||
.host-card-layout {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.9fr);
|
||||
}
|
||||
.host-card-section {
|
||||
align-content: start;
|
||||
@@ -261,15 +680,17 @@
|
||||
.host-card-timeline {
|
||||
display: grid;
|
||||
gap: 16px 22px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
.host-card-stats {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
border-top: 1px solid #e6edf4;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid #e6edf4;
|
||||
border-radius: var(--radius);
|
||||
gap: 12px 18px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding-top: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.host-card-item {
|
||||
display: grid;
|
||||
@@ -285,6 +706,10 @@
|
||||
.host-card-item .value {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-card-item .value a {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.host-card-stat {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
@@ -298,7 +723,7 @@
|
||||
}
|
||||
.host-card-stat .value {
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
font-weight: 750;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-card-stat.wide {
|
||||
@@ -315,32 +740,65 @@
|
||||
margin-top: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
.host-card-warning > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
||||
.message {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
|
||||
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
|
||||
.message.warning { border-color: #e7cf8a; background: #fff8df; color: var(--running); }
|
||||
.form-grid { display: grid; gap: 14px; max-width: 680px; }
|
||||
.field { display: grid; gap: 5px; }
|
||||
.field label { font-weight: 650; }
|
||||
.form-grid { display: grid; gap: 15px; max-width: 720px; }
|
||||
.filter-form {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
max-width: none;
|
||||
}
|
||||
.form-actions {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
.form-actions .button-link.secondary { margin-left: auto; }
|
||||
.filter-form .form-actions {
|
||||
border-top: 0;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.filter-form .form-actions .button-link.secondary { margin-left: 0; }
|
||||
.field { display: grid; gap: 6px; }
|
||||
.field label { font-weight: 700; }
|
||||
.field input[type="text"], .field input[type="number"], .field select, .field textarea {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.field input[type="text"]:focus,
|
||||
.field input[type="number"]:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
border-color: #8bb9e3;
|
||||
}
|
||||
.field textarea { min-height: 92px; resize: vertical; }
|
||||
.field .helptext { color: var(--muted); font-size: 12px; }
|
||||
.field input[type="checkbox"] { justify-self: start; }
|
||||
pre {
|
||||
background: #101820;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius);
|
||||
color: #edf4fb;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
@@ -356,28 +814,100 @@
|
||||
padding: 0;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
main { padding: 16px; }
|
||||
nav { padding: 0; }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
body > header { padding: 0 14px; position: static; }
|
||||
main { padding: 18px 14px 32px; }
|
||||
nav {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
nav strong { flex-basis: 100%; margin-right: 0; }
|
||||
.nav-secondary {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.nav-user {
|
||||
margin-left: 0;
|
||||
}
|
||||
nav .spacer { display: none; }
|
||||
.page-header {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
}
|
||||
.page-header .actions { justify-content: flex-start; }
|
||||
.two-col,
|
||||
.panel-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-priority-grid { grid-template-columns: 1fr; }
|
||||
.host-control-grid { grid-template-columns: 1fr; }
|
||||
.schedule-row { grid-template-columns: 1fr; }
|
||||
.schedule-time { justify-items: start; text-align: left; }
|
||||
.form-actions .button-link.secondary { margin-left: 0; }
|
||||
.host-card-header { display: grid; }
|
||||
.host-card-status { justify-content: flex-start; max-width: none; }
|
||||
.host-card-layout { grid-template-columns: 1fr; }
|
||||
.host-card-stats { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
||||
.insight-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-priority-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.host-card-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.host-card-status {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
.schedule-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.schedule-time {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.dashboard-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.metric {
|
||||
min-height: 76px;
|
||||
padding: 12px;
|
||||
}
|
||||
.metric .value {
|
||||
font-size: 24px;
|
||||
}
|
||||
.host-card {
|
||||
padding: 13px;
|
||||
}
|
||||
.host-card-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
||||
<a href="{% url 'admin:index' %}">Admin</a>
|
||||
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
||||
<a href="{% url 'self_check' %}">Self Check</a>
|
||||
<a href="{% url 'logs' %}">Logs</a>
|
||||
<a href="/api/status/">Status API</a>
|
||||
<span class="nav-primary" aria-label="Primary navigation">
|
||||
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
|
||||
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
|
||||
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
|
||||
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">{{ request.user.username }}</span>
|
||||
<span class="nav-secondary" aria-label="System navigation">
|
||||
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
|
||||
<a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a>
|
||||
<a href="/api/status/">Status API</a>
|
||||
<a href="{% url 'admin:index' %}">Admin</a>
|
||||
</span>
|
||||
<span class="muted nav-user">{{ request.user.username }}</span>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
@@ -390,5 +920,29 @@
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const refreshRegion = async (region) => {
|
||||
if (region.dataset.refreshActive !== "true" || document.hidden) return;
|
||||
try {
|
||||
const response = await fetch(region.dataset.refreshUrl, {
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
region.innerHTML = await response.text();
|
||||
const refreshActive = response.headers.get("X-Pobsync-Refresh-Active");
|
||||
if (refreshActive) region.dataset.refreshActive = refreshActive;
|
||||
} catch (error) {
|
||||
// Keep the current server-rendered content visible if a refresh fails.
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-refresh-url]").forEach((region) => {
|
||||
const interval = Number.parseInt(region.dataset.refreshInterval || "5000", 10);
|
||||
window.setInterval(() => refreshRegion(region), Number.isFinite(interval) ? interval : 5000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
46
src/pobsync_backend/templates/pobsync_backend/changelog.html
Normal file
46
src/pobsync_backend/templates/pobsync_backend/changelog.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}Changelog - pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Release notes</div>
|
||||
<h1>Changelog</h1>
|
||||
<div class="page-subtitle">Installed release notes rendered from the repository changelog.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Changelog actions">
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="stack spaced">
|
||||
<div><strong>Installed version:</strong> {{ app_version }}</div>
|
||||
<div class="muted">Changelog file: {{ changelog_path }}</div>
|
||||
{% if missing %}
|
||||
<div class="status warning">missing</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
{% for block in changelog_blocks %}
|
||||
{% if block.kind == "heading" %}
|
||||
{% if block.level == 1 %}
|
||||
<h2>{{ block.text }}</h2>
|
||||
{% else %}
|
||||
<h3>{{ block.text }}</h3>
|
||||
{% endif %}
|
||||
{% elif block.kind == "list" %}
|
||||
<ul>
|
||||
{% for item in block.items %}
|
||||
<li>{{ item }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{{ block.text }}</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -3,12 +3,17 @@
|
||||
{% block title %}pobsync dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Control panel</div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Dashboard actions">
|
||||
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
|
||||
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
{% if not global_config or not counts.hosts %}
|
||||
<section class="panel">
|
||||
@@ -27,75 +32,28 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="grid" aria-label="Summary">
|
||||
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
|
||||
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||
<div class="metric {% if counts.queued_runs %}queued{% endif %}"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||
<div class="metric {% if counts.running_runs %}running{% endif %}"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||
<div class="metric {% if counts.warning_runs %}warning{% endif %}"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></div>
|
||||
<div class="metric {% if counts.failed_runs %}failed{% endif %}"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
<div
|
||||
data-refresh-url="{% url 'dashboard_priority_live' %}"
|
||||
data-refresh-interval="10000"
|
||||
data-refresh-active="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
{% include "pobsync_backend/partials/dashboard_priority.html" %}
|
||||
</div>
|
||||
|
||||
<section class="grid dashboard-summary-grid" aria-label="Summary">
|
||||
<a class="metric metric-link" href="#hosts"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></a>
|
||||
<a class="metric metric-link" href="{% url 'schedules_list' %}"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></a>
|
||||
<a class="metric metric-link" href="{% url 'snapshots_list' %}"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></a>
|
||||
<a class="metric metric-link" href="{% url 'runs_list' %}"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></a>
|
||||
<a class="metric metric-link {% if counts.warning_runs %}warning{% endif %}" href="{% url 'runs_list' %}?status=warning&review=needed"><div class="label">Warnings</div><div class="value">{{ counts.warning_runs }}</div></a>
|
||||
<a class="metric metric-link {% if counts.failed_runs %}failed{% endif %}" href="{% url 'runs_list' %}?status=failed&review=needed"><div class="label">Failed</div><div class="value">{{ counts.failed_runs }}</div></a>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>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, warning, or failed runs.</p>
|
||||
{% else %}
|
||||
<p class="muted">Add a host to start tracking backup status here.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel dashboard-trends-panel">
|
||||
<h2>Backup Trends</h2>
|
||||
{% if stats_summary.runs_sampled %}
|
||||
<div class="insight-grid" aria-label="Backup trends">
|
||||
<div class="insight-main">
|
||||
<div class="label">Storage Used</div>
|
||||
<div class="value">
|
||||
{% if stats_summary.capacity.used_percent is not None %}
|
||||
{{ stats_summary.capacity.used_percent|floatformat:1 }}%
|
||||
{% else %}
|
||||
unknown
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if stats_summary.capacity.used_percent is not None %}
|
||||
<div class="storage-meter" aria-label="Backup root storage usage">
|
||||
<span style="width: {{ stats_summary.capacity.used_percent|floatformat:0 }}%"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="muted">
|
||||
{{ stats_summary.capacity.available_bytes|filesizeformat }} available from the backup root.
|
||||
</div>
|
||||
</div>
|
||||
<div class="insight-item">
|
||||
<div class="label">Runway</div>
|
||||
<div class="value">
|
||||
@@ -136,151 +94,13 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Hosts</h2>
|
||||
<div class="host-list">
|
||||
{% for host in hosts %}
|
||||
<article class="host-card">
|
||||
<div class="host-card-header">
|
||||
<div class="host-card-title">
|
||||
<a href="{% url 'host_detail' host.host %}">{{ host.host }}</a>
|
||||
<span class="muted">{{ host.address }}</span>
|
||||
<div
|
||||
data-refresh-url="{% url 'dashboard_hosts_live' %}"
|
||||
data-refresh-interval="15000"
|
||||
data-refresh-active="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
{% include "pobsync_backend/partials/dashboard_hosts.html" %}
|
||||
</div>
|
||||
<div class="host-card-status">
|
||||
<span class="status {% if host.enabled %}ok{% else %}skipped{% endif %}">{{ host.enabled|yesno:"enabled,disabled" }}</span>
|
||||
{% if host.queued_run_count %}
|
||||
<span class="status queued">queued {{ host.queued_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.running_run_count %}
|
||||
<span class="status running">running {{ host.running_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.warning_run_count %}
|
||||
<span class="status warning">warning {{ host.warning_run_count }}</span>
|
||||
{% endif %}
|
||||
{% if host.failed_run_count %}
|
||||
<span class="status failed">failed {{ host.failed_run_count }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-layout">
|
||||
<div class="host-card-section">
|
||||
<div class="host-card-section-title">Backup activity</div>
|
||||
<div class="host-card-timeline">
|
||||
<div class="host-card-item">
|
||||
<div class="label">Latest Snapshot</div>
|
||||
<div class="value">
|
||||
{% if host.latest_snapshot %}
|
||||
<a href="{% url 'snapshot_detail' host.latest_snapshot.id %}">{{ host.latest_snapshot.dirname }}</a>
|
||||
<div class="muted">{{ host.latest_snapshot.kind }} {{ host.latest_snapshot.status }}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Last Good Backup</div>
|
||||
<div class="value">
|
||||
{% if host.stats_summary.latest_good_run.id %}
|
||||
<a href="{% url 'run_detail' host.stats_summary.latest_good_run.id %}">Run {{ host.stats_summary.latest_good_run.id }}</a>
|
||||
<div class="muted">{{ host.stats_summary.latest_good_run.run_type }} {{ host.stats_summary.latest_good_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_good_run.duration_seconds is not None %}s{% endif %}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Latest Issue</div>
|
||||
<div class="value">
|
||||
{% if host.stats_summary.latest_problem_run.id %}
|
||||
<a href="{% url 'run_detail' host.stats_summary.latest_problem_run.id %}">Run {{ host.stats_summary.latest_problem_run.id }}</a>
|
||||
<div><span class="status {{ host.stats_summary.latest_problem_run.status }}">{{ host.stats_summary.latest_problem_run.status }}</span></div>
|
||||
<div class="muted">{{ host.stats_summary.latest_problem_run.run_type }} {{ host.stats_summary.latest_problem_run.duration_seconds|default:"" }}{% if host.stats_summary.latest_problem_run.duration_seconds is not None %}s{% endif %}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-item">
|
||||
<div class="label">Next Run</div>
|
||||
<div class="value">
|
||||
{% if host.next_run_at %}
|
||||
{{ host.next_run_at|date:"Y-m-d H:i T" }}
|
||||
<div class="muted">{{ scheduler_timezone }}</div>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-card-section">
|
||||
<div class="host-card-section-title">Snapshot health</div>
|
||||
<div class="host-card-stats">
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Snapshots</div>
|
||||
<div class="value">{{ host.snapshot_count }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Runs</div>
|
||||
<div class="value">{{ host.run_count }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">New Data</div>
|
||||
<div class="value">{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</div>
|
||||
</div>
|
||||
<div class="host-card-stat">
|
||||
<div class="label">Retention</div>
|
||||
<div class="value">d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if host.retention_warning.has_warning %}
|
||||
<div class="host-card-warning">
|
||||
<span class="status warning">retention</span>
|
||||
{% if host.retention_warning.prune_exceeded %}
|
||||
Scheduled prune would delete {{ host.retention_warning.delete_count }} snapshot(s), above max {{ host.retention_warning.max_delete }}.
|
||||
{% endif %}
|
||||
{% if host.retention_warning.incomplete_count %}
|
||||
{{ host.retention_warning.incomplete_count }} incomplete snapshot(s) need review.
|
||||
{% 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 %}
|
||||
|
||||
@@ -3,17 +3,22 @@
|
||||
{% block title %}Global Config{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Configuration</div>
|
||||
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
|
||||
|
||||
<div class="page-subtitle">Defaults used by hosts unless a host overrides them explicitly.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Global config actions">
|
||||
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
|
||||
<div class="stack spaced">
|
||||
<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>
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
@@ -28,8 +33,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Save global config</button>
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -3,70 +3,13 @@
|
||||
{% block title %}{{ host.host }} | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Host</div>
|
||||
<h1>{{ host.host }}</h1>
|
||||
|
||||
<section class="actions" aria-label="Host actions">
|
||||
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Discover snapshots</button>
|
||||
</form>
|
||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
|
||||
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Prepare directories</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Scan SSH host key</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'run_host_preflight' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Run connection preflight</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="grid" aria-label="Host summary">
|
||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="panel">
|
||||
<h2>Config</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Address:</strong> {{ host.address }}</div>
|
||||
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
|
||||
<div><strong>SSH key:</strong> {{ host.ssh_credential|default:"global default" }}</div>
|
||||
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
|
||||
<div><strong>Source:</strong> {{ host.source_root|default:"global default" }}</div>
|
||||
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Schedule</h2>
|
||||
{% if schedule %}
|
||||
<div class="stack">
|
||||
<div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div>
|
||||
<div class="muted">Evaluated by the pobsync scheduler service.</div>
|
||||
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
|
||||
<div><strong>Next run:</strong> {% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }} <span class="muted">{{ scheduler_timezone }}</span>{% endif %}</div>
|
||||
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
|
||||
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
|
||||
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>
|
||||
<div><strong>Last finished:</strong> {{ schedule.last_finished_at|default:"" }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No schedule configured.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
<div class="page-subtitle">{{ host.address }} · {{ host.enabled|yesno:"enabled,disabled" }}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if retention_warning.has_warning %}
|
||||
<section class="panel highlight warning">
|
||||
@@ -84,6 +27,10 @@
|
||||
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
|
||||
snapshots automatically; inspect them before cleanup.
|
||||
</div>
|
||||
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Mark incomplete reviewed</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if retention_warning.error %}
|
||||
<div>{{ retention_warning.error }}</div>
|
||||
@@ -92,60 +39,137 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if effective_config %}
|
||||
<section class="panel">
|
||||
<h2>Effective Config</h2>
|
||||
<div class="two-col">
|
||||
<div class="stack">
|
||||
<div><strong>Source root:</strong> {{ effective_config.source_root }}</div>
|
||||
<div><strong>Destination subdir:</strong> {{ effective_config.destination_subdir|default:"none" }}</div>
|
||||
<div><strong>SSH:</strong> {{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</div>
|
||||
<div><strong>SSH key:</strong> {{ effective_config.ssh.credential|default:"none selected" }}</div>
|
||||
<div><strong>SSH options:</strong> {{ effective_config.ssh.options|join:" " }}</div>
|
||||
<div><strong>Rsync binary:</strong> {{ effective_config.rsync.binary }}</div>
|
||||
<div><strong>Rsync args:</strong> {{ effective_config.rsync.args|join:" " }}</div>
|
||||
<div><strong>Timeout:</strong> {{ effective_config.rsync.timeout_seconds }}s</div>
|
||||
<div><strong>Bandwidth limit:</strong> {{ effective_config.rsync.bwlimit_kbps }} KB/s</div>
|
||||
<section class="host-control-grid" aria-label="Host control workspace">
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Host Status</h2>
|
||||
<div class="host-control-primary">
|
||||
<div>
|
||||
<strong>Retention:</strong>
|
||||
d{{ effective_config.retention.daily }}
|
||||
w{{ effective_config.retention.weekly }}
|
||||
m{{ effective_config.retention.monthly }}
|
||||
y{{ effective_config.retention.yearly }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div><strong>Includes:</strong> {{ effective_config.includes|length }}</div>
|
||||
{% if effective_config.includes %}
|
||||
<pre>{{ effective_config.includes|join:" " }}</pre>
|
||||
{% if host.enabled %}
|
||||
<span class="status ok">enabled</span>
|
||||
{% else %}
|
||||
<div class="muted">No include rules configured.</div>
|
||||
<span class="status failed">disabled</span>
|
||||
{% endif %}
|
||||
<div><strong>Excludes:</strong> {{ effective_config.excludes|length }}</div>
|
||||
{% if effective_config.excludes %}
|
||||
<pre>{{ effective_config.excludes|join:" " }}</pre>
|
||||
<span class="muted">{{ host.address }}</span>
|
||||
</div>
|
||||
{% if active_run %}
|
||||
<a class="status-summary {{ active_run.status }}" href="{% url 'run_detail' active_run.id %}">
|
||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||
<strong>Run {{ active_run.id }} is active.</strong>
|
||||
</a>
|
||||
{% elif counts.failed_runs %}
|
||||
<a class="status-summary failed" href="{% url 'runs_list' %}?host={{ host.host }}&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 %}
|
||||
<div class="muted">No exclude rules configured.</div>
|
||||
<span class="status-summary success">
|
||||
<span class="status ok">ok</span>
|
||||
<strong>No active blockers for this host.</strong>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Snapshots</span><strong>{{ counts.snapshots }}</strong></div>
|
||||
<div><span class="label">Runs</span><strong>{{ counts.runs }}</strong></div>
|
||||
<div><span class="label">Incomplete</span><strong>{{ counts.incomplete_snapshots }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshot Discovery</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Backup root:</strong> {{ discovery.backup_root|default:"" }}</div>
|
||||
<div><strong>Host root:</strong> {{ discovery.host_root|default:"" }}</div>
|
||||
<div><strong>Status:</strong> {{ discovery.message }}</div>
|
||||
{% if discovery.kind_counts %}
|
||||
<div><strong>On disk:</strong>
|
||||
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
|
||||
manual {{ discovery.kind_counts.manual|default:0 }},
|
||||
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
|
||||
</div>
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Backup Control</h2>
|
||||
<div class="operator-state">
|
||||
{% if active_run %}
|
||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
||||
{% elif has_global_config and host.enabled %}
|
||||
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
|
||||
<span class="muted">{{ backup_gate.message }}</span>
|
||||
{% elif not host.enabled %}
|
||||
<span class="status failed">disabled</span>
|
||||
{% elif not has_global_config %}
|
||||
<span class="status failed">missing global config</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<section class="actions inline" aria-label="Quick backup actions">
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dry_run" value="on">
|
||||
<input type="hidden" name="verbose_output" value="on">
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
|
||||
</form>
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
||||
</form>
|
||||
</section>
|
||||
{% if active_run %}
|
||||
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
|
||||
{% elif not can_queue_dry_run or not can_queue_real_backup %}
|
||||
{% if not has_global_config %}
|
||||
<p class="muted">Create the default global config before queueing backups.</p>
|
||||
{% elif not host.enabled %}
|
||||
<p class="muted">Enable this host before queueing backups.</p>
|
||||
{% elif backup_gate.real_blockers %}
|
||||
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Schedule <a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a></h2>
|
||||
{% if schedule %}
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
|
||||
<div><span class="label">Next run</span><strong>{% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }}{% else %}none{% endif %}</strong></div>
|
||||
<div><span class="label">Timezone</span><strong>{{ scheduler_timezone }}</strong></div>
|
||||
<div><span class="label">Prune</span><strong>{{ schedule.prune|yesno:"yes,no" }}</strong></div>
|
||||
<div><span class="label">Last status</span><strong>{{ schedule.last_status|default:"none" }}</strong></div>
|
||||
</div>
|
||||
<p class="muted">Evaluated by the pobsync scheduler service.</p>
|
||||
{% else %}
|
||||
<p class="muted">No schedule configured.</p>
|
||||
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel host-control-panel">
|
||||
<h2>Current Activity</h2>
|
||||
{% if latest_runs %}
|
||||
{% with run=latest_runs.0 %}
|
||||
<a class="activity-row" href="{% url 'run_detail' run.id %}">
|
||||
<span class="status {{ run.status }}">{{ run.status }}</span>
|
||||
<span>
|
||||
<strong>Run {{ run.id }}</strong>
|
||||
<span class="muted">{{ run.run_type }} · {{ run.started_at|default:run.created_at }}</span>
|
||||
</span>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<p class="muted">No backup runs recorded for this host.</p>
|
||||
{% endif %}
|
||||
{% if stats_summary.latest_run.duration_seconds is not None %}
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Latest duration</span><strong>{{ stats_summary.latest_run.duration_seconds }}s</strong></div>
|
||||
<div><span class="label">New data</span><strong>{{ stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</strong></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid" aria-label="Host summary">
|
||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||
<div class="metric"><div class="label">Runs</div><div class="value">{{ counts.runs }}</div></div>
|
||||
<div class="metric"><div class="label">Queued</div><div class="value">{{ counts.queued_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Running</div><div class="value">{{ counts.running_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Failed Runs</div><div class="value">{{ counts.failed_runs }}</div></div>
|
||||
<div class="metric"><div class="label">Incomplete</div><div class="value">{{ counts.incomplete_snapshots }}</div></div>
|
||||
</section>
|
||||
|
||||
{% if stats_summary.runs %}
|
||||
@@ -205,104 +229,197 @@
|
||||
<div class="metric"><div class="label">Failed</div><div class="value">{{ host_check_summary.failed }}</div></div>
|
||||
<div class="metric"><div class="label">Skipped</div><div class="value">{{ host_check_summary.skipped }}</div></div>
|
||||
</section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Check</th>
|
||||
<th>Message</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="record-list">
|
||||
{% for check in host_checks %}
|
||||
<tr>
|
||||
<td><span class="status {{ check.status }}">{{ check.status }}</span></td>
|
||||
<td>{{ check.name }}</td>
|
||||
<td>{{ check.message }}</td>
|
||||
<td class="muted">{{ check.detail }}</td>
|
||||
</tr>
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>{{ check.name }}</strong>
|
||||
<span class="muted">{{ check.message }}</span>
|
||||
</div>
|
||||
<span class="status {{ check.status }}">{{ check.status }}</span>
|
||||
</div>
|
||||
{% if check.detail %}
|
||||
<div class="record-fact">
|
||||
<span class="label">Detail</span>
|
||||
<span class="muted">{{ check.detail }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="panel-grid">
|
||||
<section class="panel">
|
||||
<h2>Configuration</h2>
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
|
||||
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
|
||||
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
|
||||
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
|
||||
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
|
||||
</div>
|
||||
<div class="actions inline">
|
||||
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Connection Preflight & SSH</h2>
|
||||
{% if last_preflight %}
|
||||
<section class="panel">
|
||||
<h2>Connection Preflight</h2>
|
||||
<div class="stack spaced">
|
||||
<div><strong>Status:</strong> <span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">{% if last_preflight.ok %}ok{% else %}failed{% endif %}</span></div>
|
||||
<div><strong>Target:</strong> {{ last_preflight.target }}</div>
|
||||
<div><strong>Source root:</strong> {{ last_preflight.source_root }}</div>
|
||||
<div><strong>Remote rsync:</strong> {{ last_preflight.rsync_binary }}</div>
|
||||
<div class="host-control-meta">
|
||||
<div>
|
||||
<span class="label">Preflight</span>
|
||||
<strong>
|
||||
<span class="status {% if last_preflight.ok %}ok{% else %}failed{% endif %}">
|
||||
{% if last_preflight.ok %}ok{% else %}failed{% endif %}
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Check</th>
|
||||
<th>Message</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div><span class="label">Target</span><strong>{{ last_preflight.target }}</strong></div>
|
||||
<div><span class="label">Backup source</span><strong>{{ last_preflight.source_root }}</strong></div>
|
||||
<div><span class="label">Remote rsync</span><strong>{{ last_preflight.rsync_binary }}</strong></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No connection preflight recorded yet.</p>
|
||||
{% endif %}
|
||||
<div class="actions inline">
|
||||
<form method="post" action="{% url 'run_host_preflight' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Run connection preflight</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'scan_host_known_key' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Scan SSH host key</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if last_preflight.checks %}
|
||||
<div class="activity-list">
|
||||
{% for check in last_preflight.checks %}
|
||||
<tr>
|
||||
<td><span class="status {% if check.ok %}ok{% else %}failed{% endif %}">{% if check.ok %}ok{% else %}failed{% endif %}</span></td>
|
||||
<td>{{ check.name }}</td>
|
||||
<td>{{ check.message }}</td>
|
||||
<td class="muted">{{ check.detail }}</td>
|
||||
</tr>
|
||||
<div class="activity-row">
|
||||
<span class="status {% if check.ok %}ok{% else %}failed{% endif %}">
|
||||
{% if check.ok %}ok{% else %}failed{% endif %}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{{ check.name }}</strong>
|
||||
<span class="muted">{{ check.message }}{% if check.detail %} · {{ check.detail }}{% endif %}</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshot Storage</h2>
|
||||
<div class="host-control-meta">
|
||||
<div><span class="label">Backup root</span><strong>{{ discovery.backup_root|default:"" }}</strong></div>
|
||||
<div><span class="label">Host root</span><strong>{{ discovery.host_root|default:"" }}</strong></div>
|
||||
<div><span class="label">Status</span><strong>{{ discovery.message }}</strong></div>
|
||||
{% if discovery.kind_counts %}
|
||||
<div>
|
||||
<span class="label">On disk</span>
|
||||
<strong>
|
||||
scheduled {{ discovery.kind_counts.scheduled|default:0 }},
|
||||
manual {{ discovery.kind_counts.manual|default:0 }},
|
||||
incomplete {{ discovery.kind_counts.incomplete|default:0 }}
|
||||
</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="actions inline">
|
||||
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Discover snapshots</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary compact">Prepare directories</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if effective_config %}
|
||||
<section class="panel">
|
||||
<h2>Effective Config</h2>
|
||||
<p class="muted">Runtime settings after global defaults and host overrides are combined.</p>
|
||||
<div class="record-list">
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>Backup target</strong>
|
||||
<span class="muted">Source and destination used by rsync.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact"><span class="label">Backup source:</span><strong>{{ effective_config.source_root }}</strong></div>
|
||||
<div class="record-fact"><span class="label">Destination subdir:</span><strong>{{ effective_config.destination_subdir|default:"none" }}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>Connection</strong>
|
||||
<span class="muted">SSH and rsync execution settings.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact"><span class="label">SSH:</span><strong>{{ effective_config.ssh.user }}@{{ host.address }}:{{ effective_config.ssh.port }}</strong></div>
|
||||
<div class="record-fact"><span class="label">SSH key:</span><strong>{{ effective_config.ssh.credential|default:"none selected" }}</strong></div>
|
||||
<div class="record-fact"><span class="label">SSH options:</span><span>{{ effective_config.ssh.options|join:" " }}</span></div>
|
||||
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
|
||||
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
|
||||
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
|
||||
<div class="record-fact"><span class="label">Bandwidth limit:</span><strong>{{ effective_config.rsync.bwlimit_kbps }} KB/s</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<strong>Selection & 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>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Backup Control</h2>
|
||||
<div class="operator-state">
|
||||
{% if active_run %}
|
||||
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
|
||||
<a href="{% url 'run_detail' active_run.id %}">Run {{ active_run.id }}</a>
|
||||
{% elif has_global_config and host.enabled %}
|
||||
<span class="status {{ backup_gate.state }}">{{ backup_gate.state }}</span>
|
||||
<span class="muted">{{ backup_gate.message }}</span>
|
||||
{% elif not host.enabled %}
|
||||
<span class="status failed">disabled</span>
|
||||
{% elif not has_global_config %}
|
||||
<span class="status failed">missing global config</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="actions inline" aria-label="Quick backup actions">
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="dry_run" value="on">
|
||||
<input type="hidden" name="verbose_output" value="on">
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" class="secondary" {% if not can_queue_dry_run %}disabled{% endif %}>Queue dry-run</button>
|
||||
</form>
|
||||
<form class="inline-form" method="post" action="{% url 'queue_manual_backup' host.host %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prune_max_delete" value="10">
|
||||
<button type="submit" {% if not can_queue_real_backup %}disabled{% endif %}>Queue backup</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if active_run %}
|
||||
<p class="muted">Wait for the active run to finish, or cancel it from the run detail page.</p>
|
||||
{% elif not can_queue_dry_run or not can_queue_real_backup %}
|
||||
{% if not has_global_config %}
|
||||
<p class="muted">Create the default global config before queueing backups.</p>
|
||||
{% elif not host.enabled %}
|
||||
<p class="muted">Enable this host before queueing backups.</p>
|
||||
{% elif backup_gate.real_blockers %}
|
||||
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h3>Advanced Options</h3>
|
||||
<h2>Backup Options</h2>
|
||||
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
|
||||
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
{{ manual_backup_form.non_field_errors }}
|
||||
@@ -316,65 +433,95 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Latest Runs</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Ended</th>
|
||||
<th>Snapshot</th>
|
||||
<th>Base</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>
|
||||
<div class="record-list">
|
||||
{% for run in latest_runs %}
|
||||
<tr>
|
||||
<td><a href="{% url 'run_detail' run.id %}"><span class="status {{ run.status }}">{{ run.status }}</span></a></td>
|
||||
<td>{{ run.started_at|default:"" }}</td>
|
||||
<td>{{ run.ended_at|default:"" }}</td>
|
||||
<td>{% if run.snapshot %}<a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a>{% else %}<span class="muted">{{ run.snapshot_path }}</span>{% endif %}</td>
|
||||
<td>{{ run.base_path|default:"" }}</td>
|
||||
</tr>
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<a href="{% url 'run_detail' run.id %}">Run {{ run.id }}</a>
|
||||
<span class="muted">{{ run.run_type }}{% if run.result.duration_seconds %} · {{ run.result.duration_seconds }}s{% endif %}</span>
|
||||
</div>
|
||||
<span class="status {{ run.status }}">{{ run.status }}</span>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact">
|
||||
<span class="label">Started</span>
|
||||
<strong>{{ run.started_at|default:run.created_at }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Ended</span>
|
||||
<strong>{{ run.ended_at|default:"running or queued" }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Snapshot</span>
|
||||
{% if run.snapshot %}
|
||||
<strong><a href="{% url 'snapshot_detail' run.snapshot.id %}">{{ run.snapshot.dirname }}</a></strong>
|
||||
{% elif run.snapshot_path %}
|
||||
<span class="muted">{{ run.snapshot_path }}</span>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Base</span>
|
||||
<span class="muted">{{ run.base_path|default:"none" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No backup runs recorded for this host.</td></tr>
|
||||
<p class="muted">No backup runs recorded for this host.</p>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Snapshots</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Dirname</th>
|
||||
<th>Base</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<h2>Snapshots <a class="button-link secondary compact" href="{% url 'snapshots_list' %}?host={{ host.host }}">View all</a></h2>
|
||||
<div class="record-list">
|
||||
{% for snapshot in snapshots %}
|
||||
<tr>
|
||||
<td>{{ snapshot.kind }}</td>
|
||||
<td>{{ snapshot.status }}</td>
|
||||
<td>{{ snapshot.started_at|default:"" }}</td>
|
||||
<td><a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a></td>
|
||||
<td>{% if snapshot.base %}<a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a>{% else %}{{ snapshot.base_dirname }}{% endif %}</td>
|
||||
</tr>
|
||||
<article class="record-card">
|
||||
<div class="record-card-header">
|
||||
<div class="record-title">
|
||||
<a href="{% url 'snapshot_detail' snapshot.id %}">{{ snapshot.dirname }}</a>
|
||||
<span class="muted">{{ snapshot.kind }}</span>
|
||||
</div>
|
||||
<span class="status {{ snapshot.status }}">{{ snapshot.status }}</span>
|
||||
</div>
|
||||
<div class="record-facts">
|
||||
<div class="record-fact">
|
||||
<span class="label">Started</span>
|
||||
<strong>{{ snapshot.started_at|default:"unknown" }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Ended</span>
|
||||
<strong>{{ snapshot.ended_at|default:"unknown" }}</strong>
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Base</span>
|
||||
{% if snapshot.base %}
|
||||
<strong><a href="{% url 'snapshot_detail' snapshot.base.id %}">{{ snapshot.base.dirname }}</a></strong>
|
||||
{% elif snapshot.base_dirname %}
|
||||
<span class="muted">{{ snapshot.base_dirname }}</span>
|
||||
{% else %}
|
||||
<span class="muted">none</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="record-fact">
|
||||
<span class="label">Path</span>
|
||||
<span class="muted">{{ snapshot.path|default:"not recorded" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No snapshots discovered for this host.</td></tr>
|
||||
<p class="muted">No snapshots discovered for this host.</p>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Configuration</div>
|
||||
<h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
|
||||
|
||||
<div class="page-subtitle">Host-specific backup, retention, SSH, include, and exclude settings.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Config actions">
|
||||
{% if host %}
|
||||
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||
@@ -12,6 +16,7 @@
|
||||
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>
|
||||
@@ -28,8 +33,13 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">{% if host %}Save config{% else %}Create host{% endif %}</button>
|
||||
{% if host %}
|
||||
<a class="button-link secondary" href="{% url 'host_detail' host.host %}">Cancel</a>
|
||||
{% else %}
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
{% block title %}Logs | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Operations</div>
|
||||
<h1>Logs</h1>
|
||||
|
||||
<div class="page-subtitle">Filter pobsync service logs by unit, priority, host, run, or message content.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Log actions">
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filter</h2>
|
||||
<form method="get" class="form-grid">
|
||||
<form method="get" class="filter-form">
|
||||
<div class="field">
|
||||
<label for="unit">Unit</label>
|
||||
<select id="unit" name="unit">
|
||||
@@ -49,8 +54,9 @@
|
||||
<label for="q">Message contains</label>
|
||||
<input id="q" name="q" value="{{ query }}">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Filter logs</button>
|
||||
<a class="button-link secondary" href="{% url 'logs' %}">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<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 %}
|
||||
</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>
|
||||
@@ -0,0 +1,130 @@
|
||||
<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 %}
|
||||
</article>
|
||||
</section>
|
||||
@@ -0,0 +1,150 @@
|
||||
<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>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>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}Purged Snapshots | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Retention</div>
|
||||
<h1>Purged Snapshots</h1>
|
||||
<div class="page-subtitle">Audit trail for snapshots removed by retention or manual purge actions.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Purged snapshot actions">
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Filters</h2>
|
||||
<form method="get" class="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="action">Action</label>
|
||||
<select id="action" name="action">
|
||||
<option value="">All actions</option>
|
||||
{% for value, label in actions %}
|
||||
<option value="{{ value }}" {% if selected_action == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="button-link secondary" href="{% url 'purged_snapshots' %}">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Purged Snapshot History</h2>
|
||||
<p class="muted">Showing up to 200 of {{ total_count }} purged snapshot record(s).</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Purged</th>
|
||||
<th>Host</th>
|
||||
<th>Kind</th>
|
||||
<th>Dirname</th>
|
||||
<th>Action</th>
|
||||
<th>Reason</th>
|
||||
<th>Triggered by</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for snapshot in purged_snapshots %}
|
||||
<tr>
|
||||
<td>{{ snapshot.purged_at }}</td>
|
||||
<td>{% if snapshot.host %}<a href="{% url 'host_detail' snapshot.host.host %}">{{ snapshot.host_name }}</a>{% else %}{{ snapshot.host_name }}{% endif %}</td>
|
||||
<td>{{ snapshot.kind }}</td>
|
||||
<td>{{ snapshot.dirname }}</td>
|
||||
<td><span class="status skipped">{{ snapshot.get_action_display }}</span></td>
|
||||
<td>{{ snapshot.reason|default:"" }}</td>
|
||||
<td>{{ snapshot.triggered_by|default:"" }}</td>
|
||||
<td class="muted">{{ snapshot.path }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8" class="muted">No purged snapshots recorded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -3,8 +3,12 @@
|
||||
{% block title %}Retention plan | {{ host.host }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Retention Plan: {{ host.host }}</h1>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Retention</div>
|
||||
<h1>{{ host.host }}</h1>
|
||||
<div class="page-subtitle">Preview which snapshots stay, which would be deleted, and whether incomplete cleanup is needed.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Retention filters">
|
||||
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=scheduled">Scheduled</a>
|
||||
@@ -12,9 +16,9 @@
|
||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind=all">All</a>
|
||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}&protect_bases=1">Protect bases</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<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">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>
|
||||
@@ -40,6 +44,10 @@
|
||||
{{ plan.incomplete|length }} incomplete snapshot(s) exist for this host. Retention does not delete incomplete
|
||||
snapshots automatically because they can indicate an interrupted backup that should be inspected first.
|
||||
</p>
|
||||
<p>
|
||||
After inspection, use the dedicated cleanup form below to delete only incomplete snapshot directories and their
|
||||
tracking records. Successful scheduled and manual snapshots are not touched by this cleanup.
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -96,8 +104,12 @@
|
||||
</section>
|
||||
|
||||
{% if plan.delete %}
|
||||
<section class="panel">
|
||||
<section class="panel highlight warning">
|
||||
<h2>Apply Retention</h2>
|
||||
<p class="muted">
|
||||
This permanently deletes the snapshot directories listed in Would Delete. Confirm the host and delete count
|
||||
before applying the plan.
|
||||
</p>
|
||||
<form method="post" action="{% url 'apply_host_retention' host.host %}" class="form-grid">
|
||||
{% csrf_token %}
|
||||
{{ apply_form.non_field_errors }}
|
||||
@@ -130,8 +142,9 @@
|
||||
<div class="helptext">{{ apply_form.confirm_delete_count.help_text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Apply retention</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger">Apply retention</button>
|
||||
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -190,6 +203,42 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Cleanup Incomplete Snapshots</h3>
|
||||
<p class="muted">
|
||||
This deletes only 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">
|
||||
{{ incomplete_cleanup_form.max_delete.errors }}
|
||||
<label for="{{ incomplete_cleanup_form.max_delete.id_for_label }}">Max delete</label>
|
||||
{{ incomplete_cleanup_form.max_delete }}
|
||||
<div class="helptext">{{ incomplete_cleanup_form.max_delete.help_text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
{{ incomplete_cleanup_form.confirm_host.errors }}
|
||||
<label for="{{ incomplete_cleanup_form.confirm_host.id_for_label }}">Confirm host</label>
|
||||
{{ incomplete_cleanup_form.confirm_host }}
|
||||
<div class="helptext">{{ incomplete_cleanup_form.confirm_host.help_text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
{{ incomplete_cleanup_form.confirm_delete_count.errors }}
|
||||
<label for="{{ incomplete_cleanup_form.confirm_delete_count.id_for_label }}">Confirm incomplete count</label>
|
||||
{{ incomplete_cleanup_form.confirm_delete_count }}
|
||||
<div class="helptext">{{ incomplete_cleanup_form.confirm_delete_count.help_text }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger">Delete incomplete snapshots</button>
|
||||
<a class="button-link secondary" href="{% url 'host_retention_plan' host.host %}?kind={{ kind }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,102 +3,24 @@
|
||||
{% block title %}Run {{ run.id }} | {{ run.host.host }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Backup run</div>
|
||||
<h1>Run {{ run.id }}</h1>
|
||||
|
||||
<div class="page-subtitle">{{ run.host.host }} · {{ run.run_type }} · {{ run.status }}</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Run actions">
|
||||
<a class="button-link" href="{% url 'host_detail' run.host.host %}">Back to host</a>
|
||||
{% if can_cancel %}
|
||||
<form method="post" action="{% url 'cancel_run' run.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="secondary">Cancel run</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="grid" aria-label="Run summary">
|
||||
<div class="metric"><div class="label">Host</div><div class="value">{{ run.host.host }}</div></div>
|
||||
<div class="metric"><div class="label">Status</div><div class="value"><span class="status {{ run.status }}">{{ run.status }}</span></div></div>
|
||||
<div class="metric"><div class="label">Type</div><div class="value">{{ run.run_type }}</div></div>
|
||||
<div class="metric"><div class="label">Rsync</div><div class="value">{{ run.rsync_exit_code|default:"" }}</div></div>
|
||||
</section>
|
||||
|
||||
{% if failure %}
|
||||
<section class="panel highlight failed">
|
||||
<h2>Failure</h2>
|
||||
<div class="stack">
|
||||
<div><strong>Category:</strong> {{ failure.category|default:"unknown" }}</div>
|
||||
<div><strong>Summary:</strong> {{ failure_summary }}</div>
|
||||
<div><strong>Hint:</strong> {{ failure.hint|default:"" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if 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>
|
||||
</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
|
||||
data-refresh-url="{% url 'run_detail_live' run.id %}"
|
||||
data-refresh-interval="5000"
|
||||
data-refresh-active="{{ can_auto_refresh|yesno:'true,false' }}"
|
||||
aria-live="polite"
|
||||
>
|
||||
{% include "pobsync_backend/partials/run_detail_live.html" %}
|
||||
</div>
|
||||
|
||||
{% if requested %}
|
||||
@@ -124,26 +46,6 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Rsync Log</h2>
|
||||
<div class="stack spaced">
|
||||
{% if rsync_log_exists %}
|
||||
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
|
||||
<div class="muted">{{ rsync_log_path }}</div>
|
||||
{% elif rsync_log_path %}
|
||||
<div class="muted">{{ rsync_log_path }} (missing)</div>
|
||||
{% else %}
|
||||
<div class="muted">No rsync log path recorded yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if rsync_log_tail %}
|
||||
<pre>{% for line in rsync_log_tail %}{{ line }}{% if not forloop.last %}
|
||||
{% endif %}{% endfor %}</pre>
|
||||
{% else %}
|
||||
<p class="muted">No recent rsync log output recorded yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if stats %}
|
||||
<section class="panel">
|
||||
<h2>Stats</h2>
|
||||
@@ -171,7 +73,6 @@
|
||||
<h2>Retention</h2>
|
||||
<div class="stack">
|
||||
<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.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 %}
|
||||
|
||||
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 content %}
|
||||
<h1>Schedule: {{ host.host }}</h1>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Schedule</div>
|
||||
<h1>{{ host.host }}</h1>
|
||||
<div class="page-subtitle">Automatic backup timing and scheduled prune behavior for this host.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Schedule actions">
|
||||
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</h2>
|
||||
@@ -25,8 +30,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Save schedule</button>
|
||||
<a class="button-link secondary" href="{% url 'host_detail' host.host %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -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 content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Operations</div>
|
||||
<h1>Self Check</h1>
|
||||
|
||||
<div class="page-subtitle">Runtime, filesystem, service, and configuration checks for this pobsync installation.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Self check actions">
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="grid" aria-label="Self check summary">
|
||||
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div>
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
{% block title %}Snapshot {{ snapshot.dirname }} | {{ snapshot.host.host }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Snapshot</div>
|
||||
<h1>{{ snapshot.dirname }}</h1>
|
||||
|
||||
<div class="page-subtitle">{{ snapshot.host.host }} · {{ snapshot.kind }} · {{ snapshot.status }}</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="Snapshot actions">
|
||||
<a class="button-link" href="{% url 'host_detail' snapshot.host.host %}">Back to host</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="grid" aria-label="Snapshot summary">
|
||||
<div class="metric"><div class="label">Host</div><div class="value">{{ snapshot.host.host }}</div></div>
|
||||
@@ -63,7 +68,7 @@
|
||||
<section class="panel">
|
||||
<h2>Restore Guidance</h2>
|
||||
<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 class="muted">
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
<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 content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Access</div>
|
||||
<h1>{% if credential %}SSH Key: {{ credential.name }}{% else %}New SSH Key{% endif %}</h1>
|
||||
|
||||
<div class="page-subtitle">{% if credential %}Review key metadata, known hosts, and deletion safety for this credential.{% else %}Register an existing private key for use by pobsync backups.{% endif %}</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="SSH key form actions">
|
||||
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>
|
||||
@@ -36,8 +41,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">{% if credential %}Save SSH key{% else %}Create SSH key{% endif %}</button>
|
||||
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -45,9 +51,24 @@
|
||||
{% if credential %}
|
||||
<section class="panel">
|
||||
<h2>Delete SSH Key</h2>
|
||||
{% if credential.hosts.exists or credential.global_configs.exists %}
|
||||
<p class="muted">
|
||||
This SSH key is still selected by {{ credential.hosts.count }} host(s) or
|
||||
{{ credential.global_configs.count }} global config(s). Select another key there before deleting it.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="muted">Type <strong>{{ credential.name }}</strong> to confirm deletion.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'delete_ssh_credential' credential.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="danger">Delete SSH key</button>
|
||||
<div class="field">
|
||||
<label for="confirm_name">Confirm key name</label>
|
||||
<input id="confirm_name" name="confirm_name" type="text" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger" {% if credential.hosts.exists or credential.global_configs.exists %}disabled{% endif %}>Delete SSH key</button>
|
||||
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
{% block title %}Generate SSH Key | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Access</div>
|
||||
<h1>Generate SSH Key</h1>
|
||||
|
||||
<div class="page-subtitle">Create a pobsync-managed SSH key pair for one or more backup targets.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="SSH key form actions">
|
||||
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Create Key Pair</h2>
|
||||
@@ -24,8 +29,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<div class="form-actions">
|
||||
<button type="submit">Generate SSH key</button>
|
||||
<a class="button-link secondary" href="{% url 'ssh_credentials' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
{% block title %}SSH Keys | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<div class="page-kicker">Access</div>
|
||||
<h1>SSH Keys</h1>
|
||||
|
||||
<div class="page-subtitle">Manage the key pairs pobsync uses to reach backup targets.</div>
|
||||
</div>
|
||||
<section class="actions" aria-label="SSH key actions">
|
||||
<a class="button-link" href="{% url 'generate_ssh_credential' %}">Generate SSH key</a>
|
||||
<a class="button-link secondary" href="{% url 'create_ssh_credential' %}">Add existing key</a>
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Credentials</h2>
|
||||
@@ -23,6 +28,7 @@
|
||||
<th>Known hosts</th>
|
||||
<th>Hosts</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -35,9 +41,10 @@
|
||||
<td>{{ credential.known_hosts|yesno:"yes,no" }}</td>
|
||||
<td>{{ credential.hosts.count }}</td>
|
||||
<td>{{ credential.updated_at }}</td>
|
||||
<td><a class="button-link secondary" href="{% url 'edit_ssh_credential' credential.id %}">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="7" class="muted">No SSH credentials configured yet.</td></tr>
|
||||
<tr><td colspan="8" class="muted">No SSH credentials configured yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -61,6 +61,9 @@ class BackupWorkerTests(TestCase):
|
||||
def fake_run_scheduled(**kwargs):
|
||||
run.refresh_from_db()
|
||||
self.assertIn("execution", run.result)
|
||||
self.assertIn("worker_pid", run.result["execution"])
|
||||
self.assertIn("worker_host", run.result["execution"])
|
||||
self.assertIn("heartbeat_at", run.result["execution"])
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": False,
|
||||
@@ -82,6 +85,57 @@ class BackupWorkerTests(TestCase):
|
||||
self.assertEqual(SnapshotRecord.objects.count(), 1)
|
||||
self.assertEqual(run.snapshot, SnapshotRecord.objects.get())
|
||||
|
||||
def test_worker_refreshes_heartbeat_while_run_is_active(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = queue_backup_run(host=host)
|
||||
|
||||
with patch("pobsync_backend.backup_runner.run_scheduled") as run_scheduled:
|
||||
def fake_run_scheduled(**kwargs):
|
||||
run.refresh_from_db()
|
||||
old_heartbeat = timezone.now() - timedelta(seconds=120)
|
||||
run.result["execution"]["heartbeat_at"] = old_heartbeat.isoformat()
|
||||
run.save(update_fields=["result"])
|
||||
|
||||
self.assertFalse(kwargs["cancel_check"]())
|
||||
run.refresh_from_db()
|
||||
self.assertGreater(
|
||||
timezone.datetime.fromisoformat(run.result["execution"]["heartbeat_at"]),
|
||||
old_heartbeat,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": False,
|
||||
"host": host.host,
|
||||
"snapshot": "",
|
||||
"base": None,
|
||||
"rsync": {"exit_code": 0},
|
||||
}
|
||||
|
||||
run_scheduled.side_effect = fake_run_scheduled
|
||||
Command()._run_once(prefix=Path(tmp) / "home")
|
||||
|
||||
def test_worker_reconciles_stale_real_run_after_heartbeat_timeout(self) -> None:
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = queue_backup_run(host=host)
|
||||
run.status = BackupRun.Status.RUNNING
|
||||
run.started_at = timezone.now() - timedelta(seconds=120)
|
||||
run.result["execution"] = {
|
||||
"worker_pid": 123,
|
||||
"worker_host": "backup",
|
||||
"heartbeat_at": (timezone.now() - timedelta(seconds=90)).isoformat(),
|
||||
}
|
||||
run.save(update_fields=["status", "started_at", "result"])
|
||||
|
||||
reconciled = reconcile_running_runs(stale_worker_seconds=30)
|
||||
|
||||
self.assertEqual(reconciled, 1)
|
||||
run.refresh_from_db()
|
||||
self.assertEqual(run.status, BackupRun.Status.FAILED)
|
||||
self.assertEqual(run.result["failure"]["category"], "worker")
|
||||
self.assertIn("heartbeat stopped", run.result["failure"]["message"])
|
||||
|
||||
def test_worker_records_dry_run_log_path_while_running(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups"))
|
||||
|
||||
@@ -9,6 +9,14 @@ from pobsync.cli import main
|
||||
|
||||
|
||||
class ConsoleEntrypointTests(SimpleTestCase):
|
||||
def test_version_prints_package_version(self) -> None:
|
||||
stdout = StringIO()
|
||||
with patch("sys.stdout", stdout):
|
||||
exit_code = main(["--version"])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.1.0")
|
||||
|
||||
def test_maps_backup_alias_to_django_command(self) -> None:
|
||||
with patch("pobsync.cli.execute_from_command_line") as execute:
|
||||
exit_code = main(["backup", "web-01", "--dry-run"])
|
||||
|
||||
@@ -96,6 +96,7 @@ class RunBackupRecordsSnapshotTests(TestCase):
|
||||
protect_bases=True,
|
||||
yes=True,
|
||||
max_delete=3,
|
||||
action=BackupRun.RunType.SCHEDULED,
|
||||
acquire_lock=False,
|
||||
)
|
||||
run = BackupRun.objects.get()
|
||||
|
||||
@@ -11,8 +11,8 @@ from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from pobsync.errors import ConfigError
|
||||
from pobsync_backend.models import HostConfig, SnapshotRecord
|
||||
from pobsync_backend.retention import run_sql_retention_apply, run_sql_retention_plan
|
||||
from pobsync_backend.models import HostConfig, PurgedSnapshot, SnapshotRecord
|
||||
from pobsync_backend.retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
|
||||
|
||||
|
||||
class SqlRetentionTests(TestCase):
|
||||
@@ -87,10 +87,26 @@ class SqlRetentionTests(TestCase):
|
||||
self.assertTrue(new_dir.exists())
|
||||
self.assertTrue(SnapshotRecord.objects.filter(pk=new.pk).exists())
|
||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
|
||||
self.assertEqual(result["deleted"], [{"dirname": old.dirname, "kind": "scheduled", "path": str(old_dir)}])
|
||||
self.assertEqual(
|
||||
result["deleted"],
|
||||
[
|
||||
{
|
||||
"dirname": old.dirname,
|
||||
"kind": "scheduled",
|
||||
"path": str(old_dir),
|
||||
"reason": "outside retention policy",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(result["planned_delete_count"], 1)
|
||||
self.assertEqual(result["max_delete"], 1)
|
||||
self.assertEqual(result["incomplete_ignored_count"], 0)
|
||||
purged = PurgedSnapshot.objects.get(dirname=old.dirname)
|
||||
self.assertEqual(purged.host_name, host.host)
|
||||
self.assertEqual(purged.kind, "scheduled")
|
||||
self.assertEqual(purged.path, str(old_dir))
|
||||
self.assertEqual(purged.reason, "outside retention policy")
|
||||
self.assertEqual(purged.action, PurgedSnapshot.Action.MANUAL)
|
||||
|
||||
def test_apply_deletes_snapshot_with_readonly_data_directory(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
@@ -126,7 +142,17 @@ class SqlRetentionTests(TestCase):
|
||||
|
||||
self.assertFalse(old_dir.exists())
|
||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old.pk).exists())
|
||||
self.assertEqual(result["deleted"], [{"dirname": old.dirname, "kind": "scheduled", "path": str(old_dir)}])
|
||||
self.assertEqual(
|
||||
result["deleted"],
|
||||
[
|
||||
{
|
||||
"dirname": old.dirname,
|
||||
"kind": "scheduled",
|
||||
"path": str(old_dir),
|
||||
"reason": "outside retention policy",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_apply_respects_max_delete(self) -> None:
|
||||
host = HostConfig.objects.create(
|
||||
@@ -152,6 +178,81 @@ class SqlRetentionTests(TestCase):
|
||||
acquire_lock=False,
|
||||
)
|
||||
|
||||
def test_incomplete_cleanup_deletes_directory_and_record(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"
|
||||
incomplete_dir.mkdir(parents=True)
|
||||
incomplete_dir.joinpath("partial-file").write_text("interrupted\n")
|
||||
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),
|
||||
)
|
||||
|
||||
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"],
|
||||
[{"dirname": incomplete_dir.name, "kind": SnapshotRecord.Kind.INCOMPLETE, "path": str(incomplete_dir)}],
|
||||
)
|
||||
self.assertEqual(result["planned_delete_count"], 1)
|
||||
purged = PurgedSnapshot.objects.get(dirname=incomplete_dir.name)
|
||||
self.assertEqual(purged.action, PurgedSnapshot.Action.INCOMPLETE_CLEANUP)
|
||||
self.assertEqual(purged.reason, "manual incomplete cleanup")
|
||||
|
||||
def test_incomplete_cleanup_respects_max_delete(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, "blocked by --max-delete=0"):
|
||||
run_incomplete_cleanup(
|
||||
prefix=Path("/tmp/pobsync-test"),
|
||||
host=host.host,
|
||||
yes=True,
|
||||
max_delete=0,
|
||||
acquire_lock=False,
|
||||
)
|
||||
|
||||
def test_incomplete_cleanup_rejects_unexpected_path(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}/scheduled/20260519-031500Z__BROKEN01",
|
||||
status="failed",
|
||||
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ConfigError, "unexpected incomplete snapshot path"):
|
||||
run_incomplete_cleanup(
|
||||
prefix=Path("/tmp/pobsync-test"),
|
||||
host=host.host,
|
||||
yes=True,
|
||||
max_delete=1,
|
||||
acquire_lock=False,
|
||||
)
|
||||
|
||||
def test_management_command_plans_from_sql(self) -> None:
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
|
||||
@@ -12,7 +12,15 @@ from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from pobsync.util import write_yaml_atomic
|
||||
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
|
||||
from pobsync_backend.models import (
|
||||
BackupRun,
|
||||
GlobalConfig,
|
||||
HostConfig,
|
||||
PurgedSnapshot,
|
||||
ScheduleConfig,
|
||||
SnapshotRecord,
|
||||
SshCredential,
|
||||
)
|
||||
|
||||
|
||||
class ViewTests(TestCase):
|
||||
@@ -31,6 +39,58 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/admin/login/", response["Location"])
|
||||
|
||||
def test_base_navigation_groups_primary_and_system_links(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'aria-label="Primary navigation"', html=False)
|
||||
self.assertContains(response, 'aria-label="System navigation"', html=False)
|
||||
self.assertContains(response, reverse("dashboard"))
|
||||
self.assertContains(response, reverse("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:
|
||||
response = self.client.get(reverse("changelog"))
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/admin/login/", response["Location"])
|
||||
|
||||
def test_changelog_renders_repository_changelog(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
with TemporaryDirectory() as tmp:
|
||||
changelog = Path(tmp) / "CHANGELOG.md"
|
||||
changelog.write_text(
|
||||
"# Changelog\n\n## 1.0.0 - 2026-05-21\n\n- Django control panel\n- Native systemd installer\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with override_settings(BASE_DIR=Path(tmp)):
|
||||
response = self.client.get(reverse("changelog"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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, "Django control panel")
|
||||
self.assertContains(response, "Native systemd installer")
|
||||
|
||||
def test_dashboard_renders_hosts_and_latest_runs(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
@@ -67,6 +127,15 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Control panel")
|
||||
self.assertContains(response, "Backup health, required action, storage pressure, and recent activity in one place.")
|
||||
self.assertContains(response, "dashboard-panel-required")
|
||||
self.assertContains(response, "dashboard-panel-schedules")
|
||||
self.assertContains(response, "dashboard-panel-activity")
|
||||
self.assertContains(response, "dashboard-panel-storage")
|
||||
self.assertContains(response, "dashboard-summary-grid")
|
||||
self.assertContains(response, "dashboard-trends-panel")
|
||||
self.assertContains(response, "dashboard-hosts-panel")
|
||||
self.assertContains(response, "Dashboard")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||
@@ -84,11 +153,54 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "running 1")
|
||||
self.assertContains(response, "warning 1")
|
||||
self.assertContains(response, "failed 1")
|
||||
self.assertContains(response, "Operational Status")
|
||||
self.assertContains(response, "1 failed run needs review.")
|
||||
self.assertContains(response, "1 run completed with warnings.")
|
||||
self.assertContains(response, "Required Action")
|
||||
self.assertContains(response, "Failed runs")
|
||||
self.assertContains(response, "1 failed run(s) need review.")
|
||||
self.assertContains(response, "1 run(s) completed with warnings.")
|
||||
self.assertContains(response, "1 backup run in progress.")
|
||||
self.assertContains(response, "1 backup run waiting for the worker.")
|
||||
self.assertContains(response, "1 backup run waiting.")
|
||||
self.assertContains(response, "Next Scheduled Work")
|
||||
self.assertContains(response, "Recent Activity")
|
||||
self.assertContains(response, f'data-refresh-url="{reverse("dashboard_priority_live")}"', html=False)
|
||||
self.assertContains(response, f'data-refresh-url="{reverse("dashboard_hosts_live")}"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=queued"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=running"', html=False)
|
||||
self.assertContains(response, f'href="{reverse("runs_list")}?status=warning&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_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_renders_backup_trend_summary(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -126,14 +238,14 @@ class ViewTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Backup Trends")
|
||||
self.assertContains(response, "Storage Used")
|
||||
self.assertContains(response, "Storage Pressure")
|
||||
self.assertContains(response, "Backup root used")
|
||||
self.assertContains(response, "Runway")
|
||||
self.assertContains(response, "New Data")
|
||||
self.assertContains(response, "Link-Dest Savings")
|
||||
self.assertContains(response, "80.0%")
|
||||
self.assertContains(response, "10 days")
|
||||
self.assertContains(response, "Warnings")
|
||||
self.assertContains(response, "Queued")
|
||||
self.assertContains(response, "Next Run")
|
||||
self.assertContains(response, "UTC")
|
||||
self.assertContains(response, "10")
|
||||
@@ -161,8 +273,99 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Operational Status")
|
||||
self.assertContains(response, "No queued, running, warning, or failed runs.")
|
||||
self.assertContains(response, "Required Action")
|
||||
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||
|
||||
def test_runs_list_filters_by_status_and_review(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||
failed = BackupRun.objects.create(host=web, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL)
|
||||
success = BackupRun.objects.create(host=db, status=BackupRun.Status.SUCCESS, run_type=BackupRun.RunType.SCHEDULED)
|
||||
BackupRun.objects.create(
|
||||
host=web,
|
||||
status=BackupRun.Status.WARNING,
|
||||
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||
reviewed_by="admin",
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("runs_list"), {"status": "failed", "review": "needed"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Runs")
|
||||
self.assertContains(response, "Review queued, running, completed")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("runs_list"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, f"Run {failed.id}")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "needed")
|
||||
self.assertNotContains(response, f"Run {success.id}")
|
||||
|
||||
def test_runs_list_can_mark_problem_run_reviewed(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = BackupRun.objects.create(host=host, status=BackupRun.Status.FAILED, run_type=BackupRun.RunType.MANUAL)
|
||||
list_url = f'{reverse("runs_list")}?status=failed&review=needed'
|
||||
|
||||
response = self.client.get(list_url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Mark reviewed")
|
||||
self.assertContains(response, 'value="/runs/?status=failed&review=needed"', html=False)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("resolve_run_review", args=[run.id]),
|
||||
{"next": list_url},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
run.refresh_from_db()
|
||||
self.assertIsNotNone(run.reviewed_at)
|
||||
self.assertEqual(run.reviewed_by, self.staff_user.username)
|
||||
self.assertRedirects(response, list_url)
|
||||
self.assertContains(response, f"Run {run.id} marked reviewed.")
|
||||
self.assertNotContains(response, f"Run {run.id}</a>", html=False)
|
||||
|
||||
def test_snapshots_list_filters_by_host_and_kind(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||
manual = self._snapshot(web, "20260519-021500Z__MANUAL01", kind=SnapshotRecord.Kind.MANUAL)
|
||||
scheduled = self._snapshot(db, "20260519-021500Z__SCHED01", kind=SnapshotRecord.Kind.SCHEDULED)
|
||||
|
||||
response = self.client.get(reverse("snapshots_list"), {"host": web.host, "kind": SnapshotRecord.Kind.MANUAL})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Snapshots")
|
||||
self.assertContains(response, "Browse discovered scheduled, manual, and incomplete snapshots")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("snapshots_list"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, manual.dirname)
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertNotContains(response, scheduled.dirname)
|
||||
|
||||
def test_schedules_list_filters_by_enabled_and_prune(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
web = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
db = HostConfig.objects.create(host="db-01", address="db-01.example.test")
|
||||
ScheduleConfig.objects.create(host=web, cron_expr="15 2 * * *", enabled=True, prune=True, last_status="success")
|
||||
ScheduleConfig.objects.create(host=db, cron_expr="30 3 * * *", enabled=False, prune=False, last_status="failed")
|
||||
|
||||
response = self.client.get(reverse("schedules_list"), {"enabled": "yes", "prune": "yes"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Schedules")
|
||||
self.assertContains(response, "Review configured backup schedules")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("schedules_list"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "success")
|
||||
self.assertContains(response, "UTC")
|
||||
self.assertNotContains(response, "30 3 * * *")
|
||||
|
||||
def test_dashboard_surfaces_retention_warnings(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -192,6 +395,30 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Scheduled prune would delete 2 snapshot(s), above max 1.")
|
||||
self.assertContains(response, "1 incomplete snapshot(s) need review.")
|
||||
self.assertContains(response, "Mark reviewed")
|
||||
|
||||
def test_dashboard_ignores_reviewed_problem_runs(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.FAILED,
|
||||
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||
reviewed_by="admin",
|
||||
)
|
||||
BackupRun.objects.create(
|
||||
host=host,
|
||||
status=BackupRun.Status.WARNING,
|
||||
reviewed_at=datetime(2026, 5, 19, 4, 20, tzinfo=timezone.utc),
|
||||
reviewed_by="admin",
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "No queued, running, unreviewed warning/failed runs, or retention warnings.")
|
||||
self.assertNotContains(response, "failed 1")
|
||||
self.assertNotContains(response, "warning 1")
|
||||
|
||||
def test_dashboard_links_latest_snapshot_for_each_host(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -235,6 +462,7 @@ class ViewTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Self Check")
|
||||
self.assertContains(response, "Runtime, filesystem, service, and configuration checks")
|
||||
self.assertContains(response, "Django debug")
|
||||
self.assertContains(response, "Database connection")
|
||||
self.assertContains(response, "State root")
|
||||
@@ -269,6 +497,10 @@ class ViewTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Logs")
|
||||
self.assertContains(response, "Filter pobsync service logs")
|
||||
self.assertContains(response, "Filter logs")
|
||||
self.assertContains(response, reverse("logs"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, "web-01 failed backup run 12")
|
||||
self.assertNotContains(response, "web-02 failed backup run 12")
|
||||
self.assertNotContains(response, "started")
|
||||
@@ -280,6 +512,65 @@ class ViewTests(TestCase):
|
||||
self.assertIn("--since", command)
|
||||
self.assertIn("6 hours ago", command)
|
||||
|
||||
def test_purged_snapshots_view_renders_history(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
PurgedSnapshot.objects.create(
|
||||
host=host,
|
||||
host_name=host.host,
|
||||
kind=SnapshotRecord.Kind.SCHEDULED,
|
||||
dirname="20260518-021500Z__OLDSNAP",
|
||||
path=f"/backups/{host.host}/scheduled/20260518-021500Z__OLDSNAP",
|
||||
reason="outside retention policy",
|
||||
action=PurgedSnapshot.Action.SCHEDULED,
|
||||
triggered_by="pobsync-scheduler",
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("purged_snapshots"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Purged Snapshots")
|
||||
self.assertContains(response, "Audit trail for snapshots removed")
|
||||
self.assertContains(response, "Apply filters")
|
||||
self.assertContains(response, reverse("purged_snapshots"))
|
||||
self.assertContains(response, "Clear")
|
||||
self.assertContains(response, "20260518-021500Z__OLDSNAP")
|
||||
self.assertContains(response, "outside retention policy")
|
||||
self.assertContains(response, "Scheduled")
|
||||
self.assertContains(response, "pobsync-scheduler")
|
||||
|
||||
def test_purged_snapshots_view_filters_by_host_and_action(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")
|
||||
PurgedSnapshot.objects.create(
|
||||
host=web,
|
||||
host_name=web.host,
|
||||
kind=SnapshotRecord.Kind.SCHEDULED,
|
||||
dirname="20260518-021500Z__WEBOLD",
|
||||
path=f"/backups/{web.host}/scheduled/20260518-021500Z__WEBOLD",
|
||||
reason="outside retention policy",
|
||||
action=PurgedSnapshot.Action.MANUAL,
|
||||
)
|
||||
PurgedSnapshot.objects.create(
|
||||
host=db,
|
||||
host_name=db.host,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
dirname="20260518-021500Z__DBBROKEN",
|
||||
path=f"/backups/{db.host}/.incomplete/20260518-021500Z__DBBROKEN",
|
||||
reason="manual incomplete cleanup",
|
||||
action=PurgedSnapshot.Action.INCOMPLETE_CLEANUP,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("purged_snapshots"),
|
||||
{"host": db.host, "action": PurgedSnapshot.Action.INCOMPLETE_CLEANUP},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "20260518-021500Z__DBBROKEN")
|
||||
self.assertNotContains(response, "20260518-021500Z__WEBOLD")
|
||||
|
||||
def test_ssh_credentials_view_creates_key(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
@@ -297,6 +588,7 @@ class ViewTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("ssh_credentials"))
|
||||
self.assertContains(response, "Manage the key pairs pobsync uses")
|
||||
self.assertContains(response, "SSH credential saved for backup-key.")
|
||||
self.assertContains(response, "backup-key")
|
||||
credential = SshCredential.objects.get(name="backup-key")
|
||||
@@ -326,6 +618,21 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
|
||||
self.assertEqual(credential.public_key, "DERIVED PUBLIC KEY")
|
||||
|
||||
def test_ssh_credential_forms_render_cancel_actions(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
credential = SshCredential.objects.create(name="backup-key")
|
||||
|
||||
create_response = self.client.get(reverse("create_ssh_credential"))
|
||||
edit_response = self.client.get(reverse("edit_ssh_credential", args=[credential.id]))
|
||||
generate_response = self.client.get(reverse("generate_ssh_credential"))
|
||||
|
||||
for response in (create_response, edit_response, generate_response):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("ssh_credentials"))
|
||||
self.assertContains(edit_response, "Delete SSH key")
|
||||
self.assertContains(edit_response, 'class="danger"', html=False)
|
||||
|
||||
def test_ssh_credentials_view_generates_filesystem_key(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||
@@ -358,13 +665,46 @@ class ViewTests(TestCase):
|
||||
generate_ssh_key(credential)
|
||||
key_path = Path(credential.key_path)
|
||||
|
||||
response = self.client.post(reverse("delete_ssh_credential", args=[credential.id]), follow=True)
|
||||
response = self.client.post(
|
||||
reverse("delete_ssh_credential", args=[credential.id]),
|
||||
{"confirm_name": credential.name},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("ssh_credentials"))
|
||||
self.assertContains(response, "SSH key deleted: generated-key.")
|
||||
self.assertFalse(SshCredential.objects.exists())
|
||||
self.assertFalse(key_path.exists())
|
||||
|
||||
def test_ssh_credentials_view_requires_delete_confirmation(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
credential = SshCredential.objects.create(name="backup-key")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("delete_ssh_credential", args=[credential.id]),
|
||||
{"confirm_name": "wrong"},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("edit_ssh_credential", args=[credential.id]))
|
||||
self.assertContains(response, "Type backup-key to confirm SSH key deletion.")
|
||||
self.assertTrue(SshCredential.objects.filter(pk=credential.pk).exists())
|
||||
|
||||
def test_ssh_credentials_view_blocks_delete_when_key_is_in_use(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
credential = SshCredential.objects.create(name="backup-key")
|
||||
HostConfig.objects.create(host="web-01", address="web-01.example.test", ssh_credential=credential)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("delete_ssh_credential", args=[credential.id]),
|
||||
{"confirm_name": credential.name},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("edit_ssh_credential", args=[credential.id]))
|
||||
self.assertContains(response, "SSH key backup-key is still in use and cannot be deleted.")
|
||||
self.assertTrue(SshCredential.objects.filter(pk=credential.pk).exists())
|
||||
|
||||
def test_ssh_credentials_view_rejects_invalid_key(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
|
||||
@@ -428,7 +768,7 @@ class ViewTests(TestCase):
|
||||
response = self.client.post(
|
||||
reverse("edit_ssh_credential", args=[credential.id]),
|
||||
{
|
||||
"name": "backup-key",
|
||||
"name": "renamed-backup-key",
|
||||
"private_key": "UPDATED KEY",
|
||||
"public_key": "",
|
||||
"known_hosts": "",
|
||||
@@ -439,6 +779,7 @@ class ViewTests(TestCase):
|
||||
|
||||
self.assertRedirects(response, reverse("ssh_credentials"))
|
||||
credential.refresh_from_db()
|
||||
self.assertEqual(credential.name, "renamed-backup-key")
|
||||
self.assertEqual(credential.private_key, "UPDATED KEY\n")
|
||||
self.assertEqual(credential.public_key, "UPDATED PUBLIC KEY")
|
||||
self.assertEqual(credential.notes, "rotated")
|
||||
@@ -492,9 +833,12 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("edit_global_config"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Defaults used by hosts unless a host overrides them")
|
||||
self.assertContains(response, f'value="{credential.id}" selected')
|
||||
self.assertContains(response, "--archive")
|
||||
self.assertContains(response, "/proc/***")
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("dashboard"))
|
||||
|
||||
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -672,7 +1016,7 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "Schedule expression")
|
||||
self.assertContains(response, "Evaluated by the pobsync scheduler service.")
|
||||
self.assertContains(response, "Next run:")
|
||||
self.assertContains(response, "Next run")
|
||||
self.assertContains(response, "UTC")
|
||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||
self.assertContains(response, "Discover snapshots")
|
||||
@@ -685,10 +1029,12 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "Host Check")
|
||||
self.assertContains(response, reverse("prepare_host_directories", args=[host.host]))
|
||||
self.assertContains(response, "warning")
|
||||
self.assertContains(response, "Snapshot Discovery")
|
||||
self.assertContains(response, "Snapshot Storage")
|
||||
self.assertContains(response, reverse("queue_manual_backup", args=[host.host]))
|
||||
self.assertContains(response, reverse("run_detail", args=[BackupRun.objects.get().id]))
|
||||
self.assertContains(response, reverse("snapshot_detail", args=[snapshot.id]))
|
||||
self.assertContains(response, f'{reverse("runs_list")}?host={host.host}', html=False)
|
||||
self.assertContains(response, f'{reverse("snapshots_list")}?host={host.host}', html=False)
|
||||
|
||||
def test_host_detail_renders_effective_config_preview(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -722,7 +1068,11 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Host")
|
||||
self.assertContains(response, "web-01.example.test")
|
||||
self.assertContains(response, "Effective Config")
|
||||
self.assertContains(response, "Backup source:")
|
||||
self.assertNotContains(response, "Source root:")
|
||||
self.assertContains(response, "root@web-01.example.test:2222")
|
||||
self.assertContains(response, "default-key")
|
||||
self.assertContains(response, "-oBatchMode=yes")
|
||||
@@ -966,7 +1316,8 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, f"Host root:</strong> {backup_root / host.host}")
|
||||
self.assertContains(response, "Host root")
|
||||
self.assertContains(response, str(backup_root / host.host))
|
||||
self.assertContains(response, "Found 2 snapshot directories")
|
||||
self.assertContains(response, "scheduled 1")
|
||||
self.assertContains(response, "incomplete 1")
|
||||
@@ -1276,16 +1627,47 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Backup run")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, "Failure")
|
||||
self.assertContains(response, "transport")
|
||||
self.assertContains(response, "Check network connectivity.")
|
||||
self.assertContains(response, "Retention")
|
||||
self.assertContains(response, "Planned deletions")
|
||||
self.assertNotContains(response, "Source:</strong> sql")
|
||||
self.assertContains(response, "Max delete")
|
||||
self.assertContains(response, "Protect bases")
|
||||
self.assertContains(response, "Incomplete ignored")
|
||||
self.assertContains(response, "deleted scheduled 20260518-021500Z__OLD")
|
||||
|
||||
def test_run_review_action_marks_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, result={"ok": False})
|
||||
|
||||
response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True)
|
||||
|
||||
run.refresh_from_db()
|
||||
self.assertIsNotNone(run.reviewed_at)
|
||||
self.assertEqual(run.reviewed_by, self.staff_user.username)
|
||||
self.assertRedirects(response, reverse("run_detail", args=[run.id]))
|
||||
self.assertContains(response, f"Run {run.id} marked reviewed.")
|
||||
self.assertContains(response, "Review")
|
||||
self.assertContains(response, self.staff_user.username)
|
||||
self.assertNotContains(response, "Mark reviewed")
|
||||
|
||||
def test_run_review_action_ignores_successful_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, result={"ok": True})
|
||||
|
||||
response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True)
|
||||
|
||||
run.refresh_from_db()
|
||||
self.assertIsNone(run.reviewed_at)
|
||||
self.assertRedirects(response, reverse("run_detail", args=[run.id]))
|
||||
self.assertContains(response, f"Run {run.id} does not need review.")
|
||||
|
||||
def test_run_detail_surfaces_host_retention_warnings(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(
|
||||
@@ -1346,8 +1728,74 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Run Control")
|
||||
self.assertContains(response, "Cancelling a queued run stops it immediately")
|
||||
self.assertContains(response, "Cancel run")
|
||||
self.assertContains(response, reverse("cancel_run", args=[run.id]))
|
||||
self.assertContains(response, 'class="danger"', html=False)
|
||||
|
||||
def test_run_detail_enables_live_refresh_for_active_run(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = BackupRun.objects.create(host=host, status=BackupRun.Status.RUNNING)
|
||||
|
||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, f'data-refresh-url="{reverse("run_detail_live", args=[run.id])}"', html=False)
|
||||
self.assertContains(response, 'data-refresh-interval="5000"', html=False)
|
||||
self.assertContains(response, 'data-refresh-active="true"', html=False)
|
||||
|
||||
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_stops_refresh_for_terminal_run(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS)
|
||||
|
||||
response = self.client.get(reverse("run_detail_live", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["X-Pobsync-Refresh-Active"], "false")
|
||||
self.assertNotContains(response, "Run Control")
|
||||
|
||||
def test_run_detail_renders_worker_execution_metadata(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
run = BackupRun.objects.create(
|
||||
host=host,
|
||||
status=BackupRun.Status.RUNNING,
|
||||
result={
|
||||
"execution": {
|
||||
"worker_host": "backup-01",
|
||||
"worker_pid": 4242,
|
||||
"heartbeat_at": "2026-05-21T10:30:00+00:00",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("run_detail", args=[run.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Worker:")
|
||||
self.assertContains(response, "backup-01")
|
||||
self.assertContains(response, "pid 4242")
|
||||
self.assertContains(response, "Worker heartbeat:")
|
||||
|
||||
def test_cancel_run_marks_queued_run_cancelled(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1414,12 +1862,17 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("snapshot_detail", args=[base.id]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Snapshot")
|
||||
self.assertContains(response, base.dirname)
|
||||
self.assertContains(response, "BASESNAP")
|
||||
self.assertContains(response, "Stats")
|
||||
self.assertContains(response, "Files seen:</strong> 100")
|
||||
self.assertContains(response, "Hardlinked files:</strong> 9")
|
||||
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"/restore/{host.host}")
|
||||
self.assertContains(response, "rsync -aHAX --numeric-ids --info=progress2 --dry-run")
|
||||
@@ -1492,14 +1945,20 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("host_retention_plan", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Retention Plan: web-01")
|
||||
self.assertContains(response, "Retention")
|
||||
self.assertContains(response, "Preview which snapshots stay")
|
||||
self.assertContains(response, "web-01")
|
||||
self.assertContains(response, old_snapshot.dirname)
|
||||
self.assertContains(response, new_snapshot.dirname)
|
||||
self.assertContains(response, "newest")
|
||||
self.assertContains(response, "Would Delete")
|
||||
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, "Type 1 to confirm the current number of planned deletions.")
|
||||
self.assertContains(response, "This permanently deletes the snapshot directories listed in Would Delete.")
|
||||
self.assertContains(response, 'class="danger"', html=False)
|
||||
self.assertContains(response, "Cancel")
|
||||
|
||||
def test_retention_plan_warns_when_scheduled_prune_limit_is_exceeded(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1573,6 +2032,70 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "Incomplete Snapshots")
|
||||
self.assertContains(response, "20260519-031500Z__BROKEN01")
|
||||
self.assertContains(response, "excluded from retention cleanup")
|
||||
self.assertContains(response, "Delete incomplete snapshots")
|
||||
self.assertContains(response, "Type 1 to confirm the current number of incomplete snapshots.")
|
||||
self.assertContains(response, "This deletes only incomplete snapshot directories")
|
||||
self.assertContains(response, 'class="danger"', html=False)
|
||||
|
||||
def test_incomplete_cleanup_deletes_incomplete_snapshot_after_confirmation(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
with TemporaryDirectory() as tmp:
|
||||
home = 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"
|
||||
incomplete_dir.mkdir(parents=True)
|
||||
incomplete_dir.joinpath("partial-file").write_text("interrupted\n")
|
||||
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),
|
||||
)
|
||||
|
||||
with override_settings(POBSYNC_HOME=str(home)):
|
||||
response = self.client.post(
|
||||
reverse("cleanup_host_incomplete_snapshots", args=[host.host]),
|
||||
{
|
||||
"max_delete": "1",
|
||||
"confirm_host": host.host,
|
||||
"confirm_delete_count": "1",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertFalse(incomplete_dir.exists())
|
||||
|
||||
self.assertRedirects(response, reverse("host_retention_plan", args=[host.host]))
|
||||
self.assertContains(response, "Deleted 1 incomplete snapshot(s) for web-01.")
|
||||
self.assertFalse(SnapshotRecord.objects.filter(pk=record.pk).exists())
|
||||
|
||||
def test_incomplete_cleanup_rejects_bad_confirmation(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),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("cleanup_host_incomplete_snapshots", args=[host.host]),
|
||||
{
|
||||
"max_delete": "1",
|
||||
"confirm_host": "wrong",
|
||||
"confirm_delete_count": "1",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse("host_retention_plan", args=[host.host]))
|
||||
self.assertContains(response, "Incomplete cleanup confirmation is invalid.")
|
||||
self.assertEqual(SnapshotRecord.objects.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(), 1)
|
||||
|
||||
def test_host_detail_surfaces_retention_warnings(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1595,6 +2118,46 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "Retention Warnings")
|
||||
self.assertContains(response, "Scheduled pruning would delete 2 snapshot(s), above max delete")
|
||||
|
||||
def test_host_detail_can_mark_incomplete_snapshots_reviewed(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
incomplete = 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),
|
||||
)
|
||||
|
||||
response = self.client.post(reverse("resolve_host_incomplete_reviews", args=[host.host]), follow=True)
|
||||
|
||||
incomplete.refresh_from_db()
|
||||
self.assertIsNotNone(incomplete.reviewed_at)
|
||||
self.assertEqual(incomplete.reviewed_by, self.staff_user.username)
|
||||
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||
self.assertContains(response, "Marked 1 incomplete snapshot(s) reviewed for web-01.")
|
||||
self.assertNotContains(response, "Retention Warnings")
|
||||
|
||||
def test_host_detail_does_not_warn_for_reviewed_incomplete_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),
|
||||
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
|
||||
reviewed_by="admin",
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("host_detail", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, "Retention Warnings")
|
||||
|
||||
def test_retention_plan_rejects_invalid_kind(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
@@ -1646,6 +2209,10 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "Retention deleted 1 snapshot(s) for web-01.")
|
||||
self.assertFalse(SnapshotRecord.objects.filter(pk=old_snapshot.pk).exists())
|
||||
self.assertTrue(SnapshotRecord.objects.filter(pk=new_snapshot.pk).exists())
|
||||
purged = PurgedSnapshot.objects.get(dirname=old_snapshot.dirname)
|
||||
self.assertEqual(purged.action, PurgedSnapshot.Action.MANUAL)
|
||||
self.assertEqual(purged.triggered_by, self.staff_user.username)
|
||||
self.assertEqual(purged.reason, "outside retention policy")
|
||||
|
||||
def test_retention_apply_rejects_bad_confirmation(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1710,11 +2277,14 @@ class ViewTests(TestCase):
|
||||
response = self.client.get(reverse("edit_host_schedule", args=[host.host]))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Automatic backup timing and scheduled prune behavior")
|
||||
self.assertContains(response, "Create Schedule")
|
||||
self.assertContains(response, "Schedule expression")
|
||||
self.assertContains(response, "evaluated by the pobsync scheduler service")
|
||||
self.assertContains(response, "15 2 * * *")
|
||||
self.assertContains(response, "Save schedule")
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("host_detail", args=[host.host]))
|
||||
|
||||
def test_schedule_form_creates_schedule(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1816,6 +2386,8 @@ class ViewTests(TestCase):
|
||||
self.assertContains(response, "/srv")
|
||||
self.assertContains(response, "*.tmp")
|
||||
self.assertContains(response, "--numeric-ids")
|
||||
self.assertContains(response, "Cancel")
|
||||
self.assertContains(response, reverse("host_detail", args=[host.host]))
|
||||
|
||||
def test_host_config_form_renders_effective_config_check(self) -> None:
|
||||
self.client.force_login(self.staff_user)
|
||||
@@ -1896,13 +2468,19 @@ class ViewTests(TestCase):
|
||||
self.assertEqual(host.excludes_add, [])
|
||||
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
|
||||
|
||||
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
|
||||
def _snapshot(
|
||||
self,
|
||||
host: HostConfig,
|
||||
dirname: str,
|
||||
*,
|
||||
kind: str = SnapshotRecord.Kind.SCHEDULED,
|
||||
) -> SnapshotRecord:
|
||||
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||
return SnapshotRecord.objects.create(
|
||||
host=host,
|
||||
kind=SnapshotRecord.Kind.SCHEDULED,
|
||||
kind=kind,
|
||||
dirname=dirname,
|
||||
path=f"/backups/{host.host}/scheduled/{dirname}",
|
||||
path=f"/backups/{host.host}/{kind}/{dirname}",
|
||||
status="success",
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ import json
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone as datetime_timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
@@ -12,9 +14,11 @@ from django.conf import settings
|
||||
from django.http import FileResponse, Http404
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from pobsync import __version__
|
||||
from pobsync.errors import PobsyncError
|
||||
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS
|
||||
|
||||
@@ -24,6 +28,7 @@ from .forms import (
|
||||
CreateHostConfigForm,
|
||||
GlobalConfigForm,
|
||||
HostConfigForm,
|
||||
IncompleteCleanupForm,
|
||||
ManualBackupForm,
|
||||
RetentionApplyForm,
|
||||
SshCredentialGenerateForm,
|
||||
@@ -31,9 +36,9 @@ from .forms import (
|
||||
SshCredentialForm,
|
||||
)
|
||||
from .host_ops import ensure_host_directories
|
||||
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
|
||||
from .models import BackupRun, GlobalConfig, HostConfig, PurgedSnapshot, ScheduleConfig, SnapshotRecord, SshCredential
|
||||
from .preflight import collect_backup_gate, effective_host_config_preview, run_remote_preflight
|
||||
from .retention import run_sql_retention_apply, run_sql_retention_plan
|
||||
from .retention import run_incomplete_cleanup, run_sql_retention_apply, run_sql_retention_plan
|
||||
from .self_check import collect_self_checks, summarize_self_checks
|
||||
from .scheduler import next_due_after
|
||||
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
||||
@@ -43,6 +48,20 @@ from .stats_summary import collect_dashboard_stats, collect_host_stats
|
||||
|
||||
@staff_member_required
|
||||
def dashboard(request):
|
||||
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 _dashboard_context() -> dict[str, object]:
|
||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||
hosts = list(
|
||||
HostConfig.objects.select_related("schedule")
|
||||
@@ -51,8 +70,16 @@ def dashboard(request):
|
||||
run_count=Count("runs", distinct=True),
|
||||
queued_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.QUEUED), distinct=True),
|
||||
running_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.RUNNING), distinct=True),
|
||||
warning_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.WARNING), distinct=True),
|
||||
failed_run_count=Count("runs", filter=Q(runs__status=BackupRun.Status.FAILED), distinct=True),
|
||||
warning_run_count=Count(
|
||||
"runs",
|
||||
filter=Q(runs__status=BackupRun.Status.WARNING, runs__reviewed_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
failed_run_count=Count(
|
||||
"runs",
|
||||
filter=Q(runs__status=BackupRun.Status.FAILED, runs__reviewed_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
.order_by("host")
|
||||
)
|
||||
@@ -64,13 +91,18 @@ def dashboard(request):
|
||||
)
|
||||
host_config.next_run_at = _next_run_for_host(host_config)
|
||||
host_config.retention_warning = _retention_warning_for_host(host_config, _schedule_for_host(host_config))
|
||||
action_items = _dashboard_action_items(hosts)
|
||||
next_schedule_rows = _dashboard_next_schedule_rows()
|
||||
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
|
||||
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
|
||||
context = {
|
||||
"hosts": hosts,
|
||||
"global_config": global_config,
|
||||
"stats_summary": stats_summary,
|
||||
"scheduler_timezone": timezone.get_current_timezone_name(),
|
||||
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
||||
"action_items": action_items,
|
||||
"next_schedule_rows": next_schedule_rows,
|
||||
"recent_runs": recent_runs,
|
||||
"counts": {
|
||||
"global_configs": GlobalConfig.objects.count(),
|
||||
"hosts": HostConfig.objects.count(),
|
||||
@@ -81,11 +113,107 @@ def dashboard(request):
|
||||
"runs": BackupRun.objects.count(),
|
||||
"queued_runs": BackupRun.objects.filter(status=BackupRun.Status.QUEUED).count(),
|
||||
"running_runs": BackupRun.objects.filter(status=BackupRun.Status.RUNNING).count(),
|
||||
"warning_runs": BackupRun.objects.filter(status=BackupRun.Status.WARNING).count(),
|
||||
"failed_runs": BackupRun.objects.filter(status=BackupRun.Status.FAILED).count(),
|
||||
"warning_runs": BackupRun.objects.filter(
|
||||
status=BackupRun.Status.WARNING,
|
||||
reviewed_at__isnull=True,
|
||||
).count(),
|
||||
"failed_runs": BackupRun.objects.filter(
|
||||
status=BackupRun.Status.FAILED,
|
||||
reviewed_at__isnull=True,
|
||||
).count(),
|
||||
},
|
||||
}
|
||||
return render(request, "pobsync_backend/dashboard.html", context)
|
||||
return context
|
||||
|
||||
|
||||
def _dashboard_action_items(hosts: list[HostConfig]) -> list[dict[str, object]]:
|
||||
action_items: list[dict[str, object]] = []
|
||||
for host_config in hosts:
|
||||
if host_config.failed_run_count:
|
||||
action_items.append(
|
||||
{
|
||||
"host": host_config,
|
||||
"status": BackupRun.Status.FAILED,
|
||||
"label": "Failed runs",
|
||||
"message": f"{host_config.failed_run_count} failed run(s) need review.",
|
||||
"url": _runs_list_url(host=host_config.host, status="failed", review="needed"),
|
||||
}
|
||||
)
|
||||
if host_config.warning_run_count:
|
||||
action_items.append(
|
||||
{
|
||||
"host": host_config,
|
||||
"status": BackupRun.Status.WARNING,
|
||||
"label": "Warnings",
|
||||
"message": f"{host_config.warning_run_count} run(s) completed with warnings.",
|
||||
"url": _runs_list_url(host=host_config.host, status="warning", review="needed"),
|
||||
}
|
||||
)
|
||||
if host_config.retention_warning.get("has_warning"):
|
||||
action_items.append(
|
||||
{
|
||||
"host": host_config,
|
||||
"status": BackupRun.Status.WARNING,
|
||||
"label": "Retention",
|
||||
"message": _retention_warning_summary(host_config.retention_warning),
|
||||
"url": reverse("host_detail", args=[host_config.host]),
|
||||
}
|
||||
)
|
||||
return action_items
|
||||
|
||||
|
||||
def _runs_list_url(**params: str) -> str:
|
||||
return f"{reverse('runs_list')}?{urlencode(params)}"
|
||||
|
||||
|
||||
def _dashboard_next_schedule_rows() -> list[dict[str, object]]:
|
||||
rows = []
|
||||
schedules = ScheduleConfig.objects.select_related("host").filter(enabled=True).order_by("host__host")
|
||||
for schedule in schedules[:200]:
|
||||
rows.append(
|
||||
{
|
||||
"schedule": schedule,
|
||||
"next_run_at": _next_run_for_schedule(schedule, schedule.host),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: row["next_run_at"] or datetime.max.replace(tzinfo=datetime_timezone.utc))
|
||||
return rows[:6]
|
||||
|
||||
|
||||
def _retention_warning_summary(retention_warning) -> str:
|
||||
parts = []
|
||||
if retention_warning.get("prune_exceeded"):
|
||||
parts.append(
|
||||
f"Scheduled prune would delete {retention_warning.get('delete_count')} snapshot(s), "
|
||||
f"above max {retention_warning.get('max_delete')}."
|
||||
)
|
||||
if retention_warning.get("incomplete_count"):
|
||||
parts.append(f"{retention_warning.get('incomplete_count')} incomplete snapshot(s) need review.")
|
||||
if retention_warning.get("error"):
|
||||
parts.append(str(retention_warning.get("error")))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def changelog(request):
|
||||
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
|
||||
try:
|
||||
changelog_text = changelog_path.read_text(encoding="utf-8")
|
||||
missing = False
|
||||
except FileNotFoundError:
|
||||
changelog_text = "CHANGELOG.md was not found in this installation."
|
||||
missing = True
|
||||
|
||||
return render(
|
||||
request,
|
||||
"pobsync_backend/changelog.html",
|
||||
{
|
||||
"app_version": __version__,
|
||||
"changelog_blocks": _parse_changelog(changelog_text),
|
||||
"changelog_path": changelog_path,
|
||||
"missing": missing,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@@ -107,6 +235,123 @@ def logs(request):
|
||||
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
|
||||
def purged_snapshots(request):
|
||||
host = request.GET.get("host", "").strip()
|
||||
action = request.GET.get("action", "").strip()
|
||||
purged = PurgedSnapshot.objects.select_related("host").order_by("-purged_at", "host_name", "dirname")
|
||||
if host:
|
||||
purged = purged.filter(host_name=host)
|
||||
if action:
|
||||
purged = purged.filter(action=action)
|
||||
|
||||
context = {
|
||||
"purged_snapshots": purged[:200],
|
||||
"hosts": HostConfig.objects.order_by("host"),
|
||||
"actions": PurgedSnapshot.Action.choices,
|
||||
"selected_host": host,
|
||||
"selected_action": action,
|
||||
"total_count": purged.count(),
|
||||
}
|
||||
return render(request, "pobsync_backend/purged_snapshots.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def ssh_credentials(request):
|
||||
context = {
|
||||
@@ -201,6 +446,9 @@ def delete_ssh_credential(request, credential_id: int):
|
||||
if credential.hosts.exists() or credential.global_configs.exists():
|
||||
messages.error(request, f"SSH key {credential.name} is still in use and cannot be deleted.")
|
||||
return redirect("edit_ssh_credential", credential_id=credential.id)
|
||||
if request.POST.get("confirm_name", "").strip() != credential.name:
|
||||
messages.error(request, f"Type {credential.name} to confirm SSH key deletion.")
|
||||
return redirect("edit_ssh_credential", credential_id=credential.id)
|
||||
|
||||
name = credential.name
|
||||
try:
|
||||
@@ -307,7 +555,10 @@ def host_detail(request, host: str):
|
||||
"runs": host_config.runs.count(),
|
||||
"queued_runs": queued_runs.count(),
|
||||
"running_runs": running_runs.count(),
|
||||
"failed_runs": host_config.runs.filter(status=BackupRun.Status.FAILED).count(),
|
||||
"failed_runs": host_config.runs.filter(
|
||||
status=BackupRun.Status.FAILED,
|
||||
reviewed_at__isnull=True,
|
||||
).count(),
|
||||
"incomplete_snapshots": host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count(),
|
||||
},
|
||||
}
|
||||
@@ -420,18 +671,35 @@ def queue_manual_backup(request, host: str):
|
||||
@staff_member_required
|
||||
def run_detail(request, run_id: int):
|
||||
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
||||
return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run))
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def run_detail_live(request, run_id: int):
|
||||
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
|
||||
context = _run_detail_context(run)
|
||||
response = render(request, "pobsync_backend/partials/run_detail_live.html", context)
|
||||
response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false"
|
||||
return response
|
||||
|
||||
|
||||
def _run_detail_context(run: BackupRun) -> dict[str, object]:
|
||||
result = run.result if isinstance(run.result, dict) else {}
|
||||
run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
|
||||
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
|
||||
failure = result.get("failure") if isinstance(result.get("failure"), dict) else {}
|
||||
prune_result = result.get("prune") if isinstance(result.get("prune"), dict) else {}
|
||||
execution = result.get("execution") if isinstance(result.get("execution"), dict) else {}
|
||||
rsync_log_path = _run_rsync_log_path(run)
|
||||
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
|
||||
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
|
||||
context = {
|
||||
can_cancel = run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}
|
||||
return {
|
||||
"run": run,
|
||||
"can_cancel": run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING},
|
||||
"can_cancel": can_cancel,
|
||||
"can_auto_refresh": can_cancel,
|
||||
"requested": requested,
|
||||
"execution": execution,
|
||||
"stats": run_stats if isinstance(run_stats, dict) else {},
|
||||
"rsync": rsync_result,
|
||||
"rsync_command": _run_rsync_command(rsync_result),
|
||||
@@ -453,7 +721,6 @@ def run_detail(request, run_id: int):
|
||||
),
|
||||
"result_json": _pretty_json(run.result),
|
||||
}
|
||||
return render(request, "pobsync_backend/run_detail.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@@ -489,6 +756,40 @@ def cancel_run(request, run_id: int):
|
||||
return redirect("run_detail", run_id=run.id)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_POST
|
||||
def resolve_run_review(request, run_id: int):
|
||||
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
|
||||
if run.status not in {BackupRun.Status.FAILED, BackupRun.Status.WARNING}:
|
||||
messages.warning(request, f"Run {run.id} does not need review.")
|
||||
return redirect("run_detail", run_id=run.id)
|
||||
if run.reviewed_at:
|
||||
messages.info(request, f"Run {run.id} was already marked reviewed.")
|
||||
return _redirect_after_run_review(request, run)
|
||||
|
||||
run.reviewed_at = timezone.now()
|
||||
run.reviewed_by = request.user.get_username()
|
||||
run.save(update_fields=["reviewed_at", "reviewed_by"])
|
||||
messages.success(request, f"Run {run.id} marked reviewed.")
|
||||
return _redirect_after_run_review(request, run)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_POST
|
||||
def resolve_host_incomplete_reviews(request, host: str):
|
||||
host_config = get_object_or_404(HostConfig, host=host)
|
||||
reviewed_count = host_config.snapshots.filter(
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
reviewed_at__isnull=True,
|
||||
).update(reviewed_at=timezone.now(), reviewed_by=request.user.get_username())
|
||||
|
||||
if reviewed_count:
|
||||
messages.success(request, f"Marked {reviewed_count} incomplete snapshot(s) reviewed for {host_config.host}.")
|
||||
else:
|
||||
messages.info(request, f"No incomplete snapshots needed review for {host_config.host}.")
|
||||
return redirect("host_detail", host=host_config.host)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def snapshot_detail(request, snapshot_id: int):
|
||||
snapshot = get_object_or_404(
|
||||
@@ -544,6 +845,7 @@ def host_retention_plan(request, host: str):
|
||||
schedule = _schedule_for_host(host_config)
|
||||
scheduled_prune_limit = schedule.prune_max_delete if schedule and schedule.prune else None
|
||||
delete_count = len(plan["delete"])
|
||||
incomplete_count = len(plan["incomplete"])
|
||||
context = {
|
||||
"host": host_config,
|
||||
"kind": kind,
|
||||
@@ -562,6 +864,14 @@ def host_retention_plan(request, host: str):
|
||||
"confirm_delete_count": delete_count,
|
||||
},
|
||||
),
|
||||
"incomplete_cleanup_form": IncompleteCleanupForm(
|
||||
host_name=host_config.host,
|
||||
expected_delete_count=incomplete_count,
|
||||
initial={
|
||||
"max_delete": incomplete_count,
|
||||
"confirm_delete_count": incomplete_count,
|
||||
},
|
||||
),
|
||||
}
|
||||
return render(request, "pobsync_backend/retention_plan.html", context)
|
||||
|
||||
@@ -604,6 +914,8 @@ def apply_host_retention(request, host: str):
|
||||
protect_bases=protect_bases,
|
||||
yes=True,
|
||||
max_delete=form.cleaned_data["max_delete"],
|
||||
action=PurgedSnapshot.Action.MANUAL,
|
||||
triggered_by=request.user.get_username(),
|
||||
)
|
||||
except PobsyncError as exc:
|
||||
messages.error(request, str(exc))
|
||||
@@ -618,6 +930,41 @@ def apply_host_retention(request, host: str):
|
||||
return target
|
||||
|
||||
|
||||
@staff_member_required
|
||||
@require_POST
|
||||
def cleanup_host_incomplete_snapshots(request, host: str):
|
||||
host_config = get_object_or_404(HostConfig, host=host)
|
||||
try:
|
||||
plan = run_sql_retention_plan(host=host_config.host, kind="all", protect_bases=True)
|
||||
except PobsyncError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect("host_retention_plan", host=host_config.host)
|
||||
|
||||
incomplete_count = len(plan.get("incomplete") or [])
|
||||
form = IncompleteCleanupForm(
|
||||
request.POST,
|
||||
host_name=host_config.host,
|
||||
expected_delete_count=incomplete_count,
|
||||
)
|
||||
if not form.is_valid():
|
||||
messages.error(request, "Incomplete cleanup confirmation is invalid.")
|
||||
return redirect("host_retention_plan", host=host_config.host)
|
||||
|
||||
try:
|
||||
result = run_incomplete_cleanup(
|
||||
prefix=Path(settings.POBSYNC_HOME),
|
||||
host=host_config.host,
|
||||
yes=True,
|
||||
max_delete=form.cleaned_data["max_delete"],
|
||||
triggered_by=request.user.get_username(),
|
||||
)
|
||||
except PobsyncError as exc:
|
||||
messages.error(request, str(exc))
|
||||
else:
|
||||
messages.success(request, f"Deleted {len(result['deleted'])} incomplete snapshot(s) for {host_config.host}.")
|
||||
return redirect("host_retention_plan", host=host_config.host)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def edit_host_config(request, host: str):
|
||||
host_config = get_object_or_404(HostConfig, host=host)
|
||||
@@ -694,8 +1041,18 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon
|
||||
return None
|
||||
|
||||
|
||||
def _redirect_after_run_review(request, run: BackupRun):
|
||||
next_url = request.POST.get("next", "").strip()
|
||||
if next_url.startswith("/"):
|
||||
return redirect(next_url)
|
||||
return redirect("run_detail", run_id=run.id)
|
||||
|
||||
|
||||
def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]:
|
||||
incomplete_count = host_config.snapshots.filter(kind=SnapshotRecord.Kind.INCOMPLETE).count()
|
||||
incomplete_count = host_config.snapshots.filter(
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
reviewed_at__isnull=True,
|
||||
).count()
|
||||
warning: dict[str, object] = {
|
||||
"has_warning": incomplete_count > 0,
|
||||
"incomplete_count": incomplete_count,
|
||||
@@ -793,6 +1150,49 @@ def _pretty_json(value: object) -> str:
|
||||
return json.dumps(value or {}, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def _parse_changelog(text: str) -> list[dict[str, object]]:
|
||||
blocks: list[dict[str, object]] = []
|
||||
paragraph: list[str] = []
|
||||
list_items: list[str] = []
|
||||
|
||||
def flush_paragraph() -> None:
|
||||
if paragraph:
|
||||
blocks.append({"kind": "paragraph", "text": " ".join(paragraph)})
|
||||
paragraph.clear()
|
||||
|
||||
def flush_list() -> None:
|
||||
if list_items:
|
||||
blocks.append({"kind": "list", "items": list(list_items)})
|
||||
list_items.clear()
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
flush_paragraph()
|
||||
flush_list()
|
||||
continue
|
||||
|
||||
if line.startswith("#"):
|
||||
flush_paragraph()
|
||||
flush_list()
|
||||
marker, _space, heading = line.partition(" ")
|
||||
level = min(max(len(marker), 1), 3)
|
||||
blocks.append({"kind": "heading", "level": level, "text": heading.strip() or line.lstrip("#").strip()})
|
||||
continue
|
||||
|
||||
if line.startswith("- "):
|
||||
flush_paragraph()
|
||||
list_items.append(line[2:].strip())
|
||||
continue
|
||||
|
||||
flush_list()
|
||||
paragraph.append(line)
|
||||
|
||||
flush_paragraph()
|
||||
flush_list()
|
||||
return blocks
|
||||
|
||||
|
||||
def _snapshot_restore_guidance(snapshot: SnapshotRecord) -> dict[str, str]:
|
||||
source_path = Path(snapshot.path) / "data"
|
||||
destination_path = Path("/restore") / snapshot.host.host
|
||||
|
||||
@@ -8,8 +8,13 @@ from pobsync_backend import api, views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
path("dashboard/priority-live/", views.dashboard_priority_live, name="dashboard_priority_live"),
|
||||
path("dashboard/hosts-live/", views.dashboard_hosts_live, name="dashboard_hosts_live"),
|
||||
path("changelog/", views.changelog, name="changelog"),
|
||||
path("self-check/", views.self_check, name="self_check"),
|
||||
path("logs/", views.logs, name="logs"),
|
||||
path("purged-snapshots/", views.purged_snapshots, name="purged_snapshots"),
|
||||
path("schedules/", views.schedules_list, name="schedules_list"),
|
||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||
@@ -26,10 +31,24 @@ urlpatterns = [
|
||||
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
|
||||
path("hosts/<str:host>/retention-apply/", views.apply_host_retention, name="apply_host_retention"),
|
||||
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
|
||||
path(
|
||||
"hosts/<str:host>/incomplete-cleanup/",
|
||||
views.cleanup_host_incomplete_snapshots,
|
||||
name="cleanup_host_incomplete_snapshots",
|
||||
),
|
||||
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||
path("runs/", views.runs_list, name="runs_list"),
|
||||
path("runs/<int:run_id>/", views.run_detail, name="run_detail"),
|
||||
path("runs/<int:run_id>/live/", views.run_detail_live, name="run_detail_live"),
|
||||
path("runs/<int:run_id>/rsync-log/", views.run_rsync_log, name="run_rsync_log"),
|
||||
path("runs/<int:run_id>/cancel/", views.cancel_run, name="cancel_run"),
|
||||
path("runs/<int:run_id>/resolve-review/", views.resolve_run_review, name="resolve_run_review"),
|
||||
path(
|
||||
"hosts/<str:host>/resolve-incomplete-reviews/",
|
||||
views.resolve_host_incomplete_reviews,
|
||||
name="resolve_host_incomplete_reviews",
|
||||
),
|
||||
path("snapshots/", views.snapshots_list, name="snapshots_list"),
|
||||
path("snapshots/<int:snapshot_id>/", views.snapshot_detail, name="snapshot_detail"),
|
||||
path("api/", api.api_index),
|
||||
path("api/status/", api.status),
|
||||
|
||||
Reference in New Issue
Block a user