Compare commits
2 Commits
123583a502
...
4dbde43465
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dbde43465 | |||
| 6d7bf531ac |
@@ -155,6 +155,8 @@ Staff-only JSON endpoints expose service status, hosts, snapshots, and backup ru
|
|||||||
Staff-only dashboard views expose the same operational state through Django templates.
|
Staff-only dashboard views expose the same operational state through Django templates.
|
||||||
Host pages include a safe snapshot discovery action that records existing snapshots into SQL.
|
Host pages include a safe snapshot discovery action that records existing snapshots into SQL.
|
||||||
Host pages also include a read-only SQL retention plan view before any destructive pruning action.
|
Host pages also include a read-only SQL retention plan view before any destructive pruning action.
|
||||||
|
Schedules can be created or updated from host pages using the same SQL-backed scheduler model.
|
||||||
|
Host config can be edited from host pages while keeping host identity stable.
|
||||||
|
|
||||||
The remaining internal engine code still contains reusable backup primitives:
|
The remaining internal engine code still contains reusable backup primitives:
|
||||||
|
|
||||||
|
|||||||
91
src/pobsync_backend/forms.py
Normal file
91
src/pobsync_backend/forms.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from .models import HostConfig, ScheduleConfig
|
||||||
|
from .scheduler import parse_cron_expr
|
||||||
|
|
||||||
|
|
||||||
|
class NewlineListField(forms.CharField):
|
||||||
|
widget = forms.Textarea
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
kwargs.setdefault("required", False)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def prepare_value(self, value):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "\n".join(str(item) for item in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_python(self, value) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
return [line.strip() for line in str(value).splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class NullableNewlineListField(NewlineListField):
|
||||||
|
def to_python(self, value) -> list[str] | None:
|
||||||
|
parsed = super().to_python(value)
|
||||||
|
return parsed or None
|
||||||
|
|
||||||
|
|
||||||
|
class HostConfigForm(forms.ModelForm):
|
||||||
|
includes = NewlineListField(help_text="One include path per line. Leave empty to include defaults.")
|
||||||
|
excludes_add = NewlineListField(help_text="One additional exclude pattern per line.")
|
||||||
|
excludes_replace = NullableNewlineListField(
|
||||||
|
help_text="Optional. When set, replaces global excludes; one pattern per line."
|
||||||
|
)
|
||||||
|
rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = HostConfig
|
||||||
|
fields = (
|
||||||
|
"address",
|
||||||
|
"enabled",
|
||||||
|
"ssh_user",
|
||||||
|
"ssh_port",
|
||||||
|
"source_root",
|
||||||
|
"includes",
|
||||||
|
"excludes_add",
|
||||||
|
"excludes_replace",
|
||||||
|
"rsync_extra_args",
|
||||||
|
"retention_daily",
|
||||||
|
"retention_weekly",
|
||||||
|
"retention_monthly",
|
||||||
|
"retention_yearly",
|
||||||
|
)
|
||||||
|
help_texts = {
|
||||||
|
"ssh_user": "Leave empty to use the global SSH user.",
|
||||||
|
"ssh_port": "Leave empty to use the global SSH port.",
|
||||||
|
"source_root": "Leave empty to use the global default source root.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleConfigForm(forms.ModelForm):
|
||||||
|
cron_expr = forms.CharField(
|
||||||
|
label="Cron expression",
|
||||||
|
help_text='Five-field cron expression, for example "15 2 * * *".',
|
||||||
|
)
|
||||||
|
prune_max_delete = forms.IntegerField(min_value=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ScheduleConfig
|
||||||
|
fields = (
|
||||||
|
"cron_expr",
|
||||||
|
"user",
|
||||||
|
"enabled",
|
||||||
|
"prune",
|
||||||
|
"prune_max_delete",
|
||||||
|
"prune_protect_bases",
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_cron_expr(self) -> str:
|
||||||
|
cron_expr = self.cleaned_data["cron_expr"].strip()
|
||||||
|
try:
|
||||||
|
parse_cron_expr(cron_expr)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise forms.ValidationError(str(exc)) from exc
|
||||||
|
return cron_expr
|
||||||
@@ -101,6 +101,24 @@
|
|||||||
}
|
}
|
||||||
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
|
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
|
||||||
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
|
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
|
||||||
|
.form-grid { display: grid; gap: 14px; max-width: 680px; }
|
||||||
|
.field { display: grid; gap: 5px; }
|
||||||
|
.field label { font-weight: 650; }
|
||||||
|
.field input[type="text"], .field input[type="number"], .field textarea {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.field textarea { min-height: 92px; resize: vertical; }
|
||||||
|
.field .helptext { color: var(--muted); font-size: 12px; }
|
||||||
|
.errorlist {
|
||||||
|
color: var(--failed);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
main { padding: 16px; }
|
main { padding: 16px; }
|
||||||
nav { padding: 0; }
|
nav { padding: 0; }
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
<h1>{{ host.host }}</h1>
|
<h1>{{ host.host }}</h1>
|
||||||
|
|
||||||
<section class="actions" aria-label="Host actions">
|
<section class="actions" aria-label="Host actions">
|
||||||
|
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
|
||||||
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit">Discover snapshots</button>
|
<button type="submit">Discover snapshots</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
|
||||||
|
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid" aria-label="Host summary">
|
<section class="grid" aria-label="Host summary">
|
||||||
|
|||||||
32
src/pobsync_backend/templates/pobsync_backend/host_form.html
Normal file
32
src/pobsync_backend/templates/pobsync_backend/host_form.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Config | {{ host.host }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Config: {{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Config actions">
|
||||||
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Edit Host Config</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 config</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Schedule | {{ host.host }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Schedule: {{ host.host }}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Schedule actions">
|
||||||
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% 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 schedule</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -67,6 +67,8 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "15 2 * * *")
|
self.assertContains(response, "15 2 * * *")
|
||||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||||
self.assertContains(response, "Discover snapshots")
|
self.assertContains(response, "Discover snapshots")
|
||||||
|
self.assertContains(response, "Edit schedule")
|
||||||
|
self.assertContains(response, "Edit config")
|
||||||
|
|
||||||
def test_host_detail_returns_404_for_unknown_host(self) -> None:
|
def test_host_detail_returns_404_for_unknown_host(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
@@ -152,6 +154,170 @@ class ViewTests(TestCase):
|
|||||||
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||||
self.assertContains(response, "Retention kind must be scheduled, manual, or all.")
|
self.assertContains(response, "Retention kind must be scheduled, manual, or all.")
|
||||||
|
|
||||||
|
def test_schedule_form_renders_defaults_for_new_schedule(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("edit_host_schedule", args=[host.host]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Create Schedule")
|
||||||
|
self.assertContains(response, "15 2 * * *")
|
||||||
|
self.assertContains(response, "Save schedule")
|
||||||
|
|
||||||
|
def test_schedule_form_creates_schedule(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("edit_host_schedule", args=[host.host]),
|
||||||
|
{
|
||||||
|
"cron_expr": "30 3 * * *",
|
||||||
|
"user": "root",
|
||||||
|
"enabled": "on",
|
||||||
|
"prune": "on",
|
||||||
|
"prune_max_delete": "4",
|
||||||
|
"prune_protect_bases": "on",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||||
|
self.assertContains(response, "Schedule saved for web-01.")
|
||||||
|
schedule = ScheduleConfig.objects.get(host=host)
|
||||||
|
self.assertEqual(schedule.cron_expr, "30 3 * * *")
|
||||||
|
self.assertTrue(schedule.enabled)
|
||||||
|
self.assertTrue(schedule.prune)
|
||||||
|
self.assertEqual(schedule.prune_max_delete, 4)
|
||||||
|
self.assertTrue(schedule.prune_protect_bases)
|
||||||
|
|
||||||
|
def test_schedule_form_updates_existing_schedule(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
schedule = ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("edit_host_schedule", args=[host.host]),
|
||||||
|
{
|
||||||
|
"cron_expr": "45 4 * * 1",
|
||||||
|
"user": "backup",
|
||||||
|
"prune_max_delete": "8",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||||
|
schedule.refresh_from_db()
|
||||||
|
self.assertEqual(schedule.cron_expr, "45 4 * * 1")
|
||||||
|
self.assertEqual(schedule.user, "backup")
|
||||||
|
self.assertFalse(schedule.enabled)
|
||||||
|
self.assertFalse(schedule.prune)
|
||||||
|
self.assertEqual(schedule.prune_max_delete, 8)
|
||||||
|
|
||||||
|
def test_schedule_form_rejects_invalid_cron(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("edit_host_schedule", args=[host.host]),
|
||||||
|
{
|
||||||
|
"cron_expr": "bad cron",
|
||||||
|
"user": "root",
|
||||||
|
"enabled": "on",
|
||||||
|
"prune_max_delete": "10",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "cron expression must have exactly 5 fields")
|
||||||
|
self.assertFalse(ScheduleConfig.objects.filter(host=host).exists())
|
||||||
|
|
||||||
|
def test_host_config_form_renders_existing_values(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(
|
||||||
|
host="web-01",
|
||||||
|
address="web-01.example.test",
|
||||||
|
includes=["/srv"],
|
||||||
|
excludes_add=["*.tmp"],
|
||||||
|
rsync_extra_args=["--numeric-ids"],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("edit_host_config", args=[host.host]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Config: web-01")
|
||||||
|
self.assertContains(response, "web-01.example.test")
|
||||||
|
self.assertContains(response, "/srv")
|
||||||
|
self.assertContains(response, "*.tmp")
|
||||||
|
self.assertContains(response, "--numeric-ids")
|
||||||
|
|
||||||
|
def test_host_config_form_updates_host_config(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="old.example.test")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("edit_host_config", args=[host.host]),
|
||||||
|
{
|
||||||
|
"address": "new.example.test",
|
||||||
|
"enabled": "on",
|
||||||
|
"ssh_user": "backup",
|
||||||
|
"ssh_port": "2222",
|
||||||
|
"source_root": "/srv",
|
||||||
|
"includes": "/srv/www\n/srv/db",
|
||||||
|
"excludes_add": "*.tmp\ncache/",
|
||||||
|
"excludes_replace": "",
|
||||||
|
"rsync_extra_args": "--numeric-ids\n--delete",
|
||||||
|
"retention_daily": "7",
|
||||||
|
"retention_weekly": "4",
|
||||||
|
"retention_monthly": "2",
|
||||||
|
"retention_yearly": "1",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||||
|
self.assertContains(response, "Host config saved for web-01.")
|
||||||
|
host.refresh_from_db()
|
||||||
|
self.assertEqual(host.address, "new.example.test")
|
||||||
|
self.assertEqual(host.ssh_user, "backup")
|
||||||
|
self.assertEqual(host.ssh_port, 2222)
|
||||||
|
self.assertEqual(host.source_root, "/srv")
|
||||||
|
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
|
||||||
|
self.assertEqual(host.excludes_add, ["*.tmp", "cache/"])
|
||||||
|
self.assertIsNone(host.excludes_replace)
|
||||||
|
self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"])
|
||||||
|
self.assertEqual(host.retention_daily, 7)
|
||||||
|
self.assertEqual(host.retention_yearly, 1)
|
||||||
|
|
||||||
|
def test_host_config_form_can_replace_global_excludes(self) -> None:
|
||||||
|
self.client.force_login(self.staff_user)
|
||||||
|
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("edit_host_config", args=[host.host]),
|
||||||
|
{
|
||||||
|
"address": host.address,
|
||||||
|
"ssh_user": "",
|
||||||
|
"ssh_port": "",
|
||||||
|
"source_root": "",
|
||||||
|
"includes": "",
|
||||||
|
"excludes_add": "",
|
||||||
|
"excludes_replace": "*.cache\nnode_modules/",
|
||||||
|
"rsync_extra_args": "",
|
||||||
|
"retention_daily": "14",
|
||||||
|
"retention_weekly": "8",
|
||||||
|
"retention_monthly": "12",
|
||||||
|
"retention_yearly": "0",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
|
||||||
|
host.refresh_from_db()
|
||||||
|
self.assertFalse(host.enabled)
|
||||||
|
self.assertEqual(host.excludes_add, [])
|
||||||
|
self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"])
|
||||||
|
|
||||||
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
|
def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord:
|
||||||
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
return SnapshotRecord.objects.create(
|
return SnapshotRecord.objects.create(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.views.decorators.http import require_POST
|
|||||||
|
|
||||||
from pobsync.errors import ConfigError
|
from pobsync.errors import ConfigError
|
||||||
|
|
||||||
|
from .forms import HostConfigForm, ScheduleConfigForm
|
||||||
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
from .models import BackupRun, 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
|
||||||
@@ -95,8 +96,65 @@ def host_retention_plan(request, host: str):
|
|||||||
return render(request, "pobsync_backend/retention_plan.html", context)
|
return render(request, "pobsync_backend/retention_plan.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def edit_host_config(request, host: str):
|
||||||
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
|
if request.method == "POST":
|
||||||
|
form = HostConfigForm(request.POST, instance=host_config)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, f"Host config saved for {host_config.host}.")
|
||||||
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
else:
|
||||||
|
form = HostConfigForm(instance=host_config)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"pobsync_backend/host_form.html",
|
||||||
|
{
|
||||||
|
"host": host_config,
|
||||||
|
"form": form,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def edit_host_schedule(request, host: str):
|
||||||
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
|
schedule = _schedule_for_host(host_config)
|
||||||
|
if request.method == "POST":
|
||||||
|
form = ScheduleConfigForm(request.POST, instance=schedule)
|
||||||
|
if form.is_valid():
|
||||||
|
saved_schedule = form.save(commit=False)
|
||||||
|
saved_schedule.host = host_config
|
||||||
|
saved_schedule.save()
|
||||||
|
messages.success(request, f"Schedule saved for {host_config.host}.")
|
||||||
|
return redirect("host_detail", host=host_config.host)
|
||||||
|
else:
|
||||||
|
form = ScheduleConfigForm(instance=schedule, initial=_default_schedule_initial())
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"pobsync_backend/schedule_form.html",
|
||||||
|
{
|
||||||
|
"host": host_config,
|
||||||
|
"schedule": schedule,
|
||||||
|
"form": form,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
|
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
|
||||||
try:
|
try:
|
||||||
return host_config.schedule
|
return host_config.schedule
|
||||||
except ScheduleConfig.DoesNotExist:
|
except ScheduleConfig.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _default_schedule_initial() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"cron_expr": "15 2 * * *",
|
||||||
|
"user": "root",
|
||||||
|
"enabled": True,
|
||||||
|
"prune_max_delete": 10,
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ from pobsync_backend import api, views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
path("", views.dashboard, name="dashboard"),
|
||||||
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>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
|
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
|
||||||
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
|
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
|
||||||
|
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),
|
||||||
path("api/", api.api_index),
|
path("api/", api.api_index),
|
||||||
path("api/status/", api.status),
|
path("api/status/", api.status),
|
||||||
path("api/hosts/", api.hosts),
|
path("api/hosts/", api.hosts),
|
||||||
|
|||||||
Reference in New Issue
Block a user