(feature) Add full native installer and self-check page
Expand the systemd installer so it can perform a complete native installation with sensible defaults: copy the checkout into the target app directory, create runtime directories, write the environment file, install dependencies, configure systemd units, and optionally configure nginx. Add a staff-only Django self-check page that verifies runtime settings, required binaries, writable paths, database connectivity, global config state, and systemd service status when available. Document installer overrides and expose the self-check from the main navigation.
This commit is contained in:
27
README.md
27
README.md
@@ -131,14 +131,29 @@ Install OS packages first:
|
|||||||
apt install python3 python3-venv rsync openssh-client
|
apt install python3 python3-venv rsync openssh-client
|
||||||
```
|
```
|
||||||
|
|
||||||
Clone or update the app at `/opt/pobsync/app`, then run:
|
From a checked-out copy of this repository, run:
|
||||||
|
|
||||||
```
|
```
|
||||||
cd /opt/pobsync/app
|
|
||||||
sudo scripts/install-systemd
|
sudo scripts/install-systemd
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer creates:
|
By default the installer copies the checkout to `/opt/pobsync/app`, creates `/opt/pobsync/venv`, writes
|
||||||
|
`/etc/pobsync/pobsync.env`, creates `/var/lib/pobsync` and `/backups`, installs dependencies, runs migrations, collects
|
||||||
|
static files, and starts the services.
|
||||||
|
|
||||||
|
Common overrides:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo scripts/install-systemd \
|
||||||
|
--app-dir /opt/pobsync/app \
|
||||||
|
--backup-root /mnt/backups/pobsync \
|
||||||
|
--allowed-hosts backup.example.com,localhost,127.0.0.1 \
|
||||||
|
--csrf-trusted-origins https://backup.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--force-env` when you intentionally want the installer to rewrite an existing `/etc/pobsync/pobsync.env`.
|
||||||
|
|
||||||
|
The installer creates or updates:
|
||||||
|
|
||||||
- `pobsync-web.service` for Gunicorn on `127.0.0.1:8010`
|
- `pobsync-web.service` for Gunicorn on `127.0.0.1:8010`
|
||||||
- `pobsync-worker.service` for queued backup runs
|
- `pobsync-worker.service` for queued backup runs
|
||||||
@@ -167,12 +182,14 @@ systemctl status pobsync-web pobsync-worker pobsync-scheduler
|
|||||||
journalctl -u pobsync-worker -f
|
journalctl -u pobsync-worker -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Django UI also has a staff-only `/self-check/` page that verifies runtime settings, required binaries, writable
|
||||||
|
paths, database connectivity, global config state, and systemd service state when systemd is available.
|
||||||
|
|
||||||
Update an existing native install:
|
Update an existing native install:
|
||||||
|
|
||||||
```
|
```
|
||||||
cd /opt/pobsync/app
|
|
||||||
git pull
|
git pull
|
||||||
sudo scripts/install-systemd
|
sudo scripts/install-systemd --app-dir /opt/pobsync/app
|
||||||
```
|
```
|
||||||
|
|
||||||
Use an existing reverse proxy by forwarding to `http://127.0.0.1:8010`. To install a simple nginx site file as a
|
Use an existing reverse proxy by forwarding to `http://127.0.0.1:8010`. To install a simple nginx site file as a
|
||||||
|
|||||||
@@ -1,17 +1,67 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
APP_DIR=${POBSYNC_APP_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
|
SOURCE_DIR=${POBSYNC_SOURCE_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
|
||||||
|
APP_DIR=${POBSYNC_APP_DIR:-/opt/pobsync/app}
|
||||||
VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv}
|
VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv}
|
||||||
ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env}
|
ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env}
|
||||||
SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync}
|
SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync}
|
||||||
SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync}
|
SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync}
|
||||||
INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-}
|
INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-}
|
||||||
SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
|
SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
|
||||||
|
ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||||
|
CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-}
|
||||||
|
BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups}
|
||||||
|
WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010}
|
||||||
|
FORCE_ENV=0
|
||||||
WITH_NGINX=0
|
WITH_NGINX=0
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
--source-dir)
|
||||||
|
SOURCE_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--app-dir)
|
||||||
|
APP_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--venv-dir)
|
||||||
|
VENV_DIR=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--env-file)
|
||||||
|
ENV_FILE=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--service-user)
|
||||||
|
SERVICE_USER=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--service-group)
|
||||||
|
SERVICE_GROUP=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--backup-root)
|
||||||
|
BACKUP_ROOT=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--allowed-hosts)
|
||||||
|
ALLOWED_HOSTS=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--csrf-trusted-origins)
|
||||||
|
CSRF_TRUSTED_ORIGINS=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-bind)
|
||||||
|
WEB_BIND=$2
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--force-env)
|
||||||
|
FORCE_ENV=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--with-nginx)
|
--with-nginx)
|
||||||
WITH_NGINX=1
|
WITH_NGINX=1
|
||||||
shift
|
shift
|
||||||
@@ -47,6 +97,11 @@ if ! command -v ssh >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$SOURCE_DIR/manage.py" ]; then
|
||||||
|
echo "Source directory does not look like a pobsync checkout: $SOURCE_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
|
||||||
groupadd --system "$SERVICE_GROUP"
|
groupadd --system "$SERVICE_GROUP"
|
||||||
fi
|
fi
|
||||||
@@ -55,28 +110,39 @@ if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
|||||||
useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
|
useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")"
|
mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT"
|
||||||
chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
|
chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
|
||||||
chmod 0750 /var/lib/pobsync /var/log/pobsync
|
chmod 0750 /var/lib/pobsync /var/log/pobsync
|
||||||
|
|
||||||
|
if [ "$SOURCE_DIR" != "$APP_DIR" ]; then
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude .git \
|
||||||
|
--exclude .venv \
|
||||||
|
--exclude __pycache__ \
|
||||||
|
--exclude .pytest_cache \
|
||||||
|
--exclude .mypy_cache \
|
||||||
|
--exclude var \
|
||||||
|
"$SOURCE_DIR"/ "$APP_DIR"/
|
||||||
|
fi
|
||||||
|
|
||||||
python3 -m venv "$VENV_DIR"
|
python3 -m venv "$VENV_DIR"
|
||||||
"$VENV_DIR/bin/python" -m pip install --upgrade pip
|
"$VENV_DIR/bin/python" -m pip install --upgrade pip
|
||||||
"$VENV_DIR/bin/python" -m pip install -e "$APP_DIR$INSTALL_EXTRAS"
|
"$VENV_DIR/bin/python" -m pip install -e "$APP_DIR$INSTALL_EXTRAS"
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ] || [ "$FORCE_ENV" -eq 1 ]; then
|
||||||
secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
|
secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
|
||||||
cat > "$ENV_FILE" <<EOF
|
cat > "$ENV_FILE" <<EOF
|
||||||
POBSYNC_DJANGO_DEBUG=0
|
POBSYNC_DJANGO_DEBUG=0
|
||||||
POBSYNC_DJANGO_SECRET_KEY=$secret
|
POBSYNC_DJANGO_SECRET_KEY=$secret
|
||||||
POBSYNC_DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
POBSYNC_DJANGO_ALLOWED_HOSTS=$ALLOWED_HOSTS
|
||||||
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=
|
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=$CSRF_TRUSTED_ORIGINS
|
||||||
|
|
||||||
POBSYNC_HOME=/var/lib/pobsync
|
POBSYNC_HOME=/var/lib/pobsync
|
||||||
POBSYNC_BACKUP_ROOT=/backups
|
POBSYNC_BACKUP_ROOT=$BACKUP_ROOT
|
||||||
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
|
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
|
||||||
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static
|
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static
|
||||||
|
|
||||||
POBSYNC_WEB_BIND=127.0.0.1:8010
|
POBSYNC_WEB_BIND=$WEB_BIND
|
||||||
POBSYNC_GUNICORN_WORKERS=2
|
POBSYNC_GUNICORN_WORKERS=2
|
||||||
POBSYNC_GUNICORN_TIMEOUT=120
|
POBSYNC_GUNICORN_TIMEOUT=120
|
||||||
POBSYNC_WORKER_INTERVAL=15
|
POBSYNC_WORKER_INTERVAL=15
|
||||||
@@ -84,7 +150,9 @@ POBSYNC_SCHEDULER_INTERVAL=60
|
|||||||
EOF
|
EOF
|
||||||
chmod 0640 "$ENV_FILE"
|
chmod 0640 "$ENV_FILE"
|
||||||
chown "root:$SERVICE_GROUP" "$ENV_FILE"
|
chown "root:$SERVICE_GROUP" "$ENV_FILE"
|
||||||
echo "Created $ENV_FILE. Edit allowed hosts and backup root before exposing the service."
|
echo "Wrote $ENV_FILE."
|
||||||
|
else
|
||||||
|
echo "Keeping existing $ENV_FILE. Use --force-env to rewrite it."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
install_unit() {
|
install_unit() {
|
||||||
|
|||||||
200
src/pobsync_backend/self_check.py
Normal file
200
src/pobsync_backend/self_check.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
from .models import GlobalConfig
|
||||||
|
|
||||||
|
|
||||||
|
CheckStatus = Literal["ok", "warning", "failed", "skipped"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SelfCheck:
|
||||||
|
name: str
|
||||||
|
status: CheckStatus
|
||||||
|
message: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_self_checks() -> list[SelfCheck]:
|
||||||
|
checks: list[SelfCheck] = []
|
||||||
|
checks.extend(_django_checks())
|
||||||
|
checks.extend(_path_checks())
|
||||||
|
checks.extend(_binary_checks())
|
||||||
|
checks.extend(_database_checks())
|
||||||
|
checks.extend(_config_checks())
|
||||||
|
checks.extend(_systemd_checks())
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_self_checks(checks: list[SelfCheck]) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"ok": sum(1 for check in checks if check.status == "ok"),
|
||||||
|
"warning": sum(1 for check in checks if check.status == "warning"),
|
||||||
|
"failed": sum(1 for check in checks if check.status == "failed"),
|
||||||
|
"skipped": sum(1 for check in checks if check.status == "skipped"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _django_checks() -> list[SelfCheck]:
|
||||||
|
checks = [
|
||||||
|
SelfCheck(
|
||||||
|
"Django debug",
|
||||||
|
"warning" if settings.DEBUG else "ok",
|
||||||
|
"DEBUG is enabled." if settings.DEBUG else "DEBUG is disabled.",
|
||||||
|
),
|
||||||
|
SelfCheck(
|
||||||
|
"Django secret key",
|
||||||
|
"failed" if settings.SECRET_KEY == "dev-only-change-me" else "ok",
|
||||||
|
"Default development secret key is still active."
|
||||||
|
if settings.SECRET_KEY == "dev-only-change-me"
|
||||||
|
else "Secret key is configured.",
|
||||||
|
),
|
||||||
|
SelfCheck(
|
||||||
|
"Allowed hosts",
|
||||||
|
"ok" if settings.ALLOWED_HOSTS else "failed",
|
||||||
|
", ".join(settings.ALLOWED_HOSTS) if settings.ALLOWED_HOSTS else "No allowed hosts configured.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _path_checks() -> list[SelfCheck]:
|
||||||
|
checks = []
|
||||||
|
checks.append(_path_check("POBSYNC_HOME", Path(settings.POBSYNC_HOME), must_be_absolute=True, must_be_writable=True))
|
||||||
|
checks.append(
|
||||||
|
_path_check(
|
||||||
|
"POBSYNC_BACKUP_ROOT",
|
||||||
|
Path(settings.POBSYNC_BACKUP_ROOT),
|
||||||
|
must_be_absolute=True,
|
||||||
|
must_exist=True,
|
||||||
|
must_be_writable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
_path_check(
|
||||||
|
"Static root",
|
||||||
|
Path(settings.STATIC_ROOT),
|
||||||
|
must_be_absolute=True,
|
||||||
|
must_exist=False,
|
||||||
|
must_be_writable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_settings = settings.DATABASES["default"]
|
||||||
|
if db_settings["ENGINE"] == "django.db.backends.sqlite3":
|
||||||
|
checks.append(
|
||||||
|
_path_check(
|
||||||
|
"SQLite directory",
|
||||||
|
Path(str(db_settings["NAME"])).parent,
|
||||||
|
must_be_absolute=True,
|
||||||
|
must_exist=True,
|
||||||
|
must_be_writable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _path_check(
|
||||||
|
name: str,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
must_be_absolute: bool,
|
||||||
|
must_exist: bool = False,
|
||||||
|
must_be_writable: bool,
|
||||||
|
) -> SelfCheck:
|
||||||
|
if must_be_absolute and not path.is_absolute():
|
||||||
|
return SelfCheck(name, "failed", f"{path} is not absolute.")
|
||||||
|
if must_exist and not path.exists():
|
||||||
|
return SelfCheck(name, "failed", f"{path} does not exist.")
|
||||||
|
|
||||||
|
target = path if path.exists() else path.parent
|
||||||
|
if not target.exists():
|
||||||
|
return SelfCheck(name, "failed", f"{target} does not exist.")
|
||||||
|
if must_be_writable and not os.access(target, os.W_OK):
|
||||||
|
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
|
||||||
|
return SelfCheck(name, "ok", str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_checks() -> list[SelfCheck]:
|
||||||
|
checks = []
|
||||||
|
for binary in ("rsync", "ssh", "ssh-keygen", "gunicorn"):
|
||||||
|
path = shutil.which(binary)
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
f"Binary: {binary}",
|
||||||
|
"ok" if path else "failed",
|
||||||
|
path or f"{binary} was not found in PATH.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def _database_checks() -> list[SelfCheck]:
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
cursor.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
return [SelfCheck("Database connection", "failed", f"{type(exc).__name__}: {exc}")]
|
||||||
|
return [SelfCheck("Database connection", "ok", settings.DATABASES["default"]["ENGINE"])]
|
||||||
|
|
||||||
|
|
||||||
|
def _config_checks() -> list[SelfCheck]:
|
||||||
|
try:
|
||||||
|
global_config = GlobalConfig.objects.get(name="default")
|
||||||
|
except GlobalConfig.DoesNotExist:
|
||||||
|
return [SelfCheck("Global config", "warning", "Default global config has not been created yet.")]
|
||||||
|
|
||||||
|
status: CheckStatus = "ok"
|
||||||
|
message = "Default global config exists."
|
||||||
|
if global_config.backup_root != settings.POBSYNC_BACKUP_ROOT:
|
||||||
|
status = "warning"
|
||||||
|
message = "Global config backup root differs from runtime POBSYNC_BACKUP_ROOT."
|
||||||
|
return [
|
||||||
|
SelfCheck(
|
||||||
|
"Global config",
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
f"database={global_config.backup_root} runtime={settings.POBSYNC_BACKUP_ROOT}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_checks() -> list[SelfCheck]:
|
||||||
|
if not Path("/run/systemd/system").exists() or shutil.which("systemctl") is None:
|
||||||
|
return [
|
||||||
|
SelfCheck(
|
||||||
|
"Systemd services",
|
||||||
|
"skipped",
|
||||||
|
"systemd is not available in this runtime.",
|
||||||
|
"This is expected inside Docker.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
checks = []
|
||||||
|
for service in ("pobsync-web.service", "pobsync-worker.service", "pobsync-scheduler.service"):
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "is-active", service],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
active_state = result.stdout.strip() or result.stderr.strip()
|
||||||
|
checks.append(
|
||||||
|
SelfCheck(
|
||||||
|
service,
|
||||||
|
"ok" if result.returncode == 0 else "failed",
|
||||||
|
active_state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return checks
|
||||||
@@ -75,9 +75,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
|
.status.success { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
|
||||||
|
.status.ok { color: var(--success); border-color: #a7d8b9; background: #edf8f1; }
|
||||||
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
|
.status.failed { color: var(--failed); border-color: #e8b4b4; background: #fff0f0; }
|
||||||
.status.running { color: var(--running); border-color: #e7cf8a; background: #fff8df; }
|
.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(--link); border-color: #b5cdea; background: #eef6ff; }
|
||||||
|
.status.skipped { color: var(--muted); background: #f7f9fb; }
|
||||||
.stack { display: grid; gap: 4px; }
|
.stack { display: grid; gap: 4px; }
|
||||||
.stack.spaced { margin-bottom: 14px; }
|
.stack.spaced { margin-bottom: 14px; }
|
||||||
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
@@ -170,6 +173,7 @@
|
|||||||
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
||||||
<a href="{% url 'admin:index' %}">Admin</a>
|
<a href="{% url 'admin:index' %}">Admin</a>
|
||||||
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
||||||
|
<a href="{% url 'self_check' %}">Self Check</a>
|
||||||
<a href="/api/status/">Status API</a>
|
<a href="/api/status/">Status API</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="muted">{{ request.user.username }}</span>
|
<span class="muted">{{ request.user.username }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Self Check | pobsync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Self Check</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Self check actions">
|
||||||
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Self check summary">
|
||||||
|
<div class="metric"><div class="label">OK</div><div class="value">{{ summary.ok }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Warnings</div><div class="value">{{ summary.warning }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Failed</div><div class="value">{{ summary.failed }}</div></div>
|
||||||
|
<div class="metric"><div class="label">Skipped</div><div class="value">{{ summary.skipped }}</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Checks</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Check</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for check in 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>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -83,6 +83,17 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, reverse("create_host_config"))
|
self.assertContains(response, reverse("create_host_config"))
|
||||||
self.assertContains(response, "Add first host")
|
self.assertContains(response, "Add first host")
|
||||||
|
|
||||||
|
def test_self_check_renders_runtime_checks(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, "Self Check")
|
||||||
|
self.assertContains(response, "Django debug")
|
||||||
|
self.assertContains(response, "Database connection")
|
||||||
|
self.assertContains(response, "POBSYNC_HOME")
|
||||||
|
|
||||||
def test_ssh_credentials_view_creates_key(self) -> None:
|
def test_ssh_credentials_view_creates_key(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .forms import (
|
|||||||
)
|
)
|
||||||
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
|
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
|
||||||
from .retention import run_sql_retention_apply, run_sql_retention_plan
|
from .retention import run_sql_retention_apply, run_sql_retention_plan
|
||||||
|
from .self_check import collect_self_checks, summarize_self_checks
|
||||||
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +59,19 @@ def dashboard(request):
|
|||||||
return render(request, "pobsync_backend/dashboard.html", context)
|
return render(request, "pobsync_backend/dashboard.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def self_check(request):
|
||||||
|
checks = collect_self_checks()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"pobsync_backend/self_check.html",
|
||||||
|
{
|
||||||
|
"checks": checks,
|
||||||
|
"summary": summarize_self_checks(checks),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def ssh_credentials(request):
|
def ssh_credentials(request):
|
||||||
context = {
|
context = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pobsync_backend import api, views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
path("", views.dashboard, name="dashboard"),
|
||||||
|
path("self-check/", views.self_check, name="self_check"),
|
||||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||||
|
|||||||
Reference in New Issue
Block a user