(feature) add Django setup flow for initial pobsync configuration

Add staff-only UI routes for creating/editing the default GlobalConfig
and creating the first HostConfig from the dashboard.

Improve the empty dashboard state so a fresh database guides the user
towards the next useful setup action instead of only showing empty tables.

Cover the setup flow with view tests for empty state prompts, global
config creation, and host creation.
This commit is contained in:
2026-05-19 12:25:45 +02:00
parent 4dbde43465
commit 6bcc15c174
8 changed files with 290 additions and 8 deletions

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from django import forms from django import forms
from .models import HostConfig, ScheduleConfig from .models import GlobalConfig, HostConfig, ScheduleConfig
from .scheduler import parse_cron_expr from .scheduler import parse_cron_expr
@@ -64,6 +64,52 @@ class HostConfigForm(forms.ModelForm):
} }
class CreateHostConfigForm(HostConfigForm):
class Meta(HostConfigForm.Meta):
fields = ("host", *HostConfigForm.Meta.fields)
help_texts = {
**HostConfigForm.Meta.help_texts,
"host": "Stable internal host name used for backup paths.",
}
class GlobalConfigForm(forms.ModelForm):
ssh_options = NewlineListField(help_text="One SSH option per line.")
rsync_args = NewlineListField(help_text="One default rsync argument per line.")
rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.")
excludes_default = NewlineListField(help_text="One default exclude pattern per line.")
class Meta:
model = GlobalConfig
fields = (
"name",
"backup_root",
"pobsync_home",
"ssh_user",
"ssh_port",
"ssh_options",
"rsync_binary",
"rsync_args",
"rsync_extra_args",
"rsync_timeout_seconds",
"rsync_bwlimit_kbps",
"default_source_root",
"default_destination_subdir",
"excludes_default",
"retention_daily",
"retention_weekly",
"retention_monthly",
"retention_yearly",
)
help_texts = {
"name": "Usually 'default'. The backup engine currently reads the default config.",
"backup_root": "Directory that contains host backup folders.",
"pobsync_home": "Base directory for runtime state inside the container or host.",
"default_source_root": "Used by hosts without a custom source root.",
"default_destination_subdir": "Optional subdirectory below each snapshot.",
}
class ScheduleConfigForm(forms.ModelForm): class ScheduleConfigForm(forms.ModelForm):
cron_expr = forms.CharField( cron_expr = forms.CharField(
label="Cron expression", label="Cron expression",

View File

@@ -80,6 +80,7 @@
.stack { display: grid; gap: 4px; } .stack { display: grid; gap: 4px; }
.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); }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; } .actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
.actions.inline { margin: 12px 0 0; }
button, .button-link { button, .button-link {
appearance: none; appearance: none;
background: #17202a; background: #17202a;
@@ -92,6 +93,12 @@
padding: 8px 12px; padding: 8px 12px;
} }
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; } button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
.button-link.secondary {
background: #fff;
border-color: var(--border);
color: var(--text);
}
.button-link.secondary:hover { background: #eef3f8; }
.messages { display: grid; gap: 8px; margin-bottom: 18px; } .messages { display: grid; gap: 8px; margin-bottom: 18px; }
.message { .message {
background: var(--panel); background: var(--panel);
@@ -113,6 +120,7 @@
} }
.field textarea { min-height: 92px; resize: vertical; } .field textarea { min-height: 92px; resize: vertical; }
.field .helptext { color: var(--muted); font-size: 12px; } .field .helptext { color: var(--muted); font-size: 12px; }
.field input[type="checkbox"] { justify-self: start; }
.errorlist { .errorlist {
color: var(--failed); color: var(--failed);
list-style: none; list-style: none;

View File

@@ -5,7 +5,30 @@
{% block content %} {% block content %}
<h1>Dashboard</h1> <h1>Dashboard</h1>
<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>
{% if not global_config or not counts.hosts %}
<section class="panel">
<h2>Setup</h2>
{% if not global_config %}
<p class="muted">No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'edit_global_config' %}">Create global config</a>
</div>
{% elif not counts.hosts %}
<p class="muted">Global config is ready. Add the first host to make this dashboard useful.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'create_host_config' %}">Add first host</a>
</div>
{% endif %}
</section>
{% endif %}
<section class="grid" aria-label="Summary"> <section class="grid" aria-label="Summary">
<div class="metric"><div class="label">Global Configs</div><div class="value">{{ counts.global_configs }}</div></div>
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div> <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">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">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>

View File

@@ -0,0 +1,32 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Global Config{% endblock %}
{% block content %}
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
<section class="actions" aria-label="Global config actions">
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
<section class="panel">
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}</h2>
<form method="post" class="form-grid">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="actions">
<button type="submit">Save global config</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -1,16 +1,20 @@
{% extends "pobsync_backend/base.html" %} {% extends "pobsync_backend/base.html" %}
{% block title %}Config | {{ host.host }}{% endblock %} {% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %}
{% block content %} {% block content %}
<h1>Config: {{ host.host }}</h1> <h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
<section class="actions" aria-label="Config actions"> <section class="actions" aria-label="Config actions">
{% if host %}
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a> <a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
{% else %}
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
{% endif %}
</section> </section>
<section class="panel"> <section class="panel">
<h2>Edit Host Config</h2> <h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>
<form method="post" class="form-grid"> <form method="post" class="form-grid">
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors }} {{ form.non_field_errors }}
@@ -25,7 +29,7 @@
{% endfor %} {% endfor %}
<div class="actions"> <div class="actions">
<button type="submit">Save config</button> <button type="submit">{% if host %}Save config{% else %}Create host{% endif %}</button>
</div> </div>
</form> </form>
</section> </section>

View File

@@ -47,6 +47,102 @@ class ViewTests(TestCase):
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "success") self.assertContains(response, "success")
def test_dashboard_prompts_for_global_config_when_database_is_empty(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No default global config exists yet.")
self.assertContains(response, reverse("edit_global_config"))
self.assertContains(response, "Create global config")
def test_dashboard_prompts_for_first_host_after_global_config_exists(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/opt/pobsync/backups")
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Global config is ready.")
self.assertContains(response, reverse("create_host_config"))
self.assertContains(response, "Add first host")
def test_global_config_form_creates_default_config(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.post(
reverse("edit_global_config"),
{
"name": "default",
"backup_root": "/backups",
"pobsync_home": "/opt/pobsync",
"ssh_user": "backup",
"ssh_port": "2222",
"ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes",
"rsync_binary": "rsync",
"rsync_args": "-a\n--numeric-ids",
"rsync_extra_args": "--delete",
"rsync_timeout_seconds": "60",
"rsync_bwlimit_kbps": "1000",
"default_source_root": "/srv",
"default_destination_subdir": "rootfs",
"excludes_default": "*.tmp\ncache/",
"retention_daily": "7",
"retention_weekly": "4",
"retention_monthly": "2",
"retention_yearly": "1",
},
follow=True,
)
self.assertRedirects(response, reverse("dashboard"))
self.assertContains(response, "Global config saved for default.")
config = GlobalConfig.objects.get(name="default")
self.assertEqual(config.backup_root, "/backups")
self.assertEqual(config.ssh_user, "backup")
self.assertEqual(config.ssh_port, 2222)
self.assertEqual(config.ssh_options, ["StrictHostKeyChecking=no", "BatchMode=yes"])
self.assertEqual(config.rsync_args, ["-a", "--numeric-ids"])
self.assertEqual(config.rsync_extra_args, ["--delete"])
self.assertEqual(config.excludes_default, ["*.tmp", "cache/"])
self.assertEqual(config.retention_daily, 7)
self.assertEqual(config.retention_yearly, 1)
def test_create_host_config_form_creates_host(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.post(
reverse("create_host_config"),
{
"host": "web-01",
"address": "web-01.example.test",
"enabled": "on",
"ssh_user": "backup",
"ssh_port": "2222",
"source_root": "/srv",
"includes": "/srv/www\n/srv/db",
"excludes_add": "*.tmp",
"excludes_replace": "",
"rsync_extra_args": "--numeric-ids",
"retention_daily": "7",
"retention_weekly": "4",
"retention_monthly": "2",
"retention_yearly": "1",
},
follow=True,
)
self.assertRedirects(response, reverse("host_detail", args=["web-01"]))
self.assertContains(response, "Host config created for web-01.")
host = HostConfig.objects.get(host="web-01")
self.assertEqual(host.address, "web-01.example.test")
self.assertEqual(host.ssh_user, "backup")
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
self.assertEqual(host.excludes_add, ["*.tmp"])
self.assertEqual(host.rsync_extra_args, ["--numeric-ids"])
self.assertEqual(host.retention_weekly, 4)
def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None: def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
host = HostConfig.objects.create( host = HostConfig.objects.create(

View File

@@ -8,8 +8,8 @@ from django.views.decorators.http import require_POST
from pobsync.errors import ConfigError from pobsync.errors import ConfigError
from .forms import HostConfigForm, ScheduleConfigForm from .forms import CreateHostConfigForm, GlobalConfigForm, HostConfigForm, ScheduleConfigForm
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
from .retention import run_sql_retention_plan from .retention import run_sql_retention_plan
from .snapshot_discovery import discover_snapshots from .snapshot_discovery import discover_snapshots
@@ -22,8 +22,10 @@ def dashboard(request):
) )
context = { context = {
"hosts": host_qs, "hosts": host_qs,
"global_config": GlobalConfig.objects.filter(name="default").first(),
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10], "latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
"counts": { "counts": {
"global_configs": GlobalConfig.objects.count(),
"hosts": HostConfig.objects.count(), "hosts": HostConfig.objects.count(),
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(), "enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
"schedules": ScheduleConfig.objects.count(), "schedules": ScheduleConfig.objects.count(),
@@ -37,6 +39,49 @@ def dashboard(request):
return render(request, "pobsync_backend/dashboard.html", context) return render(request, "pobsync_backend/dashboard.html", context)
@staff_member_required
def edit_global_config(request):
global_config = GlobalConfig.objects.filter(name="default").first()
if request.method == "POST":
form = GlobalConfigForm(request.POST, instance=global_config)
if form.is_valid():
saved_config = form.save()
messages.success(request, f"Global config saved for {saved_config.name}.")
return redirect("dashboard")
else:
form = GlobalConfigForm(instance=global_config, initial=_default_global_initial())
return render(
request,
"pobsync_backend/global_form.html",
{
"global_config": global_config,
"form": form,
},
)
@staff_member_required
def create_host_config(request):
if request.method == "POST":
form = CreateHostConfigForm(request.POST)
if form.is_valid():
host_config = form.save()
messages.success(request, f"Host config created for {host_config.host}.")
return redirect("host_detail", host=host_config.host)
else:
form = CreateHostConfigForm(initial=_default_host_initial())
return render(
request,
"pobsync_backend/host_form.html",
{
"host": None,
"form": form,
},
)
@staff_member_required @staff_member_required
def host_detail(request, host: str): def host_detail(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)
@@ -158,3 +203,29 @@ def _default_schedule_initial() -> dict[str, object]:
"enabled": True, "enabled": True,
"prune_max_delete": 10, "prune_max_delete": 10,
} }
def _default_global_initial() -> dict[str, object]:
return {
"name": "default",
"backup_root": "/opt/pobsync/backups",
"pobsync_home": "/opt/pobsync",
"ssh_user": "root",
"ssh_port": 22,
"rsync_binary": "rsync",
"default_source_root": "/",
"retention_daily": 14,
"retention_weekly": 8,
"retention_monthly": 12,
"retention_yearly": 0,
}
def _default_host_initial() -> dict[str, object]:
return {
"enabled": True,
"retention_daily": 14,
"retention_weekly": 8,
"retention_monthly": 12,
"retention_yearly": 0,
}

View File

@@ -8,6 +8,8 @@ from pobsync_backend import api, views
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("config/global/", views.edit_global_config, name="edit_global_config"),
path("hosts/new/", views.create_host_config, name="create_host_config"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"), path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"), path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),