Compare commits

...

4 Commits

15 changed files with 203 additions and 31 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.
* ``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. If first element of the list equals ``0``, auto refresh will be disabled by default.
* ``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.

View File

@ -28,6 +28,10 @@ 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. 0 can only be the first element and will disable auto refresh
; by default.
autorefresh_intervals = 5 1 10 30 60
; Enable file upload endpoint used by some triggers.
;enable_archive_upload = no
; Address to bind the server.

View File

@ -80,6 +80,21 @@
<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>
</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>
<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-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 %}
{% if autorefresh_intervals %}
<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-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-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>
</div>
</div>
@ -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();
{% if autorefresh_intervals %}
togglePackageInfoAutoReload();
{% endif %}
}
}
function togglePackageInfoAutoReload() {
function togglePackageInfoAutoReload(interval) {
clearInterval(packageInfoAutoReloadTask);
if (packageInfoAutoReloadButton.checked) {
packageInfoAutoReloadTask = setInterval(_ => {
packageInfoAutoReloadTask = toggleAutoReload(packageInfoAutoReloadButton, interval, packageInfoAutoReloadInput, _ => {
if (!hasActiveSelection()) {
const packageBase = packageInfoModal.dataset.package;
// we only poll status and logs here
loadPackage(packageBase);
reloadActiveLogs(packageBase);
}
}, 5000);
}
});
}
ready(_ => {

View File

@ -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() {
function reload(silent) {
if (!silent) {
table.bootstrapTable("showLoading");
}
const badgeClass = status => {
if (status === "pending") return "btn-outline-warning";
@ -128,6 +134,7 @@
table.bootstrapTable("hideLoading");
},
error => {
if (!silent) {
if ((error.status === 401) || (error.status === 403)) {
// authorization error
const text = "In order to see statuses you must login first.";
@ -139,6 +146,7 @@
const message = details => `Could not load list of packages: ${details}`;
showFailure("Load failure", message, error);
}
}
},
);
@ -230,6 +238,17 @@
return {classes: cellClass(value)};
}
function toggleTableAutoReload(interval) {
clearInterval(tableAutoReloadTask);
tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => {
if ((getSelection().length === 0) &&
(table.bootstrapTable("getOptions").pageNumber === 1) &&
(!dashboardModal.classList.contains("show"))) {
reload(true);
}
});
}
ready(_ => {
document.querySelectorAll("#repositories a").forEach(element => {
element.onclick = _ => {
@ -289,5 +308,8 @@
});
selectRepository();
{% if autorefresh_intervals %}
toggleTableAutoReload();
{% endif %}
});
</script>

View File

@ -137,6 +137,30 @@
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);
}
} else {
toggle.checked = false; // no active interval found, disable toggle
}
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())}`;

View File

@ -94,6 +94,15 @@ class Remote(SyncHttpClient):
for package in portion
if package.name in packages or not packages
}
# simple check for duplicates. This method will remove all packages under base if there is
# a package named exactly as its base
packages = {
package.name: package
for package in packages.values()
if package.package_base not in packages or package.package_base == package.name
}
return list(packages.values())
@classmethod

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# 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]

View File

@ -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",

View File

@ -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

View File

@ -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,19 @@ class IndexView(BaseView):
"username": auth_username,
}
autorefresh_intervals = [
{
"interval": interval * 1000, # milliseconds
"is_active": index == 0, # first element is always default
"text": pretty_interval(interval),
}
for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
]
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": [

View File

@ -70,7 +70,7 @@ class SearchView(BaseView):
if not packages:
raise HTTPNotFound(reason=f"No packages found for terms: {search}")
comparator: Callable[[AURPackage], str] = lambda item: str(item.package_base)
comparator: Callable[[AURPackage], str] = lambda item: item.package_base
response = [
{
"package": package.package_base,

View File

@ -1,5 +1,6 @@
import pytest
from dataclasses import replace
from pytest_mock import MockerFixture
from unittest.mock import call as MockCall
@ -88,6 +89,17 @@ def test_multisearch_single(aur_package_ahriman: AURPackage, pacman: Pacman, moc
search_mock.assert_called_once_with("ahriman", pacman=pacman, search_by=None)
def test_multisearch_remove_duplicates(aur_package_ahriman: AURPackage, pacman: Pacman, mocker: MockerFixture) -> None:
"""
must remove duplicates from search result
"""
package1 = replace(aur_package_ahriman)
package2 = replace(aur_package_ahriman, name="ahriman-triggers")
mocker.patch("ahriman.core.alpm.remote.Remote.package_search", return_value=[package1, package2])
assert Remote.multisearch("ahriman", pacman=pacman) == [package1]
def test_remote_git_url(remote: Remote) -> None:
"""
must raise NotImplemented for missing remote git url

View File

@ -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

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.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