From a9e40df44b128497fb7d9457ebc457c87c5639ba Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 21 May 2026 00:24:07 +0200 Subject: [PATCH] (feature) Add focused filtering to the service logs view Extend the Django logs view with filters for service unit, severity, time window, host, run id, and message text. Pass severity and time window directly to journalctl, then apply host/run/message filtering to the returned pobsync journal lines. This makes failed or slow backups easier to investigate from the control panel without needing shell access. --- .../templates/pobsync_backend/logs.html | 16 +++++++ src/pobsync_backend/tests/test_views.py | 30 ++++++++++-- src/pobsync_backend/views.py | 48 +++++++++++++++++-- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/pobsync_backend/templates/pobsync_backend/logs.html b/src/pobsync_backend/templates/pobsync_backend/logs.html index ba5adae..537fb3d 100644 --- a/src/pobsync_backend/templates/pobsync_backend/logs.html +++ b/src/pobsync_backend/templates/pobsync_backend/logs.html @@ -29,6 +29,22 @@ {% endfor %} +
+ + +
+
+ + +
+
+ + +
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 7670133..6b00d13 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -150,21 +150,41 @@ class ViewTests(TestCase): completed = subprocess.CompletedProcess( args=["journalctl"], returncode=0, - stdout="2026-05-19 pobsync-worker.service failed backup\n2026-05-19 pobsync-web.service started\n", + stdout=( + "2026-05-19 pobsync-worker.service web-01 failed backup run 12\n" + "2026-05-19 pobsync-worker.service web-02 failed backup run 12\n" + "2026-05-19 pobsync-web.service web-01 started run 12\n" + ), stderr="", ) with patch("pobsync_backend.views.shutil.which", return_value="/usr/bin/journalctl"), patch( "pobsync_backend.views.subprocess.run", return_value=completed ) as run: - response = self.client.get(reverse("logs"), {"unit": "pobsync-worker.service", "priority": "0..3", "q": "failed"}) + response = self.client.get( + reverse("logs"), + { + "unit": "pobsync-worker.service", + "priority": "0..3", + "window": "6h", + "host": "web-01", + "run": "12", + "q": "failed", + }, + ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Logs") - self.assertContains(response, "failed backup") + self.assertContains(response, "web-01 failed backup run 12") + self.assertNotContains(response, "web-02 failed backup run 12") self.assertNotContains(response, "started") - self.assertIn("-u", run.call_args.args[0]) - self.assertIn("pobsync-worker.service", run.call_args.args[0]) + command = run.call_args.args[0] + self.assertIn("-u", command) + self.assertIn("pobsync-worker.service", command) + self.assertIn("-p", command) + self.assertIn("0..3", command) + self.assertIn("--since", command) + self.assertIn("6 hours ago", command) def test_ssh_credentials_view_creates_key(self) -> None: self.client.force_login(self.staff_user) diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 5d16cd4..98b4969 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -703,8 +703,26 @@ def _log_context(request) -> dict[str, object]: "6": "Info", "7": "Debug", } + time_windows = { + "1h": "Last hour", + "6h": "Last 6 hours", + "24h": "Last 24 hours", + "7d": "Last 7 days", + "": "All available", + } + since_values = { + "1h": "1 hour ago", + "6h": "6 hours ago", + "24h": "24 hours ago", + "7d": "7 days ago", + } selected_unit = request.GET.get("unit", "") priority = request.GET.get("priority", "0..4") + time_window = request.GET.get("window", "24h") + if time_window not in time_windows: + time_window = "24h" + host_filter = request.GET.get("host", "").strip() + run_filter = request.GET.get("run", "").strip() query = request.GET.get("q", "").strip() lines = [] error = "" @@ -713,6 +731,8 @@ def _log_context(request) -> dict[str, object]: error = "journalctl is not available in this runtime." else: command = ["journalctl", "--no-pager", "-n", "300", "-o", "short-iso"] + if time_window: + command.extend(["--since", since_values[time_window]]) if selected_unit in units: command.extend(["-u", selected_unit]) else: @@ -725,16 +745,38 @@ def _log_context(request) -> dict[str, object]: error = result.stderr.strip() or "Could not read journal logs." else: lines = result.stdout.splitlines() - if query: - lowered_query = query.lower() - lines = [line for line in lines if lowered_query in line.lower()] + lines = _filter_log_lines(lines, query=query, host=host_filter, run_id=run_filter) return { "units": units, "priorities": priorities, + "time_windows": time_windows, "selected_unit": selected_unit, "selected_priority": priority, + "selected_window": time_window, + "host_filter": host_filter, + "run_filter": run_filter, "query": query, "lines": lines, "error": error, } + + +def _filter_log_lines(lines: list[str], *, query: str, host: str, run_id: str) -> list[str]: + filters = [] + if query: + filters.append(lambda line: query.lower() in line.lower()) + if host: + filters.append(lambda line: host.lower() in line.lower()) + if run_id: + run_tokens = ( + f"run {run_id}", + f"run={run_id}", + f"run_id={run_id}", + f"run-{run_id}", + f"#{run_id}", + ) + filters.append(lambda line: any(token in line.lower() for token in run_tokens)) + if not filters: + return lines + return [line for line in lines if all(matches(line) for matches in filters)]