Compare commits

..

1 Commits

Author SHA1 Message Date
cd6fbaae31 feat: brand-new interface
This was initally generated by ai, but later has been heavily edited.
The reason why it has been implemented is that there are plans to
implement more features to ui, but it becomes hard to add new features
to plain js, so I decided to rewrite it in typescript.

Yet because it is still ai slop, it is still possible to enable old
interface via configuration, even though new interface is turned on by
default to get feedback
2026-03-03 03:29:23 +02:00
294 changed files with 1936 additions and 6223 deletions

View File

@@ -1,7 +1,7 @@
version: 2 version: 2
build: build:
os: ubuntu-lts-latest os: ubuntu-20.04
tools: tools:
python: "3.12" python: "3.12"
apt_packages: apt_packages:

View File

@@ -125,7 +125,7 @@ Again, the most checks can be performed by `tox` command, though some additional
def __hash__(self) -> int: ... # basically any magic (or look-alike) method def __hash__(self) -> int: ... # basically any magic (or look-alike) method
``` ```
Methods inside one group should be ordered alphabetically, the only exceptions are `__init__` (`__post_init__` for dataclasses), `__new__` and `__del__` methods which should be defined first. For test methods it is recommended to follow the order in which functions are defined. Same idea applies to frontend classes. Methods inside one group should be ordered alphabetically, the only exceptions are `__init__` (`__post_init__` for dataclasses), `__new__` and `__del__` methods which should be defined first. For test methods it is recommended to follow the order in which functions are defined.
Though, we would like to highlight abstract methods (i.e. ones which raise `NotImplementedError`), we still keep in global order at the moment. Though, we would like to highlight abstract methods (i.e. ones which raise `NotImplementedError`), we still keep in global order at the moment.
@@ -172,9 +172,8 @@ Again, the most checks can be performed by `tox` command, though some additional
) )
``` ```
* Imports goes in alphabetical order, no relative imports allowed. Same rule applies to frontend classes. * One file should define only one class, exception is class satellites in case if file length remains less than 400 lines.
* One file should define only one class, exception is class satellites in case if file length remains less than 400 lines. Same rule applies to frontend classes. * It is possible to create file which contains some functions (e.g. `ahriman.core.util`), but in this case you would need to define `__all__` attribute.
* It is possible to create file which contains some functions (e.g. `ahriman.core.utils`), but in this case you would need to define `__all__` attribute.
* The file size mentioned above must be applicable in general. In case of big classes consider splitting them into traits. Note, however, that `pylint` includes comments and docstrings into counter, thus you need to check file size by other tools. * The file size mentioned above must be applicable in general. In case of big classes consider splitting them into traits. Note, however, that `pylint` includes comments and docstrings into counter, thus you need to check file size by other tools.
* No global variable is allowed outside of `ahriman` module. `ahriman.core.context` is also special case. * No global variable is allowed outside of `ahriman` module. `ahriman.core.context` is also special case.
* Single quotes are not allowed. The reason behind this restriction is the fact that docstrings must be written by using double quotes only, and we would like to make style consistent. * Single quotes are not allowed. The reason behind this restriction is the fact that docstrings must be written by using double quotes only, and we would like to make style consistent.
@@ -227,8 +226,6 @@ Again, the most checks can be performed by `tox` command, though some additional
The projects also uses typing checks (provided by `mypy`) and some linter checks provided by `pylint` and `bandit`. Those checks must be passed successfully for any open pull requests. The projects also uses typing checks (provided by `mypy`) and some linter checks provided by `pylint` and `bandit`. Those checks must be passed successfully for any open pull requests.
Frontend checks normally are performed by `eslint` (e.g. `npx run eslint`).
## Developers how to ## Developers how to
### Run automated checks ### Run automated checks

View File

@@ -29,16 +29,17 @@ RUN pacman -S --noconfirm --asdeps \
python-filelock \ python-filelock \
python-inflection \ python-inflection \
python-pyelftools \ python-pyelftools \
python-requests python-requests \
RUN pacman -S --noconfirm --asdeps \ && \
pacman -S --noconfirm --asdeps \
base-devel \ base-devel \
python-build \ python-build \
python-flit \ python-flit \
python-installer \ python-installer \
python-setuptools \
python-tox \ python-tox \
python-wheel python-wheel \
RUN pacman -S --noconfirm --asdeps \ && \
pacman -S --noconfirm --asdeps \
git \ git \
python-aiohttp \ python-aiohttp \
python-aiohttp-openmetrics \ python-aiohttp-openmetrics \
@@ -47,8 +48,9 @@ RUN pacman -S --noconfirm --asdeps \
python-cryptography \ python-cryptography \
python-jinja \ python-jinja \
python-systemd \ python-systemd \
rsync rsync \
RUN runuser -u build -- install-aur-package \ && \
runuser -u build -- install-aur-package \
python-aioauth-client \ python-aioauth-client \
python-sphinx-typlog-theme \ python-sphinx-typlog-theme \
python-webargs \ python-webargs \
@@ -57,7 +59,6 @@ RUN runuser -u build -- install-aur-package \
python-aiohttp-jinja2 \ python-aiohttp-jinja2 \
python-aiohttp-session \ python-aiohttp-session \
python-aiohttp-security \ python-aiohttp-security \
python-aiohttp-sse-git \
python-requests-unixsocket2 python-requests-unixsocket2
# install ahriman # install ahriman

View File

@@ -138,12 +138,11 @@ digraph G {
ahriman_core_http [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp",shape="box"]; ahriman_core_http [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp",shape="box"];
ahriman_core_http_sync_ahriman_client [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp\.\nsync_ahriman_client",shape="box"]; ahriman_core_http_sync_ahriman_client [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp\.\nsync_ahriman_client",shape="box"];
ahriman_core_http_sync_http_client [fillcolor="#933f24",fontcolor="#ffffff",label="ahriman\.\ncore\.\nhttp\.\nsync_http_client"]; ahriman_core_http_sync_http_client [fillcolor="#933f24",fontcolor="#ffffff",label="ahriman\.\ncore\.\nhttp\.\nsync_http_client"];
ahriman_core_log [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nlog",shape="box"]; ahriman_core_log [fillcolor="#e53b05",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog"];
ahriman_core_log_http_log_handler [fillcolor="#914730",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nhttp_log_handler"]; ahriman_core_log_http_log_handler [fillcolor="#914730",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nhttp_log_handler"];
ahriman_core_log_journal_handler [fillcolor="#ac6149",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\njournal_handler"]; ahriman_core_log_journal_handler [fillcolor="#ac6149",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\njournal_handler"];
ahriman_core_log_lazy_logging [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nlog\.\nlazy_logging",shape="box"]; ahriman_core_log_lazy_logging [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlazy_logging"];
ahriman_core_log_log_context [fillcolor="#db582f",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlog_context"]; ahriman_core_log_log_loader [fillcolor="#82402b",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlog_loader"];
ahriman_core_log_log_loader [fillcolor="#7a3c28",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlog_loader"];
ahriman_core_module_loader [fillcolor="#ce5e3b",fontcolor="#ffffff",label="ahriman\.\ncore\.\nmodule_loader"]; ahriman_core_module_loader [fillcolor="#ce5e3b",fontcolor="#ffffff",label="ahriman\.\ncore\.\nmodule_loader"];
ahriman_core_report [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport",shape="box"]; ahriman_core_report [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport",shape="box"];
ahriman_core_report_console [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport\.\nconsole",shape="box"]; ahriman_core_report_console [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport\.\nconsole",shape="box"];
@@ -243,18 +242,15 @@ digraph G {
ahriman_web_apispec_info [fillcolor="#a14f35",fontcolor="#ffffff",label="ahriman\.\nweb\.\napispec\.\ninfo"]; ahriman_web_apispec_info [fillcolor="#a14f35",fontcolor="#ffffff",label="ahriman\.\nweb\.\napispec\.\ninfo"];
ahriman_web_cors [fillcolor="#b0573a",fontcolor="#ffffff",label="ahriman\.\nweb\.\ncors"]; ahriman_web_cors [fillcolor="#b0573a",fontcolor="#ffffff",label="ahriman\.\nweb\.\ncors"];
ahriman_web_keys [fillcolor="#823017",fontcolor="#ffffff",label="ahriman\.\nweb\.\nkeys"]; ahriman_web_keys [fillcolor="#823017",fontcolor="#ffffff",label="ahriman\.\nweb\.\nkeys"];
ahriman_web_middlewares [fillcolor="#ef3e06",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares"]; ahriman_web_middlewares [fillcolor="#e9410c",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares"];
ahriman_web_middlewares_auth_handler [fillcolor="#733826",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nauth_handler"]; ahriman_web_middlewares_auth_handler [fillcolor="#733826",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nauth_handler"];
ahriman_web_middlewares_exception_handler [fillcolor="#994b33",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nexception_handler"]; ahriman_web_middlewares_exception_handler [fillcolor="#994b33",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nexception_handler"];
ahriman_web_middlewares_metrics_handler [fillcolor="#a34628",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nmetrics_handler"]; ahriman_web_middlewares_metrics_handler [fillcolor="#a34628",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nmetrics_handler"];
ahriman_web_middlewares_request_id_handler [fillcolor="#8a442e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nrequest_id_handler"];
ahriman_web_routes [fillcolor="#8a442e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nroutes"]; ahriman_web_routes [fillcolor="#8a442e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nroutes"];
ahriman_web_schemas [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas",shape="box"]; ahriman_web_schemas [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas",shape="box"];
ahriman_web_schemas_any_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nany_schema"]; ahriman_web_schemas_any_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nany_schema"];
ahriman_web_schemas_aur_package_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\naur_package_schema"]; ahriman_web_schemas_aur_package_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\naur_package_schema"];
ahriman_web_schemas_auth_info_schema [fillcolor="#c45431",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nauth_info_schema"];
ahriman_web_schemas_auth_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nauth_schema"]; ahriman_web_schemas_auth_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nauth_schema"];
ahriman_web_schemas_auto_refresh_interval_schema [fillcolor="#c45431",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nauto_refresh_interval_schema"];
ahriman_web_schemas_build_options_schema [fillcolor="#d04e24",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nbuild_options_schema"]; ahriman_web_schemas_build_options_schema [fillcolor="#d04e24",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nbuild_options_schema"];
ahriman_web_schemas_changes_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nchanges_schema"]; ahriman_web_schemas_changes_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nchanges_schema"];
ahriman_web_schemas_configuration_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nconfiguration_schema"]; ahriman_web_schemas_configuration_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nconfiguration_schema"];
@@ -265,7 +261,6 @@ digraph G {
ahriman_web_schemas_event_search_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\nevent_search_schema",shape="box"]; ahriman_web_schemas_event_search_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\nevent_search_schema",shape="box"];
ahriman_web_schemas_file_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nfile_schema"]; ahriman_web_schemas_file_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nfile_schema"];
ahriman_web_schemas_info_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\ninfo_schema",shape="box"]; ahriman_web_schemas_info_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\ninfo_schema",shape="box"];
ahriman_web_schemas_info_v2_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\ninfo_v2_schema",shape="box"];
ahriman_web_schemas_internal_status_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\ninternal_status_schema",shape="box"]; ahriman_web_schemas_internal_status_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\ninternal_status_schema",shape="box"];
ahriman_web_schemas_log_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nlog_schema"]; ahriman_web_schemas_log_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nlog_schema"];
ahriman_web_schemas_login_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nlogin_schema"]; ahriman_web_schemas_login_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nlogin_schema"];
@@ -294,12 +289,11 @@ digraph G {
ahriman_web_schemas_status_schema [fillcolor="#ca4116",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nstatus_schema"]; ahriman_web_schemas_status_schema [fillcolor="#ca4116",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nstatus_schema"];
ahriman_web_schemas_update_flags_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\nupdate_flags_schema",shape="box"]; ahriman_web_schemas_update_flags_schema [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas\.\nupdate_flags_schema",shape="box"];
ahriman_web_schemas_worker_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nworker_schema"]; ahriman_web_schemas_worker_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nworker_schema"];
ahriman_web_server_info [fillcolor="#93371a",fontcolor="#ffffff",label="ahriman\.\nweb\.\nserver_info"];
ahriman_web_views [fillcolor="#f94810",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews"]; ahriman_web_views [fillcolor="#f94810",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews"];
ahriman_web_views_api_docs [fillcolor="#794434",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\napi\.\ndocs"]; ahriman_web_views_api_docs [fillcolor="#794434",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\napi\.\ndocs"];
ahriman_web_views_api_swagger [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\napi\.\nswagger"]; ahriman_web_views_api_swagger [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\napi\.\nswagger"];
ahriman_web_views_base [fillcolor="#952603",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nbase"]; ahriman_web_views_base [fillcolor="#952603",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nbase"];
ahriman_web_views_index [fillcolor="#794434",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nindex"]; ahriman_web_views_index [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nindex"];
ahriman_web_views_static [fillcolor="#884d3a",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nstatic"]; ahriman_web_views_static [fillcolor="#884d3a",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nstatic"];
ahriman_web_views_status_view_guard [fillcolor="#ef3e06",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nstatus_view_guard"]; ahriman_web_views_status_view_guard [fillcolor="#ef3e06",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nstatus_view_guard"];
ahriman_web_views_v1_auditlog_events [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nauditlog\.\nevents"]; ahriman_web_views_v1_auditlog_events [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nauditlog\.\nevents"];
@@ -322,14 +316,13 @@ digraph G {
ahriman_web_views_v1_service_search [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nservice\.\nsearch"]; ahriman_web_views_v1_service_search [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nservice\.\nsearch"];
ahriman_web_views_v1_service_update [fillcolor="#724031",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nservice\.\nupdate"]; ahriman_web_views_v1_service_update [fillcolor="#724031",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nservice\.\nupdate"];
ahriman_web_views_v1_service_upload [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nservice\.\nupload"]; ahriman_web_views_v1_service_upload [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nservice\.\nupload"];
ahriman_web_views_v1_status_info [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\ninfo"]; ahriman_web_views_v1_status_info [fillcolor="#724031",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\ninfo"];
ahriman_web_views_v1_status_metrics [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\nmetrics"]; ahriman_web_views_v1_status_metrics [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\nmetrics"];
ahriman_web_views_v1_status_repositories [fillcolor="#724031",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\nrepositories"]; ahriman_web_views_v1_status_repositories [fillcolor="#724031",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\nrepositories"];
ahriman_web_views_v1_status_status [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\nstatus"]; ahriman_web_views_v1_status_status [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nstatus\.\nstatus"];
ahriman_web_views_v1_user_login [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nuser\.\nlogin"]; ahriman_web_views_v1_user_login [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nuser\.\nlogin"];
ahriman_web_views_v1_user_logout [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nuser\.\nlogout"]; ahriman_web_views_v1_user_logout [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv1\.\nuser\.\nlogout"];
ahriman_web_views_v2_packages_logs [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv2\.\npackages\.\nlogs"]; ahriman_web_views_v2_packages_logs [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv2\.\npackages\.\nlogs"];
ahriman_web_views_v2_status_info [fillcolor="#6b3c2e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nviews\.\nv2\.\nstatus\.\ninfo"];
ahriman_web_web [fillcolor="#733826",fontcolor="#ffffff",label="ahriman\.\nweb\.\nweb"]; ahriman_web_web [fillcolor="#733826",fontcolor="#ffffff",label="ahriman\.\nweb\.\nweb"];
aioauth_client [fillcolor="#c07d40",shape="folder"]; aioauth_client [fillcolor="#c07d40",shape="folder"];
aiohttp [fillcolor="#f9b506",shape="folder"]; aiohttp [fillcolor="#f9b506",shape="folder"];
@@ -495,12 +488,11 @@ digraph G {
ahriman_core -> ahriman_models_worker [fillcolor="#ef3e06",minlen="2"]; ahriman_core -> ahriman_models_worker [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_keys [fillcolor="#ef3e06",minlen="2"]; ahriman_core -> ahriman_web_keys [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_middlewares_auth_handler [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_middlewares_auth_handler [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_middlewares_request_id_handler [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_routes [fillcolor="#ef3e06",minlen="2"]; ahriman_core -> ahriman_web_routes [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_server_info [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_views_api_docs [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_views_api_docs [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_views_api_swagger [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_views_api_swagger [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_views_base [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_views_base [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_views_index [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_views_status_view_guard [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_views_status_view_guard [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_views_v1_distributed_workers [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_views_v1_distributed_workers [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_views_v1_packages_logs [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_views_v1_packages_logs [fillcolor="#ef3e06",minlen="3"];
@@ -550,13 +542,13 @@ digraph G {
ahriman_core_archive_archive_trigger -> ahriman_core_archive [fillcolor="blue",weight="3"]; ahriman_core_archive_archive_trigger -> ahriman_core_archive [fillcolor="blue",weight="3"];
ahriman_core_auth -> ahriman_web_keys [fillcolor="blue",minlen="2"]; ahriman_core_auth -> ahriman_web_keys [fillcolor="blue",minlen="2"];
ahriman_core_auth -> ahriman_web_middlewares_auth_handler [fillcolor="blue",minlen="3"]; ahriman_core_auth -> ahriman_web_middlewares_auth_handler [fillcolor="blue",minlen="3"];
ahriman_core_auth -> ahriman_web_server_info [fillcolor="blue",minlen="2"];
ahriman_core_auth -> ahriman_web_views_base [fillcolor="blue",minlen="3"]; ahriman_core_auth -> ahriman_web_views_base [fillcolor="blue",minlen="3"];
ahriman_core_auth -> ahriman_web_views_index [fillcolor="blue",minlen="3"];
ahriman_core_auth -> ahriman_web_views_v1_user_login [fillcolor="blue",minlen="3"]; ahriman_core_auth -> ahriman_web_views_v1_user_login [fillcolor="blue",minlen="3"];
ahriman_core_auth -> ahriman_web_views_v1_user_logout [fillcolor="blue",minlen="3"]; ahriman_core_auth -> ahriman_web_views_v1_user_logout [fillcolor="blue",minlen="3"];
ahriman_core_auth -> ahriman_web_web [fillcolor="blue",minlen="2"]; ahriman_core_auth -> ahriman_web_web [fillcolor="blue",minlen="2"];
ahriman_core_auth_auth -> ahriman_core_auth [fillcolor="blue",weight="3"]; ahriman_core_auth_auth -> ahriman_core_auth [fillcolor="blue",weight="3"];
ahriman_core_auth_helpers -> ahriman_web_server_info [fillcolor="#d04e24",minlen="3"]; ahriman_core_auth_helpers -> ahriman_web_views_index [fillcolor="#d04e24",minlen="3"];
ahriman_core_auth_helpers -> ahriman_web_views_v1_user_login [fillcolor="#d04e24",minlen="3"]; ahriman_core_auth_helpers -> ahriman_web_views_v1_user_login [fillcolor="#d04e24",minlen="3"];
ahriman_core_auth_helpers -> ahriman_web_views_v1_user_logout [fillcolor="#d04e24",minlen="3"]; ahriman_core_auth_helpers -> ahriman_web_views_v1_user_logout [fillcolor="#d04e24",minlen="3"];
ahriman_core_auth_mapping -> ahriman_core_auth_auth [fillcolor="blue",weight="3"]; ahriman_core_auth_mapping -> ahriman_core_auth_auth [fillcolor="blue",weight="3"];
@@ -851,39 +843,35 @@ digraph G {
ahriman_core_http_sync_ahriman_client -> ahriman_core_http [fillcolor="blue",weight="3"]; ahriman_core_http_sync_ahriman_client -> ahriman_core_http [fillcolor="blue",weight="3"];
ahriman_core_http_sync_http_client -> ahriman_core_http [fillcolor="#933f24",weight="3"]; ahriman_core_http_sync_http_client -> ahriman_core_http [fillcolor="#933f24",weight="3"];
ahriman_core_http_sync_http_client -> ahriman_core_http_sync_ahriman_client [fillcolor="#933f24",weight="3"]; ahriman_core_http_sync_http_client -> ahriman_core_http_sync_ahriman_client [fillcolor="#933f24",weight="3"];
ahriman_core_log -> ahriman_application_application_application_properties [fillcolor="blue",minlen="3"]; ahriman_core_log -> ahriman_application_application_application_properties [fillcolor="#e53b05",minlen="3"];
ahriman_core_log -> ahriman_application_application_workers_updater [fillcolor="blue",minlen="3"]; ahriman_core_log -> ahriman_application_application_workers_updater [fillcolor="#e53b05",minlen="3"];
ahriman_core_log -> ahriman_application_handlers_handler [fillcolor="blue",minlen="3"]; ahriman_core_log -> ahriman_application_handlers_handler [fillcolor="#e53b05",minlen="3"];
ahriman_core_log -> ahriman_application_lock [fillcolor="blue",minlen="2"]; ahriman_core_log -> ahriman_application_lock [fillcolor="#e53b05",minlen="2"];
ahriman_core_log -> ahriman_core_alpm_pacman [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_alpm_pacman [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_alpm_repo [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_alpm_repo [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_archive_archive_tree [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_archive_archive_tree [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_auth_auth [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_auth_auth [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_build_tools_package_version [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_build_tools_package_version [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_build_tools_sources [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_build_tools_sources [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_build_tools_task [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_build_tools_task [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_database_migrations [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_database_migrations [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_database_operations_operations [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_database_operations_operations [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_distributed_workers_cache [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_distributed_workers_cache [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_gitremote_remote_pull [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_gitremote_remote_pull [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_gitremote_remote_push [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_gitremote_remote_push [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_http_sync_http_client [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_http_sync_http_client [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_report_report [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_report_report [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_repository_repository_properties [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_repository_repository_properties [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_spawn [fillcolor="blue",weight="2"]; ahriman_core_log -> ahriman_core_spawn [fillcolor="#e53b05",weight="2"];
ahriman_core_log -> ahriman_core_status_watcher [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_status_watcher [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_triggers_trigger [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_triggers_trigger [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_triggers_trigger_loader [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_triggers_trigger_loader [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_upload_upload [fillcolor="blue",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_upload_upload [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_core_log -> ahriman_models_package [fillcolor="blue",minlen="2"]; ahriman_core_log -> ahriman_models_package [fillcolor="#e53b05",minlen="2"];
ahriman_core_log -> ahriman_models_repository_paths [fillcolor="blue",minlen="2"]; ahriman_core_log -> ahriman_models_repository_paths [fillcolor="#e53b05",minlen="2"];
ahriman_core_log -> ahriman_web_middlewares_request_id_handler [fillcolor="blue",minlen="3"];
ahriman_core_log_http_log_handler -> ahriman_core_log_log_loader [fillcolor="#914730",weight="3"]; ahriman_core_log_http_log_handler -> ahriman_core_log_log_loader [fillcolor="#914730",weight="3"];
ahriman_core_log_lazy_logging -> ahriman_core_log [fillcolor="blue",weight="3"]; ahriman_core_log_lazy_logging -> ahriman_core_log [fillcolor="#b85a3d",weight="3"];
ahriman_core_log_log_context -> ahriman_core_log_lazy_logging [fillcolor="#db582f",weight="3"]; ahriman_core_log_log_loader -> ahriman_application_handlers_handler [fillcolor="#82402b",minlen="3"];
ahriman_core_log_log_context -> ahriman_core_log_log_loader [fillcolor="#db582f",weight="3"];
ahriman_core_log_log_context -> ahriman_web_middlewares_request_id_handler [fillcolor="#db582f",minlen="3"];
ahriman_core_log_log_loader -> ahriman_application_handlers_handler [fillcolor="#7a3c28",minlen="3"];
ahriman_core_module_loader -> ahriman_application_ahriman [fillcolor="#ce5e3b",minlen="2"]; ahriman_core_module_loader -> ahriman_application_ahriman [fillcolor="#ce5e3b",minlen="2"];
ahriman_core_module_loader -> ahriman_web_routes [fillcolor="#ce5e3b",minlen="2"]; ahriman_core_module_loader -> ahriman_web_routes [fillcolor="#ce5e3b",minlen="2"];
ahriman_core_report_console -> ahriman_core_report_report [fillcolor="blue",weight="3"]; ahriman_core_report_console -> ahriman_core_report_report [fillcolor="blue",weight="3"];
@@ -1003,7 +991,6 @@ digraph G {
ahriman_core_types -> ahriman_core_report_jinja_template [fillcolor="#f94810",minlen="2",weight="2"]; ahriman_core_types -> ahriman_core_report_jinja_template [fillcolor="#f94810",minlen="2",weight="2"];
ahriman_core_types -> ahriman_core_report_rss [fillcolor="#f94810",minlen="2",weight="2"]; ahriman_core_types -> ahriman_core_report_rss [fillcolor="#f94810",minlen="2",weight="2"];
ahriman_core_types -> ahriman_core_utils [fillcolor="#f94810",weight="2"]; ahriman_core_types -> ahriman_core_utils [fillcolor="#f94810",weight="2"];
ahriman_core_types -> ahriman_web_server_info [fillcolor="#f94810",minlen="2"];
ahriman_core_types -> ahriman_web_views_v1_distributed_workers [fillcolor="#f94810",minlen="3"]; ahriman_core_types -> ahriman_web_views_v1_distributed_workers [fillcolor="#f94810",minlen="3"];
ahriman_core_types -> ahriman_web_views_v1_packages_packages [fillcolor="#f94810",minlen="3"]; ahriman_core_types -> ahriman_web_views_v1_packages_packages [fillcolor="#f94810",minlen="3"];
ahriman_core_types -> ahriman_web_views_v1_service_search [fillcolor="#f94810",minlen="3"]; ahriman_core_types -> ahriman_web_views_v1_service_search [fillcolor="#f94810",minlen="3"];
@@ -1073,9 +1060,8 @@ digraph G {
ahriman_core_utils -> ahriman_models_repository_paths [fillcolor="#db3805",minlen="2"]; ahriman_core_utils -> ahriman_models_repository_paths [fillcolor="#db3805",minlen="2"];
ahriman_core_utils -> ahriman_models_repository_stats [fillcolor="#db3805",minlen="2"]; ahriman_core_utils -> ahriman_models_repository_stats [fillcolor="#db3805",minlen="2"];
ahriman_core_utils -> ahriman_models_worker [fillcolor="#db3805",minlen="2"]; ahriman_core_utils -> ahriman_models_worker [fillcolor="#db3805",minlen="2"];
ahriman_core_utils -> ahriman_web_server_info [fillcolor="#db3805",minlen="2"];
ahriman_core_utils -> ahriman_web_views_api_swagger [fillcolor="#db3805",minlen="3"]; ahriman_core_utils -> ahriman_web_views_api_swagger [fillcolor="#db3805",minlen="3"];
ahriman_core_utils -> ahriman_web_views_base [fillcolor="#db3805",minlen="3"]; ahriman_core_utils -> ahriman_web_views_index [fillcolor="#db3805",minlen="3"];
ahriman_core_utils -> ahriman_web_views_v1_packages_logs [fillcolor="#db3805",minlen="3"]; ahriman_core_utils -> ahriman_web_views_v1_packages_logs [fillcolor="#db3805",minlen="3"];
ahriman_core_utils -> ahriman_web_views_v1_service_upload [fillcolor="#db3805",minlen="3"]; ahriman_core_utils -> ahriman_web_views_v1_service_upload [fillcolor="#db3805",minlen="3"];
ahriman_models -> ahriman_application_ahriman [fillcolor="#f94810",minlen="2"]; ahriman_models -> ahriman_application_ahriman [fillcolor="#f94810",minlen="2"];
@@ -1259,7 +1245,6 @@ digraph G {
ahriman_models -> ahriman_web_views_v1_user_login [fillcolor="#f94810",minlen="3"]; ahriman_models -> ahriman_web_views_v1_user_login [fillcolor="#f94810",minlen="3"];
ahriman_models -> ahriman_web_views_v1_user_logout [fillcolor="#f94810",minlen="3"]; ahriman_models -> ahriman_web_views_v1_user_logout [fillcolor="#f94810",minlen="3"];
ahriman_models -> ahriman_web_views_v2_packages_logs [fillcolor="#f94810",minlen="3"]; ahriman_models -> ahriman_web_views_v2_packages_logs [fillcolor="#f94810",minlen="3"];
ahriman_models -> ahriman_web_views_v2_status_info [fillcolor="#f94810",minlen="3"];
ahriman_models -> ahriman_web_web [fillcolor="#f94810",minlen="2"]; ahriman_models -> ahriman_web_web [fillcolor="#f94810",minlen="2"];
ahriman_models_action -> ahriman_application_handlers_change [fillcolor="#e75222",minlen="3"]; ahriman_models_action -> ahriman_application_handlers_change [fillcolor="#e75222",minlen="3"];
ahriman_models_action -> ahriman_application_handlers_patch [fillcolor="#e75222",minlen="3"]; ahriman_models_action -> ahriman_application_handlers_patch [fillcolor="#e75222",minlen="3"];
@@ -1668,7 +1653,6 @@ digraph G {
ahriman_models_user_access -> ahriman_web_views_v1_user_login [fillcolor="#f94810",minlen="3"]; ahriman_models_user_access -> ahriman_web_views_v1_user_login [fillcolor="#f94810",minlen="3"];
ahriman_models_user_access -> ahriman_web_views_v1_user_logout [fillcolor="#f94810",minlen="3"]; ahriman_models_user_access -> ahriman_web_views_v1_user_logout [fillcolor="#f94810",minlen="3"];
ahriman_models_user_access -> ahriman_web_views_v2_packages_logs [fillcolor="#f94810",minlen="3"]; ahriman_models_user_access -> ahriman_web_views_v2_packages_logs [fillcolor="#f94810",minlen="3"];
ahriman_models_user_access -> ahriman_web_views_v2_status_info [fillcolor="#f94810",minlen="3"];
ahriman_models_waiter -> ahriman_application_lock [fillcolor="#c45431",minlen="2"]; ahriman_models_waiter -> ahriman_application_lock [fillcolor="#c45431",minlen="2"];
ahriman_models_waiter -> ahriman_core_report_remote_call [fillcolor="#c45431",minlen="3"]; ahriman_models_waiter -> ahriman_core_report_remote_call [fillcolor="#c45431",minlen="3"];
ahriman_models_worker -> ahriman_application_application_workers_remote_updater [fillcolor="#e9410c",minlen="3"]; ahriman_models_worker -> ahriman_application_application_workers_remote_updater [fillcolor="#e9410c",minlen="3"];
@@ -1679,9 +1663,7 @@ digraph G {
ahriman_web -> ahriman_application_handlers_web [fillcolor="#f94810",minlen="3"]; ahriman_web -> ahriman_application_handlers_web [fillcolor="#f94810",minlen="3"];
ahriman_web_apispec -> ahriman_web_schemas_any_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_any_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_aur_package_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_aur_package_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_auth_info_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_auth_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_auth_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_auto_refresh_interval_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_build_options_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_build_options_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_changes_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_changes_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_configuration_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_configuration_schema [fillcolor="#e53b05",minlen="2",weight="2"];
@@ -1692,7 +1674,6 @@ digraph G {
ahriman_web_apispec -> ahriman_web_schemas_event_search_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_event_search_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_file_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_file_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_info_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_info_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_info_v2_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_internal_status_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_internal_status_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_log_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_log_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_login_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_login_schema [fillcolor="#e53b05",minlen="2",weight="2"];
@@ -1721,9 +1702,9 @@ digraph G {
ahriman_web_apispec -> ahriman_web_schemas_status_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_status_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_update_flags_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_update_flags_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_schemas_worker_schema [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_schemas_worker_schema [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_server_info [fillcolor="#e53b05",weight="2"];
ahriman_web_apispec -> ahriman_web_views_api_docs [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_api_docs [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_api_swagger [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_api_swagger [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_index [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_v1_auditlog_events [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_v1_auditlog_events [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_v1_distributed_workers [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_v1_distributed_workers [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_v1_packages_changes [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_v1_packages_changes [fillcolor="#e53b05",minlen="2",weight="2"];
@@ -1751,7 +1732,6 @@ digraph G {
ahriman_web_apispec -> ahriman_web_views_v1_user_login [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_v1_user_login [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_v1_user_logout [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_v1_user_logout [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_v2_packages_logs [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_web_apispec -> ahriman_web_views_v2_packages_logs [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_views_v2_status_info [fillcolor="#e53b05",minlen="2",weight="2"];
ahriman_web_apispec -> ahriman_web_web [fillcolor="#e53b05",weight="2"]; ahriman_web_apispec -> ahriman_web_web [fillcolor="#e53b05",weight="2"];
ahriman_web_apispec_decorators -> ahriman_web_views_v1_auditlog_events [fillcolor="#bd3104",minlen="2",weight="2"]; ahriman_web_apispec_decorators -> ahriman_web_views_v1_auditlog_events [fillcolor="#bd3104",minlen="2",weight="2"];
ahriman_web_apispec_decorators -> ahriman_web_views_v1_distributed_workers [fillcolor="#bd3104",minlen="2",weight="2"]; ahriman_web_apispec_decorators -> ahriman_web_views_v1_distributed_workers [fillcolor="#bd3104",minlen="2",weight="2"];
@@ -1780,19 +1760,17 @@ digraph G {
ahriman_web_apispec_decorators -> ahriman_web_views_v1_user_login [fillcolor="#bd3104",minlen="2",weight="2"]; ahriman_web_apispec_decorators -> ahriman_web_views_v1_user_login [fillcolor="#bd3104",minlen="2",weight="2"];
ahriman_web_apispec_decorators -> ahriman_web_views_v1_user_logout [fillcolor="#bd3104",minlen="2",weight="2"]; ahriman_web_apispec_decorators -> ahriman_web_views_v1_user_logout [fillcolor="#bd3104",minlen="2",weight="2"];
ahriman_web_apispec_decorators -> ahriman_web_views_v2_packages_logs [fillcolor="#bd3104",minlen="2",weight="2"]; ahriman_web_apispec_decorators -> ahriman_web_views_v2_packages_logs [fillcolor="#bd3104",minlen="2",weight="2"];
ahriman_web_apispec_decorators -> ahriman_web_views_v2_status_info [fillcolor="#bd3104",minlen="2",weight="2"];
ahriman_web_apispec_info -> ahriman_web_web [fillcolor="#a14f35",minlen="2",weight="2"]; ahriman_web_apispec_info -> ahriman_web_web [fillcolor="#a14f35",minlen="2",weight="2"];
ahriman_web_cors -> ahriman_web_web [fillcolor="#b0573a",weight="2"]; ahriman_web_cors -> ahriman_web_web [fillcolor="#b0573a",weight="2"];
ahriman_web_keys -> ahriman_web_apispec_info [fillcolor="#823017",minlen="2",weight="2"]; ahriman_web_keys -> ahriman_web_apispec_info [fillcolor="#823017",minlen="2",weight="2"];
ahriman_web_keys -> ahriman_web_views_base [fillcolor="#823017",minlen="2",weight="2"]; ahriman_web_keys -> ahriman_web_views_base [fillcolor="#823017",minlen="2",weight="2"];
ahriman_web_keys -> ahriman_web_web [fillcolor="#823017",weight="2"]; ahriman_web_keys -> ahriman_web_web [fillcolor="#823017",weight="2"];
ahriman_web_middlewares -> ahriman_web_views_v1_status_metrics [fillcolor="#ef3e06",minlen="2",weight="2"]; ahriman_web_middlewares -> ahriman_web_views_v1_status_metrics [fillcolor="#e9410c",minlen="2",weight="2"];
ahriman_web_middlewares -> ahriman_web_web [fillcolor="#ef3e06",weight="2"]; ahriman_web_middlewares -> ahriman_web_web [fillcolor="#e9410c",weight="2"];
ahriman_web_middlewares_auth_handler -> ahriman_web_web [fillcolor="#733826",minlen="2",weight="2"]; ahriman_web_middlewares_auth_handler -> ahriman_web_web [fillcolor="#733826",minlen="2",weight="2"];
ahriman_web_middlewares_exception_handler -> ahriman_web_web [fillcolor="#994b33",minlen="2",weight="2"]; ahriman_web_middlewares_exception_handler -> ahriman_web_web [fillcolor="#994b33",minlen="2",weight="2"];
ahriman_web_middlewares_metrics_handler -> ahriman_web_views_v1_status_metrics [fillcolor="#a34628",minlen="2",weight="2"]; ahriman_web_middlewares_metrics_handler -> ahriman_web_views_v1_status_metrics [fillcolor="#a34628",minlen="2",weight="2"];
ahriman_web_middlewares_metrics_handler -> ahriman_web_web [fillcolor="#a34628",minlen="2",weight="2"]; ahriman_web_middlewares_metrics_handler -> ahriman_web_web [fillcolor="#a34628",minlen="2",weight="2"];
ahriman_web_middlewares_request_id_handler -> ahriman_web_web [fillcolor="#8a442e",minlen="2",weight="2"];
ahriman_web_routes -> ahriman_web_web [fillcolor="#8a442e",weight="2"]; ahriman_web_routes -> ahriman_web_web [fillcolor="#8a442e",weight="2"];
ahriman_web_schemas -> ahriman_web_apispec_decorators [fillcolor="blue",minlen="2",weight="2"]; ahriman_web_schemas -> ahriman_web_apispec_decorators [fillcolor="blue",minlen="2",weight="2"];
ahriman_web_schemas -> ahriman_web_views_v1_auditlog_events [fillcolor="blue",minlen="2",weight="2"]; ahriman_web_schemas -> ahriman_web_views_v1_auditlog_events [fillcolor="blue",minlen="2",weight="2"];
@@ -1821,14 +1799,9 @@ digraph G {
ahriman_web_schemas -> ahriman_web_views_v1_status_status [fillcolor="blue",minlen="2",weight="2"]; ahriman_web_schemas -> ahriman_web_views_v1_status_status [fillcolor="blue",minlen="2",weight="2"];
ahriman_web_schemas -> ahriman_web_views_v1_user_login [fillcolor="blue",minlen="2",weight="2"]; ahriman_web_schemas -> ahriman_web_views_v1_user_login [fillcolor="blue",minlen="2",weight="2"];
ahriman_web_schemas -> ahriman_web_views_v2_packages_logs [fillcolor="blue",minlen="2",weight="2"]; ahriman_web_schemas -> ahriman_web_views_v2_packages_logs [fillcolor="blue",minlen="2",weight="2"];
ahriman_web_schemas -> ahriman_web_views_v2_status_info [fillcolor="blue",minlen="2",weight="2"];
ahriman_web_schemas_any_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"]; ahriman_web_schemas_any_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"];
ahriman_web_schemas_aur_package_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"]; ahriman_web_schemas_aur_package_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"];
ahriman_web_schemas_auth_info_schema -> ahriman_web_schemas [fillcolor="#c45431",weight="3"];
ahriman_web_schemas_auth_info_schema -> ahriman_web_schemas_info_v2_schema [fillcolor="#c45431",weight="3"];
ahriman_web_schemas_auth_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"]; ahriman_web_schemas_auth_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"];
ahriman_web_schemas_auto_refresh_interval_schema -> ahriman_web_schemas [fillcolor="#c45431",weight="3"];
ahriman_web_schemas_auto_refresh_interval_schema -> ahriman_web_schemas_info_v2_schema [fillcolor="#c45431",weight="3"];
ahriman_web_schemas_build_options_schema -> ahriman_web_schemas [fillcolor="#d04e24",weight="3"]; ahriman_web_schemas_build_options_schema -> ahriman_web_schemas [fillcolor="#d04e24",weight="3"];
ahriman_web_schemas_build_options_schema -> ahriman_web_schemas_package_names_schema [fillcolor="#d04e24",weight="3"]; ahriman_web_schemas_build_options_schema -> ahriman_web_schemas_package_names_schema [fillcolor="#d04e24",weight="3"];
ahriman_web_schemas_build_options_schema -> ahriman_web_schemas_update_flags_schema [fillcolor="#d04e24",weight="3"]; ahriman_web_schemas_build_options_schema -> ahriman_web_schemas_update_flags_schema [fillcolor="#d04e24",weight="3"];
@@ -1842,7 +1815,6 @@ digraph G {
ahriman_web_schemas_event_search_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"]; ahriman_web_schemas_event_search_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"];
ahriman_web_schemas_file_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"]; ahriman_web_schemas_file_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"];
ahriman_web_schemas_info_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"]; ahriman_web_schemas_info_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"];
ahriman_web_schemas_info_v2_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"];
ahriman_web_schemas_internal_status_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"]; ahriman_web_schemas_internal_status_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"];
ahriman_web_schemas_log_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"]; ahriman_web_schemas_log_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"];
ahriman_web_schemas_login_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"]; ahriman_web_schemas_login_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"];
@@ -1875,7 +1847,6 @@ digraph G {
ahriman_web_schemas_remote_schema -> ahriman_web_schemas_package_schema [fillcolor="#b44d2d",weight="3"]; ahriman_web_schemas_remote_schema -> ahriman_web_schemas_package_schema [fillcolor="#b44d2d",weight="3"];
ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas [fillcolor="#ef3e06",weight="3"]; ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas [fillcolor="#ef3e06",weight="3"];
ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_info_schema [fillcolor="#ef3e06",weight="3"]; ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_info_schema [fillcolor="#ef3e06",weight="3"];
ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_info_v2_schema [fillcolor="#ef3e06",weight="3"];
ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_internal_status_schema [fillcolor="#ef3e06",weight="3"]; ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_internal_status_schema [fillcolor="#ef3e06",weight="3"];
ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_package_status_schema [fillcolor="#ef3e06",weight="3"]; ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_package_status_schema [fillcolor="#ef3e06",weight="3"];
ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_package_version_schema [fillcolor="#ef3e06",weight="3"]; ahriman_web_schemas_repository_id_schema -> ahriman_web_schemas_package_version_schema [fillcolor="#ef3e06",weight="3"];
@@ -1889,13 +1860,8 @@ digraph G {
ahriman_web_schemas_status_schema -> ahriman_web_schemas_package_status_schema [fillcolor="#ca4116",weight="3"]; ahriman_web_schemas_status_schema -> ahriman_web_schemas_package_status_schema [fillcolor="#ca4116",weight="3"];
ahriman_web_schemas_update_flags_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"]; ahriman_web_schemas_update_flags_schema -> ahriman_web_schemas [fillcolor="blue",weight="3"];
ahriman_web_schemas_worker_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"]; ahriman_web_schemas_worker_schema -> ahriman_web_schemas [fillcolor="#b85a3d",weight="3"];
ahriman_web_server_info -> ahriman_web_views_index [fillcolor="#93371a",minlen="2",weight="2"];
ahriman_web_server_info -> ahriman_web_views_v1_status_info [fillcolor="#93371a",minlen="2",weight="2"];
ahriman_web_server_info -> ahriman_web_views_v2_status_info [fillcolor="#93371a",minlen="2",weight="2"];
ahriman_web_views -> ahriman_web_routes [fillcolor="#f94810",weight="2"]; ahriman_web_views -> ahriman_web_routes [fillcolor="#f94810",weight="2"];
ahriman_web_views -> ahriman_web_server_info [fillcolor="#f94810",weight="2"];
ahriman_web_views_base -> ahriman_web_routes [fillcolor="#952603",minlen="2",weight="2"]; ahriman_web_views_base -> ahriman_web_routes [fillcolor="#952603",minlen="2",weight="2"];
ahriman_web_views_base -> ahriman_web_server_info [fillcolor="#952603",minlen="2",weight="2"];
ahriman_web_views_base -> ahriman_web_views_api_docs [fillcolor="#952603",weight="3"]; ahriman_web_views_base -> ahriman_web_views_api_docs [fillcolor="#952603",weight="3"];
ahriman_web_views_base -> ahriman_web_views_api_swagger [fillcolor="#952603",weight="3"]; ahriman_web_views_base -> ahriman_web_views_api_swagger [fillcolor="#952603",weight="3"];
ahriman_web_views_base -> ahriman_web_views_index [fillcolor="#952603",weight="3"]; ahriman_web_views_base -> ahriman_web_views_index [fillcolor="#952603",weight="3"];
@@ -1927,7 +1893,6 @@ digraph G {
ahriman_web_views_base -> ahriman_web_views_v1_user_login [fillcolor="#952603",weight="3"]; ahriman_web_views_base -> ahriman_web_views_v1_user_login [fillcolor="#952603",weight="3"];
ahriman_web_views_base -> ahriman_web_views_v1_user_logout [fillcolor="#952603",weight="3"]; ahriman_web_views_base -> ahriman_web_views_v1_user_logout [fillcolor="#952603",weight="3"];
ahriman_web_views_base -> ahriman_web_views_v2_packages_logs [fillcolor="#952603",weight="3"]; ahriman_web_views_base -> ahriman_web_views_v2_packages_logs [fillcolor="#952603",weight="3"];
ahriman_web_views_base -> ahriman_web_views_v2_status_info [fillcolor="#952603",weight="3"];
ahriman_web_views_status_view_guard -> ahriman_web_views_v1_packages_changes [fillcolor="#ef3e06",weight="3"]; ahriman_web_views_status_view_guard -> ahriman_web_views_v1_packages_changes [fillcolor="#ef3e06",weight="3"];
ahriman_web_views_status_view_guard -> ahriman_web_views_v1_packages_dependencies [fillcolor="#ef3e06",weight="3"]; ahriman_web_views_status_view_guard -> ahriman_web_views_v1_packages_dependencies [fillcolor="#ef3e06",weight="3"];
ahriman_web_views_status_view_guard -> ahriman_web_views_v1_packages_logs [fillcolor="#ef3e06",weight="3"]; ahriman_web_views_status_view_guard -> ahriman_web_views_v1_packages_logs [fillcolor="#ef3e06",weight="3"];
@@ -1947,11 +1912,9 @@ digraph G {
aiohttp -> ahriman_web_middlewares_auth_handler [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_middlewares_auth_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_middlewares_exception_handler [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_middlewares_exception_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_middlewares_metrics_handler [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_middlewares_metrics_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_middlewares_request_id_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_routes [fillcolor="#f9b506",minlen="2"]; aiohttp -> ahriman_web_routes [fillcolor="#f9b506",minlen="2"];
aiohttp -> ahriman_web_views_api_swagger [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_api_swagger [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_base [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_base [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_index [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_static [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_static [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_v1_auditlog_events [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_v1_auditlog_events [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_v1_distributed_workers [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_v1_distributed_workers [fillcolor="#f9b506",minlen="3"];
@@ -1980,7 +1943,6 @@ digraph G {
aiohttp -> ahriman_web_views_v1_user_login [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_v1_user_login [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_v1_user_logout [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_v1_user_logout [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_v2_packages_logs [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_v2_packages_logs [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_v2_status_info [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_web [fillcolor="#f9b506",minlen="2"]; aiohttp -> ahriman_web_web [fillcolor="#f9b506",minlen="2"];
aiohttp -> aiohttp_cors [fillcolor="#f9b506",minlen="2"]; aiohttp -> aiohttp_cors [fillcolor="#f9b506",minlen="2"];
aiohttp -> aiohttp_jinja2 [fillcolor="#f9b506",minlen="2"]; aiohttp -> aiohttp_jinja2 [fillcolor="#f9b506",minlen="2"];

View File

@@ -12,14 +12,6 @@ ahriman.application.handlers.add module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.archives module
--------------------------------------------
.. automodule:: ahriman.application.handlers.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.backup module ahriman.application.handlers.backup module
------------------------------------------ ------------------------------------------
@@ -84,14 +76,6 @@ ahriman.application.handlers.help module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.hold module
----------------------------------------
.. automodule:: ahriman.application.handlers.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.key\_import module ahriman.application.handlers.key\_import module
----------------------------------------------- -----------------------------------------------
@@ -108,14 +92,6 @@ ahriman.application.handlers.patch module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.pkgbuild module
--------------------------------------------
.. automodule:: ahriman.application.handlers.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rebuild module ahriman.application.handlers.rebuild module
------------------------------------------- -------------------------------------------
@@ -164,14 +140,6 @@ ahriman.application.handlers.restore module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.rollback module
--------------------------------------------
.. automodule:: ahriman.application.handlers.rollback
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.run module ahriman.application.handlers.run module
--------------------------------------- ---------------------------------------

View File

@@ -28,14 +28,6 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.alpm.pacman\_handle module
---------------------------------------
.. automodule:: ahriman.core.alpm.pacman_handle
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pkgbuild\_parser module ahriman.core.alpm.pkgbuild\_parser module
----------------------------------------- -----------------------------------------

View File

@@ -140,22 +140,6 @@ ahriman.core.database.migrations.m016\_archive module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m017\_pkgbuild module
------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m017_pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.database.migrations.m018\_package\_hold module
-----------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m018_package_hold
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -76,14 +76,6 @@ ahriman.core.formatters.patch\_printer module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.formatters.pkgbuild\_printer module
------------------------------------------------
.. automodule:: ahriman.core.formatters.pkgbuild_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.printer module ahriman.core.formatters.printer module
-------------------------------------- --------------------------------------

View File

@@ -28,14 +28,6 @@ ahriman.core.log.lazy\_logging module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.log.log\_context module
------------------------------------
.. automodule:: ahriman.core.log.log_context
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.log.log\_loader module ahriman.core.log.log\_loader module
----------------------------------- -----------------------------------

View File

@@ -12,14 +12,6 @@ ahriman.core.status.client module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.status.event\_bus module
-------------------------------------
.. automodule:: ahriman.core.status.event_bus
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.status.local\_client module ahriman.core.status.local\_client module
---------------------------------------- ----------------------------------------

View File

@@ -12,14 +12,6 @@ ahriman.web.middlewares.auth\_handler module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.middlewares.etag\_handler module
--------------------------------------------
.. automodule:: ahriman.web.middlewares.etag_handler
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.middlewares.exception\_handler module ahriman.web.middlewares.exception\_handler module
------------------------------------------------- -------------------------------------------------
@@ -36,14 +28,6 @@ ahriman.web.middlewares.metrics\_handler module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.middlewares.request\_id\_handler module
---------------------------------------------------
.. automodule:: ahriman.web.middlewares.request_id_handler
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -92,14 +92,6 @@ ahriman.web.schemas.error\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.event\_bus\_filter\_schema module
-----------------------------------------------------
.. automodule:: ahriman.web.schemas.event_bus_filter_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.event\_schema module ahriman.web.schemas.event\_schema module
---------------------------------------- ----------------------------------------
@@ -124,14 +116,6 @@ ahriman.web.schemas.file\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.hold\_schema module
---------------------------------------
.. automodule:: ahriman.web.schemas.hold_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.info\_schema module ahriman.web.schemas.info\_schema module
--------------------------------------- ---------------------------------------
@@ -260,14 +244,6 @@ ahriman.web.schemas.package\_version\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.packager\_schema module
-------------------------------------------
.. automodule:: ahriman.web.schemas.packager_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.pagination\_schema module ahriman.web.schemas.pagination\_schema module
--------------------------------------------- ---------------------------------------------
@@ -348,14 +324,6 @@ ahriman.web.schemas.repository\_stats\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.rollback\_schema module
-------------------------------------------
.. automodule:: ahriman.web.schemas.rollback_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.search\_schema module ahriman.web.schemas.search\_schema module
----------------------------------------- -----------------------------------------
@@ -364,14 +332,6 @@ ahriman.web.schemas.search\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.sse\_schema module
--------------------------------------
.. automodule:: ahriman.web.schemas.sse_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.status\_schema module ahriman.web.schemas.status\_schema module
----------------------------------------- -----------------------------------------

View File

@@ -4,14 +4,6 @@ ahriman.web.views.v1.auditlog package
Submodules Submodules
---------- ----------
ahriman.web.views.v1.auditlog.event\_bus module
-----------------------------------------------
.. automodule:: ahriman.web.views.v1.auditlog.event_bus
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.auditlog.events module ahriman.web.views.v1.auditlog.events module
------------------------------------------- -------------------------------------------

View File

@@ -4,14 +4,6 @@ ahriman.web.views.v1.packages package
Submodules Submodules
---------- ----------
ahriman.web.views.v1.packages.archives module
---------------------------------------------
.. automodule:: ahriman.web.views.v1.packages.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.changes module ahriman.web.views.v1.packages.changes module
-------------------------------------------- --------------------------------------------
@@ -28,14 +20,6 @@ ahriman.web.views.v1.packages.dependencies module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.views.v1.packages.hold module
-----------------------------------------
.. automodule:: ahriman.web.views.v1.packages.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.logs module ahriman.web.views.v1.packages.logs module
----------------------------------------- -----------------------------------------

View File

@@ -68,14 +68,6 @@ ahriman.web.views.v1.service.request module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.views.v1.service.rollback module
--------------------------------------------
.. automodule:: ahriman.web.views.v1.service.rollback
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.service.search module ahriman.web.views.v1.service.search module
------------------------------------------ ------------------------------------------

View File

@@ -9,8 +9,7 @@ Packages have strict rules of importing:
* ``ahriman.application`` package must not be used outside of this package. * ``ahriman.application`` package must not be used outside of this package.
* ``ahriman.core`` and ``ahriman.models`` packages don't have any import restriction. Actually we would like to totally restrict importing of ``core`` package from ``models``, but it is impossible at the moment. * ``ahriman.core`` and ``ahriman.models`` packages don't have any import restriction. Actually we would like to totally restrict importing of ``core`` package from ``models``, but it is impossible at the moment.
* ``ahriman.web`` package is allowed to be imported from ``ahriman.application`` (web handler only, only ``ahriman.web.web`` methods). * ``ahriman.web`` package is allowed to be imported from ``ahriman.application`` (web handler only, only ``ahriman.web.web`` methods).
* The idea remains the same for all imports, if an package requires some specific dependencies, it must be imported locally to keep dependencies optional.
The idea remains the same for all imports, if a package requires some specific dependencies, it must be imported locally to keep dependencies optional.
Full dependency diagram: Full dependency diagram:
@@ -43,7 +42,7 @@ This package contains everything required for the most of application actions an
* ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly. * ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly.
* ``ahriman.core.housekeeping`` package provides few triggers for removing old data. * ``ahriman.core.housekeeping`` package provides few triggers for removing old data.
* ``ahriman.core.http`` package provides HTTP clients which can be used later by other classes. * ``ahriman.core.http`` package provides HTTP clients which can be used later by other classes.
* ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger, log context for injecting context variables into log records and some wrappers. * ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger and some wrappers.
* ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly. * ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly.
* ``ahriman.core.repository`` contains several traits and base repository (``ahriman.core.repository.Repository`` class) implementation. * ``ahriman.core.repository`` contains several traits and base repository (``ahriman.core.repository.Repository`` class) implementation.
* ``ahriman.core.sign`` package provides sign feature (only gpg calls are available). * ``ahriman.core.sign`` package provides sign feature (only gpg calls are available).
@@ -86,7 +85,6 @@ Application run
#. Call ``Handler.execute`` method. #. Call ``Handler.execute`` method.
#. Define list of architectures to run. In case if there is more than one architecture specified run several subprocesses or continue in current process otherwise. Class attribute ``ALLOW_MULTI_ARCHITECTURE_RUN`` controls whether the application can be run in multiple processes or not - this feature is required for some handlers (e.g. ``Config``, which utilizes stdout to print messages). #. Define list of architectures to run. In case if there is more than one architecture specified run several subprocesses or continue in current process otherwise. Class attribute ``ALLOW_MULTI_ARCHITECTURE_RUN`` controls whether the application can be run in multiple processes or not - this feature is required for some handlers (e.g. ``Config``, which utilizes stdout to print messages).
#. In each child process call lock functions. #. In each child process call lock functions.
#. Load configuration and install logging.
#. After success checks pass control to ``Handler.run`` method defined by specific handler class. #. After success checks pass control to ``Handler.run`` method defined by specific handler class.
#. Return result (success or failure) of each subprocess and exit from application. #. Return result (success or failure) of each subprocess and exit from application.
#. Some handlers may override their status and throw ``ExitCode`` exception. This exception is just silently suppressed and changes application exit code to ``1``. #. Some handlers may override their status and throw ``ExitCode`` exception. This exception is just silently suppressed and changes application exit code to ``1``.
@@ -161,12 +159,12 @@ Having default root as ``/var/lib/ahriman`` (differs from container though), the
├── aur.files -> aur.files.tar.gz ├── aur.files -> aur.files.tar.gz
└── aur.files.tar.gz └── aur.files.tar.gz
There are multiple subdirectories, some of them are common for any repository, but some of them are not. There are multiple subdirectories, some of them are commons for any repository, but some of them are not.
* ``archive`` is the package archive directory. It is common for all repositories and architectures and contains two subdirectories: * ``archive`` is the package archive directory. It is common for all repositories and architectures and contains two subdirectories:
* ``archive/packages/{first_letter}/{package_base}`` stores the actual built package files and their signatures. * ``archive/packages/{first_letter}/{package_base}`` stores the actual built package files and their signatures.
* ``archive/repos/{YYYY}/{MM}/{DD}/{repository}/{architecture}`` contains daily repository snapshots. Each snapshot is a repository database with symlinks pointing to the corresponding packages in the ``archive/packages`` tree. These directories only appear if ``ahriman.core.archive.ArchiveTrigger`` is enabled. * ``archive/repos/{YYYY}/{MM}/{DD}/{repository}/{architecture}`` contains daily repository snapshots. Each snapshot is a repository database with symlinks pointing to the corresponding packages in the ``archive/packages`` tree.
The archive also allows the build process to skip rebuilding a package if a matching version already exists. The archive also allows the build process to skip rebuilding a package if a matching version already exists.
@@ -235,27 +233,15 @@ Remove packages
This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods. This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods.
Rollback packages
^^^^^^^^^^^^^^^^^
This flow restores a package to a previously built version:
#. Load the current package definition from the repository database.
#. Replace its version with the requested rollback target.
#. Search the archive directory for built artifacts (packages and signatures) matching the target version.
#. Add the found artifacts to the repository via the same path as ``package-add`` with ``PackageSource.Archive``.
#. Trigger an immediate update to process the added packages.
#. If ``--hold`` is enabled (the default), mark the package as held in the database to prevent automatic updates from overriding the rollback.
Check outdated packages Check outdated packages
^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
There are few ways for packages to be marked as out-of-date and hence requiring rebuild. Those are following: There are few ways for packages to be marked as out-of-date and hence requiring rebuild. Those are following:
#. User requested update of the package. It can be caused by calling ``package-add`` subcommand (or ``package-update`` with arguments). #. User requested update of the package. It can be caused by calling ``package-add`` subcommand (or ``package-update`` with arguments).
#. The most common way for packages to be marked as out-of-date is that the version in AUR (or the official repositories) is newer than in the repository. #. The most common way for packages to be marked as out-of-dated is that the version in AUR (or the official repositories) is newer than in the repository.
#. In addition to the above, if package is named as VCS (e.g. has suffix ``-git``) and the last update was more than specified threshold ago, the service will also try to fetch sources and check if the revision is newer than the built one. #. In addition to the above, if package is named as VCS (e.g. has suffix ``-git``) and the last update was more than specified threshold ago, the service will also try to fetch sources and check if the revision is newer than the built one.
#. In addition, there is ability to check if the dependencies of the package have been updated (e.g. if linked library has been renamed or the modules directory - e.g. in case of python and ruby packages - has been changed). And if so, the package will be marked as out-of-date as well. #. In addition, there is ability to check if the dependencies of the package have been updated (e.g. if linked library has been renamed or the modules directory - e.g. in case of python and ruby packages - has been changed). And if so, the package will be marked as out-of-dated as well.
Update packages Update packages
^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
@@ -269,7 +255,6 @@ This feature is divided into the following stages: check AUR for updates and run
#. Download package data from AUR. #. Download package data from AUR.
#. Bump ``pkgrel`` if there is duplicate version in the local repository (see explanation below). #. Bump ``pkgrel`` if there is duplicate version in the local repository (see explanation below).
#. Check if there is already built package of the same version in archive (cross-repository support). If so, then just copy built archives and skip steps below.
#. Build every package in clean chroot. #. Build every package in clean chroot.
#. Sign packages if required. #. Sign packages if required.
#. Add packages to database and sign database if required. #. Add packages to database and sign database if required.
@@ -328,7 +313,7 @@ Having the initial dependencies tree, the application is looking for packages wh
Those paths are also filtered by regular expressions set in the configuration. Those paths are also filtered by regular expressions set in the configuration.
All those implicit dependencies are stored in the database and extracted on each check. In case if any of the repository packages doesn't contain any entry anymore (e.g. so version has been changed or modules directory has been changed), the dependent package will be marked as out-of-date. All those implicit dependencies are stored in the database and extracted on each check. In case if any of the repository packages doesn't contain any entry anymore (e.g. so version has been changed or modules directory has been changed), the dependent package will be marked as out-of-dated.
Core functions reference Core functions reference
------------------------ ------------------------
@@ -359,8 +344,6 @@ The ``_Context`` class itself mimics default collection interface (as is ``Mappi
In order to provide statically typed interface, the ``ahriman.models.context_key.ContextKey`` class is used for both ``_Content.get`` and ``_Content.set`` methods; the context instance itself, however, does not store information about types. In order to provide statically typed interface, the ``ahriman.models.context_key.ContextKey`` class is used for both ``_Content.get`` and ``_Content.set`` methods; the context instance itself, however, does not store information about types.
Logging module has its own context variables, which are required to be registered in advance to avoid possible race conditions.
Submodules Submodules
^^^^^^^^^^ ^^^^^^^^^^
@@ -387,7 +370,7 @@ Passwords must be stored in database as ``hash(password + salt)``, where ``passw
means that there is user ``username`` with ``read`` access and password ``password`` hashed by ``sha512`` with salt ``salt``. means that there is user ``username`` with ``read`` access and password ``password`` hashed by ``sha512`` with salt ``salt``.
OAuth provider uses library definitions (``aioauth-client``) in order to *authenticate* users. It still requires user permission to be set in database, thus it inherits mapping provider without any changes. Whereas we could override ``check_credentials`` (authentication method) by something custom, OAuth flow is a bit more complex than just forward request, thus we have to implement the flow in login form. OAuth provider uses library definitions (``aioauth-client``) in order *authenticate* users. It still requires user permission to be set in database, thus it inherits mapping provider without any changes. Whereas we could override ``check_credentials`` (authentication method) by something custom, OAuth flow is a bit more complex than just forward request, thus we have to implement the flow in login form.
OAuth's implementation also allows authenticating users via username + password (in the same way as mapping does) though it is not recommended for end-users and password must be left blank. In particular this feature can be used by service reporting (aka robots). OAuth's implementation also allows authenticating users via username + password (in the same way as mapping does) though it is not recommended for end-users and password must be left blank. In particular this feature can be used by service reporting (aka robots).
@@ -400,7 +383,7 @@ Triggers
Triggers are extensions which can be used in order to perform any actions on application start, after the update process and, finally, before the application exit. Triggers are extensions which can be used in order to perform any actions on application start, after the update process and, finally, before the application exit.
The main idea is to load classes by their full path (e.g. ``ahriman.core.upload.UploadTrigger``) by using ``importlib``: get the last part of the import and treat it as class name, join the remaining part by ``.`` and interpret as module path, import module and extract attribute from it. The main idea is to load classes by their full path (e.g. ``ahriman.core.upload.UploadTrigger``) by using ``importlib``: get the last part of the import and treat it as class name, join remain part by ``.`` and interpret as module path, import module and extract attribute from it.
The loaded triggers will be called with ``ahriman.models.result.Result`` and ``list[Packages]`` arguments, which describes the process result and current repository packages respectively. Any exception raised will be suppressed and will generate an exception message in logs. The loaded triggers will be called with ``ahriman.models.result.Result`` and ``list[Packages]`` arguments, which describes the process result and current repository packages respectively. Any exception raised will be suppressed and will generate an exception message in logs.
@@ -424,7 +407,7 @@ PKGBUILD parsing
The application provides a house-made shell parser ``ahriman.core.alpm.pkgbuild_parser.PkgbuildParser`` to process PKGBUILDs and extract package data from them. It relies on the ``shlex.shlex`` parser with some configuration tweaks and adds some token post-processing. The application provides a house-made shell parser ``ahriman.core.alpm.pkgbuild_parser.PkgbuildParser`` to process PKGBUILDs and extract package data from them. It relies on the ``shlex.shlex`` parser with some configuration tweaks and adds some token post-processing.
#. During the parser process, firstly, it extracts the next token from the source file (basically, the word) and tries to match it to the variable assignment. If so, then just processes accordingly. #. During the parser process, firstly, it extract next token from the source file (basically, the word) and tries to match it to the variable assignment. If so, then just processes accordingly.
#. If it is not an assignment, the parser checks if the token was quoted. #. If it is not an assignment, the parser checks if the token was quoted.
#. If it wasn't quoted then the parser tries to match the array starts (two consecutive tokens like ``array=`` and ``(``) or it is function (``function``, ``()`` and ``{``). #. If it wasn't quoted then the parser tries to match the array starts (two consecutive tokens like ``array=`` and ``(``) or it is function (``function``, ``()`` and ``{``).
#. The arrays are processed until the next closing bracket ``)``. After extraction, the parser tries to expand an array according to bash rules (``prefix{first,second}suffix`` constructions). #. The arrays are processed until the next closing bracket ``)``. After extraction, the parser tries to expand an array according to bash rules (``prefix{first,second}suffix`` constructions).
@@ -437,15 +420,6 @@ The PKGBUILD class also provides some additional functions on top of that:
* Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multi-packages. * Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multi-packages.
* Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``). * Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``).
HTTP client
^^^^^^^^^^^
The ``ahriman.core.http`` package provides a HTTP client built on top of the ``requests`` library.
The base class ``ahriman.core.http.SyncHttpClient`` wraps ``requests.Session`` and provides common features for all HTTP interactions: configurable timeouts, retry policies with exponential backoff (using ``urllib3.util.retry.Retry``), basic authentication, custom User-Agent header, error processing, and ``make_request`` method. The session is lazily created (via ``cached_property``).
On top of that, ``ahriman.core.http.SyncAhrimanClient`` extends the base client for communication with the ahriman web service specifically. It adds automatic login on session creation (using configured credentials), ``X-Request-ID`` header injection and Unix socket transport support (via ``requests-unixsocket2``) if required.
Additional features Additional features
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
@@ -471,7 +445,7 @@ Web application requires the following python packages to be installed:
Middlewares Middlewares
^^^^^^^^^^^ ^^^^^^^^^^^
Service provides some custom middlewares, e.g. logging every exception (except for user ones), user authorization and request tracing via ``X-Request-ID`` header. Service provides some custom middlewares, e.g. logging every exception (except for user ones) and user authorization.
HEAD and OPTIONS requests HEAD and OPTIONS requests
^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -483,7 +457,7 @@ On the other side, ``OPTIONS`` method is implemented in the ``ahriman.web.middle
Web views Web views
^^^^^^^^^ ^^^^^^^^^
All web views are defined in a separate package and derived from ``ahriman.web.views.base.Base`` class which provides typed interfaces for web application. All web views are defined in separated package and derived from ``ahriman.web.views.base.Base`` class which provides typed interfaces for web application.
REST API supports only JSON data. REST API supports only JSON data.
@@ -502,7 +476,7 @@ The views are also divided by supporting API versions (e.g. ``v1``, ``v2``).
Templating Templating
^^^^^^^^^^ ^^^^^^^^^^
Package provides base jinja templates which can be overridden by settings. The default web interface is a React application. The classic bootstrap-based template is still available as ``build-status-classic.jinja2`` and can be enabled via the ``web.template`` configuration option. Package provides base jinja templates which can be overridden by settings. Vanilla templates actively use bootstrap library.
Requests and scopes Requests and scopes
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^

View File

@@ -180,15 +180,10 @@ 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. If first element of the list equals ``0``, auto refresh will be disabled by default. * ``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.
* ``cors_allow_headers`` - allowed CORS headers, space separated list of strings, optional.
* ``cors_allow_methods`` - allowed CORS methods, space separated list of strings, optional.
* ``cors_allow_origins`` - allowed CORS origins, space separated list of strings, optional, default ``*``.
* ``cors_expose_headers`` - exposed CORS headers, space separated list of strings, optional.
* ``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.
* ``max_body_size`` - max body size in bytes to be validated for archive upload, integer, optional. If not set, validation will be disabled. * ``max_body_size`` - max body size in bytes to be validated for archive upload, integer, optional. If not set, validation will be disabled.
* ``max_queue_size`` - max queue size for server sent event streams, integer, optional, default ``0``. If set to ``0``, queue is unlimited.
* ``port`` - port to bind, integer, optional. * ``port`` - port to bind, integer, optional.
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``. * ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
* ``static_path`` - path to directory with static files, string, required. * ``static_path`` - path to directory with static files, string, required.
@@ -196,7 +191,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``templates`` - path to templates directories, space separated list of paths, required. * ``templates`` - path to templates directories, space separated list of paths, required.
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization. * ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration. * ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.
* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional. If set to ``0``, wait infinitely. * ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional.
``archive`` group ``archive`` group
----------------- -----------------

View File

@@ -33,28 +33,3 @@ The service provides several commands aim to do easy repository backup and resto
.. code-block:: shell .. code-block:: shell
sudo -u ahriman ahriman repo-rebuild --from-database sudo -u ahriman ahriman repo-rebuild --from-database
Package rollback
================
If the ``archive.keep_built_packages`` option is enabled, the service keeps previously built package files in the archive directory. These archives can be used to rollback a package to a previous successfully built version.
#.
List available archive versions for a package:
.. code-block:: shell
ahriman package-archives ahriman
#.
Rollback the package to the desired version:
.. code-block:: shell
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1
By default, the ``--hold`` flag is enabled, which prevents the package from being automatically updated on subsequent ``repo-update`` runs. To rollback without holding the package use:
.. code-block:: shell
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1 --no-hold

View File

@@ -7,13 +7,10 @@ aiohttp==3.11.18
# ahriman (pyproject.toml) # ahriman (pyproject.toml)
# aiohttp-cors # aiohttp-cors
# aiohttp-jinja2 # aiohttp-jinja2
# aiohttp-sse
aiohttp-cors==0.8.1 aiohttp-cors==0.8.1
# via ahriman (pyproject.toml) # via ahriman (pyproject.toml)
aiohttp-jinja2==1.6 aiohttp-jinja2==1.6
# via ahriman (pyproject.toml) # via ahriman (pyproject.toml)
aiohttp-sse==2.2.0
# via ahriman (pyproject.toml)
aiosignal==1.3.2 aiosignal==1.3.2
# via aiohttp # via aiohttp
alabaster==1.0.0 alabaster==1.0.0

View File

@@ -1,6 +1,5 @@
import js from "@eslint/js"; import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin"; import stylistic from "@stylistic/eslint-plugin";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from "eslint-plugin-react-refresh";
import simpleImportSort from "eslint-plugin-simple-import-sort"; import simpleImportSort from "eslint-plugin-simple-import-sort";
@@ -9,11 +8,7 @@ import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist"] }, { ignores: ["dist"] },
{ {
extends: [ extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
js.configs.recommended,
react.configs.flat.recommended,
...tseslint.configs.recommendedTypeChecked,
],
files: ["src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}"],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
@@ -22,37 +17,31 @@ export default tseslint.config(
}, },
}, },
plugins: { plugins: {
"@stylistic": stylistic,
"react-hooks": reactHooks, "react-hooks": reactHooks,
"react-refresh": reactRefresh, "react-refresh": reactRefresh,
"simple-import-sort": simpleImportSort, "simple-import-sort": simpleImportSort,
"@stylistic": stylistic,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// imports // imports
"simple-import-sort/exports": "error",
"simple-import-sort/imports": "error", "simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
// core // brackets
"curly": "error", "curly": "error",
"eqeqeq": "error", "@stylistic/brace-style": ["error", "1tbs"],
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-eval": "error",
// stylistic // stylistic
"@stylistic/array-bracket-spacing": ["error", "never"], "@stylistic/array-bracket-spacing": ["error", "never"],
"@stylistic/arrow-parens": ["error", "as-needed"], "@stylistic/arrow-parens": ["error", "as-needed"],
"@stylistic/brace-style": ["error", "1tbs"],
"@stylistic/comma-dangle": ["error", "always-multiline"], "@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/comma-spacing": ["error", { before: false, after: true }], "@stylistic/comma-spacing": ["error", { before: false, after: true }],
"@stylistic/eol-last": ["error", "always"], "@stylistic/eol-last": ["error", "always"],
"@stylistic/indent": ["error", 4], "@stylistic/indent": ["error", 4],
"@stylistic/jsx-curly-brace-presence": ["error", { props: "never", children: "never" }],
"@stylistic/jsx-quotes": ["error", "prefer-double"], "@stylistic/jsx-quotes": ["error", "prefer-double"],
"@stylistic/jsx-self-closing-comp": ["error", { component: true, html: true }],
"@stylistic/max-len": ["error", { "@stylistic/max-len": ["error", {
code: 120, code: 120,
ignoreComments: true, ignoreComments: true,
@@ -60,7 +49,6 @@ export default tseslint.config(
ignoreTemplateLiterals: true, ignoreTemplateLiterals: true,
ignoreUrls: true, ignoreUrls: true,
}], }],
"@stylistic/member-delimiter-style": ["error", { multiline: { delimiter: "semi" }, singleline: { delimiter: "semi" } }],
"@stylistic/no-extra-parens": ["error", "all"], "@stylistic/no-extra-parens": ["error", "all"],
"@stylistic/no-multi-spaces": "error", "@stylistic/no-multi-spaces": "error",
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }], "@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
@@ -70,15 +58,10 @@ export default tseslint.config(
"@stylistic/semi": ["error", "always"], "@stylistic/semi": ["error", "always"],
// typescript // typescript
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }], "@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error", "@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
); );

View File

@@ -1,36 +1,8 @@
{ {
"dependencies": {
"@emotion/react": ">=11.14.0 <11.15.0",
"@emotion/styled": ">=11.14.0 <11.15.0",
"@mui/icons-material": ">=7.3.0 <7.4.0",
"@mui/material": ">=7.3.0 <7.4.0",
"@mui/x-data-grid": ">=8.28.0 <8.29.0",
"@tanstack/react-query": ">=5.94.0 <5.95.0",
"chart.js": ">=4.5.0 <4.6.0",
"react": ">=19.2.0 <19.3.0",
"react-chartjs-2": ">=5.3.0 <5.4.0",
"react-dom": ">=19.2.0 <19.3.0",
"react-error-boundary": ">=6.1.0 <6.2.0",
"react-syntax-highlighter": ">=16.1.0 <16.2.0"
},
"devDependencies": {
"@eslint/js": ">=9.39.0 <9.40.0",
"@stylistic/eslint-plugin": ">=5.10.0 <5.11.0",
"@types/react": ">=19.2.0 <19.3.0",
"@types/react-dom": ">=19.2.0 <19.3.0",
"@types/react-syntax-highlighter": ">=15.5.0 <15.6.0",
"@vitejs/plugin-react": ">=6.0.0 <6.1.0",
"eslint": ">=9.39.0 <9.40.0",
"eslint-plugin-react": ">=7.37.0 <7.38.0",
"eslint-plugin-react-hooks": ">=7.0.0 <7.1.0",
"eslint-plugin-react-refresh": ">=0.5.0 <0.6.0",
"eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0",
"typescript": ">=5.9.0 <5.10.0",
"typescript-eslint": ">=8.57.0 <8.58.0",
"vite": ">=8.0.0 <8.1.0"
},
"name": "ahriman-frontend", "name": "ahriman-frontend",
"private": true, "private": true,
"type": "module",
"version": "2.20.0-rc4",
"scripts": { "scripts": {
"build": "tsc && vite build", "build": "tsc && vite build",
"dev": "vite", "dev": "vite",
@@ -38,6 +10,32 @@
"lint:fix": "eslint --fix src/", "lint:fix": "eslint --fix src/",
"preview": "vite preview" "preview": "vite preview"
}, },
"type": "module", "dependencies": {
"version": "2.20.0" "@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"@mui/x-data-grid": "^8.27.3",
"@tanstack/react-query": "^5.0.0",
"chart.js": "^4.5.0",
"highlight.js": "^11.11.0",
"react": "^19.2.4",
"react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^5.3.0",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1"
}
} }

View File

@@ -17,36 +17,41 @@
* 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/>.
*/ */
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppLayout from "components/layout/AppLayout"; import AppLayout from "components/layout/AppLayout";
import { AuthProvider } from "contexts/AuthProvider"; import { AuthProvider } from "contexts/AuthProvider";
import { ClientProvider } from "contexts/ClientProvider"; import { ClientProvider } from "contexts/ClientProvider";
import { NotificationProvider } from "contexts/NotificationProvider"; import { NotificationProvider } from "contexts/NotificationProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider"; import { RepositoryProvider } from "contexts/RepositoryProvider";
import { ThemeProvider } from "contexts/ThemeProvider";
import type React from "react"; import type React from "react";
import Theme from "theme/Theme";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: 1,
staleTime: 30_000, staleTime: 30_000,
retry: 1,
}, },
}, },
}); });
export default function App(): React.JSX.Element { export default function App(): React.JSX.Element {
return <QueryClientProvider client={queryClient}> return (
<ThemeProvider> <QueryClientProvider client={queryClient}>
<NotificationProvider> <ThemeProvider theme={Theme}>
<CssBaseline />
<ClientProvider> <ClientProvider>
<AuthProvider> <AuthProvider>
<RepositoryProvider> <RepositoryProvider>
<AppLayout /> <NotificationProvider>
<AppLayout />
</NotificationProvider>
</RepositoryProvider> </RepositoryProvider>
</AuthProvider> </AuthProvider>
</ClientProvider> </ClientProvider>
</NotificationProvider> </ThemeProvider>
</ThemeProvider> </QueryClientProvider>
</QueryClientProvider>; );
} }

View File

@@ -17,15 +17,14 @@
* 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/>.
*/ */
import { Client } from "api/client/Client"; import { BaseClient } from "api/client/BaseClient";
import { FetchClient } from "api/client/FetchClient"; import { FetchMixin } from "api/client/FetchMixin";
import { ServiceClient } from "api/client/ServiceClient"; import { ServiceMixin } from "api/client/ServiceMixin";
import type { LoginRequest } from "models/LoginRequest"; import type { LoginRequest } from "models/LoginRequest";
import { applyMixins } from "utils";
export class AhrimanClient extends Client { /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
export class AhrimanClient extends BaseClient {
readonly fetch = new FetchClient(this);
readonly service = new ServiceClient(this);
async login(data: LoginRequest): Promise<void> { async login(data: LoginRequest): Promise<void> {
return this.request("/api/v1/login", { method: "POST", json: data }); return this.request("/api/v1/login", { method: "POST", json: data });
@@ -35,3 +34,7 @@ export class AhrimanClient extends Client {
return this.request("/api/v1/logout", { method: "POST" }); return this.request("/api/v1/logout", { method: "POST" });
} }
} }
export interface AhrimanClient extends FetchMixin, ServiceMixin {}
/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */
applyMixins(AhrimanClient, [FetchMixin, ServiceMixin]);

View File

@@ -18,10 +18,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export class ApiError extends Error { export class ApiError extends Error {
body: string;
status: number; status: number;
statusText: string; statusText: string;
body: string;
constructor(status: number, statusText: string, body: string) { constructor(status: number, statusText: string, body: string) {
super(`${status} ${statusText}`); super(`${status} ${statusText}`);

View File

@@ -20,12 +20,10 @@
import { ApiError } from "api/client/ApiError"; import { ApiError } from "api/client/ApiError";
import type { RequestOptions } from "api/client/RequestOptions"; import type { RequestOptions } from "api/client/RequestOptions";
export class Client { export class BaseClient {
private static readonly DEFAULT_TIMEOUT = 30_000; protected async request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const { method, query, json } = options;
async request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const { method, query, json, timeout = Client.DEFAULT_TIMEOUT } = options;
let fullUrl = url; let fullUrl = url;
if (query) { if (query) {
@@ -40,41 +38,35 @@ export class Client {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/json", Accept: "application/json",
"X-Request-ID": crypto.randomUUID?.() ?? Date.now().toString(),
}; };
if (json !== undefined) { if (json !== undefined) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const requestInit: RequestInit = { const requestInit: RequestInit = {
method: method ?? (json ? "POST" : "GET"), method: method || (json ? "POST" : "GET"),
headers, headers,
signal: controller.signal,
}; };
if (json !== undefined) { if (json !== undefined) {
requestInit.body = JSON.stringify(json); requestInit.body = JSON.stringify(json);
} }
let response: Response; const response = await fetch(fullUrl, requestInit);
try {
response = await fetch(fullUrl, requestInit);
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) { if (!response.ok) {
const body = await response.text(); const body = await response.text();
throw new ApiError(response.status, response.statusText, body); throw new ApiError(response.status, response.statusText, body);
} }
const contentType = response.headers.get("Content-Type") ?? ""; if (response.redirected) {
if (!contentType.includes("application/json")) {
return undefined as T; return undefined as T;
} }
return await response.json() as T;
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/json")) {
return await response.json() as T;
}
return await response.text() as T;
} }
} }

View File

@@ -17,46 +17,33 @@
* 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/>.
*/ */
import type { Client } from "api/client/Client"; import { BaseClient } from "api/client/BaseClient";
import type { Changes } from "models/Changes"; import type { Changes } from "models/Changes";
import type { Dependencies } from "models/Dependencies"; import type { Dependencies } from "models/Dependencies";
import type { Event } from "models/Event"; import type { Event } from "models/Event";
import type { InfoResponse } from "models/InfoResponse"; import type { InfoResponse } from "models/InfoResponse";
import type { InternalStatus } from "models/InternalStatus"; import type { InternalStatus } from "models/InternalStatus";
import type { LogRecord } from "models/LogRecord"; import type { LogRecord } from "models/LogRecord";
import type { Package } from "models/Package";
import type { PackageStatus } from "models/PackageStatus"; import type { PackageStatus } from "models/PackageStatus";
import type { Patch } from "models/Patch"; import type { Patch } from "models/Patch";
import { RepositoryId } from "models/RepositoryId"; import { RepositoryId } from "models/RepositoryId";
export class FetchClient { export class FetchMixin extends BaseClient {
protected client: Client;
constructor(client: Client) {
this.client = client;
}
async fetchPackage(packageBase: string, repository: RepositoryId): Promise<PackageStatus[]> { async fetchPackage(packageBase: string, repository: RepositoryId): Promise<PackageStatus[]> {
return this.client.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}`, { return this.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}`, {
query: repository.toQuery(),
});
}
async fetchPackageArtifacts(packageBase: string, repository: RepositoryId): Promise<Package[]> {
return this.client.request<Package[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/archives`, {
query: repository.toQuery(), query: repository.toQuery(),
}); });
} }
async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise<Changes> { async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise<Changes> {
return this.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, { return this.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
query: repository.toQuery(), query: repository.toQuery(),
}); });
} }
async fetchPackageDependencies(packageBase: string, repository: RepositoryId): Promise<Dependencies> { async fetchPackageDependencies(packageBase: string, repository: RepositoryId): Promise<Dependencies> {
return this.client.request<Dependencies>(`/api/v1/packages/${encodeURIComponent(packageBase)}/dependencies`, { return this.request<Dependencies>(`/api/v1/packages/${encodeURIComponent(packageBase)}/dependencies`, {
query: repository.toQuery(), query: repository.toQuery(),
}); });
} }
@@ -69,7 +56,7 @@ export class FetchClient {
if (limit) { if (limit) {
query.limit = limit; query.limit = limit;
} }
return this.client.request<Event[]>("/api/v1/events", { query }); return this.request<Event[]>("/api/v1/events", { query });
} }
async fetchPackageLogs( async fetchPackageLogs(
@@ -89,28 +76,26 @@ export class FetchClient {
if (head) { if (head) {
query.head = true; query.head = true;
} }
return this.client.request<LogRecord[]>(`/api/v2/packages/${encodeURIComponent(packageBase)}/logs`, { query }); return this.request<LogRecord[]>(`/api/v2/packages/${encodeURIComponent(packageBase)}/logs`, { query });
} }
async fetchPackagePatches(packageBase: string): Promise<Patch[]> { async fetchPackagePatches(packageBase: string): Promise<Patch[]> {
return this.client.request<Patch[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches`); return this.request<Patch[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches`);
} }
async fetchPackages(repository: RepositoryId): Promise<PackageStatus[]> { async fetchPackages(repository: RepositoryId): Promise<PackageStatus[]> {
return this.client.request<PackageStatus[]>("/api/v1/packages", { query: repository.toQuery() }); return this.request<PackageStatus[]>("/api/v1/packages", { query: repository.toQuery() });
} }
async fetchServerInfo(): Promise<InfoResponse> { async fetchServerInfo(): Promise<InfoResponse> {
const info = await this.client.request<InfoResponse>("/api/v2/info"); const info = await this.request<InfoResponse>("/api/v2/info");
return { info.repositories = info.repositories.map(repositories =>
...info, new RepositoryId(repositories.architecture, repositories.repository),
repositories: info.repositories.map(repo => );
new RepositoryId(repo.architecture, repo.repository), return info;
),
};
} }
async fetchServerStatus(repository: RepositoryId): Promise<InternalStatus> { async fetchServerStatus(repository: RepositoryId): Promise<InternalStatus> {
return this.client.request<InternalStatus>("/api/v1/status", { query: repository.toQuery() }); return this.request<InternalStatus>("/api/v1/status", { query: repository.toQuery() });
} }
} }

View File

@@ -18,8 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export interface RequestOptions { export interface RequestOptions {
json?: unknown;
method?: string; method?: string;
query?: Record<string, string | number | boolean>; query?: Record<string, string | number | boolean>;
timeout?: number; json?: unknown;
} }

View File

@@ -17,42 +17,27 @@
* 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/>.
*/ */
import type { Client } from "api/client/Client"; import { BaseClient } from "api/client/BaseClient";
import type { AURPackage } from "models/AURPackage"; import type { AURPackage } from "models/AURPackage";
import type { PackageActionRequest } from "models/PackageActionRequest"; import type { PackageActionRequest } from "models/PackageActionRequest";
import type { PGPKey } from "models/PGPKey"; import type { PGPKey } from "models/PGPKey";
import type { PGPKeyRequest } from "models/PGPKeyRequest"; import type { PGPKeyRequest } from "models/PGPKeyRequest";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import type { RollbackRequest } from "models/RollbackRequest";
export class ServiceClient { export class ServiceMixin extends BaseClient {
protected client: Client;
constructor(client: Client) {
this.client = client;
}
async servicePackageAdd(repository: RepositoryId, data: PackageActionRequest): Promise<void> { async servicePackageAdd(repository: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data }); return this.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data });
}
async servicePackageHold(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
method: "POST",
query: repository.toQuery(),
json: { is_held: isHeld },
});
} }
async servicePackagePatchRemove(packageBase: string, key: string): Promise<void> { async servicePackagePatchRemove(packageBase: string, key: string): Promise<void> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, { return this.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
method: "DELETE", method: "DELETE",
}); });
} }
async servicePackageRemove(repository: RepositoryId, packages: string[]): Promise<void> { async servicePackageRemove(repository: RepositoryId, packages: string[]): Promise<void> {
return this.client.request("/api/v1/service/remove", { return this.request("/api/v1/service/remove", {
method: "POST", method: "POST",
query: repository.toQuery(), query: repository.toQuery(),
json: { packages }, json: { packages },
@@ -60,15 +45,7 @@ export class ServiceClient {
} }
async servicePackageRequest(repository: RepositoryId, data: PackageActionRequest): Promise<void> { async servicePackageRequest(repository: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.client.request("/api/v1/service/request", { return this.request("/api/v1/service/request", {
method: "POST",
query: repository.toQuery(),
json: data,
});
}
async servicePackageRollback(repository: RepositoryId, data: RollbackRequest): Promise<void> {
return this.client.request("/api/v1/service/rollback", {
method: "POST", method: "POST",
query: repository.toQuery(), query: repository.toQuery(),
json: data, json: data,
@@ -76,11 +53,11 @@ export class ServiceClient {
} }
async servicePackageSearch(query: string): Promise<AURPackage[]> { async servicePackageSearch(query: string): Promise<AURPackage[]> {
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } }); return this.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
} }
async servicePackageUpdate(repository: RepositoryId, data: PackageActionRequest): Promise<void> { async servicePackageUpdate(repository: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.client.request("/api/v1/service/update", { return this.request("/api/v1/service/update", {
method: "POST", method: "POST",
query: repository.toQuery(), query: repository.toQuery(),
json: data, json: data,
@@ -88,15 +65,15 @@ export class ServiceClient {
} }
async servicePGPFetch(key: string, server: string): Promise<PGPKey> { async servicePGPFetch(key: string, server: string): Promise<PGPKey> {
return this.client.request<PGPKey>("/api/v1/service/pgp", { query: { key, server } }); return this.request<PGPKey>("/api/v1/service/pgp", { query: { key, server } });
} }
async servicePGPImport(data: PGPKeyRequest): Promise<void> { async servicePGPImport(data: PGPKeyRequest): Promise<void> {
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data }); return this.request("/api/v1/service/pgp", { method: "POST", json: data });
} }
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> { async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
return this.client.request("/api/v1/service/rebuild", { return this.request("/api/v1/service/rebuild", {
method: "POST", method: "POST",
query: repository.toQuery(), query: repository.toQuery(),
json: { packages }, json: { packages },

View File

@@ -17,7 +17,6 @@
* 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/>.
*/ */
import { blue } from "@mui/material/colors";
import type { Event } from "models/Event"; import type { Event } from "models/Event";
import type React from "react"; import type React from "react";
import { Line } from "react-chartjs-2"; import { Line } from "react-chartjs-2";
@@ -32,11 +31,9 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()), labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
datasets: [ datasets: [
{ {
backgroundColor: blue[200],
borderColor: blue[500],
cubicInterpolationMode: "monotone" as const,
data: updateEvents.map(event => event.data?.took ?? 0),
label: "update duration, s", label: "update duration, s",
data: updateEvents.map(event => event.data?.took ?? 0),
cubicInterpolationMode: "monotone" as const,
tension: 0.4, tension: 0.4,
}, },
], ],

View File

@@ -29,26 +29,26 @@ interface PackageCountBarChartProps {
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element { export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
return <Bar return <Bar
data={{ data={{
labels: ["packages"],
datasets: [ datasets: [
{ {
backgroundColor: indigo[300], label: "archives",
data: [stats.bases ?? 0], data: [stats.packages ?? 0],
label: "bases", backgroundColor: blue[500],
}, },
{ {
backgroundColor: blue[500], label: "bases",
data: [stats.packages ?? 0], data: [stats.bases ?? 0],
label: "archives", backgroundColor: indigo[300],
}, },
], ],
labels: ["packages"],
}} }}
options={{ options={{
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true, responsive: true,
scales: { scales: {
x: { stacked: true }, x: { stacked: true },
y: { stacked: false }, y: { stacked: true },
}, },
}} }}
/>; />;

View File

@@ -17,7 +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/>.
*/ */
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus.ts";
import type { Counters } from "models/Counters"; import type { Counters } from "models/Counters";
import type React from "react"; import type React from "react";
import { Pie } from "react-chartjs-2"; import { Pie } from "react-chartjs-2";
@@ -30,14 +30,14 @@ interface StatusPieChartProps {
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element { export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[]; const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
const data = { const data = {
labels: labels,
datasets: [ datasets: [
{ {
backgroundColor: labels.map(label => StatusColors[label]),
data: labels.map(label => counters[label]),
label: "packages in status", label: "packages in status",
data: labels.map(label => counters[label]),
backgroundColor: labels.map(label => StatusColors[label]),
}, },
], ],
labels: labels,
}; };
return <Pie data={data} options={{ responsive: true }} />; return <Pie data={data} options={{ responsive: true }} />;

View File

@@ -25,14 +25,14 @@ import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import React, { useState } from "react"; import React, { useState } from "react";
interface AutoRefreshControlProps { interface AutoRefreshControlProps {
currentInterval: number;
intervals: AutoRefreshInterval[]; intervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void; onIntervalChange: (interval: number) => void;
} }
export default function AutoRefreshControl({ export default function AutoRefreshControl({
currentInterval,
intervals, intervals,
currentInterval,
onIntervalChange, onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null { }: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
@@ -46,25 +46,24 @@ export default function AutoRefreshControl({
return <> return <>
<Tooltip title="Auto-refresh"> <Tooltip title="Auto-refresh">
<IconButton <IconButton
aria-label="Auto-refresh"
color={enabled ? "primary" : "default"}
onClick={event => setAnchorEl(event.currentTarget)}
size="small" size="small"
onClick={event => setAnchorEl(event.currentTarget)}
color={enabled ? "primary" : "default"}
> >
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />} {enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Menu <Menu
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)} open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
> >
<MenuItem <MenuItem
selected={!enabled}
onClick={() => { onClick={() => {
onIntervalChange(0); onIntervalChange(0);
setAnchorEl(null); setAnchorEl(null);
}} }}
selected={!enabled}
> >
<ListItemIcon> <ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />} {!enabled && <CheckIcon fontSize="small" />}
@@ -74,11 +73,11 @@ export default function AutoRefreshControl({
{intervals.map(interval => {intervals.map(interval =>
<MenuItem <MenuItem
key={interval.interval} key={interval.interval}
selected={enabled && interval.interval === currentInterval}
onClick={() => { onClick={() => {
onIntervalChange(interval.interval); onIntervalChange(interval.interval);
setAnchorEl(null); setAnchorEl(null);
}} }}
selected={enabled && interval.interval === currentInterval}
> >
<ListItemIcon> <ListItemIcon>
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />} {enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}

View File

@@ -17,54 +17,51 @@
* 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/>.
*/ */
import "components/common/syntaxLanguages"; import { Box } from "@mui/material";
import { Box, useTheme } from "@mui/material";
import CopyButton from "components/common/CopyButton"; import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
import React, { type RefObject } from "react"; import React, { type RefObject } from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface CodeBlockProps { interface CodeBlockProps {
content: string; codeRef?: RefObject<HTMLElement | null>;
height?: number | string;
language?: string;
onScroll?: () => void;
preRef?: RefObject<HTMLElement | null>; preRef?: RefObject<HTMLElement | null>;
className?: string;
getText: () => string;
height?: number | string;
onScroll?: () => void;
wordBreak?: boolean;
} }
export default function CodeBlock({ export default function CodeBlock({
content, codeRef,
height,
language = "text",
onScroll,
preRef, preRef,
className,
getText,
height,
onScroll,
wordBreak,
}: CodeBlockProps): React.JSX.Element { }: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}> return <Box sx={{ position: "relative" }}>
<Box <Box
onScroll={onScroll}
ref={preRef} ref={preRef}
sx={{ overflow: "auto", height }} component="pre"
onScroll={onScroll}
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
height,
fontSize: "0.8rem",
fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
}}
> >
<SyntaxHighlighter <code ref={codeRef} className={className}>
customStyle={{ {!codeRef && getText()}
borderRadius: `${theme.shape.borderRadius}px`, </code>
fontSize: "0.8rem", </Box>
padding: theme.spacing(2), <Box sx={{ position: "absolute", top: 8, right: 8 }}>
}} <CopyButton getText={getText} />
language={language}
style={mode === "dark" ? vs2015 : githubGist}
wrapLongLines
>
{content}
</SyntaxHighlighter>
</Box> </Box>
{content && <Box sx={{ position: "absolute", right: 8, top: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>; </Box>;
} }

View File

@@ -23,24 +23,24 @@ import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
interface CopyButtonProps { interface CopyButtonProps {
text: string; getText: () => string;
} }
export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element { export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined); const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []); useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => { const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(getText());
setCopied(true); setCopied(true);
clearTimeout(timer.current); clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000); timer.current = setTimeout(() => setCopied(false), 2000);
}; };
return <Tooltip title={copied ? "Copied!" : "Copy"}> return <Tooltip title={copied ? "Copied!" : "Copy"}>
<IconButton aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()} size="small"> <IconButton size="small" aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()}>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />} {copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton> </IconButton>
</Tooltip>; </Tooltip>;

View File

@@ -28,7 +28,7 @@ interface DialogHeaderProps {
} }
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element { export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
return <DialogTitle sx={{ alignItems: "center", display: "flex", justifyContent: "space-between", ...sx }}> return <DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", ...sx }}>
{children} {children}
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}> <IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
<CloseIcon /> <CloseIcon />

View File

@@ -1,55 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import { Box, Button, Typography } from "@mui/material";
import type React from "react";
import type { FallbackProps } from "react-error-boundary";
interface ErrorDetails {
message: string;
stack: string | undefined;
}
export default function ErrorFallback({ error }: FallbackProps): React.JSX.Element {
const details: ErrorDetails = error instanceof Error
? { message: error.message, stack: error.stack }
: { message: String(error), stack: undefined };
return <Box role="alert" sx={{ color: "text.primary", minHeight: "100vh", p: 6 }}>
<Typography sx={{ fontWeight: 700 }} variant="h4">
Something went wrong
</Typography>
<Typography color="error" sx={{ fontFamily: "monospace", mt: 2 }}>
{details.message}
</Typography>
{details.stack && <Typography
component="pre"
sx={{ color: "text.secondary", fontFamily: "monospace", fontSize: "0.75rem", mt: 3, whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{details.stack}
</Typography>}
<Box sx={{ display: "flex", gap: 2, mt: 4 }}>
<Button onClick={() => window.location.reload()} variant="outlined">Reload page</Button>
</Box>
</Box>;
}

View File

@@ -25,14 +25,14 @@ import type React from "react";
export default function RepositorySelect({ export default function RepositorySelect({
repositorySelect, repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element { }: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, currentRepository } = useRepository(); const { repositories, current } = useRepository();
return <FormControl fullWidth margin="normal"> return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel> <InputLabel>repository</InputLabel>
<Select <Select
value={repositorySelect.selectedKey || (current?.key ?? "")}
label="repository" label="repository"
onChange={event => repositorySelect.setSelectedKey(event.target.value)} onChange={event => repositorySelect.setSelectedKey(event.target.value)}
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
> >
{repositories.map(repository => {repositories.map(repository =>
<MenuItem key={repository.key} value={repository.key}> <MenuItem key={repository.key} value={repository.key}>

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext";
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("text", plaintext);

View File

@@ -30,23 +30,23 @@ import type React from "react";
import { StatusHeaderStyles } from "theme/StatusColors"; import { StatusHeaderStyles } from "theme/StatusColors";
interface DashboardDialogProps { interface DashboardDialogProps {
onClose: () => void;
open: boolean; open: boolean;
onClose: () => void;
} }
export default function DashboardDialog({ onClose, open }: DashboardDialogProps): React.JSX.Element { export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { data: status } = useQuery<InternalStatus>({ const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetchServerStatus(current) : skipToken,
enabled: open, enabled: open,
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
}); });
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return <Dialog fullWidth maxWidth="lg" onClose={onClose} open={open}> return <Dialog open={open} onClose={onClose} keepMounted maxWidth="lg" fullWidth>
<DialogHeader onClose={onClose} sx={headerStyle}> <DialogHeader onClose={onClose} sx={headerStyle}>
System health System health
</DialogHeader> </DialogHeader>
@@ -55,43 +55,43 @@ export default function DashboardDialog({ onClose, open }: DashboardDialogProps)
{status && {status &&
<> <>
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography align="right" color="text.secondary" variant="body2">Repository name</Typography> <Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
</Grid> </Grid>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{status.repository}</Typography> <Typography variant="body2">{status.repository}</Typography>
</Grid> </Grid>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography align="right" color="text.secondary" variant="body2">Repository architecture</Typography> <Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
</Grid> </Grid>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{status.architecture}</Typography> <Typography variant="body2">{status.architecture}</Typography>
</Grid> </Grid>
</Grid> </Grid>
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography align="right" color="text.secondary" variant="body2">Current status</Typography> <Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
</Grid> </Grid>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{status.status.status}</Typography> <Typography variant="body2">{status.status.status}</Typography>
</Grid> </Grid>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography align="right" color="text.secondary" variant="body2">Updated at</Typography> <Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
</Grid> </Grid>
<Grid size={{ md: 3, xs: 6 }}> <Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography> <Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
</Grid> </Grid>
</Grid> </Grid>
<Grid container spacing={2} sx={{ mt: 2 }}> <Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ md: 6, xs: 12 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ height: 300 }}> <Box sx={{ maxHeight: 300 }}>
<PackageCountBarChart stats={status.stats} /> <PackageCountBarChart stats={status.stats} />
</Box> </Box>
</Grid> </Grid>
<Grid size={{ md: 6, xs: 12 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ alignItems: "center", display: "flex", height: 300, justifyContent: "center" }}> <Box sx={{ maxHeight: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
<StatusPieChart counters={status.packages} /> <StatusPieChart counters={status.packages} />
</Box> </Box>
</Grid> </Grid>

View File

@@ -35,11 +35,11 @@ import { useNotification } from "hooks/useNotification";
import React, { useState } from "react"; import React, { useState } from "react";
interface KeyImportDialogProps { interface KeyImportDialogProps {
onClose: () => void;
open: boolean; open: boolean;
onClose: () => void;
} }
export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps): React.JSX.Element { export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
@@ -47,19 +47,23 @@ export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps)
const [server, setServer] = useState("keyserver.ubuntu.com"); const [server, setServer] = useState("keyserver.ubuntu.com");
const [keyBody, setKeyBody] = useState(""); const [keyBody, setKeyBody] = useState("");
const handleClose = (): void => { const resetFields = (): void => {
setFingerprint(""); setFingerprint("");
setServer("keyserver.ubuntu.com"); setServer("keyserver.ubuntu.com");
setKeyBody(""); setKeyBody("");
};
const handleClose = (): void => {
resetFields();
onClose(); onClose();
}; };
const handleFetch = async (): Promise<void> => { const handleFetch: () => Promise<void> = async () => {
if (!fingerprint || !server) { if (!fingerprint || !server) {
return; return;
} }
try { try {
const result = await client.service.servicePGPFetch(fingerprint, server); const result = await client.servicePGPFetch(fingerprint, server);
setKeyBody(result.key); setKeyBody(result.key);
} catch (exception) { } catch (exception) {
const detail = ApiError.errorDetail(exception); const detail = ApiError.errorDetail(exception);
@@ -67,12 +71,12 @@ export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps)
} }
}; };
const handleImport = async (): Promise<void> => { const handleImport: () => Promise<void> = async () => {
if (!fingerprint || !server) { if (!fingerprint || !server) {
return; return;
} }
try { try {
await client.service.servicePGPImport({ key: fingerprint, server }); await client.servicePGPImport({ key: fingerprint, server });
handleClose(); handleClose();
showSuccess("Success", `Key ${fingerprint} has been imported`); showSuccess("Success", `Key ${fingerprint} has been imported`);
} catch (exception) { } catch (exception) {
@@ -81,38 +85,38 @@ export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps)
} }
}; };
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}> return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="lg" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Import key from PGP server Import key from PGP server
</DialogHeader> </DialogHeader>
<DialogContent> <DialogContent>
<TextField <TextField
fullWidth
label="fingerprint" label="fingerprint"
margin="normal"
onChange={event => setFingerprint(event.target.value)}
placeholder="PGP key fingerprint" placeholder="PGP key fingerprint"
fullWidth
margin="normal"
value={fingerprint} value={fingerprint}
onChange={event => setFingerprint(event.target.value)}
/> />
<TextField <TextField
fullWidth
label="key server" label="key server"
margin="normal"
onChange={event => setServer(event.target.value)}
placeholder="PGP key server" placeholder="PGP key server"
fullWidth
margin="normal"
value={server} value={server}
onChange={event => setServer(event.target.value)}
/> />
{keyBody && {keyBody &&
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<CodeBlock height={300} content={keyBody} /> <CodeBlock getText={() => keyBody} height={300} />
</Box> </Box>
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleImport()} startIcon={<PlayArrowIcon />} variant="contained">import</Button> <Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
<Button color="success" onClick={() => void handleFetch()} startIcon={<RefreshIcon />} variant="contained">fetch</Button> <Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -36,11 +36,11 @@ import { useNotification } from "hooks/useNotification";
import React, { useState } from "react"; import React, { useState } from "react";
interface LoginDialogProps { interface LoginDialogProps {
onClose: () => void;
open: boolean; open: boolean;
onClose: () => void;
} }
export default function LoginDialog({ onClose, open }: LoginDialogProps): React.JSX.Element { export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -54,7 +54,7 @@ export default function LoginDialog({ onClose, open }: LoginDialogProps): React.
onClose(); onClose();
}; };
const handleSubmit = async (): Promise<void> => { const handleSubmit: () => Promise<void> = async () => {
if (!username || !password) { if (!username || !password) {
return; return;
} }
@@ -72,24 +72,26 @@ export default function LoginDialog({ onClose, open }: LoginDialogProps): React.
} }
}; };
return <Dialog fullWidth maxWidth="xs" onClose={handleClose} open={open}> return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="xs" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Login Login
</DialogHeader> </DialogHeader>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus
fullWidth
label="username" label="username"
fullWidth
margin="normal" margin="normal"
onChange={event => setUsername(event.target.value)}
value={username} value={username}
onChange={event => setUsername(event.target.value)}
autoFocus
/> />
<TextField <TextField
fullWidth
label="password" label="password"
fullWidth
margin="normal" margin="normal"
type={showPassword ? "text" : "password"}
value={password}
onChange={event => setPassword(event.target.value)} onChange={event => setPassword(event.target.value)}
onKeyDown={event => { onKeyDown={event => {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -100,24 +102,17 @@ export default function LoginDialog({ onClose, open }: LoginDialogProps): React.
input: { input: {
endAdornment: endAdornment:
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton <IconButton aria-label={showPassword ? "Hide password" : "Show password"} onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
aria-label={showPassword ? "Hide password" : "Show password"}
edge="end"
onClick={() => setShowPassword(!showPassword)}
size="small"
>
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton> </IconButton>
</InputAdornment>, </InputAdornment>,
}, },
}} }}
type={showPassword ? "text" : "password"}
value={password}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleSubmit()} startIcon={<PersonIcon />} variant="contained">login</Button> <Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -42,7 +42,6 @@ import { useDebounce } from "hooks/useDebounce";
import { useNotification } from "hooks/useNotification"; import { useNotification } from "hooks/useNotification";
import { useSelectedRepository } from "hooks/useSelectedRepository"; import { useSelectedRepository } from "hooks/useSelectedRepository";
import type { AURPackage } from "models/AURPackage"; import type { AURPackage } from "models/AURPackage";
import type { PackageActionRequest } from "models/PackageActionRequest";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
interface EnvironmentVariable { interface EnvironmentVariable {
@@ -52,11 +51,11 @@ interface EnvironmentVariable {
} }
interface PackageAddDialogProps { interface PackageAddDialogProps {
onClose: () => void;
open: boolean; open: boolean;
onClose: () => void;
} }
export default function PackageAddDialog({ onClose, open }: PackageAddDialogProps): React.JSX.Element { export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const repositorySelect = useSelectedRepository(); const repositorySelect = useSelectedRepository();
@@ -77,12 +76,12 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
const debouncedSearch = useDebounce(packageName, 500); const debouncedSearch = useDebounce(packageName, 500);
const { data: searchResults = [] } = useQuery<AURPackage[]>({ const { data: searchResults = [] } = useQuery<AURPackage[]>({
enabled: debouncedSearch.length >= 3,
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
queryKey: QueryKeys.search(debouncedSearch), queryKey: QueryKeys.search(debouncedSearch),
queryFn: () => client.servicePackageSearch(debouncedSearch),
enabled: debouncedSearch.length >= 3,
}); });
const handleSubmit = async (action: "add" | "request"): Promise<void> => { const handleAdd: () => Promise<void> = async () => {
if (!packageName) { if (!packageName) {
return; return;
} }
@@ -92,22 +91,42 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
} }
try { try {
const patches = environmentVariables.filter(variable => variable.key); const patches = environmentVariables.filter(variable => variable.key);
const request: PackageActionRequest = { packages: [packageName], patches }; await client.servicePackageAdd(repository, {
if (action === "add") { packages: [packageName],
request.refresh = refreshDatabase; patches,
await client.service.servicePackageAdd(repository, request); refresh: refreshDatabase,
} else { });
await client.service.servicePackageRequest(repository, request);
}
handleClose(); handleClose();
showSuccess("Success", `Packages ${packageName} have been ${action === "add" ? "added" : "requested"}`); showSuccess("Success", `Packages ${packageName} have been added`);
} catch (exception) { } catch (exception) {
const detail = ApiError.errorDetail(exception); const detail = ApiError.errorDetail(exception);
showError("Action failed", `Package ${action} failed: ${detail}`); showError("Action failed", `Package addition failed: ${detail}`);
} }
}; };
return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}> const handleRequest: () => Promise<void> = async () => {
if (!packageName) {
return;
}
const repository = repositorySelect.selectedRepository;
if (!repository) {
return;
}
try {
const patches = environmentVariables.filter(variable => variable.key);
await client.servicePackageRequest(repository, {
packages: [packageName],
patches,
});
handleClose();
showSuccess("Success", `Packages ${packageName} have been requested`);
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Action failed", `Package request failed: ${detail}`);
}
};
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Add new packages Add new packages
</DialogHeader> </DialogHeader>
@@ -117,18 +136,20 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
<Autocomplete <Autocomplete
freeSolo freeSolo
options={searchResults.map(pkg => pkg.package)}
inputValue={packageName} inputValue={packageName}
onInputChange={(_, value) => setPackageName(value)} onInputChange={(_, value) => setPackageName(value)}
options={searchResults.map(pkg => pkg.package)}
renderInput={params =>
<TextField {...params} label="package" margin="normal" placeholder="AUR package" />
}
renderOption={(props, option) => { renderOption={(props, option) => {
const pkg = searchResults.find(pkg => pkg.package === option); const pkg = searchResults.find(pkg => pkg.package === option);
return <li {...props} key={option}> return (
{option}{pkg ? ` (${pkg.description})` : ""} <li {...props} key={option}>
</li>; {option}{pkg ? ` (${pkg.description})` : ""}
</li>
);
}} }}
renderInput={params =>
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
}
/> />
<FormControlLabel <FormControlLabel
@@ -138,50 +159,45 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
<Button <Button
fullWidth fullWidth
variant="outlined"
startIcon={<AddIcon />}
onClick={() => { onClick={() => {
const id = variableIdCounter.current++; const id = variableIdCounter.current++;
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]); setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
}} }}
startIcon={<AddIcon />}
sx={{ mt: 1 }} sx={{ mt: 1 }}
variant="outlined"
> >
add environment variable add environment variable
</Button> </Button>
{environmentVariables.map(variable => {environmentVariables.map(variable =>
<Box key={variable.id} sx={{ alignItems: "center", display: "flex", gap: 1, mt: 1 }}> <Box key={variable.id} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
<TextField <TextField
size="small"
placeholder="name"
value={variable.key}
onChange={event => { onChange={event => {
const newKey = event.target.value; const newKey = event.target.value;
setEnvironmentVariables(prev => setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry), prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
); );
}} }}
placeholder="name"
size="small"
sx={{ flex: 1 }} sx={{ flex: 1 }}
value={variable.key}
/> />
<Box>=</Box> <Box>=</Box>
<TextField <TextField
size="small"
placeholder="value" placeholder="value"
value={variable.value}
onChange={event => { onChange={event => {
const newValue = event.target.value; const newValue = event.target.value;
setEnvironmentVariables(prev => setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry), prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
); );
}} }}
size="small"
sx={{ flex: 1 }} sx={{ flex: 1 }}
value={variable.value}
/> />
<IconButton <IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}>
aria-label="Remove variable"
color="error"
onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}
size="small"
>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</Box>, </Box>,
@@ -189,8 +205,8 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleSubmit("add")} startIcon={<PlayArrowIcon />} variant="contained">add</Button> <Button onClick={() => void handleAdd()} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
<Button color="success" onClick={() => void handleSubmit("request")} startIcon={<AddIcon />} variant="contained">request</Button> <Button onClick={() => void handleRequest()} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -21,15 +21,12 @@ import { Box, Dialog, DialogContent, Tab, Tabs } from "@mui/material";
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "api/client/ApiError"; import { ApiError } from "api/client/ApiError";
import DialogHeader from "components/common/DialogHeader"; import DialogHeader from "components/common/DialogHeader";
import ArtifactsTab from "components/package/ArtifactsTab";
import BuildLogsTab from "components/package/BuildLogsTab"; import BuildLogsTab from "components/package/BuildLogsTab";
import ChangesTab from "components/package/ChangesTab"; import ChangesTab from "components/package/ChangesTab";
import EventsTab from "components/package/EventsTab"; import EventsTab from "components/package/EventsTab";
import PackageDetailsGrid from "components/package/PackageDetailsGrid"; import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackageInfoActions from "components/package/PackageInfoActions"; import PackageInfoActions from "components/package/PackageInfoActions";
import PackagePatchesList from "components/package/PackagePatchesList"; import PackagePatchesList from "components/package/PackagePatchesList";
import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh"; import { useAutoRefresh } from "hooks/useAutoRefresh";
@@ -45,34 +42,29 @@ import { StatusHeaderStyles } from "theme/StatusColors";
import { defaultInterval } from "utils"; import { defaultInterval } from "utils";
interface PackageInfoDialogProps { interface PackageInfoDialogProps {
autoRefreshIntervals: AutoRefreshInterval[];
onClose: () => void;
open: boolean;
packageBase: string | null; packageBase: string | null;
open: boolean;
onClose: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
} }
export default function PackageInfoDialog({ export default function PackageInfoDialog({
autoRefreshIntervals,
onClose,
open,
packageBase, packageBase,
open,
onClose,
autoRefreshIntervals,
}: PackageInfoDialogProps): React.JSX.Element { }: PackageInfoDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [localPackageBase, setLocalPackageBase] = useState(packageBase); const [tabIndex, setTabIndex] = useState(0);
if (packageBase !== null && packageBase !== localPackageBase) {
setLocalPackageBase(packageBase);
}
const [activeTab, setActiveTab] = useState<TabKey>("logs");
const [refreshDatabase, setRefreshDatabase] = useState(true); const [refreshDatabase, setRefreshDatabase] = useState(true);
const handleClose = (): void => { const handleClose = (): void => {
setActiveTab("logs"); setTabIndex(0);
setRefreshDatabase(true); setRefreshDatabase(true);
onClose(); onClose();
}; };
@@ -80,143 +72,117 @@ export default function PackageInfoDialog({
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({ const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: packageBase && current ? QueryKeys.package(packageBase, current) : ["packages"],
queryFn: packageBase && current ? () => client.fetchPackage(packageBase, current) : skipToken,
enabled: open, enabled: open,
queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: dependencies } = useQuery<Dependencies>({ const { data: dependencies } = useQuery<Dependencies>({
queryKey: packageBase && current ? QueryKeys.dependencies(packageBase, current) : ["dependencies"],
queryFn: packageBase && current ? () => client.fetchPackageDependencies(packageBase, current) : skipToken,
enabled: open, enabled: open,
queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
}); });
const { data: patches = [] } = useQuery<Patch[]>({ const { data: patches = [] } = useQuery<Patch[]>({
queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches"],
queryFn: packageBase ? () => client.fetchPackagePatches(packageBase) : skipToken,
enabled: open, enabled: open,
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
}); });
const description = packageData?.[0]; const description: PackageStatus | undefined = packageData?.[0];
const pkg = description?.package; const pkg = description?.package;
const status = description?.status; const status = description?.status;
const headerStyle = status ? StatusHeaderStyles[status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate = async (): Promise<void> => { const handleUpdate: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) { if (!packageBase || !current) {
return; return;
} }
try { try {
await client.service.servicePackageAdd( await client.servicePackageAdd(current, { packages: [packageBase], refresh: refreshDatabase });
currentRepository, { packages: [localPackageBase], refresh: refreshDatabase }); showSuccess("Success", `Run update for packages ${packageBase}`);
showSuccess("Success", `Run update for packages ${localPackageBase}`);
} catch (exception) { } catch (exception) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
} }
}; };
const handleRemove = async (): Promise<void> => { const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) { if (!packageBase || !current) {
return; return;
} }
try { try {
await client.service.servicePackageRemove(currentRepository, [localPackageBase]); await client.servicePackageRemove(current, [packageBase]);
showSuccess("Success", `Packages ${localPackageBase} have been removed`); showSuccess("Success", `Packages ${packageBase} have been removed`);
onClose(); onClose();
} catch (exception) { } catch (exception) {
showError("Action failed", `Could not remove package: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Could not remove package: ${ApiError.errorDetail(exception)}`);
} }
}; };
const handleHoldToggle = async (): Promise<void> => { const handleDeletePatch: (key: string) => Promise<void> = async key => {
if (!localPackageBase || !currentRepository) { if (!packageBase) {
return; return;
} }
try { try {
const newHeldStatus = !(status?.is_held ?? false); await client.servicePackagePatchRemove(packageBase, key);
await client.service.servicePackageHold(localPackageBase, currentRepository, newHeldStatus); void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
} catch (exception) {
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
}
};
const handleDeletePatch = async (key: string): Promise<void> => {
if (!localPackageBase) {
return;
}
try {
await client.service.servicePackagePatchRemove(localPackageBase, key);
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(localPackageBase) });
} catch (exception) { } catch (exception) {
showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(exception)}`);
} }
}; };
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}> return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="lg" fullWidth>
<DialogHeader onClose={handleClose} sx={headerStyle}> <DialogHeader onClose={handleClose} sx={headerStyle}>
{pkg && status {pkg && status
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}` ? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
: localPackageBase ?? ""} : packageBase ?? ""}
</DialogHeader> </DialogHeader>
<DialogContent> <DialogContent>
{pkg && {pkg &&
<> <>
<PackageDetailsGrid dependencies={dependencies} pkg={pkg} /> <PackageDetailsGrid pkg={pkg} dependencies={dependencies} />
<PackagePatchesList <PackagePatchesList
patches={patches}
editable={isAuthorized} editable={isAuthorized}
onDelete={key => void handleDeletePatch(key)} onDelete={key => void handleDeletePatch(key)}
patches={patches}
/> />
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}> <Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs onChange={(_, tab: TabKey) => setActiveTab(tab)} value={activeTab}> <Tabs value={tabIndex} onChange={(_, index: number) => setTabIndex(index)}>
{tabs.map(({ key, label }) => <Tab key={key} label={label} value={key} />)} <Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
</Tabs> </Tabs>
</Box> </Box>
{activeTab === "logs" && localPackageBase && currentRepository && {tabIndex === 0 && packageBase && current &&
<BuildLogsTab <BuildLogsTab
packageBase={localPackageBase} packageBase={packageBase}
repository={current}
refreshInterval={autoRefresh.interval} refreshInterval={autoRefresh.interval}
repository={currentRepository}
/> />
} }
{activeTab === "changes" && localPackageBase && currentRepository && {tabIndex === 1 && packageBase && current &&
<ChangesTab packageBase={localPackageBase} repository={currentRepository} /> <ChangesTab packageBase={packageBase} repository={current} />
} }
{activeTab === "pkgbuild" && localPackageBase && currentRepository && {tabIndex === 2 && packageBase && current &&
<PkgbuildTab packageBase={localPackageBase} repository={currentRepository} /> <EventsTab packageBase={packageBase} repository={current} />
}
{activeTab === "events" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "artifacts" && localPackageBase && currentRepository &&
<ArtifactsTab
currentVersion={pkg.version}
packageBase={localPackageBase}
repository={currentRepository}
/>
} }
</> </>
} }
</DialogContent> </DialogContent>
<PackageInfoActions <PackageInfoActions
autoRefreshInterval={autoRefresh.interval}
autoRefreshIntervals={autoRefreshIntervals}
isAuthorized={isAuthorized} isAuthorized={isAuthorized}
isHeld={status?.is_held ?? false}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
onHoldToggle={() => void handleHoldToggle()}
onRefreshDatabaseChange={setRefreshDatabase}
onRemove={() => void handleRemove()}
onUpdate={() => void handleUpdate()}
refreshDatabase={refreshDatabase} refreshDatabase={refreshDatabase}
onRefreshDatabaseChange={setRefreshDatabase}
onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
/> />
</Dialog>; </Dialog>;
} }

View File

@@ -28,11 +28,11 @@ import { useSelectedRepository } from "hooks/useSelectedRepository";
import React, { useState } from "react"; import React, { useState } from "react";
interface PackageRebuildDialogProps { interface PackageRebuildDialogProps {
onClose: () => void;
open: boolean; open: boolean;
onClose: () => void;
} }
export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDialogProps): React.JSX.Element { export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const repositorySelect = useSelectedRepository(); const repositorySelect = useSelectedRepository();
@@ -45,7 +45,7 @@ export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDi
onClose(); onClose();
}; };
const handleRebuild = async (): Promise<void> => { const handleRebuild: () => Promise<void> = async () => {
if (!dependency) { if (!dependency) {
return; return;
} }
@@ -54,7 +54,7 @@ export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDi
return; return;
} }
try { try {
await client.service.serviceRebuild(repository, [dependency]); await client.serviceRebuild(repository, [dependency]);
handleClose(); handleClose();
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`); showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
} catch (exception) { } catch (exception) {
@@ -63,7 +63,7 @@ export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDi
} }
}; };
return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}> return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Rebuild depending packages Rebuild depending packages
</DialogHeader> </DialogHeader>
@@ -72,17 +72,17 @@ export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDi
<RepositorySelect repositorySelect={repositorySelect} /> <RepositorySelect repositorySelect={repositorySelect} />
<TextField <TextField
fullWidth
label="dependency" label="dependency"
margin="normal"
placeholder="packages dependency" placeholder="packages dependency"
onChange={event => setDependency(event.target.value)} fullWidth
margin="normal"
value={dependency} value={dependency}
onChange={event => setDependency(event.target.value)}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleRebuild()} startIcon={<PlayArrowIcon />} variant="contained">rebuild</Button> <Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -17,9 +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/>.
*/ */
import Brightness4Icon from "@mui/icons-material/Brightness4"; import { Box, Container } from "@mui/material";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import { Box, Container, IconButton, Tooltip } from "@mui/material";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import LoginDialog from "components/dialogs/LoginDialog"; import LoginDialog from "components/dialogs/LoginDialog";
import Footer from "components/layout/Footer"; import Footer from "components/layout/Footer";
@@ -29,7 +27,6 @@ import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository"; import { useRepository } from "hooks/useRepository";
import { useThemeMode } from "hooks/useThemeMode";
import type { InfoResponse } from "models/InfoResponse"; import type { InfoResponse } from "models/InfoResponse";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
@@ -37,12 +34,11 @@ export default function AppLayout(): React.JSX.Element {
const client = useClient(); const client = useClient();
const { setAuthState } = useAuth(); const { setAuthState } = useAuth();
const { setRepositories } = useRepository(); const { setRepositories } = useRepository();
const { mode, toggleTheme } = useThemeMode();
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({ const { data: info } = useQuery<InfoResponse>({
queryFn: () => client.fetch.fetchServerInfo(),
queryKey: QueryKeys.info, queryKey: QueryKeys.info,
queryFn: () => client.fetchServerInfo(),
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -55,29 +51,26 @@ export default function AppLayout(): React.JSX.Element {
}, [info, setAuthState, setRepositories]); }, [info, setAuthState, setRepositories]);
return <Container maxWidth="xl"> return <Container maxWidth="xl">
<Box sx={{ alignItems: "center", display: "flex", gap: 1, py: 1 }}> <Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
<a href="https://ahriman.readthedocs.io/" title="logo"> <a href="https://github.com/arcan1s/ahriman" title="logo">
<img alt="" height={30} src="/static/logo.svg" width={30} /> <img src="/static/logo.svg" width={30} height={30} alt="" />
</a> </a>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Navbar /> <Navbar />
</Box> </Box>
<Tooltip title="Toggle theme">
<IconButton aria-label="Toggle theme" onClick={toggleTheme}>
{mode === "dark" ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Tooltip>
</Box> </Box>
<PackageTable autoRefreshIntervals={info?.autorefresh_intervals ?? []} /> <PackageTable
autoRefreshIntervals={info?.autorefresh_intervals ?? []}
/>
<Footer <Footer
version={info?.version ?? ""}
docsEnabled={info?.docs_enabled ?? false} docsEnabled={info?.docs_enabled ?? false}
indexUrl={info?.index_url} indexUrl={info?.index_url}
onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)} onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)}
version={info?.version ?? ""}
/> />
<LoginDialog onClose={() => setLoginOpen(false)} open={loginOpen} /> <LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
</Container>; </Container>;
} }

View File

@@ -26,41 +26,41 @@ import { useAuth } from "hooks/useAuth";
import type React from "react"; import type React from "react";
interface FooterProps { interface FooterProps {
version: string;
docsEnabled: boolean; docsEnabled: boolean;
indexUrl?: string; indexUrl?: string;
onLoginClick: () => void; onLoginClick: () => void;
version: string;
} }
export default function Footer({ docsEnabled, indexUrl, onLoginClick, version }: FooterProps): React.JSX.Element { export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
const { enabled: authEnabled, username, logout } = useAuth(); const { enabled: authEnabled, username, logout } = useAuth();
return <Box return <Box
component="footer" component="footer"
sx={{ sx={{
alignItems: "center",
borderColor: "divider",
borderTop: 1,
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center",
borderTop: 1,
borderColor: "divider",
mt: 2, mt: 2,
py: 1, py: 1,
}} }}
> >
<Box sx={{ alignItems: "center", display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<Link color="inherit" href="https://github.com/arcan1s/ahriman" sx={{ alignItems: "center", display: "flex", gap: 0.5 }} underline="hover"> <Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<GitHubIcon fontSize="small" /> <GitHubIcon fontSize="small" />
<Typography variant="body2">ahriman {version}</Typography> <Typography variant="body2">ahriman {version}</Typography>
</Link> </Link>
<Link color="text.secondary" href="https://github.com/arcan1s/ahriman/releases" underline="hover" variant="body2"> <Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
releases releases
</Link> </Link>
<Link color="text.secondary" href="https://github.com/arcan1s/ahriman/issues" underline="hover" variant="body2"> <Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
report a bug report a bug
</Link> </Link>
{docsEnabled && {docsEnabled &&
<Link color="text.secondary" href="/api-docs" underline="hover" variant="body2"> <Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
api api
</Link> </Link>
} }
@@ -68,7 +68,7 @@ export default function Footer({ docsEnabled, indexUrl, onLoginClick, version }:
{indexUrl && {indexUrl &&
<Box> <Box>
<Link color="inherit" href={indexUrl} underline="hover" sx={{ alignItems: "center", display: "flex", gap: 0.5 }}> <Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<HomeIcon fontSize="small" /> <HomeIcon fontSize="small" />
<Typography variant="body2">repo index</Typography> <Typography variant="body2">repo index</Typography>
</Link> </Link>
@@ -78,11 +78,11 @@ export default function Footer({ docsEnabled, indexUrl, onLoginClick, version }:
{authEnabled && {authEnabled &&
<Box> <Box>
{username ? {username ?
<Button onClick={() => void logout()} size="small" startIcon={<LogoutIcon />} sx={{ textTransform: "none" }}> <Button size="small" startIcon={<LogoutIcon />} onClick={() => void logout()} sx={{ textTransform: "none" }}>
logout ({username}) logout ({username})
</Button> </Button>
: :
<Button onClick={onLoginClick} size="small" startIcon={<LoginIcon />} sx={{ textTransform: "none" }}> <Button size="small" startIcon={<LoginIcon />} onClick={onLoginClick} sx={{ textTransform: "none" }}>
login login
</Button> </Button>
} }

View File

@@ -22,28 +22,27 @@ import { useRepository } from "hooks/useRepository";
import type React from "react"; import type React from "react";
export default function Navbar(): React.JSX.Element | null { export default function Navbar(): React.JSX.Element | null {
const { repositories, currentRepository, setCurrentRepository } = useRepository(); const { repositories, current, setCurrent } = useRepository();
if (repositories.length === 0 || !currentRepository) { if (repositories.length === 0 || !current) {
return null; return null;
} }
const currentIndex = repositories.findIndex(repository => const currentIndex = repositories.findIndex(repository =>
repository.architecture === currentRepository.architecture && repository.architecture === current.architecture && repository.repository === current.repository,
repository.repository === currentRepository.repository,
); );
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}> return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs <Tabs
value={currentIndex >= 0 ? currentIndex : 0}
onChange={(_, newValue: number) => { onChange={(_, newValue: number) => {
const repository = repositories[newValue]; const repository = repositories[newValue];
if (repository) { if (repository) {
setCurrentRepository(repository); setCurrent(repository);
} }
}} }}
scrollButtons="auto"
value={currentIndex >= 0 ? currentIndex : 0}
variant="scrollable" variant="scrollable"
scrollButtons="auto"
> >
{repositories.map(repository => {repositories.map(repository =>
<Tab <Tab

View File

@@ -1,119 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import RestoreIcon from "@mui/icons-material/Restore";
import { Box, IconButton, Tooltip } from "@mui/material";
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import type { RepositoryId } from "models/RepositoryId";
import type React from "react";
import { useCallback, useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface ArtifactsTabProps {
currentVersion: string;
packageBase: string;
repository: RepositoryId;
}
interface ArtifactRow {
id: string;
packager: string;
packages: string[];
version: string;
}
const staticColumns: GridColDef<ArtifactRow>[] = [
{ align: "right", field: "version", flex: 1, headerAlign: "right", headerName: "version" },
{
field: "packages",
flex: 2,
headerName: "packages",
renderCell: params =>
<Box sx={{ whiteSpace: "pre-line" }}>{params.row.packages.join("\n")}</Box>,
},
{ field: "packager", flex: 1, headerName: "packager" },
];
export default function ArtifactsTab({
currentVersion,
packageBase,
repository,
}: ArtifactsTabProps): React.JSX.Element {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification();
const { data: rows = [] } = useQuery<ArtifactRow[]>({
enabled: !!packageBase,
queryFn: async () => {
const packages = await client.fetch.fetchPackageArtifacts(packageBase, repository);
return packages.map(artifact => ({
id: artifact.version,
packager: artifact.packager ?? "",
packages: Object.keys(artifact.packages).sort(),
version: artifact.version,
})).reverse();
},
queryKey: QueryKeys.artifacts(packageBase, repository),
});
const handleRollback = useCallback(async (version: string): Promise<void> => {
try {
await client.service.servicePackageRollback(repository, { package: packageBase, version });
void queryClient.invalidateQueries({ queryKey: QueryKeys.artifacts(packageBase, repository) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repository) });
showSuccess("Success", `Rollback ${packageBase} to ${version} has been started`);
} catch (exception) {
showError("Action failed", `Rollback failed: ${ApiError.errorDetail(exception)}`);
}
}, [client, repository, packageBase, queryClient, showSuccess, showError]);
const columns = useMemo<GridColDef<ArtifactRow>[]>(() => [
...staticColumns,
...isAuthorized ? [{
field: "actions",
filterable: false,
headerName: "",
renderCell: params =>
<Tooltip title={params.row.version === currentVersion ? "Current version" : "Rollback to this version"}>
<span>
<IconButton
disabled={params.row.version === currentVersion}
onClick={() => void handleRollback(params.row.version)}
size="small"
>
<RestoreIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>,
width: 60,
} satisfies GridColDef<ArtifactRow>] : [],
], [isAuthorized, currentVersion, handleRollback]);
return <Box sx={{ mt: 1 }}>
<DataGrid columns={columns} getRowHeight={() => "auto"} rows={rows} {...DETAIL_TABLE_PROPS} />
</Box>;
}

View File

@@ -29,16 +29,16 @@ import type { RepositoryId } from "models/RepositoryId";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
interface Logs { interface Logs {
version: string;
processId: string;
created: number; created: number;
logs: string; logs: string;
processId: string;
version: string;
} }
interface BuildLogsTabProps { interface BuildLogsTabProps {
packageBase: string; packageBase: string;
refreshInterval: number;
repository: RepositoryId; repository: RepositoryId;
refreshInterval: number;
} }
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string { function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
@@ -50,17 +50,17 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
export default function BuildLogsTab({ export default function BuildLogsTab({
packageBase, packageBase,
refreshInterval,
repository, repository,
refreshInterval,
}: BuildLogsTabProps): React.JSX.Element { }: BuildLogsTabProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null); const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const { data: allLogs } = useQuery<LogRecord[]>({ const { data: allLogs } = useQuery<LogRecord[]>({
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
queryKey: QueryKeys.logs(packageBase, repository), queryKey: QueryKeys.logs(packageBase, repository),
queryFn: () => client.fetchPackageLogs(packageBase, repository),
enabled: !!packageBase,
refetchInterval: refreshInterval > 0 ? refreshInterval : false, refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
@@ -84,13 +84,13 @@ export default function BuildLogsTab({
return Object.values(grouped) return Object.values(grouped)
.sort((left, right) => right.minCreated - left.minCreated) .sort((left, right) => right.minCreated - left.minCreated)
.map(record => ({ .map(record => ({
version: record.version,
processId: record.process_id,
created: record.minCreated, created: record.minCreated,
logs: convertLogs( logs: convertLogs(
allLogs, allLogs,
right => record.version === right.version && record.process_id === right.process_id, right => record.version === right.version && record.process_id === right.process_id,
), ),
processId: record.process_id,
version: record.version,
})); }));
}, [allLogs]); }, [allLogs]);
@@ -110,13 +110,11 @@ export default function BuildLogsTab({
// Refresh active version logs // Refresh active version logs
const { data: versionLogs } = useQuery<LogRecord[]>({ const { data: versionLogs } = useQuery<LogRecord[]>({
placeholderData: keepPreviousData,
queryFn: activeVersion
? () => client.fetch.fetchPackageLogs(
packageBase, repository, activeVersion.version, activeVersion.processId,
)
: skipToken,
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""), queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
queryFn: activeVersion
? () => client.fetchPackageLogs(packageBase, repository, activeVersion.version, activeVersion.processId)
: skipToken,
placeholderData: keepPreviousData,
refetchInterval: refreshInterval > 0 ? refreshInterval : false, refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
@@ -143,25 +141,25 @@ export default function BuildLogsTab({
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}> return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box> <Box>
<Button <Button
aria-label="Select version"
onClick={event => setAnchorEl(event.currentTarget)}
size="small" size="small"
aria-label="Select version"
startIcon={<ListIcon />} startIcon={<ListIcon />}
onClick={event => setAnchorEl(event.currentTarget)}
/> />
<Menu <Menu
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)} open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
> >
{versions.map((logs, index) => {versions.map((logs, index) =>
<MenuItem <MenuItem
key={`${logs.version}-${logs.processId}`} key={`${logs.version}-${logs.processId}`}
selected={index === activeIndex}
onClick={() => { onClick={() => {
setSelectedVersionKey(`${logs.version}-${logs.processId}`); setSelectedVersionKey(`${logs.version}-${logs.processId}`);
setAnchorEl(null); setAnchorEl(null);
resetScroll(); resetScroll();
}} }}
selected={index === activeIndex}
> >
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography> <Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
</MenuItem>, </MenuItem>,
@@ -174,10 +172,11 @@ export default function BuildLogsTab({
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<CodeBlock <CodeBlock
content={displayedLogs} preRef={preRef}
getText={() => displayedLogs}
height={400} height={400}
onScroll={handleScroll} onScroll={handleScroll}
preRef={preRef} wordBreak
/> />
</Box> </Box>
</Box>; </Box>;

View File

@@ -17,10 +17,20 @@
* 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/>.
*/ */
import "highlight.js/styles/github.css";
import { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import CodeBlock from "components/common/CodeBlock"; import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges"; import hljs from "highlight.js/lib/core";
import diff from "highlight.js/lib/languages/diff";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import React from "react"; import React, { useEffect, useRef } from "react";
hljs.registerLanguage("diff", diff);
interface ChangesTabProps { interface ChangesTabProps {
packageBase: string; packageBase: string;
@@ -28,7 +38,24 @@ interface ChangesTabProps {
} }
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element { export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository); const client = useClient();
const codeRef = useRef<HTMLElement>(null);
return <CodeBlock content={data?.changes ?? ""} height={400} language="diff" />; const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
useEffect(() => {
if (codeRef.current) {
codeRef.current.innerHTML = hljs.highlight(changesText, { language: "diff" }).value;
}
}, [changesText]);
return <Box sx={{ mt: 1 }}>
<CodeBlock codeRef={codeRef} className="language-diff" getText={() => changesText} height={400} />
</Box>;
} }

View File

@@ -26,8 +26,6 @@ import { useClient } from "hooks/useClient";
import type { Event } from "models/Event"; import type { Event } from "models/Event";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import type React from "react"; import type React from "react";
import { useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface EventsTabProps { interface EventsTabProps {
packageBase: string; packageBase: string;
@@ -35,36 +33,47 @@ interface EventsTabProps {
} }
interface EventRow { interface EventRow {
event: string;
id: number; id: number;
message: string;
timestamp: string; timestamp: string;
event: string;
message: string;
} }
const columns: GridColDef<EventRow>[] = [ const columns: GridColDef<EventRow>[] = [
{ align: "right", field: "timestamp", headerAlign: "right", headerName: "date", width: 180 }, { field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
{ field: "event", flex: 1, headerName: "event" }, { field: "event", headerName: "event", flex: 1 },
{ field: "message", flex: 2, headerName: "description" }, { field: "message", headerName: "description", flex: 2 },
]; ];
export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element { export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { data: events = [] } = useQuery<Event[]>({ const { data: events = [] } = useQuery<Event[]>({
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
queryKey: QueryKeys.events(repository, packageBase), queryKey: QueryKeys.events(repository, packageBase),
queryFn: () => client.fetchPackageEvents(repository, packageBase, 30),
enabled: !!packageBase,
}); });
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({ const rows: EventRow[] = events.map((event, index) => ({
event: event.event,
id: index, id: index,
message: event.message ?? "",
timestamp: new Date(event.created * 1000).toISOStringShort(), timestamp: new Date(event.created * 1000).toISOStringShort(),
})), [events]); event: event.event,
message: event.message ?? "",
}));
return <Box sx={{ mt: 1 }}> return <Box sx={{ mt: 1 }}>
<EventDurationLineChart events={events} /> <EventDurationLineChart events={events} />
<DataGrid columns={columns} rows={rows} {...DETAIL_TABLE_PROPS} /> <DataGrid
rows={rows}
columns={columns}
density="compact"
initialState={{
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
}}
pageSizeOptions={[10, 25]}
autoHeight
sx={{ mt: 1 }}
disableRowSelectionOnClick
/>
</Box>; </Box>;
} }

View File

@@ -23,11 +23,21 @@ import type { Package } from "models/Package";
import React from "react"; import React from "react";
interface PackageDetailsGridProps { interface PackageDetailsGridProps {
dependencies?: Dependencies;
pkg: Package; pkg: Package;
dependencies?: Dependencies;
} }
export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetailsGridProps): React.JSX.Element { function listToString(items: string[]): React.ReactNode {
const unique = [...new Set(items)].sort();
return unique.map((item, index) =>
<React.Fragment key={item}>
{item}
{index < unique.length - 1 && <br />}
</React.Fragment>,
);
}
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
const packagesList = Object.entries(pkg.packages) const packagesList = Object.entries(pkg.packages)
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`); .map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
@@ -37,27 +47,21 @@ export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetails
const licenses = Object.values(pkg.packages) const licenses = Object.values(pkg.packages)
.flatMap(properties => properties.licenses ?? []); .flatMap(properties => properties.licenses ?? []);
const upstreamUrls = Object.values(pkg.packages) const upstreamUrls = [...new Set(
.map(properties => properties.url) Object.values(pkg.packages)
.filter((url): url is string => !!url) .map(properties => properties.url)
.unique(); .filter((url): url is string => !!url),
)].sort();
const aurUrl = pkg.remote.web_url; const aurUrl = pkg.remote.web_url;
const pkgNames = Object.keys(pkg.packages); const pkgNames = Object.keys(pkg.packages);
const pkgValues = Object.values(pkg.packages); const allDepends = Object.values(pkg.packages).flatMap(properties => {
const deps = pkgValues const deps = (properties.depends ?? []).filter(dep => !pkgNames.includes(dep));
.flatMap(properties => (properties.depends ?? []).filter(dep => !pkgNames.includes(dep))) const makeDeps = (properties.make_depends ?? []).filter(dep => !pkgNames.includes(dep)).map(dep => `${dep} (make)`);
.unique(); const optDeps = (properties.opt_depends ?? []).filter(dep => !pkgNames.includes(dep)).map(dep => `${dep} (optional)`);
const makeDeps = pkgValues return [...deps, ...makeDeps, ...optDeps];
.flatMap(properties => (properties.make_depends ?? []).filter(dep => !pkgNames.includes(dep))) });
.map(dep => `${dep} (make)`)
.unique();
const optDeps = pkgValues
.flatMap(properties => (properties.opt_depends ?? []).filter(dep => !pkgNames.includes(dep)))
.map(dep => `${dep} (optional)`)
.unique();
const allDepends = [...deps, ...makeDeps, ...optDeps];
const implicitDepends = dependencies const implicitDepends = dependencies
? Object.values(dependencies.paths).flat() ? Object.values(dependencies.paths).flat()
@@ -65,50 +69,50 @@ export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetails
return <> return <>
<Grid container spacing={1} sx={{ mt: 1 }}> <Grid container spacing={1} sx={{ mt: 1 }}>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packages</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">version</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.version}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packager</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }} /> <Grid size={{ xs: 4, md: 1 }} />
<Grid size={{ md: 5, xs: 8 }} /> <Grid size={{ xs: 8, md: 5 }} />
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">groups</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">licenses</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">upstream</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}> <Grid size={{ xs: 8, md: 5 }}>
{upstreamUrls.map(url => {upstreamUrls.map(url =>
<Link display="block" href={url} key={url} rel="noopener noreferrer" target="_blank" underline="hover" variant="body2"> <Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
{url} {url}
</Link>, </Link>,
)} )}
</Grid> </Grid>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">AUR</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}> <Grid size={{ xs: 8, md: 5 }}>
<Typography variant="body2"> <Typography variant="body2">
{aurUrl && {aurUrl &&
<Link href={aurUrl} rel="noopener noreferrer" target="_blank" underline="hover">AUR link</Link> <Link href={aurUrl} target="_blank" rel="noopener" underline="hover">AUR link</Link>
} }
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">depends</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">implicitly depends</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
</Grid> </Grid>
</>; </>;
} }

View File

@@ -18,38 +18,32 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material"; import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl"; import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type React from "react"; import type React from "react";
interface PackageInfoActionsProps { interface PackageInfoActionsProps {
autoRefreshInterval: number;
autoRefreshIntervals: AutoRefreshInterval[];
isAuthorized: boolean; isAuthorized: boolean;
isHeld: boolean;
onAutoRefreshIntervalChange: (interval: number) => void;
onHoldToggle: () => void;
onRefreshDatabaseChange: (checked: boolean) => void;
onRemove: () => void;
onUpdate: () => void;
refreshDatabase: boolean; refreshDatabase: boolean;
onRefreshDatabaseChange: (checked: boolean) => void;
onUpdate: () => void;
onRemove: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
autoRefreshInterval: number;
onAutoRefreshIntervalChange: (interval: number) => void;
} }
export default function PackageInfoActions({ export default function PackageInfoActions({
autoRefreshInterval,
autoRefreshIntervals,
isAuthorized, isAuthorized,
isHeld,
onAutoRefreshIntervalChange,
onHoldToggle,
onRefreshDatabaseChange,
onRemove,
onUpdate,
refreshDatabase, refreshDatabase,
onRefreshDatabaseChange,
onUpdate,
onRemove,
autoRefreshIntervals,
autoRefreshInterval,
onAutoRefreshIntervalChange,
}: PackageInfoActionsProps): React.JSX.Element { }: PackageInfoActionsProps): React.JSX.Element {
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}> return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{isAuthorized && {isAuthorized &&
@@ -58,20 +52,17 @@ export default function PackageInfoActions({
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />} control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
label="update pacman databases" label="update pacman databases"
/> />
<Button color="warning" onClick={onHoldToggle} size="small" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} variant="outlined"> <Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
{isHeld ? "unhold" : "hold"}
</Button>
<Button color="success" onClick={onUpdate} size="small" startIcon={<PlayArrowIcon />} variant="contained">
update update
</Button> </Button>
<Button color="error" onClick={onRemove} size="small" startIcon={<DeleteIcon />} variant="contained"> <Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
remove remove
</Button> </Button>
</> </>
} }
<AutoRefreshControl <AutoRefreshControl
currentInterval={autoRefreshInterval}
intervals={autoRefreshIntervals} intervals={autoRefreshIntervals}
currentInterval={autoRefreshInterval}
onIntervalChange={onAutoRefreshIntervalChange} onIntervalChange={onAutoRefreshIntervalChange}
/> />
</DialogActions>; </DialogActions>;

View File

@@ -23,39 +23,39 @@ import type { Patch } from "models/Patch";
import type React from "react"; import type React from "react";
interface PackagePatchesListProps { interface PackagePatchesListProps {
patches: Patch[];
editable: boolean; editable: boolean;
onDelete: (key: string) => void; onDelete: (key: string) => void;
patches: Patch[];
} }
export default function PackagePatchesList({ export default function PackagePatchesList({
patches,
editable, editable,
onDelete, onDelete,
patches,
}: PackagePatchesListProps): React.JSX.Element | null { }: PackagePatchesListProps): React.JSX.Element | null {
if (patches.length === 0) { if (patches.length === 0) {
return null; return null;
} }
return <Box sx={{ mt: 2 }}> return <Box sx={{ mt: 2 }}>
<Typography gutterBottom variant="h6">Environment variables</Typography> <Typography variant="h6" gutterBottom>Environment variables</Typography>
{patches.map(patch => {patches.map(patch =>
<Box key={patch.key} sx={{ alignItems: "center", display: "flex", gap: 1, mb: 0.5 }}> <Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<TextField <TextField
disabled
size="small" size="small"
sx={{ flex: 1 }}
value={patch.key} value={patch.key}
disabled
sx={{ flex: 1 }}
/> />
<Box>=</Box> <Box>=</Box>
<TextField <TextField
disabled
value={JSON.stringify(patch.value)}
size="small" size="small"
value={JSON.stringify(patch.value)}
disabled
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
{editable && {editable &&
<IconButton aria-label="Remove patch" color="error" onClick={() => onDelete(patch.key)} size="small"> <IconButton size="small" color="error" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
} }

View File

@@ -1,34 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
import React from "react";
interface PkgbuildTabProps {
packageBase: string;
repository: RepositoryId;
}
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository);
return <CodeBlock content={data?.pkgbuild ?? ""} height={400} language="bash" />;
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
export type TabKey = "logs" | "changes" | "pkgbuild" | "events" | "artifacts";
export const tabs: { key: TabKey; label: string }[] = [
{ key: "logs", label: "Build logs" },
{ key: "changes", label: "Changes" },
{ key: "pkgbuild", label: "PKGBUILD" },
{ key: "events", label: "Events" },
{ key: "artifacts", label: "Artifacts" },
];

View File

@@ -23,6 +23,7 @@ import {
GRID_CHECKBOX_SELECTION_COL_DEF, GRID_CHECKBOX_SELECTION_COL_DEF,
type GridColDef, type GridColDef,
type GridFilterModel, type GridFilterModel,
type GridRenderCellParams,
type GridRowId, type GridRowId,
useGridApiRef, useGridApiRef,
} from "@mui/x-data-grid"; } from "@mui/x-data-grid";
@@ -43,6 +44,8 @@ interface PackageTableProps {
autoRefreshIntervals: AutoRefreshInterval[]; autoRefreshIntervals: AutoRefreshInterval[];
} }
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function createListColumn( function createListColumn(
field: keyof PackageRow, field: keyof PackageRow,
headerName: string, headerName: string,
@@ -52,10 +55,12 @@ function createListColumn(
field, field,
headerName, headerName,
...options, ...options,
renderCell: params =>
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
sortComparator: (left: string, right: string) => left.localeCompare(right),
valueGetter: (value: string[]) => (value ?? []).join(" "), valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) =>
((params.row[field] as string[]) ?? []).map((item, index, items) =>
<React.Fragment key={`${item}-${index}`}>{item}{index < items.length - 1 && <br />}</React.Fragment>,
),
sortComparator: (left: string, right: string) => left.localeCompare(right),
}; };
} }
@@ -76,73 +81,92 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
() => [ () => [
{ {
field: "base", field: "base",
flex: 1,
headerName: "package base", headerName: "package base",
flex: 1,
minWidth: 150, minWidth: 150,
renderCell: params => renderCell: (params: GridRenderCellParams<PackageRow>) =>
params.row.webUrl ? params.row.webUrl ?
<Link href={params.row.webUrl} rel="noopener noreferrer" target="_blank" underline="hover"> <Link href={params.row.webUrl} target="_blank" rel="noopener" underline="hover">
{params.value as string} {params.value as string}
</Link> </Link>
: params.value as string, : params.value as string,
}, },
{ align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 }, { field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }), createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
createListColumn("groups", "groups", { width: 150 }), createListColumn("groups", "groups", { width: 150 }),
createListColumn("licenses", "licenses", { width: 150 }), createListColumn("licenses", "licenses", { width: 150 }),
{ field: "packager", headerName: "packager", width: 150 }, { field: "packager", headerName: "packager", width: 150 },
{ align: "right", field: "timestamp", headerName: "last update", headerAlign: "right", width: 180 },
{ {
align: "center", field: "timestamp",
headerName: "last update",
width: 180,
align: "right",
headerAlign: "right",
},
{
field: "status", field: "status",
headerAlign: "center",
headerName: "status", headerName: "status",
renderCell: params =>
<StatusCell isHeld={params.row.isHeld} status={params.row.status} />,
width: 120, width: 120,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
}, },
], ],
[], [],
); );
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}> return <Box>
<PackageTableToolbar <PackageTableToolbar
actions={{ hasSelection={table.selectionModel.length > 0}
onAddClick: () => table.setDialogOpen("add"), isAuthorized={table.isAuthorized}
onDashboardClick: () => table.setDialogOpen("dashboard"), status={table.status}
onExportClick: () => apiRef.current?.exportDataAsCsv(), searchText={table.searchText}
onKeyImportClick: () => table.setDialogOpen("keyImport"), onSearchChange={table.setSearchText}
onRebuildClick: () => table.setDialogOpen("rebuild"),
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
onReloadClick: table.handleReload,
onRemoveClick: () => void table.handleRemove(),
onUpdateClick: () => void table.handleUpdate(),
}}
autoRefresh={{ autoRefresh={{
autoRefreshIntervals, autoRefreshIntervals,
currentInterval: table.autoRefreshInterval, currentInterval: table.autoRefreshInterval,
onIntervalChange: table.onAutoRefreshIntervalChange, onIntervalChange: table.onAutoRefreshIntervalChange,
}} }}
isAuthorized={table.isAuthorized} actions={{
hasSelection={table.selectionModel.length > 0} onDashboardClick: () => table.setDialogOpen("dashboard"),
onSearchChange={table.setSearchText} onAddClick: () => table.setDialogOpen("add"),
searchText={table.searchText} onUpdateClick: () => void table.handleUpdate(),
status={table.status} onRefreshDbClick: () => void table.handleRefreshDb(),
onRebuildClick: () => table.setDialogOpen("rebuild"),
onRemoveClick: () => void table.handleRemove(),
onKeyImportClick: () => table.setDialogOpen("keyImport"),
onReloadClick: table.handleReload,
onExportClick: () => apiRef.current?.exportDataAsCsv(),
}}
/> />
<DataGrid <DataGrid
apiRef={apiRef} apiRef={apiRef}
checkboxSelection rows={table.rows}
columnVisibilityModel={table.columnVisibility}
columns={columns} columns={columns}
density="compact" loading={table.isLoading}
disableRowSelectionOnClick
filterModel={effectiveFilterModel}
getRowHeight={() => "auto"} getRowHeight={() => "auto"}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
onRowSelectionModelChange={model => {
if (model.type === "exclude") {
const excludeIds = new Set([...model.ids].map(String));
table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
} else {
table.setSelectionModel([...model.ids].map(String));
}
}}
paginationModel={table.paginationModel}
onPaginationModelChange={table.setPaginationModel}
pageSizeOptions={PAGE_SIZE_OPTIONS}
columnVisibilityModel={table.columnVisibility}
onColumnVisibilityModelChange={table.setColumnVisibility}
filterModel={effectiveFilterModel}
onFilterModelChange={table.setFilterModel}
initialState={{ initialState={{
sorting: { sortModel: [{ field: "base", sort: "asc" }] }, sorting: { sortModel: [{ field: "base", sort: "asc" }] },
}} }}
loading={table.isLoading}
onCellClick={(params, event) => { onCellClick={(params, event) => {
// Don't open info dialog when clicking checkbox or link // Don't open info dialog when clicking checkbox or link
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) { if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
@@ -153,32 +177,22 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
} }
table.setSelectedPackage(String(params.id)); table.setSelectedPackage(String(params.id));
}} }}
onColumnVisibilityModelChange={table.setColumnVisibility} autoHeight
onFilterModelChange={table.setFilterModel} sx={{
onPaginationModelChange={table.setPaginationModel} "& .MuiDataGrid-row": { cursor: "pointer" },
onRowSelectionModelChange={model => {
if (model.type === "exclude") {
const excludeIds = new Set([...model.ids].map(String));
table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
} else {
table.setSelectionModel([...model.ids].map(String));
}
}} }}
paginationModel={table.paginationModel} density="compact"
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
rows={table.rows}
sx={{ flex: 1 }}
/> />
<DashboardDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "dashboard"} /> <DashboardDialog open={table.dialogOpen === "dashboard"} onClose={() => table.setDialogOpen(null)} />
<PackageAddDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "add"} /> <PackageAddDialog open={table.dialogOpen === "add"} onClose={() => table.setDialogOpen(null)} />
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} /> <PackageRebuildDialog open={table.dialogOpen === "rebuild"} onClose={() => table.setDialogOpen(null)} />
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} /> <KeyImportDialog open={table.dialogOpen === "keyImport"} onClose={() => table.setDialogOpen(null)} />
<PackageInfoDialog <PackageInfoDialog
autoRefreshIntervals={autoRefreshIntervals}
onClose={() => table.setSelectedPackage(null)}
open={table.selectedPackage !== null}
packageBase={table.selectedPackage} packageBase={table.selectedPackage}
open={table.selectedPackage !== null}
onClose={() => table.setSelectedPackage(null)}
autoRefreshIntervals={autoRefreshIntervals}
/> />
</Box>; </Box>;
} }

View File

@@ -43,47 +43,47 @@ export interface AutoRefreshProps {
} }
export interface ToolbarActions { export interface ToolbarActions {
onAddClick: () => void;
onDashboardClick: () => void; onDashboardClick: () => void;
onExportClick: () => void; onAddClick: () => void;
onKeyImportClick: () => void;
onRebuildClick: () => void;
onRefreshDatabaseClick: () => void;
onReloadClick: () => void;
onRemoveClick: () => void;
onUpdateClick: () => void; onUpdateClick: () => void;
onRefreshDbClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
onExportClick: () => void;
} }
interface PackageTableToolbarProps { interface PackageTableToolbarProps {
actions: ToolbarActions;
autoRefresh: AutoRefreshProps;
hasSelection: boolean; hasSelection: boolean;
isAuthorized: boolean; isAuthorized: boolean;
onSearchChange: (text: string) => void;
searchText: string;
status?: BuildStatus; status?: BuildStatus;
searchText: string;
onSearchChange: (text: string) => void;
autoRefresh: AutoRefreshProps;
actions: ToolbarActions;
} }
export default function PackageTableToolbar({ export default function PackageTableToolbar({
actions,
autoRefresh,
hasSelection, hasSelection,
isAuthorized, isAuthorized,
onSearchChange,
searchText,
status, status,
searchText,
onSearchChange,
autoRefresh,
actions,
}: PackageTableToolbarProps): React.JSX.Element { }: PackageTableToolbarProps): React.JSX.Element {
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null); const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null);
return <Box sx={{ alignItems: "center", display: "flex", flexWrap: "wrap", gap: 1, mb: 1 }}> return <Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
<Tooltip title="System health"> <Tooltip title="System health">
<IconButton <IconButton
aria-label="System health" aria-label="System health"
onClick={actions.onDashboardClick} onClick={actions.onDashboardClick}
sx={{ sx={{
borderColor: status ? StatusColors[status] : undefined, borderColor: status ? StatusColors[status] : undefined,
borderStyle: "solid",
borderWidth: 1, borderWidth: 1,
borderStyle: "solid",
color: status ? StatusColors[status] : undefined, color: status ? StatusColors[status] : undefined,
}} }}
> >
@@ -94,16 +94,16 @@ export default function PackageTableToolbar({
{isAuthorized && {isAuthorized &&
<> <>
<Button <Button
onClick={event => setPackagesAnchorEl(event.currentTarget)}
startIcon={<InventoryIcon />}
variant="contained" variant="contained"
startIcon={<InventoryIcon />}
onClick={event => setPackagesAnchorEl(event.currentTarget)}
> >
packages packages
</Button> </Button>
<Menu <Menu
anchorEl={packagesAnchorEl} anchorEl={packagesAnchorEl}
onClose={() => setPackagesAnchorEl(null)}
open={Boolean(packagesAnchorEl)} open={Boolean(packagesAnchorEl)}
onClose={() => setPackagesAnchorEl(null)}
> >
<MenuItem onClick={() => { <MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onAddClick(); setPackagesAnchorEl(null); actions.onAddClick();
@@ -116,7 +116,7 @@ export default function PackageTableToolbar({
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update <PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
</MenuItem> </MenuItem>
<MenuItem onClick={() => { <MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onRefreshDatabaseClick(); setPackagesAnchorEl(null); actions.onRefreshDbClick();
}}> }}>
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases <DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
</MenuItem> </MenuItem>
@@ -126,58 +126,58 @@ export default function PackageTableToolbar({
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild <ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
</MenuItem> </MenuItem>
<Divider /> <Divider />
<MenuItem disabled={!hasSelection} onClick={() => { <MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onRemoveClick(); setPackagesAnchorEl(null); actions.onRemoveClick();
}}> }} disabled={!hasSelection}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove <DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
</MenuItem> </MenuItem>
</Menu> </Menu>
<Button color="info" onClick={actions.onKeyImportClick} startIcon={<VpnKeyIcon />} variant="contained"> <Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={actions.onKeyImportClick}>
import key import key
</Button> </Button>
</> </>
} }
<Button color="secondary" onClick={actions.onReloadClick} startIcon={<RefreshIcon />} variant="outlined"> <Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={actions.onReloadClick}>
reload reload
</Button> </Button>
<AutoRefreshControl <AutoRefreshControl
currentInterval={autoRefresh.currentInterval}
intervals={autoRefresh.autoRefreshIntervals} intervals={autoRefresh.autoRefreshIntervals}
currentInterval={autoRefresh.currentInterval}
onIntervalChange={autoRefresh.onIntervalChange} onIntervalChange={autoRefresh.onIntervalChange}
/> />
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
<TextField <TextField
aria-label="Search packages"
onChange={event => onSearchChange(event.target.value)}
placeholder="search packages..."
size="small" size="small"
aria-label="Search packages"
placeholder="search packages..."
value={searchText}
onChange={event => onSearchChange(event.target.value)}
slotProps={{ slotProps={{
input: { input: {
endAdornment: searchText ?
<InputAdornment position="end">
<IconButton aria-label="Clear search" onClick={() => onSearchChange("")} size="small">
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
: undefined,
startAdornment: startAdornment:
<InputAdornment position="start"> <InputAdornment position="start">
<SearchIcon fontSize="small" /> <SearchIcon fontSize="small" />
</InputAdornment> </InputAdornment>
, ,
endAdornment: searchText ?
<InputAdornment position="end">
<IconButton size="small" aria-label="Clear search" onClick={() => onSearchChange("")}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
: undefined,
}, },
}} }}
sx={{ minWidth: 200 }} sx={{ minWidth: 200 }}
value={searchText}
/> />
<Tooltip title="Export CSV"> <Tooltip title="Export CSV">
<IconButton aria-label="Export CSV" onClick={actions.onExportClick} size="small"> <IconButton size="small" aria-label="Export CSV" onClick={actions.onExportClick}>
<FileDownloadIcon fontSize="small" /> <FileDownloadIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>

View File

@@ -17,20 +17,17 @@
* 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/>.
*/ */
import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import { Chip } from "@mui/material"; import { Chip } from "@mui/material";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import type React from "react"; import type React from "react";
import { StatusColors } from "theme/StatusColors"; import { StatusColors } from "theme/StatusColors";
interface StatusCellProps { interface StatusCellProps {
isHeld?: boolean;
status: BuildStatus; status: BuildStatus;
} }
export default function StatusCell({ isHeld, status }: StatusCellProps): React.JSX.Element { export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
return <Chip return <Chip
icon={isHeld ? <PauseCircleIcon /> : undefined}
label={status} label={status}
size="small" size="small"
sx={{ sx={{

View File

@@ -26,9 +26,9 @@ interface AuthState {
export interface AuthContextValue extends AuthState { export interface AuthContextValue extends AuthState {
isAuthorized: boolean; isAuthorized: boolean;
setAuthState: (state: AuthState) => void;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
setAuthState: (state: AuthState) => void;
} }
export const AuthContext = createContext<AuthContextValue | null>(null); export const AuthContext = createContext<AuthContextValue | null>(null);

View File

@@ -17,41 +17,33 @@
* 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/>.
*/ */
import { ApiError } from "api/client/ApiError";
import { AuthContext } from "contexts/AuthContext"; import { AuthContext } from "contexts/AuthContext";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import React, { type ReactNode, useCallback, useMemo, useState } from "react"; import React, { type ReactNode, useCallback, useMemo, useState } from "react";
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element { export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const client = useClient(); const client = useClient();
const [authState, setAuthState] = useState({ enabled: true, username: null as string | null }); const [state, setState] = useState({ enabled: true, username: null as string | null });
const { showError } = useNotification();
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (username: string, password: string) => {
await client.login({ username, password }); await client.login({ username, password });
setAuthState(prev => ({ ...prev, username })); setState(prev => ({ ...prev, username }));
}, [client]); }, [client]);
const logout = useCallback(async () => { const doLogout = useCallback(async () => {
try { await client.logout();
await client.logout(); setState(prev => ({ ...prev, username: null }));
setAuthState(prev => ({ ...prev, username: null })); }, [client]);
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Login error", `Could not log out: ${detail}`);
}
}, [client, showError]);
const isAuthorized = useMemo(() => const isAuthorized = useMemo(() => !state.enabled || state.username !== null, [state.enabled, state.username]);
!authState.enabled || authState.username !== null, [authState.enabled, authState.username],
);
const value = useMemo(() => ({ const value = useMemo(() => ({
...authState, isAuthorized, setAuthState, login, logout, ...state, isAuthorized, setAuthState: setState, login, logout: doLogout,
}), [authState, isAuthorized, login, logout]); }), [state, isAuthorized, login, doLogout]);
return <AuthContext.Provider value={value}> return (
{children} <AuthContext.Provider value={value}>
</AuthContext.Provider>; {children}
</AuthContext.Provider>
);
} }

View File

@@ -24,7 +24,9 @@ import React, { type ReactNode, useMemo } from "react";
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element { export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
const client = useMemo(() => new AhrimanClient(), []); const client = useMemo(() => new AhrimanClient(), []);
return <ClientContext.Provider value={client}> return (
{children} <ClientContext.Provider value={client}>
</ClientContext.Provider>; {children}
</ClientContext.Provider>
);
} }

View File

@@ -20,8 +20,8 @@
import type { AlertColor } from "@mui/material"; import type { AlertColor } from "@mui/material";
export interface Notification { export interface Notification {
id: string; id: number;
title: string;
message: string; message: string;
severity: AlertColor; severity: AlertColor;
title: string;
} }

View File

@@ -20,8 +20,8 @@
import { createContext } from "react"; import { createContext } from "react";
export interface NotificationContextValue { export interface NotificationContextValue {
showError: (title: string, message: string) => void;
showSuccess: (title: string, message: string) => void; showSuccess: (title: string, message: string) => void;
showError: (title: string, message: string) => void;
} }
export const NotificationContext = createContext<NotificationContextValue | null>(null); export const NotificationContext = createContext<NotificationContextValue | null>(null);

View File

@@ -18,12 +18,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Alert, Slide } from "@mui/material"; import { Alert, Slide } from "@mui/material";
import type { Notification } from "models/Notification"; import type { Notification } from "contexts/Notification";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
interface NotificationItemProps { interface NotificationItemProps {
notification: Notification; notification: Notification;
onClose: (id: string) => void; onClose: (id: number) => void;
} }
export default function NotificationItem({ notification, onClose }: NotificationItemProps): React.JSX.Element { export default function NotificationItem({ notification, onClose }: NotificationItemProps): React.JSX.Element {
@@ -35,12 +35,12 @@ export default function NotificationItem({ notification, onClose }: Notification
}, []); }, []);
return ( return (
<Slide direction="down" in={show} mountOnEnter onExited={() => onClose(notification.id)} unmountOnExit> <Slide direction="down" in={show} mountOnEnter unmountOnExit onExited={() => onClose(notification.id)}>
<Alert <Alert
onClose={() => setShow(false)} onClose={() => setShow(false)}
severity={notification.severity} severity={notification.severity}
sx={{ width: "100%", pointerEvents: "auto" }}
variant="filled" variant="filled"
sx={{ width: "100%", pointerEvents: "auto" }}
> >
<strong>{notification.title}</strong> <strong>{notification.title}</strong>
{notification.message && ` - ${notification.message}`} {notification.message && ` - ${notification.message}`}

View File

@@ -18,26 +18,22 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { type AlertColor, Box } from "@mui/material"; import { type AlertColor, Box } from "@mui/material";
import NotificationItem from "components/common/NotificationItem"; import type { Notification } from "contexts/Notification";
import { NotificationContext } from "contexts/NotificationContext"; import { NotificationContext } from "contexts/NotificationContext";
import type { Notification } from "models/Notification"; import NotificationItem from "contexts/NotificationItem";
import React, { type ReactNode, useCallback, useMemo, useState } from "react"; import React, { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element { export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
const nextId = useRef(0);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((title: string, message: string, severity: AlertColor) => { const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
const id = `${severity}:${title}:${message}`; const id = nextId.current++;
setNotifications(prev => { setNotifications(prev => [...prev, { id, title, message, severity }]);
if (prev.some(notification => notification.id === id)) {
return prev;
}
return [...prev, { id, title, message, severity }];
});
}, []); }, []);
const removeNotification = useCallback((key: string) => { const removeNotification = useCallback((id: number) => {
setNotifications(prev => prev.filter(notification => notification.id !== key)); setNotifications(prev => prev.filter(notification => notification.id !== id));
}, []); }, []);
const showSuccess = useCallback( const showSuccess = useCallback(
@@ -51,26 +47,28 @@ export function NotificationProvider({ children }: { children: ReactNode }): Rea
const value = useMemo(() => ({ showSuccess, showError }), [showSuccess, showError]); const value = useMemo(() => ({ showSuccess, showError }), [showSuccess, showError]);
return <NotificationContext.Provider value={value}> return (
{children} <NotificationContext.Provider value={value}>
<Box {children}
sx={{ <Box
display: "flex", sx={{
flexDirection: "column", position: "fixed",
gap: 1, top: 16,
left: "50%", left: "50%",
maxWidth: 500, transform: "translateX(-50%)",
pointerEvents: "none", zIndex: theme => theme.zIndex.snackbar,
position: "fixed", display: "flex",
top: 16, flexDirection: "column",
transform: "translateX(-50%)", gap: 1,
width: "100%", maxWidth: 500,
zIndex: theme => theme.zIndex.snackbar, width: "100%",
}} pointerEvents: "none",
> }}
{notifications.map(notification => >
<NotificationItem key={notification.id} notification={notification} onClose={removeNotification} />, {notifications.map(notification =>
)} <NotificationItem key={notification.id} notification={notification} onClose={removeNotification} />,
</Box> )}
</NotificationContext.Provider>; </Box>
</NotificationContext.Provider>
);
} }

View File

@@ -21,10 +21,10 @@ import type { RepositoryId } from "models/RepositoryId";
import { createContext } from "react"; import { createContext } from "react";
export interface RepositoryContextValue { export interface RepositoryContextValue {
currentRepository: RepositoryId | null;
repositories: RepositoryId[]; repositories: RepositoryId[];
setCurrentRepository: (repository: RepositoryId) => void; current: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void; setRepositories: (repositories: RepositoryId[]) => void;
setCurrent: (repository: RepositoryId) => void;
} }
export const RepositoryContext = createContext<RepositoryContextValue | null>(null); export const RepositoryContext = createContext<RepositoryContextValue | null>(null);

View File

@@ -34,20 +34,20 @@ export function RepositoryProvider({ children }: { children: ReactNode }): React
const [repositories, setRepositories] = useState<RepositoryId[]>([]); const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot); const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const currentRepository = useMemo(() => { const current = useMemo(() => {
if (repositories.length === 0) { if (repositories.length === 0) {
return null; return null;
} }
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null; return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]); }, [repositories, hash]);
const setCurrentRepository = useCallback((repository: RepositoryId) => { const setCurrent = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key; window.location.hash = repository.key;
}, []); }, []);
const value = useMemo(() => ({ const value = useMemo(() => ({
repositories, currentRepository, setRepositories, setCurrentRepository, repositories, current, setRepositories, setCurrent,
}), [repositories, currentRepository, setCurrentRepository]); }), [repositories, current, setCurrent]);
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>; return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
} }

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import { createContext } from "react";
export interface ThemeContextValue {
mode: "light" | "dark";
toggleTheme: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);

View File

@@ -1,54 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles";
import { defaults as chartDefaults } from "chart.js";
import { ThemeContext } from "contexts/ThemeContext";
import { useLocalStorage } from "hooks/useLocalStorage";
import React, { useCallback, useEffect, useMemo } from "react";
import { createAppTheme } from "theme/Theme";
function systemPreference(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [mode, setMode] = useLocalStorage<"light" | "dark">("theme-mode", systemPreference());
const toggleTheme = useCallback(() => {
setMode(prev => prev === "light" ? "dark" : "light");
}, [setMode]);
const theme = useMemo(() => createAppTheme(mode), [mode]);
useEffect(() => {
chartDefaults.color = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
chartDefaults.borderColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
}, [mode]);
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
return <ThemeContext.Provider value={value}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>;
}

View File

@@ -21,8 +21,6 @@ import type { RepositoryId } from "models/RepositoryId";
export const QueryKeys = { export const QueryKeys = {
artifacts: (packageBase: string, repository: RepositoryId) => ["artifacts", repository.key, packageBase] as const,
changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const, changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const, dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,

View File

@@ -18,31 +18,22 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { useLocalStorage } from "hooks/useLocalStorage"; import { useLocalStorage } from "hooks/useLocalStorage";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; import { type Dispatch, type SetStateAction, useState } from "react";
interface AutoRefreshResult { interface AutoRefreshResult {
interval: number; interval: number;
paused: boolean;
setInterval: Dispatch<SetStateAction<number>>; setInterval: Dispatch<SetStateAction<number>>;
setPaused: Dispatch<SetStateAction<boolean>>; setPaused: Dispatch<SetStateAction<boolean>>;
} }
export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult { export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
const storageKey = `ahriman-${key}`; const [interval, setInterval] = useLocalStorage<number>(`ahriman-${key}`, defaultInterval);
const [interval, setInterval] = useLocalStorage<number>(storageKey, defaultInterval);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
// Apply defaultInterval when it becomes available (e.g. after info endpoint loads)
// but only if the user hasn't explicitly set a preference
useEffect(() => {
if (defaultInterval > 0 && window.localStorage.getItem(storageKey) === null) {
setInterval(defaultInterval);
}
}, [storageKey, defaultInterval, setInterval]);
const effectiveInterval = paused ? 0 : interval;
return { return {
interval: effectiveInterval, interval,
paused,
setInterval, setInterval,
setPaused, setPaused,
}; };

View File

@@ -17,13 +17,13 @@
* 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/>.
*/ */
import { type RefObject, useCallback, useRef } from "react"; import { type RefObject, useRef } from "react";
interface UseAutoScrollResult { interface UseAutoScrollResult {
handleScroll: () => void;
preRef: RefObject<HTMLElement | null>; preRef: RefObject<HTMLElement | null>;
resetScroll: () => void; handleScroll: () => void;
scrollToBottom: () => void; scrollToBottom: () => void;
resetScroll: () => void;
} }
export function useAutoScroll(): UseAutoScrollResult { export function useAutoScroll(): UseAutoScrollResult {
@@ -31,19 +31,19 @@ export function useAutoScroll(): UseAutoScrollResult {
const initialScrollDone = useRef(false); const initialScrollDone = useRef(false);
const wasAtBottom = useRef(true); const wasAtBottom = useRef(true);
const handleScroll = useCallback((): void => { const handleScroll: () => void = () => {
if (preRef.current) { if (preRef.current) {
const element = preRef.current; const element = preRef.current;
wasAtBottom.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 50; wasAtBottom.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 50;
} }
}, []); };
const resetScroll = useCallback((): void => { const resetScroll: () => void = () => {
initialScrollDone.current = false; initialScrollDone.current = false;
}, []); };
// scroll to bottom on initial load, then only if already near bottom and no active text selection // scroll to bottom on initial load, then only if already near bottom and no active text selection
const scrollToBottom = useCallback((): void => { const scrollToBottom: () => void = () => {
if (!preRef.current) { if (!preRef.current) {
return; return;
} }
@@ -57,7 +57,7 @@ export function useAutoScroll(): UseAutoScrollResult {
element.scrollTop = element.scrollHeight; element.scrollTop = element.scrollHeight;
} }
} }
}, []); };
return { handleScroll, preRef, resetScroll, scrollToBottom }; return { preRef, handleScroll, scrollToBottom, resetScroll };
} }

View File

@@ -21,7 +21,7 @@ import { type Context, useContext } from "react";
export function useContextNotNull<T>(context: Context<T | null>): T { export function useContextNotNull<T>(context: Context<T | null>): T {
const ctx = useContext(context); const ctx = useContext(context);
if (ctx === null) { if (!ctx) {
throw new Error("must be used within a Provider"); throw new Error("must be used within a Provider");
} }
return ctx; return ctx;

View File

@@ -23,7 +23,7 @@ export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<S
const [storedValue, setStoredValue] = useState<T>(() => { const [storedValue, setStoredValue] = useState<T>(() => {
try { try {
const item = window.localStorage.getItem(key); const item = window.localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : initialValue; return item ? (JSON.parse(item) as T) : initialValue;
} catch { } catch {
return initialValue; return initialValue;
} }

View File

@@ -23,13 +23,12 @@ import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification"; import { useNotification } from "hooks/useNotification";
import { useRepository } from "hooks/useRepository"; import { useRepository } from "hooks/useRepository";
import type { RepositoryId } from "models/RepositoryId";
export interface UsePackageActionsResult { export interface UsePackageActionsResult {
handleRefreshDatabase: () => Promise<void>;
handleReload: () => void; handleReload: () => void;
handleRemove: () => Promise<void>;
handleUpdate: () => Promise<void>; handleUpdate: () => Promise<void>;
handleRefreshDb: () => Promise<void>;
handleRemove: () => Promise<void>;
} }
export function usePackageActions( export function usePackageActions(
@@ -37,72 +36,84 @@ export function usePackageActions(
setSelectionModel: (model: string[]) => void, setSelectionModel: (model: string[]) => void,
): UsePackageActionsResult { ): UsePackageActionsResult {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = (repository: RepositoryId): void => { const handleReload: () => void = () => {
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(repository) }); if (!current) {
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(repository) }); return;
}
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
}; };
const performAction = async ( const handleUpdate: () => Promise<void> = async () => {
action: (repository: RepositoryId) => Promise<string>, if (!current) {
errorMessage: string,
): Promise<void> => {
if (!currentRepository) {
return; return;
} }
try { try {
const successMessage = await action(currentRepository); if (selectionModel.length === 0) {
showSuccess("Success", successMessage); await client.servicePackageUpdate(current, { packages: [] });
invalidate(currentRepository); showSuccess("Success", "Repository update has been run");
} else {
await client.servicePackageAdd(current, { packages: selectionModel });
showSuccess("Success", `Run update for packages ${selectionModel.join(", ")}`);
}
setSelectionModel([]); setSelectionModel([]);
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
} catch (exception) { } catch (exception) {
showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`); const detail = ApiError.errorDetail(exception);
showError("Action failed", `Packages update failed: ${detail}`);
} }
}; };
const handleReload = (): void => { const handleRefreshDb: () => Promise<void> = async () => {
if (currentRepository !== null) { if (!current) {
invalidate(currentRepository); return;
}
try {
await client.servicePackageUpdate(current, {
packages: [],
refresh: true,
aur: false,
local: false,
manual: false,
});
showSuccess("Success", "Pacman database update has been requested");
setSelectionModel([]);
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Action failed", `Could not update pacman databases: ${detail}`);
} }
}; };
const handleUpdate = (): Promise<void> => performAction(async (repository): Promise<string> => { const handleRemove: () => Promise<void> = async () => {
if (selectionModel.length === 0) { if (!current) {
await client.service.servicePackageUpdate(repository, { packages: [] }); return;
return "Repository update has been run";
} }
await client.service.servicePackageAdd(repository, { packages: selectionModel });
return `Run update for packages ${selectionModel.join(", ")}`;
}, "Packages update failed");
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
await client.service.servicePackageUpdate(repository, {
aur: false,
local: false,
manual: false,
packages: [],
refresh: true,
});
return "Pacman database update has been requested";
}, "Could not update pacman databases");
const handleRemove = (): Promise<void> => {
if (selectionModel.length === 0) { if (selectionModel.length === 0) {
return Promise.resolve(); return;
}
try {
await client.servicePackageRemove(current, selectionModel);
showSuccess("Success", `Packages ${selectionModel.join(", ")} have been removed`);
setSelectionModel([]);
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Action failed", `Could not remove packages: ${detail}`);
} }
return performAction(async (repository): Promise<string> => {
await client.service.servicePackageRemove(repository, selectionModel);
return `Packages ${selectionModel.join(", ")} have been removed`;
}, "Could not remove packages");
}; };
return { return {
handleRefreshDatabase,
handleReload, handleReload,
handleRemove,
handleUpdate, handleUpdate,
handleRefreshDb,
handleRemove,
}; };
} }

View File

@@ -1,36 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId";
export function usePackageChanges(packageBase: string, repository: RepositoryId): Changes | undefined {
const client = useClient();
const { data } = useQuery<Changes>({
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
queryKey: QueryKeys.changes(packageBase, repository),
});
return data;
}

View File

@@ -30,39 +30,39 @@ import { useMemo } from "react";
import { defaultInterval } from "utils"; import { defaultInterval } from "utils";
export interface UsePackageDataResult { export interface UsePackageDataResult {
autoRefresh: ReturnType<typeof useAutoRefresh>;
isAuthorized: boolean;
isLoading: boolean;
rows: PackageRow[]; rows: PackageRow[];
isLoading: boolean;
isAuthorized: boolean;
status: BuildStatus | undefined; status: BuildStatus | undefined;
autoRefresh: ReturnType<typeof useAutoRefresh>;
} }
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult { export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { current } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({ const { data: packages = [], isLoading } = useQuery({
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken, queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"], queryFn: current ? () => client.fetchPackages(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken, queryKey: current ? QueryKeys.status(current) : ["status"],
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"], queryFn: current ? () => client.fetchServerStatus(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval,
}); });
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]); const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
return { return {
autoRefresh, rows,
isLoading, isLoading,
isAuthorized, isAuthorized,
rows,
status: status?.status.status, status: status?.status.status,
autoRefresh,
}; };
} }

View File

@@ -27,30 +27,35 @@ import type { PackageRow } from "models/PackageRow";
import { useEffect } from "react"; import { useEffect } from "react";
export interface UsePackageTableResult { export interface UsePackageTableResult {
autoRefreshInterval: number;
columnVisibility: Record<string, boolean>;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
filterModel: GridFilterModel;
handleRefreshDatabase: () => Promise<void>;
handleReload: () => void;
handleRemove: () => Promise<void>;
handleUpdate: () => Promise<void>;
isAuthorized: boolean;
isLoading: boolean;
onAutoRefreshIntervalChange: (interval: number) => void;
paginationModel: { page: number; pageSize: number };
rows: PackageRow[]; rows: PackageRow[];
searchText: string; isLoading: boolean;
selectedPackage: string | null; isAuthorized: boolean;
selectionModel: string[];
setColumnVisibility: (model: Record<string, boolean>) => void;
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
setFilterModel: (model: GridFilterModel) => void;
setPaginationModel: (model: { page: number; pageSize: number }) => void;
setSearchText: (text: string) => void;
setSelectedPackage: (base: string | null) => void;
setSelectionModel: (model: string[]) => void;
status: BuildStatus | undefined; status: BuildStatus | undefined;
selectionModel: string[];
setSelectionModel: (model: string[]) => void;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
selectedPackage: string | null;
setSelectedPackage: (base: string | null) => void;
paginationModel: { pageSize: number; page: number };
setPaginationModel: (model: { pageSize: number; page: number }) => void;
columnVisibility: Record<string, boolean>;
setColumnVisibility: (model: Record<string, boolean>) => void;
filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void;
searchText: string;
setSearchText: (text: string) => void;
autoRefreshInterval: number;
onAutoRefreshIntervalChange: (interval: number) => void;
handleReload: () => void;
handleUpdate: () => Promise<void>;
handleRefreshDb: () => Promise<void>;
handleRemove: () => Promise<void>;
} }
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult { export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
@@ -66,13 +71,16 @@ export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): Us
}, [isDialogOpen, setPaused]); }, [isDialogOpen, setPaused]);
return { return {
autoRefreshInterval: autoRefresh.interval, rows,
isLoading, isLoading,
isAuthorized, isAuthorized,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
rows,
status, status,
...actions,
...tableState, ...tableState,
autoRefreshInterval: autoRefresh.interval,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
...actions,
}; };
} }

View File

@@ -22,17 +22,17 @@ import type { RepositoryId } from "models/RepositoryId";
import { useState } from "react"; import { useState } from "react";
export interface SelectedRepositoryResult { export interface SelectedRepositoryResult {
reset: () => void;
selectedKey: string; selectedKey: string;
selectedRepository: RepositoryId | null;
setSelectedKey: (key: string) => void; setSelectedKey: (key: string) => void;
selectedRepository: RepositoryId | null;
reset: () => void;
} }
export function useSelectedRepository(): SelectedRepositoryResult { export function useSelectedRepository(): SelectedRepositoryResult {
const { repositories, currentRepository } = useRepository(); const { repositories, current } = useRepository();
const [selectedKey, setSelectedKey] = useState(""); const [selectedKey, setSelectedKey] = useState("");
let selectedRepository: RepositoryId | null = currentRepository; let selectedRepository: RepositoryId | null = current;
if (selectedKey) { if (selectedKey) {
const repository = repositories.find(repository => repository.key === selectedKey); const repository = repositories.find(repository => repository.key === selectedKey);
if (repository) { if (repository) {
@@ -40,9 +40,9 @@ export function useSelectedRepository(): SelectedRepositoryResult {
} }
} }
const reset = (): void => { const reset: () => void = () => {
setSelectedKey(""); setSelectedKey("");
}; };
return { reset, selectedKey, selectedRepository, setSelectedKey }; return { selectedKey, setSelectedKey, selectedRepository, reset };
} }

View File

@@ -24,20 +24,22 @@ import { useState } from "react";
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport"; export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
export interface UseTableStateResult { export interface UseTableStateResult {
columnVisibility: Record<string, boolean>;
dialogOpen: DialogType | null;
filterModel: GridFilterModel;
paginationModel: { pageSize: number; page: number };
searchText: string;
selectedPackage: string | null;
selectionModel: string[]; selectionModel: string[];
setColumnVisibility: (model: Record<string, boolean>) => void;
setDialogOpen: (dialog: DialogType | null) => void;
setFilterModel: (model: GridFilterModel) => void;
setPaginationModel: (model: { pageSize: number; page: number }) => void;
setSearchText: (text: string) => void;
setSelectedPackage: (base: string | null) => void;
setSelectionModel: (model: string[]) => void; setSelectionModel: (model: string[]) => void;
dialogOpen: DialogType | null;
setDialogOpen: (dialog: DialogType | null) => void;
selectedPackage: string | null;
setSelectedPackage: (base: string | null) => void;
paginationModel: { pageSize: number; page: number };
setPaginationModel: (model: { pageSize: number; page: number }) => void;
columnVisibility: Record<string, boolean>;
setColumnVisibility: (model: Record<string, boolean>) => void;
filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void;
searchText: string;
setSearchText: (text: string) => void;
} }
export function useTableState(): UseTableStateResult { export function useTableState(): UseTableStateResult {
@@ -47,8 +49,8 @@ export function useTableState(): UseTableStateResult {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", { const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
pageSize: 10,
page: 0, page: 0,
pageSize: 25,
}); });
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>( const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
"ahriman-packages-columns", "ahriman-packages-columns",
@@ -60,19 +62,21 @@ export function useTableState(): UseTableStateResult {
); );
return { return {
columnVisibility,
dialogOpen,
filterModel,
paginationModel,
searchText,
selectedPackage,
selectionModel, selectionModel,
setColumnVisibility,
setDialogOpen,
setFilterModel,
setPaginationModel,
setSearchText,
setSelectedPackage,
setSelectionModel, setSelectionModel,
dialogOpen,
setDialogOpen,
selectedPackage,
setSelectedPackage,
paginationModel,
setPaginationModel,
columnVisibility,
setColumnVisibility,
filterModel,
setFilterModel,
searchText,
setSearchText,
}; };
} }

View File

@@ -1,25 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
import { ThemeContext, type ThemeContextValue } from "contexts/ThemeContext";
import { useContextNotNull } from "hooks/useContextNotNull";
export function useThemeMode(): ThemeContextValue {
return useContextNotNull(ThemeContext);
}

View File

@@ -21,18 +21,11 @@ import "chartSetup";
import "utils"; import "utils";
import App from "App"; import App from "App";
import ErrorFallback from "components/common/ErrorBoundary";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ErrorBoundary } from "react-error-boundary";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ErrorBoundary <App />
FallbackComponent={ErrorFallback}
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
>
<App />
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );

View File

@@ -18,6 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export interface AURPackage { export interface AURPackage {
description: string;
package: string; package: string;
description: string;
} }

View File

@@ -17,4 +17,4 @@
* 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/>.
*/ */
export type BuildStatus = "building" | "failed" | "pending" | "success" | "unknown"; export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";

View File

@@ -20,5 +20,4 @@
export interface Changes { export interface Changes {
changes?: string; changes?: string;
last_commit_sha?: string; last_commit_sha?: string;
pkgbuild?: string;
} }

View File

@@ -23,9 +23,9 @@ import type { RepositoryId } from "models/RepositoryId";
export interface InfoResponse { export interface InfoResponse {
auth: AuthInfo; auth: AuthInfo;
repositories: RepositoryId[];
version: string;
autorefresh_intervals: AutoRefreshInterval[]; autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean; docs_enabled: boolean;
index_url?: string; index_url?: string;
repositories: RepositoryId[];
version: string;
} }

View File

@@ -23,8 +23,8 @@ import type { Status } from "models/Status";
export interface InternalStatus { export interface InternalStatus {
architecture: string; architecture: string;
packages: Counters;
repository: string; repository: string;
packages: Counters;
stats: RepositoryStats; stats: RepositoryStats;
status: Status; status: Status;
version: string; version: string;

View File

@@ -18,6 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export interface LoginRequest { export interface LoginRequest {
password: string;
username: string; username: string;
password: string;
} }

View File

@@ -20,10 +20,10 @@
import type { Patch } from "models/Patch"; import type { Patch } from "models/Patch";
export interface PackageActionRequest { export interface PackageActionRequest {
aur?: boolean;
local?: boolean;
manual?: boolean;
packages: string[]; packages: string[];
patches?: Patch[]; patches?: Patch[];
refresh?: boolean; refresh?: boolean;
aur?: boolean;
local?: boolean;
manual?: boolean;
} }

View File

@@ -21,31 +21,30 @@ import type { BuildStatus } from "models/BuildStatus";
import type { PackageStatus } from "models/PackageStatus"; import type { PackageStatus } from "models/PackageStatus";
export class PackageRow { export class PackageRow {
base: string;
groups: string[];
id: string; id: string;
isHeld: boolean; base: string;
webUrl?: string;
version: string;
packages: string[];
groups: string[];
licenses: string[]; licenses: string[];
packager: string; packager: string;
packages: string[];
status: BuildStatus;
timestamp: string; timestamp: string;
version: string; timestampValue: number;
webUrl?: string; status: BuildStatus;
constructor(descriptor: PackageStatus) { constructor(descriptor: PackageStatus) {
this.base = descriptor.package.base;
this.groups = PackageRow.extractListProperties(descriptor.package, "groups");
this.id = descriptor.package.base; this.id = descriptor.package.base;
this.isHeld = descriptor.status.is_held ?? false; this.base = descriptor.package.base;
this.webUrl = descriptor.package.remote.web_url ?? undefined;
this.version = descriptor.package.version;
this.packages = Object.keys(descriptor.package.packages).sort();
this.groups = PackageRow.extractListProperties(descriptor.package, "groups");
this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses"); this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses");
this.packager = descriptor.package.packager ?? ""; this.packager = descriptor.package.packager ?? "";
this.packages = Object.keys(descriptor.package.packages).sort();
this.status = descriptor.status.status;
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort(); this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
this.version = descriptor.package.version; this.timestampValue = descriptor.status.timestamp;
this.webUrl = descriptor.package.remote.web_url ?? undefined; this.status = descriptor.status.status;
} }
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] { private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {

View File

@@ -18,7 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export class RepositoryId { export class RepositoryId {
readonly architecture: string; readonly architecture: string;
readonly repository: string; readonly repository: string;

View File

@@ -1,23 +0,0 @@
/*
* Copyright (c) 2021-2026 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 <http://www.gnu.org/licenses/>.
*/
export interface RollbackRequest {
package: string;
version: string;
}

View File

@@ -20,7 +20,6 @@
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
export interface Status { export interface Status {
is_held?: boolean;
status: BuildStatus; status: BuildStatus;
timestamp: number; timestamp: number;
} }

View File

@@ -21,19 +21,19 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
const base: Record<BuildStatus, string> = { const base: Record<BuildStatus, string> = {
building: orange[800], unknown: grey[800],
failed: red[700], pending: amber[900],
pending: amber[700], building: orange[900],
success: green[700], failed: red[900],
unknown: grey[600], success: green[800],
}; };
const headerBase: Record<BuildStatus, string> = { const headerBase: Record<BuildStatus, string> = {
unknown: grey[800],
pending: amber[700],
building: orange[600], building: orange[600],
failed: red[500], failed: red[500],
pending: amber[700],
success: green[600], success: green[600],
unknown: grey[600],
}; };
export const StatusColors = base; export const StatusColors = base;

View File

@@ -17,28 +17,17 @@
* 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/>.
*/ */
import { createTheme, type Theme } from "@mui/material/styles"; import { createTheme } from "@mui/material/styles";
export function createAppTheme(mode: "light" | "dark"): Theme { const Theme = createTheme({
return createTheme({ components: {
components: { MuiDialog: {
MuiButton: { defaultProps: {
styleOverrides: { maxWidth: "lg",
startIcon: { fullWidth: true,
alignItems: "center",
display: "flex",
},
},
},
MuiDialog: {
defaultProps: {
fullWidth: true,
maxWidth: "lg",
},
}, },
}, },
palette: { },
mode, });
},
}); export default Theme;
}

View File

@@ -19,32 +19,31 @@
*/ */
import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
export const DETAIL_TABLE_PROPS = { // https://www.typescriptlang.org/docs/handbook/mixins.html#alternative-pattern
density: "compact" as const, /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */
disableColumnSorting: true, export function applyMixins(clazz: any, classes: any[]): void {
disableRowSelectionOnClick: true, classes.forEach(baseClass => {
paginationModel: { page: 0, pageSize: 25 }, Object.getOwnPropertyNames(baseClass.prototype).forEach(name => {
sx: { height: 400, mt: 1 }, Object.defineProperty(
}; clazz.prototype,
name,
Object.getOwnPropertyDescriptor(baseClass.prototype, name) || Object.create(null),
);
});
});
}
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */
export function defaultInterval(intervals: AutoRefreshInterval[]): number { export function defaultInterval(intervals: AutoRefreshInterval[]): number {
return intervals.find(interval => interval.is_active)?.interval ?? 0; return intervals.find(interval => interval.is_active)?.interval ?? 0;
} }
declare global { declare global {
interface Array<T> {
unique(): T[];
}
interface Date { interface Date {
toISOStringShort(): string; toISOStringShort(): string;
} }
} }
Array.prototype.unique = function <T>(): T[] {
return [...new Set<T>(this)].sort();
};
// custom formatter to print pretty date, because there is no builtin for this // custom formatter to print pretty date, because there is no builtin for this
Date.prototype.toISOStringShort = function (): string { Date.prototype.toISOStringShort = function (): string {
const pad: (num: number) => string = num => String(num).padStart(2, "0"); const pad: (num: number) => string = num => String(num).padStart(2, "0");

View File

@@ -4,18 +4,18 @@
"baseUrl": "src", "baseUrl": "src",
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"noEmit": true, "noEmit": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noImplicitOverride": true, "noUncheckedIndexedAccess": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"target": "ESNext", "target": "ES2020",
"useDefineForClassFields": true "useDefineForClassFields": true
}, },
"include": ["src"] "include": ["src"]

View File

@@ -1,37 +1,35 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig, type Plugin } from "vite"; import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
function rename(oldName: string, newName: string): Plugin { function renameHtml(newName: string): Plugin {
return { return {
name: "rename-html",
enforce: "post", enforce: "post",
generateBundle(_, bundle) { generateBundle(_, bundle) {
if (bundle[oldName]) { if (bundle["index.html"]) {
bundle[oldName].fileName = newName; bundle["index.html"].fileName = newName;
} }
}, },
name: "rename",
}; };
} }
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")],
base: "/", base: "/",
build: { build: {
chunkSizeWarningLimit: 10000, chunkSizeWarningLimit: 10000,
emptyOutDir: false, emptyOutDir: false,
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"), outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
rolldownOptions: { rollupOptions: {
output: { output: {
assetFileNames: "static/[name].[ext]",
chunkFileNames: "static/[name].js",
entryFileNames: "static/[name].js", entryFileNames: "static/[name].js",
chunkFileNames: "static/[name].js",
assetFileNames: "static/[name].[ext]",
}, },
}, },
}, },
plugins: [react(), rename("index.html", "build-status.jinja2")],
resolve: {
tsconfigPaths: true,
},
server: { server: {
proxy: { proxy: {
"/api": "http://localhost:8080", "/api": "http://localhost:8080",

View File

@@ -2,7 +2,7 @@
pkgbase='ahriman' pkgbase='ahriman'
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web') pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
pkgver=2.20.0 pkgver=2.20.0rc4
pkgrel=1 pkgrel=1
pkgdesc="ArcH linux ReposItory MANager" pkgdesc="ArcH linux ReposItory MANager"
arch=('any') arch=('any')
@@ -17,10 +17,11 @@ source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$
build() { build() {
cd "$pkgbase-$pkgver" cd "$pkgbase-$pkgver"
npm --prefix "frontend" install --cache "$srcdir/npm-cache"
npm --prefix "frontend" run build
python -m build --wheel --no-isolation python -m build --wheel --no-isolation
cd "frontend"
npm install --cache "$srcdir/npm-cache"
npm run build
} }
package_ahriman() { package_ahriman() {
@@ -77,7 +78,7 @@ package_ahriman-triggers() {
package_ahriman-web() { package_ahriman-web() {
pkgname='ahriman-web' pkgname='ahriman-web'
pkgdesc="ArcH linux ReposItory MANager, web server" pkgdesc="ArcH linux ReposItory MANager, web server"
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2' 'python-aiohttp-sse-git') depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2')
optdepends=('python-aioauth-client: OAuth2 authorization support' optdepends=('python-aioauth-client: OAuth2 authorization support'
'python-aiohttp-apispec>=3.0.0: autogenerated API documentation' 'python-aiohttp-apispec>=3.0.0: autogenerated API documentation'
'python-aiohttp-openmetrics: HTTP metrics support' 'python-aiohttp-openmetrics: HTTP metrics support'

Some files were not shown because too many files have changed in this diff Show More