diff --git a/Dockerfile b/Dockerfile
index 5bcf687..70ce399 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,7 +10,7 @@ RUN apt-get update \
WORKDIR /app
-COPY pyproject.toml README.md ./
+COPY pyproject.toml README.md CHANGELOG.md ./
COPY src ./src
COPY manage.py ./
COPY scripts/docker-entrypoint ./scripts/docker-entrypoint
diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html
index 7177a6e..2bda493 100644
--- a/src/pobsync_backend/templates/pobsync_backend/base.html
+++ b/src/pobsync_backend/templates/pobsync_backend/base.html
@@ -375,6 +375,7 @@
SSH Keys
Self Check
Logs
+ Changelog
Status API
{{ request.user.username }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/changelog.html b/src/pobsync_backend/templates/pobsync_backend/changelog.html
new file mode 100644
index 0000000..87570fb
--- /dev/null
+++ b/src/pobsync_backend/templates/pobsync_backend/changelog.html
@@ -0,0 +1,41 @@
+{% extends "pobsync_backend/base.html" %}
+
+{% block title %}Changelog - pobsync{% endblock %}
+
+{% block content %}
+
Changelog
+
+
+
+
+
+
Installed version: {{ app_version }}
+
Source: {{ changelog_path }}
+ {% if missing %}
+
missing
+ {% endif %}
+
+
+
+ {% for block in changelog_blocks %}
+ {% if block.kind == "heading" %}
+ {% if block.level == 1 %}
+
{{ block.text }}
+ {% else %}
+
{{ block.text }}
+ {% endif %}
+ {% elif block.kind == "list" %}
+
+ {% for item in block.items %}
+ - {{ item }}
+ {% endfor %}
+
+ {% else %}
+
{{ block.text }}
+ {% endif %}
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py
index 0303bf9..f648061 100644
--- a/src/pobsync_backend/tests/test_views.py
+++ b/src/pobsync_backend/tests/test_views.py
@@ -31,6 +31,30 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
+ def test_changelog_requires_staff_login(self) -> None:
+ response = self.client.get(reverse("changelog"))
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("/admin/login/", response["Location"])
+
+ def test_changelog_renders_repository_changelog(self) -> None:
+ self.client.force_login(self.staff_user)
+ with TemporaryDirectory() as tmp:
+ changelog = Path(tmp) / "CHANGELOG.md"
+ changelog.write_text(
+ "# Changelog\n\n## 1.0.0 - 2026-05-21\n\n- Django control panel\n- Native systemd installer\n",
+ encoding="utf-8",
+ )
+
+ with override_settings(BASE_DIR=Path(tmp)):
+ response = self.client.get(reverse("changelog"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Installed version:")
+ self.assertContains(response, "1.0.0 - 2026-05-21")
+ self.assertContains(response, "Django control panel")
+ self.assertContains(response, "Native systemd installer")
+
def test_dashboard_renders_hosts_and_latest_runs(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py
index bc78428..2d22176 100644
--- a/src/pobsync_backend/views.py
+++ b/src/pobsync_backend/views.py
@@ -15,6 +15,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_POST
+from pobsync import __version__
from pobsync.errors import PobsyncError
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS
@@ -88,6 +89,28 @@ def dashboard(request):
return render(request, "pobsync_backend/dashboard.html", context)
+@staff_member_required
+def changelog(request):
+ changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
+ try:
+ changelog_text = changelog_path.read_text(encoding="utf-8")
+ missing = False
+ except FileNotFoundError:
+ changelog_text = "CHANGELOG.md was not found in this installation."
+ missing = True
+
+ return render(
+ request,
+ "pobsync_backend/changelog.html",
+ {
+ "app_version": __version__,
+ "changelog_blocks": _parse_changelog(changelog_text),
+ "changelog_path": changelog_path,
+ "missing": missing,
+ },
+ )
+
+
@staff_member_required
def self_check(request):
checks = collect_self_checks()
@@ -793,6 +816,49 @@ def _pretty_json(value: object) -> str:
return json.dumps(value or {}, indent=2, sort_keys=True)
+def _parse_changelog(text: str) -> list[dict[str, object]]:
+ blocks: list[dict[str, object]] = []
+ paragraph: list[str] = []
+ list_items: list[str] = []
+
+ def flush_paragraph() -> None:
+ if paragraph:
+ blocks.append({"kind": "paragraph", "text": " ".join(paragraph)})
+ paragraph.clear()
+
+ def flush_list() -> None:
+ if list_items:
+ blocks.append({"kind": "list", "items": list(list_items)})
+ list_items.clear()
+
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line:
+ flush_paragraph()
+ flush_list()
+ continue
+
+ if line.startswith("#"):
+ flush_paragraph()
+ flush_list()
+ marker, _space, heading = line.partition(" ")
+ level = min(max(len(marker), 1), 3)
+ blocks.append({"kind": "heading", "level": level, "text": heading.strip() or line.lstrip("#").strip()})
+ continue
+
+ if line.startswith("- "):
+ flush_paragraph()
+ list_items.append(line[2:].strip())
+ continue
+
+ flush_list()
+ paragraph.append(line)
+
+ flush_paragraph()
+ flush_list()
+ return blocks
+
+
def _snapshot_restore_guidance(snapshot: SnapshotRecord) -> dict[str, str]:
source_path = Path(snapshot.path) / "data"
destination_path = Path("/restore") / snapshot.host.host
diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py
index 32cb3b9..1973928 100644
--- a/src/pobsync_server/urls.py
+++ b/src/pobsync_server/urls.py
@@ -8,6 +8,7 @@ from pobsync_backend import api, views
urlpatterns = [
path("", views.dashboard, name="dashboard"),
+ path("changelog/", views.changelog, name="changelog"),
path("self-check/", views.self_check, name="self_check"),
path("logs/", views.logs, name="logs"),
path("config/global/", views.edit_global_config, name="edit_global_config"),