diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst index 7295a7b8..7d06bcd4 100644 --- a/docs/ahriman.web.schemas.rst +++ b/docs/ahriman.web.schemas.rst @@ -140,6 +140,14 @@ ahriman.web.schemas.logs\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.logs\_search\_schema module +----------------------------------------------- + +.. automodule:: ahriman.web.schemas.logs_search_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.oauth2\_schema module ----------------------------------------- diff --git a/package/share/ahriman/templates/build-status/package-info-modal.jinja2 b/package/share/ahriman/templates/build-status/package-info-modal.jinja2 index 4556c128..b4cd1f82 100644 --- a/package/share/ahriman/templates/build-status/package-info-modal.jinja2 +++ b/package/share/ahriman/templates/build-status/package-info-modal.jinja2 @@ -101,6 +101,8 @@ {% endif %} + + @@ -140,6 +142,9 @@ const packageInfoRefreshInput = document.getElementById("package-info-refresh-input"); + const packageInfoAutoReloadButton = document.getElementById("package-info-autoreload-button"); + let packageInfoAutoReloadTask = null; + function clearChart() { packageInfoEventsUpdateChartCanvas.hidden = true; if (packageInfoEventsUpdateChart) { @@ -148,6 +153,13 @@ } } + function convertLogs(data, filter) { + return data + .filter((filter || Boolean)) + .map(log_record => `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`) + .join("\n"); + } + async function copyChanges() { const changes = packageInfoChangesInput.textContent; await copyToClipboard(changes, packageInfoChangesCopyButton); @@ -319,15 +331,19 @@ const link = document.createElement("a"); link.classList.add("dropdown-item"); + link.dataset.version = version.version; + link.dataset.processId = version.process_id; + link.dataset.logs = convertLogs(data, log_record => log_record.version === version.version && log_record.process_id === version.process_id); + link.textContent = new Date(1000 * version.created).toISOStringShort(); link.href = "#"; link.onclick = _ => { - const logs = data - .filter(log_record => log_record.version === version.version && log_record.process_id === version.process_id) - .map(log_record => `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`); - - packageInfoLogsInput.textContent = logs.join("\n"); + // check if we are at the bottom of the code block + const isScrolledToBottom = packageInfoLogsInput.scrollTop + packageInfoLogsInput.clientHeight >= packageInfoLogsInput.scrollHeight; + packageInfoLogsInput.textContent = link.dataset.logs; highlight(packageInfoLogsInput); + if (isScrolledToBottom) + packageInfoLogsInput.scrollTop = packageInfoLogsInput.scrollHeight; // scroll to the new end Array.from(packageInfoLogsVersions.children).forEach(el => el.classList.remove("active")); link.classList.add("active"); @@ -403,23 +419,46 @@ } function packageInfoRemove() { - const packageBase = packageInfoModal.package; + const packageBase = packageInfoModal.dataset.package; packagesRemove([packageBase]); } function packageInfoUpdate() { - const packageBase = packageInfoModal.package; + const packageBase = packageInfoModal.dataset.package; packagesAdd(packageBase, [], repository, {refresh: packageInfoRefreshInput.checked}); } + function reloadActiveLogs(packageBase) { + const activeLogSelector = packageInfoLogsVersions.querySelector(".active"); + + if (activeLogSelector) { + makeRequest( + `/api/v2/packages/${packageBase}/logs`, + { + query: { + architecture: repository.architecture, + repository: repository.repository, + version: activeLogSelector.dataset.version, + process_id: activeLogSelector.dataset.processId, + }, + convert: response => response.json(), + }, + data => { + activeLogSelector.dataset.logs = convertLogs(data); + activeLogSelector.click(); + }, + ); + } + } + function showPackageInfo(packageBase) { const isPackageBaseSet = packageBase !== undefined; if (isPackageBaseSet) { // set package base as currently used - packageInfoModal.package = packageBase; + packageInfoModal.dataset.package = packageBase; } else { // read package base from the current window attribute - packageBase = packageInfoModal.package; + packageBase = packageInfoModal.dataset.package; } const onFailure = error => { @@ -438,6 +477,21 @@ if (isPackageBaseSet) { bootstrap.Modal.getOrCreateInstance(packageInfoModal).show(); + togglePackageInfoAutoReload(); + } + } + + function togglePackageInfoAutoReload() { + clearInterval(packageInfoAutoReloadTask); + if (packageInfoAutoReloadButton.checked) { + packageInfoAutoReloadTask = setInterval(_ => { + if (!hasActiveSelection()) { + const packageBase = packageInfoModal.dataset.package; + // we only poll status and logs here + loadPackage(packageBase); + reloadActiveLogs(packageBase); + } + }, 5000); } } @@ -468,6 +522,9 @@ packageInfoChangesInput.textContent = ""; packageInfoEventsTable.bootstrapTable("load", []); clearChart(); + + clearInterval(packageInfoAutoReloadTask); + packageInfoAutoReloadTask = null; // not really required (?) but lets clear everything }); }); diff --git a/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 b/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 index b350ef31..7f0c7195 100644 --- a/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 +++ b/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 @@ -58,6 +58,10 @@ return value.includes(dataList[index].toLowerCase()); } + function hasActiveSelection() { + return !document.getSelection().isCollapsed; // not sure if it is a valid way, but I guess so + } + function headerClass(status) { if (status === "pending") return ["bg-warning"]; if (status === "building") return ["bg-warning"]; diff --git a/src/ahriman/core/database/operations/logs_operations.py b/src/ahriman/core/database/operations/logs_operations.py index f77e2107..936a7fc1 100644 --- a/src/ahriman/core/database/operations/logs_operations.py +++ b/src/ahriman/core/database/operations/logs_operations.py @@ -29,13 +29,15 @@ class LogsOperations(Operations): logs operations """ - def logs_get(self, package_base: str, limit: int = -1, offset: int = 0, - repository_id: RepositoryId | None = None) -> list[LogRecord]: + def logs_get(self, package_base: str, version: str | None = None, process_id: str | None = None, + limit: int = -1, offset: int = 0, repository_id: RepositoryId | None = None) -> list[LogRecord]: """ extract logs for specified package base Args: package_base(str): package base to extract logs + version(str | None, optional): package version to filter (Default value = None) + process_id(str | None, optional): process identifier to filter (Default value = None) limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) offset(int, optional): records offset (Default value = 0) repository_id(RepositoryId, optional): repository unique identifier override (Default value = None) @@ -52,12 +54,17 @@ class LogsOperations(Operations): """ select created, message, version, process_id from ( select * from logs - where package_base = :package_base and repository = :repository + where package_base = :package_base + and repository = :repository + and (:version is null or version = :version) + and (:process_id is null or process_id = :process_id) order by created desc limit :limit offset :offset ) order by created asc """, { "package_base": package_base, + "version": version, + "process_id": process_id, "repository": repository_id.id, "limit": limit, "offset": offset, diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index 948b0a91..f74f5704 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -203,12 +203,15 @@ class Client: """ # this method does not raise NotImplementedError because it is actively used as dummy client for http log - def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[LogRecord]: + def package_logs_get(self, package_base: str, version: str | None = None, process_id: str | None = None, + limit: int = -1, offset: int = 0) -> list[LogRecord]: """ get package logs Args: package_base(str): package base + version(str | None, optional): package version to search (Default value = None) + process_id(str | None, optional): process identifier to search (Default value = None) limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) offset(int, optional): records offset (Default value = 0) diff --git a/src/ahriman/core/status/local_client.py b/src/ahriman/core/status/local_client.py index c3266ae4..bbaf9c57 100644 --- a/src/ahriman/core/status/local_client.py +++ b/src/ahriman/core/status/local_client.py @@ -152,19 +152,22 @@ class LocalClient(Client): """ self.database.logs_insert(log_record, self.repository_id) - def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[LogRecord]: + def package_logs_get(self, package_base: str, version: str | None = None, process_id: str | None = None, + limit: int = -1, offset: int = 0) -> list[LogRecord]: """ get package logs Args: package_base(str): package base + version(str | None, optional): package version to search (Default value = None) + process_id(str | None, optional): process identifier to search (Default value = None) limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) offset(int, optional): records offset (Default value = 0) Returns: list[LogRecord]: package logs """ - return self.database.logs_get(package_base, limit, offset, self.repository_id) + return self.database.logs_get(package_base, version, process_id, limit, offset, self.repository_id) def package_logs_remove(self, package_base: str, version: str | None) -> None: """ diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py index 525fd05d..5ff7bdf5 100644 --- a/src/ahriman/core/status/watcher.py +++ b/src/ahriman/core/status/watcher.py @@ -109,7 +109,7 @@ class Watcher(LazyLogging): package_logs_add: Callable[[LogRecord], None] - package_logs_get: Callable[[str, int, int], list[LogRecord]] + package_logs_get: Callable[[str, str | None, str | None, int, int], list[LogRecord]] package_logs_remove: Callable[[str, str | None], None] diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index e492de18..8c36503e 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -326,12 +326,15 @@ class WebClient(Client, SyncAhrimanClient): self.make_request("POST", self._logs_url(log_record.log_record_id.package_base), params=self.repository_id.query(), json=payload, suppress_errors=True) - def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[LogRecord]: + def package_logs_get(self, package_base: str, version: str | None = None, process_id: str | None = None, + limit: int = -1, offset: int = 0) -> list[LogRecord]: """ get package logs Args: package_base(str): package base + version(str | None, optional): package version to search (Default value = None) + process_id(str | None, optional): process identifier to search (Default value = None) limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) offset(int, optional): records offset (Default value = 0) @@ -339,6 +342,10 @@ class WebClient(Client, SyncAhrimanClient): list[LogRecord]: package logs """ query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))] + if version is not None: + query.append(("version", version)) + if process_id is not None: + query.append(("process_id", process_id)) with contextlib.suppress(Exception): response = self.make_request("GET", self._logs_url(package_base), params=query) diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py index dd440fb9..46117d58 100644 --- a/src/ahriman/web/schemas/__init__.py +++ b/src/ahriman/web/schemas/__init__.py @@ -34,6 +34,7 @@ from ahriman.web.schemas.log_schema import LogSchema from ahriman.web.schemas.login_schema import LoginSchema from ahriman.web.schemas.logs_rotate_schema import LogsRotateSchema from ahriman.web.schemas.logs_schema import LogsSchema +from ahriman.web.schemas.logs_search_schema import LogsSearchSchema from ahriman.web.schemas.oauth2_schema import OAuth2Schema from ahriman.web.schemas.package_name_schema import PackageNameSchema from ahriman.web.schemas.package_names_schema import PackageNamesSchema diff --git a/src/ahriman/web/schemas/logs_search_schema.py b/src/ahriman/web/schemas/logs_search_schema.py new file mode 100644 index 00000000..9b570d2e --- /dev/null +++ b/src/ahriman/web/schemas/logs_search_schema.py @@ -0,0 +1,36 @@ +# +# Copyright (c) 2021-2025 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from ahriman import __version__ +from ahriman.web.apispec import fields +from ahriman.web.schemas.pagination_schema import PaginationSchema + + +class LogsSearchSchema(PaginationSchema): + """ + request log search schema + """ + + version = fields.String(metadata={ + "description": "Package version to search", + "example": __version__, + }) + process_id = fields.String(metadata={ + "description": "Process unique identifier to search", + }) diff --git a/src/ahriman/web/views/v1/packages/logs.py b/src/ahriman/web/views/v1/packages/logs.py index e4ee92b5..115530db 100644 --- a/src/ahriman/web/views/v1/packages/logs.py +++ b/src/ahriman/web/views/v1/packages/logs.py @@ -90,7 +90,7 @@ class LogsView(StatusViewGuard, BaseView): try: _, status = self.service().package_get(package_base) - logs = self.service(package_base=package_base).package_logs_get(package_base, -1, 0) + logs = self.service(package_base=package_base).package_logs_get(package_base, None, None, -1, 0) except UnknownPackageError: raise HTTPNotFound(reason=f"Package {package_base} is unknown") diff --git a/src/ahriman/web/views/v2/packages/logs.py b/src/ahriman/web/views/v2/packages/logs.py index 699c52ed..0454422d 100644 --- a/src/ahriman/web/views/v2/packages/logs.py +++ b/src/ahriman/web/views/v2/packages/logs.py @@ -22,7 +22,7 @@ from typing import ClassVar from ahriman.models.user_access import UserAccess from ahriman.web.apispec.decorators import apidocs -from ahriman.web.schemas import LogSchema, PackageNameSchema, PaginationSchema +from ahriman.web.schemas import LogSchema, LogsSearchSchema, PackageNameSchema from ahriman.web.views.base import BaseView from ahriman.web.views.status_view_guard import StatusViewGuard @@ -47,7 +47,7 @@ class LogsView(StatusViewGuard, BaseView): error_404_description="Package base and/or repository are unknown", schema=LogSchema(many=True), match_schema=PackageNameSchema, - query_schema=PaginationSchema, + query_schema=LogsSearchSchema, ) async def get(self) -> Response: """ @@ -61,8 +61,10 @@ class LogsView(StatusViewGuard, BaseView): """ package_base = self.request.match_info["package"] limit, offset = self.page() + version = self.request.query.get("version", None) + process = self.request.query.get("process_id", None) - logs = self.service(package_base=package_base).package_logs_get(package_base, limit, offset) + logs = self.service(package_base=package_base).package_logs_get(package_base, version, process, limit, offset) response = [log_record.view() for log_record in logs] return json_response(response) diff --git a/tests/ahriman/core/database/operations/test_logs_operations.py b/tests/ahriman/core/database/operations/test_logs_operations.py index 65320d10..330cba7f 100644 --- a/tests/ahriman/core/database/operations/test_logs_operations.py +++ b/tests/ahriman/core/database/operations/test_logs_operations.py @@ -71,11 +71,35 @@ def test_logs_insert_get_pagination(database: SQLite, package_ahriman: Package) """ database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1")) database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2")) - assert database.logs_get(package_ahriman.base, 1, 1) == [ + assert database.logs_get(package_ahriman.base, None, None, 1, 1) == [ LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"), ] +def test_logs_insert_get_filter_by_version(database: SQLite, package_ahriman: Package) -> None: + """ + must insert and get package logs with pagination + """ + database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1", "p1"), 42.0, "message 1")) + database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1", "p2"), 43.0, "message 2")) + database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "2", "p1"), 44.0, "message 3")) + + assert database.logs_get(package_ahriman.base, "1", None) == [ + LogRecord(LogRecordId(package_ahriman.base, "1", "p1"), 42.0, "message 1"), + LogRecord(LogRecordId(package_ahriman.base, "1", "p2"), 43.0, "message 2"), + ] + assert database.logs_get(package_ahriman.base, "2", None) == [ + LogRecord(LogRecordId(package_ahriman.base, "2", "p1"), 44.0, "message 3"), + ] + assert database.logs_get(package_ahriman.base, None, "p1") == [ + LogRecord(LogRecordId(package_ahriman.base, "1", "p1"), 42.0, "message 1"), + LogRecord(LogRecordId(package_ahriman.base, "2", "p1"), 44.0, "message 3"), + ] + assert database.logs_get(package_ahriman.base, "1", "p1") == [ + LogRecord(LogRecordId(package_ahriman.base, "1", "p1"), 42.0, "message 1"), + ] + + def test_logs_insert_get_multi(database: SQLite, package_ahriman: Package) -> None: """ must insert and get package logs for multiple repositories diff --git a/tests/ahriman/core/status/test_local_client.py b/tests/ahriman/core/status/test_local_client.py index 87326e1a..38ad7330 100644 --- a/tests/ahriman/core/status/test_local_client.py +++ b/tests/ahriman/core/status/test_local_client.py @@ -124,8 +124,9 @@ def test_package_logs_get(local_client: LocalClient, package_ahriman: Package, m must retrieve package logs """ logs_mock = mocker.patch("ahriman.core.database.SQLite.logs_get") - local_client.package_logs_get(package_ahriman.base, 1, 2) - logs_mock.assert_called_once_with(package_ahriman.base, 1, 2, local_client.repository_id) + local_client.package_logs_get(package_ahriman.base, package_ahriman.version, "process", 1, 2) + logs_mock.assert_called_once_with(package_ahriman.base, package_ahriman.version, "process", 1, 2, + local_client.repository_id) def test_package_logs_remove(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None: diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index f925049a..087de9fb 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -658,7 +658,7 @@ def test_package_logs_get(web_client: WebClient, package_ahriman: Package, mocke requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj) - result = web_client.package_logs_get(package_ahriman.base, 1, 2) + result = web_client.package_logs_get(package_ahriman.base, None, None, 1, 2) requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True), params=web_client.repository_id.query() + [("limit", "1"), ("offset", "2")]) assert result == [ @@ -666,6 +666,21 @@ def test_package_logs_get(web_client: WebClient, package_ahriman: Package, mocke ] +def test_package_logs_get_filter(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must get logs with version and process id filter + """ + requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request") + web_client.package_logs_get(package_ahriman.base, package_ahriman.version, LogRecordId.DEFAULT_PROCESS_ID, 1, 2) + requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True), + params=web_client.repository_id.query() + [ + ("limit", "1"), + ("offset", "2"), + ("version", package_ahriman.version), + ("process_id", LogRecordId.DEFAULT_PROCESS_ID), + ]) + + def test_package_logs_get_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must suppress any exception happened during logs fetch diff --git a/tests/ahriman/web/schemas/test_logs_search_schema.py b/tests/ahriman/web/schemas/test_logs_search_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_logs_search_schema.py @@ -0,0 +1 @@ +# schema testing goes in view class tests diff --git a/tests/ahriman/web/views/v2/packages/test_view_v2_packages_logs.py b/tests/ahriman/web/views/v2/packages/test_view_v2_packages_logs.py index 4a3cb949..bf7a9905 100644 --- a/tests/ahriman/web/views/v2/packages/test_view_v2_packages_logs.py +++ b/tests/ahriman/web/views/v2/packages/test_view_v2_packages_logs.py @@ -86,6 +86,31 @@ async def test_get_with_pagination(client: TestClient, package_ahriman: Package) ] +async def test_get_with_filter(client: TestClient, package_ahriman: Package) -> None: + """ + must get logs with filter by version and process identifier + """ + await client.post(f"/api/v1/packages/{package_ahriman.base}", + json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()}) + await client.post(f"/api/v1/packages/{package_ahriman.base}/logs", + json={"created": 42.0, "message": "message 1", "version": "42"}) + await client.post(f"/api/v1/packages/{package_ahriman.base}/logs", + json={"created": 43.0, "message": "message 2", "version": "43"}) + request_schema = pytest.helpers.schema_request(LogsView.get, location="querystring") + response_schema = pytest.helpers.schema_response(LogsView.get) + + payload = {"version": "42", "process_id": LogRecordId.DEFAULT_PROCESS_ID} + assert not request_schema.validate(payload) + response = await client.get(f"/api/v2/packages/{package_ahriman.base}/logs", params=payload) + assert response.status == 200 + + logs = await response.json() + assert not response_schema.validate(logs) + assert logs == [ + {"created": 42.0, "message": "message 1", "version": "42", "process_id": LogRecordId.DEFAULT_PROCESS_ID}, + ] + + async def test_get_bad_request(client: TestClient, package_ahriman: Package) -> None: """ must return bad request for invalid query parameters