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