mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-07-10 04:25:47 +00:00
feat: add autorefresh button to the main page (#149)
* also add configuration options and change behaviour accordingly
This commit is contained in:
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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"
|
||||
|
@ -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 %}
|
||||
<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>
|
||||
{% 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-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();
|
||||
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(_ => {
|
||||
|
@ -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(`<tr class="unauthorized"><td colspan="100%">${safe(text)}</td></tr>`);
|
||||
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(`<tr class="unauthorized"><td colspan="100%">${safe(text)}</td></tr>`);
|
||||
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 %}
|
||||
});
|
||||
</script>
|
||||
|
@ -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())}`;
|
||||
|
@ -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]
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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": [
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user