diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py
index f2f33ad..3254dd3 100644
--- a/src/pobsync_backend/forms.py
+++ b/src/pobsync_backend/forms.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from django import forms
-from .models import HostConfig, ScheduleConfig
+from .models import GlobalConfig, HostConfig, ScheduleConfig
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):
cron_expr = forms.CharField(
label="Cron expression",
diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html
index ad73b00..5db2f87 100644
--- a/src/pobsync_backend/templates/pobsync_backend/base.html
+++ b/src/pobsync_backend/templates/pobsync_backend/base.html
@@ -80,6 +80,7 @@
.stack { display: grid; gap: 4px; }
.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.inline { margin: 12px 0 0; }
button, .button-link {
appearance: none;
background: #17202a;
@@ -92,6 +93,12 @@
padding: 8px 12px;
}
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; }
.message {
background: var(--panel);
@@ -113,6 +120,7 @@
}
.field textarea { min-height: 92px; resize: vertical; }
.field .helptext { color: var(--muted); font-size: 12px; }
+ .field input[type="checkbox"] { justify-self: start; }
.errorlist {
color: var(--failed);
list-style: none;
diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html
index 79bd17b..ce0cbb9 100644
--- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html
+++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html
@@ -5,7 +5,30 @@
{% block content %}
Dashboard
+
+
+ {% if not global_config or not counts.hosts %}
+
+ Setup
+ {% if not global_config %}
+ No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.
+
+ {% elif not counts.hosts %}
+ Global config is ready. Add the first host to make this dashboard useful.
+
+ {% endif %}
+
+ {% endif %}
+
+ Global Configs
{{ counts.global_configs }}
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/global_form.html b/src/pobsync_backend/templates/pobsync_backend/global_form.html
new file mode 100644
index 0000000..18a9e99
--- /dev/null
+++ b/src/pobsync_backend/templates/pobsync_backend/global_form.html
@@ -0,0 +1,32 @@
+{% extends "pobsync_backend/base.html" %}
+
+{% block title %}Global Config{% endblock %}
+
+{% block content %}
+ {% if global_config %}Global Config{% else %}Create Global Config{% endif %}
+
+
+
+
+ {% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}
+
+
+{% endblock %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/host_form.html b/src/pobsync_backend/templates/pobsync_backend/host_form.html
index 1e7b004..c316b88 100644
--- a/src/pobsync_backend/templates/pobsync_backend/host_form.html
+++ b/src/pobsync_backend/templates/pobsync_backend/host_form.html
@@ -1,16 +1,20 @@
{% 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 %}
- Config: {{ host.host }}
+ {% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}
- Edit Host Config
+ {% if host %}Edit Host Config{% else %}Create Host Config{% endif %}
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py
index 3f5b7a6..0346d73 100644
--- a/src/pobsync_backend/tests/test_views.py
+++ b/src/pobsync_backend/tests/test_views.py
@@ -47,6 +47,102 @@ class ViewTests(TestCase):
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
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:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(
diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py
index c8e7fa3..64925a3 100644
--- a/src/pobsync_backend/views.py
+++ b/src/pobsync_backend/views.py
@@ -8,8 +8,8 @@ from django.views.decorators.http import require_POST
from pobsync.errors import ConfigError
-from .forms import HostConfigForm, ScheduleConfigForm
-from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
+from .forms import CreateHostConfigForm, GlobalConfigForm, HostConfigForm, ScheduleConfigForm
+from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
from .retention import run_sql_retention_plan
from .snapshot_discovery import discover_snapshots
@@ -22,8 +22,10 @@ def dashboard(request):
)
context = {
"hosts": host_qs,
+ "global_config": GlobalConfig.objects.filter(name="default").first(),
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
"counts": {
+ "global_configs": GlobalConfig.objects.count(),
"hosts": HostConfig.objects.count(),
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
"schedules": ScheduleConfig.objects.count(),
@@ -37,6 +39,49 @@ def dashboard(request):
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
def host_detail(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -158,3 +203,29 @@ def _default_schedule_initial() -> dict[str, object]:
"enabled": True,
"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,
+ }
diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py
index 15f28a1..c4fe56f 100644
--- a/src/pobsync_server/urls.py
+++ b/src/pobsync_server/urls.py
@@ -8,6 +8,8 @@ from pobsync_backend import api, views
urlpatterns = [
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//", views.host_detail, name="host_detail"),
path("hosts//config/", views.edit_host_config, name="edit_host_config"),
path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),