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

+
+ New host + {% if global_config %}Edit global config{% else %}Create global config{% endif %} +
+ + {% 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.

+
+ Create global config +
+ {% elif not counts.hosts %} +

Global config is ready. Add the first host to make this dashboard useful.

+
+ Add first host +
+ {% 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 %}

+ +
+ Back to dashboard +
+ +
+

{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}

+
+ {% csrf_token %} + {{ form.non_field_errors }} + + {% for field in form %} +
+ {{ field.errors }} + + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} +
+ {% endfor %} + +
+ +
+
+
+{% 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 %}

- Back to host + {% if host %} + Back to host + {% else %} + Back to dashboard + {% endif %}
-

Edit Host Config

+

{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}

{% csrf_token %} {{ form.non_field_errors }} @@ -25,7 +29,7 @@ {% endfor %}
- +
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"),