(feature) Add host doctor checks and Django log viewer

Add host-level checks for address, enabled state, SSH credential
selection, and backup directory readiness, and show them on the host
detail page.

Create host backup directories during host creation and prefill new
hosts from the default global config.

Add a staff-only logs view backed by journalctl with filtering by
pobsync unit, priority, and message text.

Improve runtime checks for gunicorn in virtualenv installs and ensure
the native installer grants the service user access to the backup root.
This commit is contained in:
2026-05-19 19:11:57 +02:00
parent bb7907846e
commit 90f28410ce
10 changed files with 319 additions and 2 deletions

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
from django.contrib import messages
@@ -22,6 +24,7 @@ from .forms import (
ScheduleConfigForm,
SshCredentialForm,
)
from .host_ops import collect_host_checks, ensure_host_directories
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
from .retention import run_sql_retention_apply, run_sql_retention_plan
from .self_check import collect_self_checks, summarize_self_checks
@@ -72,6 +75,12 @@ def self_check(request):
)
@staff_member_required
def logs(request):
context = _log_context(request)
return render(request, "pobsync_backend/logs.html", context)
@staff_member_required
def ssh_credentials(request):
context = {
@@ -152,6 +161,13 @@ def create_host_config(request):
form = CreateHostConfigForm(request.POST)
if form.is_valid():
host_config = form.save()
try:
host_root = ensure_host_directories(host_config)
except Exception as exc:
messages.warning(request, f"Host config created, but backup directories could not be prepared: {exc}")
else:
messages.success(request, f"Host config created for {host_config.host}; prepared {host_root}.")
return redirect("host_detail", host=host_config.host)
messages.success(request, f"Host config created for {host_config.host}.")
return redirect("host_detail", host=host_config.host)
else:
@@ -176,10 +192,13 @@ def host_detail(request, host: str):
status__in=[BackupRun.Status.QUEUED, BackupRun.Status.RUNNING]
).order_by("created_at", "id").first()
has_global_config = GlobalConfig.objects.filter(name="default").exists()
host_checks = collect_host_checks(host_config)
context = {
"host": host_config,
"schedule": _schedule_for_host(host_config),
"discovery": inspect_snapshot_discovery(host=host_config),
"host_checks": host_checks,
"host_check_summary": summarize_self_checks(host_checks),
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
"can_queue_backup": host_config.enabled and has_global_config,
"has_global_config": has_global_config,
@@ -414,6 +433,19 @@ def _default_global_initial() -> dict[str, object]:
def _default_host_initial() -> dict[str, object]:
global_config = GlobalConfig.objects.filter(name="default").first()
if global_config is not None:
return {
"enabled": True,
"ssh_credential": global_config.default_ssh_credential,
"ssh_user": global_config.ssh_user,
"ssh_port": global_config.ssh_port,
"source_root": global_config.default_source_root,
"retention_daily": global_config.retention_daily,
"retention_weekly": global_config.retention_weekly,
"retention_monthly": global_config.retention_monthly,
"retention_yearly": global_config.retention_yearly,
}
return {
"enabled": True,
"retention_daily": 14,
@@ -435,3 +467,50 @@ def _default_manual_backup_initial(host_config: HostConfig) -> dict[str, object]
def _pretty_json(value: object) -> str:
return json.dumps(value or {}, indent=2, sort_keys=True)
def _log_context(request) -> dict[str, object]:
units = ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service")
priorities = {
"": "All",
"0..3": "Errors",
"4": "Warnings",
"5": "Notices",
"6": "Info",
"7": "Debug",
}
selected_unit = request.GET.get("unit", "")
priority = request.GET.get("priority", "0..4")
query = request.GET.get("q", "").strip()
lines = []
error = ""
if shutil.which("journalctl") is None:
error = "journalctl is not available in this runtime."
else:
command = ["journalctl", "--no-pager", "-n", "300", "-o", "short-iso"]
if selected_unit in units:
command.extend(["-u", selected_unit])
else:
for unit in units:
command.extend(["-u", unit])
if priority:
command.extend(["-p", priority])
result = subprocess.run(command, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10)
if result.returncode != 0:
error = result.stderr.strip() or "Could not read journal logs."
else:
lines = result.stdout.splitlines()
if query:
lowered_query = query.lower()
lines = [line for line in lines if lowered_query in line.lower()]
return {
"units": units,
"priorities": priorities,
"selected_unit": selected_unit,
"selected_priority": priority,
"query": query,
"lines": lines,
"error": error,
}