(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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user