feat: add autorefresh button to the main page (#149)

* also add configuration options and change behaviour accordingly
This commit is contained in:
2025-07-01 03:22:01 +03:00
committed by GitHub
parent 939a94d889
commit 256376df85
12 changed files with 175 additions and 30 deletions

View File

@ -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. 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. * ``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``. * ``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. * ``host`` - host to bind, string, optional.
* ``index_url`` - full URL of the repository index page, string, optional. * ``index_url`` - full URL of the repository index page, string, optional.

View File

@ -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 ; 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}
;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 file upload endpoint used by some triggers.
;enable_archive_upload = no ;enable_archive_upload = no
; Address to bind the server. ; Address to bind the server.

View File

@ -80,6 +80,21 @@
<button type="button" class="btn btn-secondary" onclick="reload()"> <button type="button" class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span> <i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span>
</button> </button>
{% if autorefresh_intervals %}
<div class="btn-group">
<input id="table-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="toggleTableAutoReload()" checked>
<label for="table-autoreload-button" class="btn btn-outline-secondary" title="toggle auto reload"><i class="bi bi-clock"></i></label>
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">select interval</span>
</button>
<ul id="table-autoreload-input" class="dropdown-menu">
{% for interval in autorefresh_intervals %}
<li><a class="dropdown-item {{ "active" if interval.is_active }}" onclick="toggleTableAutoReload({{ interval.interval }})" data-interval="{{ interval.interval }}">{{ interval.text }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div> </div>
<table id="packages" <table id="packages"

View File

@ -101,9 +101,21 @@
<button id="package-info-update-button" type="submit" class="btn btn-success" onclick="packageInfoUpdate()" data-bs-dismiss="modal"><i class="bi bi-play"></i><span class="d-none d-sm-inline"> update</span></button> <button id="package-info-update-button" type="submit" class="btn btn-success" onclick="packageInfoUpdate()" data-bs-dismiss="modal"><i class="bi bi-play"></i><span class="d-none d-sm-inline"> update</span></button>
<button id="package-info-remove-button" type="submit" class="btn btn-danger" onclick="packageInfoRemove()" data-bs-dismiss="modal"><i class="bi bi-trash"></i><span class="d-none d-sm-inline"> remove</span></button> <button id="package-info-remove-button" type="submit" class="btn btn-danger" onclick="packageInfoRemove()" data-bs-dismiss="modal"><i class="bi bi-trash"></i><span class="d-none d-sm-inline"> remove</span></button>
{% endif %} {% endif %}
<input id="package-info-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="togglePackageInfoAutoReload()" checked> {% if autorefresh_intervals %}
<label for="package-info-autoreload-button" class="btn btn-outline-secondary" title="toggle auto reload"><i class="bi bi-clock"></i></label> <button type="button" class="btn btn-secondary" onclick="showPackageInfo()"><i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span></button>
<button type="button" class="btn btn-secondary" onclick="showPackageInfo()"><i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span></button> <div class="btn-group dropup">
<input id="package-info-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="togglePackageInfoAutoReload()" checked>
<label for="package-info-autoreload-button" class="btn btn-outline-secondary" title="toggle auto reload"><i class="bi bi-clock"></i></label>
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">select interval</span>
</button>
<ul id="package-info-autoreload-input" class="dropdown-menu">
{% for interval in autorefresh_intervals %}
<li><a class="dropdown-item {{ "active" if interval.is_active }}" onclick="togglePackageInfoAutoReload({{ interval.interval }})" data-interval="{{ interval.interval }}">{{ interval.text }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i><span class="d-none d-sm-inline"> close</span></button> <button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i><span class="d-none d-sm-inline"> close</span></button>
</div> </div>
</div> </div>
@ -143,6 +155,7 @@
const packageInfoRefreshInput = document.getElementById("package-info-refresh-input"); const packageInfoRefreshInput = document.getElementById("package-info-refresh-input");
const packageInfoAutoReloadButton = document.getElementById("package-info-autoreload-button"); const packageInfoAutoReloadButton = document.getElementById("package-info-autoreload-button");
const packageInfoAutoReloadInput = document.getElementById("package-info-autoreload-input");
let packageInfoAutoReloadTask = null; let packageInfoAutoReloadTask = null;
function clearChart() { function clearChart() {
@ -477,22 +490,22 @@
if (isPackageBaseSet) { if (isPackageBaseSet) {
bootstrap.Modal.getOrCreateInstance(packageInfoModal).show(); bootstrap.Modal.getOrCreateInstance(packageInfoModal).show();
togglePackageInfoAutoReload(); {% if autorefresh_intervals %}
togglePackageInfoAutoReload();
{% endif %}
} }
} }
function togglePackageInfoAutoReload() { function togglePackageInfoAutoReload(interval) {
clearInterval(packageInfoAutoReloadTask); clearInterval(packageInfoAutoReloadTask);
if (packageInfoAutoReloadButton.checked) { packageInfoAutoReloadTask = toggleAutoReload(packageInfoAutoReloadButton, interval, packageInfoAutoReloadInput, _ => {
packageInfoAutoReloadTask = setInterval(_ => { if (!hasActiveSelection()) {
if (!hasActiveSelection()) { const packageBase = packageInfoModal.dataset.package;
const packageBase = packageInfoModal.dataset.package; // we only poll status and logs here
// we only poll status and logs here loadPackage(packageBase);
loadPackage(packageBase); reloadActiveLogs(packageBase);
reloadActiveLogs(packageBase); }
} });
}, 5000);
}
} }
ready(_ => { ready(_ => {

View File

@ -10,6 +10,10 @@
const dashboardButton = document.getElementById("dashboard-button"); const dashboardButton = document.getElementById("dashboard-button");
const versionBadge = document.getElementById("badge-version"); 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) { function doPackageAction(uri, packages, repository, successText, failureText, data) {
makeRequest( makeRequest(
uri, uri,
@ -86,8 +90,10 @@
doPackageAction("/api/v1/service/update", [], repository, onSuccess, onFailure, parameters); doPackageAction("/api/v1/service/update", [], repository, onSuccess, onFailure, parameters);
} }
function reload() { function reload(silent) {
table.bootstrapTable("showLoading"); if (!silent) {
table.bootstrapTable("showLoading");
}
const badgeClass = status => { const badgeClass = status => {
if (status === "pending") return "btn-outline-warning"; if (status === "pending") return "btn-outline-warning";
@ -128,16 +134,18 @@
table.bootstrapTable("hideLoading"); table.bootstrapTable("hideLoading");
}, },
error => { error => {
if ((error.status === 401) || (error.status === 403)) { if (!silent) {
// authorization error if ((error.status === 401) || (error.status === 403)) {
const text = "In order to see statuses you must login first."; // authorization error
table.find("tr.unauthorized").remove(); const text = "In order to see statuses you must login first.";
table.find("tbody").append(`<tr class="unauthorized"><td colspan="100%">${safe(text)}</td></tr>`); table.find("tr.unauthorized").remove();
table.bootstrapTable("hideLoading"); table.find("tbody").append(`<tr class="unauthorized"><td colspan="100%">${safe(text)}</td></tr>`);
} else { table.bootstrapTable("hideLoading");
// other errors } else {
const message = details => `Could not load list of packages: ${details}`; // other errors
showFailure("Load failure", message, error); const message = details => `Could not load list of packages: ${details}`;
showFailure("Load failure", message, error);
}
} }
}, },
); );
@ -230,6 +238,15 @@
return {classes: cellClass(value)}; return {classes: cellClass(value)};
} }
function toggleTableAutoReload(interval) {
clearInterval(tableAutoReloadTask);
tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => {
if (getSelection().length === 0) {
reload(true);
}
});
}
ready(_ => { ready(_ => {
document.querySelectorAll("#repositories a").forEach(element => { document.querySelectorAll("#repositories a").forEach(element => {
element.onclick = _ => { element.onclick = _ => {
@ -289,5 +306,8 @@
}); });
selectRepository(); selectRepository();
{% if autorefresh_intervals %}
toggleTableAutoReload();
{% endif %}
}); });
</script> </script>

View File

@ -137,6 +137,28 @@
return element; 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() { Date.prototype.toISOStringShort = function() {
const pad = number => String(number).padStart(2, "0"); 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())}`; return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`;

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
# pylint: disable=too-many-public-methods
import configparser import configparser
import shlex import shlex
import sys import sys
@ -85,9 +86,10 @@ class Configuration(configparser.RawConfigParser):
empty_lines_in_values=not allow_multi_key, empty_lines_in_values=not allow_multi_key,
interpolation=ShellInterpolator(), interpolation=ShellInterpolator(),
converters={ converters={
"intlist": lambda value: list(map(int, shlex.split(value))),
"list": shlex.split, "list": shlex.split,
"path": self._convert_path, "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 and mypy are too stupid to find these methods
# pylint: disable=missing-function-docstring,unused-argument # 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 getlist(self, *args: Any, **kwargs: Any) -> list[str]: ... # type: ignore[empty-body]
def getpath(self, *args: Any, **kwargs: Any) -> Path: ... # type: ignore[empty-body] def getpath(self, *args: Any, **kwargs: Any) -> Path: ... # type: ignore[empty-body]

View File

@ -324,6 +324,15 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"empty": False, "empty": False,
"is_url": ["http", "https"], "is_url": ["http", "https"],
}, },
"autorefresh_intervals": {
"type": "list",
"coerce": "list",
"schema": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
},
"enable_archive_upload": { "enable_archive_upload": {
"type": "boolean", "type": "boolean",
"coerce": "boolean", "coerce": "boolean",

View File

@ -51,6 +51,7 @@ __all__ = [
"parse_version", "parse_version",
"partition", "partition",
"pretty_datetime", "pretty_datetime",
"pretty_interval",
"pretty_size", "pretty_size",
"safe_filename", "safe_filename",
"srcinfo_property", "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") 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: def pretty_size(size: float | None, level: int = 0) -> str:
""" """
convert size to string convert size to string

View File

@ -22,6 +22,7 @@ import aiohttp_jinja2
from typing import Any, ClassVar from typing import Any, ClassVar
from ahriman.core.auth.helpers import authorized_userid from ahriman.core.auth.helpers import authorized_userid
from ahriman.core.utils import pretty_interval
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec import aiohttp_apispec from ahriman.web.apispec import aiohttp_apispec
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -37,6 +38,10 @@ class IndexView(BaseView):
* control - HTML to insert for login control, HTML string, required * control - HTML to insert for login control, HTML string, required
* enabled - whether authorization is enabled by configuration or not, boolean, required * enabled - whether authorization is enabled by configuration or not, boolean, required
* username - authenticated username if any, string, null means not authenticated * 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 * docs_enabled - indicates if api docs is enabled, boolean, required
* index_url - url to the repository index, string, optional * index_url - url to the repository index, string, optional
* repositories - list of repositories unique identifiers, required * repositories - list of repositories unique identifiers, required
@ -66,8 +71,18 @@ class IndexView(BaseView):
"username": auth_username, "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 { return {
"auth": auth, "auth": auth,
"autorefresh_intervals": sorted(autorefresh_intervals, key=lambda interval: interval["interval"]),
"docs_enabled": aiohttp_apispec is not None, "docs_enabled": aiohttp_apispec is not None,
"index_url": self.configuration.get("web", "index_url", fallback=None), "index_url": self.configuration.get("web", "index_url", fallback=None),
"repositories": [ "repositories": [

View File

@ -133,6 +133,14 @@ def test_dump_architecture_specific(configuration: Configuration) -> None:
assert dump["build"]["archbuild_flags"] == "hello flag" 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: def test_getlist(configuration: Configuration) -> None:
""" """
must return list of string correctly must return list of string correctly

View File

@ -10,8 +10,8 @@ from unittest.mock import call as MockCall
from ahriman.core.exceptions import BuildError, CalledProcessError, OptionError, UnsafeRunError 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, \ 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, \ full_version, minmax, package_like, parse_version, partition, pretty_datetime, pretty_interval, pretty_size, \
srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk safe_filename, srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -341,6 +341,18 @@ def test_pretty_datetime_empty() -> None:
assert pretty_datetime(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: def test_pretty_size_bytes() -> None:
""" """
must generate bytes string for bytes value must generate bytes string for bytes value