diff --git a/docs/configuration.rst b/docs/configuration.rst index 42ee24bc..bc657973 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -166,6 +166,7 @@ Reporting to web service related settings. In most cases there is fallback to we Web server settings. This feature requires ``aiohttp`` libraries to be installed. * ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used. +* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. * ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``. * ``host`` - host to bind, string, optional. * ``index_url`` - full URL of the repository index page, string, optional. diff --git a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini index 4279de26..84d03bf1 100644 --- a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini +++ b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini @@ -28,6 +28,9 @@ allow_read_only = yes ; External address of the web service. Will be used for some features like OAuth. If none set will be generated as ; address = http://${web:host}:${web:port} ;address = http://${web:host}:${web:port} +; Enable page auto refresh. Intervals are given in seconds. Default interval is always the first element of the list. +; If no intervals set, auto refresh will be disabled. +autorefresh_intervals = 5 1 10 30 60 ; Enable file upload endpoint used by some triggers. ;enable_archive_upload = no ; Address to bind the server. diff --git a/package/share/ahriman/templates/build-status.jinja2 b/package/share/ahriman/templates/build-status.jinja2 index 48ebd95a..8b8c4134 100644 --- a/package/share/ahriman/templates/build-status.jinja2 +++ b/package/share/ahriman/templates/build-status.jinja2 @@ -80,6 +80,21 @@ + + {% if autorefresh_intervals %} +
+ + + + +
+ {% endif %} update {% endif %} - - - + {% if autorefresh_intervals %} + +
+ + + + +
+ {% endif %} @@ -143,6 +155,7 @@ const packageInfoRefreshInput = document.getElementById("package-info-refresh-input"); const packageInfoAutoReloadButton = document.getElementById("package-info-autoreload-button"); + const packageInfoAutoReloadInput = document.getElementById("package-info-autoreload-input"); let packageInfoAutoReloadTask = null; function clearChart() { @@ -477,22 +490,22 @@ if (isPackageBaseSet) { bootstrap.Modal.getOrCreateInstance(packageInfoModal).show(); - togglePackageInfoAutoReload(); + {% if autorefresh_intervals %} + togglePackageInfoAutoReload(); + {% endif %} } } - function togglePackageInfoAutoReload() { + function togglePackageInfoAutoReload(interval) { 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); - } + packageInfoAutoReloadTask = toggleAutoReload(packageInfoAutoReloadButton, interval, packageInfoAutoReloadInput, _ => { + if (!hasActiveSelection()) { + const packageBase = packageInfoModal.dataset.package; + // we only poll status and logs here + loadPackage(packageBase); + reloadActiveLogs(packageBase); + } + }); } ready(_ => { diff --git a/package/share/ahriman/templates/build-status/table.jinja2 b/package/share/ahriman/templates/build-status/table.jinja2 index 6a34ca74..d99a5bb2 100644 --- a/package/share/ahriman/templates/build-status/table.jinja2 +++ b/package/share/ahriman/templates/build-status/table.jinja2 @@ -10,6 +10,10 @@ const dashboardButton = document.getElementById("dashboard-button"); const versionBadge = document.getElementById("badge-version"); + const tableAutoReloadButton = document.getElementById("table-autoreload-button"); + const tableAutoReloadInput = document.getElementById("table-autoreload-input"); + let tableAutoReloadTask = null; + function doPackageAction(uri, packages, repository, successText, failureText, data) { makeRequest( uri, @@ -86,8 +90,10 @@ doPackageAction("/api/v1/service/update", [], repository, onSuccess, onFailure, parameters); } - function reload() { - table.bootstrapTable("showLoading"); + function reload(silent) { + if (!silent) { + table.bootstrapTable("showLoading"); + } const badgeClass = status => { if (status === "pending") return "btn-outline-warning"; @@ -128,16 +134,18 @@ table.bootstrapTable("hideLoading"); }, error => { - if ((error.status === 401) || (error.status === 403)) { - // authorization error - const text = "In order to see statuses you must login first."; - table.find("tr.unauthorized").remove(); - table.find("tbody").append(``); - table.bootstrapTable("hideLoading"); - } else { - // other errors - const message = details => `Could not load list of packages: ${details}`; - showFailure("Load failure", message, error); + if (!silent) { + if ((error.status === 401) || (error.status === 403)) { + // authorization error + const text = "In order to see statuses you must login first."; + table.find("tr.unauthorized").remove(); + table.find("tbody").append(``); + table.bootstrapTable("hideLoading"); + } else { + // other errors + const message = details => `Could not load list of packages: ${details}`; + showFailure("Load failure", message, error); + } } }, ); @@ -230,6 +238,15 @@ return {classes: cellClass(value)}; } + function toggleTableAutoReload(interval) { + clearInterval(tableAutoReloadTask); + tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => { + if (getSelection().length === 0) { + reload(true); + } + }); + } + ready(_ => { document.querySelectorAll("#repositories a").forEach(element => { element.onclick = _ => { @@ -289,5 +306,8 @@ }); selectRepository(); + {% if autorefresh_intervals %} + toggleTableAutoReload(); + {% endif %} }); diff --git a/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 b/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 index 7f0c7195..b528d08d 100644 --- a/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 +++ b/package/share/ahriman/templates/utils/bootstrap-scripts.jinja2 @@ -137,6 +137,28 @@ return element; } + function toggleAutoReload(toggle, interval, intervalSelector, callback) { + if (interval) { + toggle.checked = true; // toggle reload + } else { + interval = intervalSelector.querySelector(".active")?.dataset?.interval; // find active element + } + + if (interval) { + if (toggle.checked) { + // refresh UI + Array.from(intervalSelector.children).forEach(il => { + Array.from(il.children).forEach(el => el.classList.remove("active")); + }); + intervalSelector.querySelector(`a[data-interval="${interval}"]`)?.classList?.add("active"); + // finally create timer task + return setInterval(callback, interval); + } + } + + return null; // return null to assign to keep method sane + } + Date.prototype.toISOStringShort = function() { const pad = number => String(number).padStart(2, "0"); return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`; diff --git a/src/ahriman/core/configuration/configuration.py b/src/ahriman/core/configuration/configuration.py index 4b89ab1a..2946d421 100644 --- a/src/ahriman/core/configuration/configuration.py +++ b/src/ahriman/core/configuration/configuration.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +# pylint: disable=too-many-public-methods import configparser import shlex import sys @@ -85,9 +86,10 @@ class Configuration(configparser.RawConfigParser): empty_lines_in_values=not allow_multi_key, interpolation=ShellInterpolator(), converters={ + "intlist": lambda value: list(map(int, shlex.split(value))), "list": shlex.split, "path": self._convert_path, - "pathlist": lambda value: [self._convert_path(element) for element in shlex.split(value)], + "pathlist": lambda value: list(map(self._convert_path, shlex.split(value))), }, ) @@ -236,6 +238,8 @@ class Configuration(configparser.RawConfigParser): # pylint and mypy are too stupid to find these methods # pylint: disable=missing-function-docstring,unused-argument + def getintlist(self, *args: Any, **kwargs: Any) -> list[int]: ... # type: ignore[empty-body] + def getlist(self, *args: Any, **kwargs: Any) -> list[str]: ... # type: ignore[empty-body] def getpath(self, *args: Any, **kwargs: Any) -> Path: ... # type: ignore[empty-body] diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index 87370555..e8999fbb 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -324,6 +324,15 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "empty": False, "is_url": ["http", "https"], }, + "autorefresh_intervals": { + "type": "list", + "coerce": "list", + "schema": { + "type": "integer", + "coerce": "integer", + "min": 0, + }, + }, "enable_archive_upload": { "type": "boolean", "coerce": "boolean", diff --git a/src/ahriman/core/utils.py b/src/ahriman/core/utils.py index 03864790..632b8115 100644 --- a/src/ahriman/core/utils.py +++ b/src/ahriman/core/utils.py @@ -51,6 +51,7 @@ __all__ = [ "parse_version", "partition", "pretty_datetime", + "pretty_interval", "pretty_size", "safe_filename", "srcinfo_property", @@ -353,6 +354,28 @@ def pretty_datetime(timestamp: datetime.datetime | float | int | None) -> str: return timestamp.strftime("%Y-%m-%d %H:%M:%S") +def pretty_interval(interval: int) -> str: + """ + convert time interval to string + + Args: + interval(int): time interval in seconds + + Returns: + str: pretty printable interval as string + """ + minutes, seconds = divmod(interval, 60) + hours, minutes = divmod(minutes, 60) + return " ".join([ + f"{value} {description}{"s" if value > 1 else ""}" + for value, description in [ + (hours, "hour"), + (minutes, "minute"), + (seconds, "second"), + ] if value > 0 + ]) + + def pretty_size(size: float | None, level: int = 0) -> str: """ convert size to string diff --git a/src/ahriman/web/views/index.py b/src/ahriman/web/views/index.py index ab53db27..7a9411ec 100644 --- a/src/ahriman/web/views/index.py +++ b/src/ahriman/web/views/index.py @@ -22,6 +22,7 @@ import aiohttp_jinja2 from typing import Any, ClassVar from ahriman.core.auth.helpers import authorized_userid +from ahriman.core.utils import pretty_interval from ahriman.models.user_access import UserAccess from ahriman.web.apispec import aiohttp_apispec from ahriman.web.views.base import BaseView @@ -37,6 +38,10 @@ class IndexView(BaseView): * control - HTML to insert for login control, HTML string, required * enabled - whether authorization is enabled by configuration or not, boolean, required * username - authenticated username if any, string, null means not authenticated + * autorefresh_intervals - auto refresh intervals, optional + * interval - auto refresh interval in milliseconds, integer, required + * is_active - is current interval active or not, boolean, required + * text - text representation of the interval (e.g. "30 seconds"), string, required * docs_enabled - indicates if api docs is enabled, boolean, required * index_url - url to the repository index, string, optional * repositories - list of repositories unique identifiers, required @@ -66,8 +71,18 @@ class IndexView(BaseView): "username": auth_username, } + autorefresh_intervals = [ + { + "interval": interval * 1000, # milliseconds + "is_active": index == 0, + "text": pretty_interval(interval), + } + for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[])) + ] + return { "auth": auth, + "autorefresh_intervals": sorted(autorefresh_intervals, key=lambda interval: interval["interval"]), "docs_enabled": aiohttp_apispec is not None, "index_url": self.configuration.get("web", "index_url", fallback=None), "repositories": [ diff --git a/tests/ahriman/core/configuration/test_configuration.py b/tests/ahriman/core/configuration/test_configuration.py index ab70de2c..02bb7270 100644 --- a/tests/ahriman/core/configuration/test_configuration.py +++ b/tests/ahriman/core/configuration/test_configuration.py @@ -133,6 +133,14 @@ def test_dump_architecture_specific(configuration: Configuration) -> None: assert dump["build"]["archbuild_flags"] == "hello flag" +def test_getintlist(configuration: Configuration) -> None: + """ + must extract list of integers + """ + configuration.set_option("build", "test_int_list", "1 42 3") + assert configuration.getintlist("build", "test_int_list") == [1, 42, 3] + + def test_getlist(configuration: Configuration) -> None: """ must return list of string correctly diff --git a/tests/ahriman/core/test_utils.py b/tests/ahriman/core/test_utils.py index deafc04e..100670a9 100644 --- a/tests/ahriman/core/test_utils.py +++ b/tests/ahriman/core/test_utils.py @@ -10,8 +10,8 @@ from unittest.mock import call as MockCall from ahriman.core.exceptions import BuildError, CalledProcessError, OptionError, UnsafeRunError from ahriman.core.utils import check_output, check_user, dataclass_view, enum_values, extract_user, filter_json, \ - full_version, minmax, package_like, parse_version, partition, pretty_datetime, pretty_size, safe_filename, \ - srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk + full_version, minmax, package_like, parse_version, partition, pretty_datetime, pretty_interval, pretty_size, \ + safe_filename, srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk from ahriman.models.package import Package from ahriman.models.package_source import PackageSource from ahriman.models.repository_id import RepositoryId @@ -341,6 +341,18 @@ def test_pretty_datetime_empty() -> None: assert pretty_datetime(None) == "" +def test_pretty_interval() -> None: + """ + must generate string from interval + """ + assert pretty_interval(1) == "1 second" + assert pretty_interval(42) == "42 seconds" + assert pretty_interval(62) == "1 minute 2 seconds" + assert pretty_interval(121) == "2 minutes 1 second" + assert pretty_interval(3600) == "1 hour" + assert pretty_interval(7242) == "2 hours 42 seconds" + + def test_pretty_size_bytes() -> None: """ must generate bytes string for bytes value
${safe(text)}
${safe(text)}