Compare commits

..

2 Commits

Author SHA1 Message Date
arcanis bfb51434a0 initial impl 2026-03-18 16:53:25 +02:00
arcanis a04b6c3b9c refactor: move package archive lockup to package info trait 2026-03-15 20:23:20 +02:00
189 changed files with 1227 additions and 3122 deletions
-6
View File
@@ -26,10 +26,6 @@ jobs:
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Set image date
id: args
run: echo "::set-output name=date::$(date -d yesterday +'%Y-%m-%d')"
- name: Login to docker hub - name: Login to docker hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -57,8 +53,6 @@ jobs:
- name: Build an image and push - name: Build an image and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
build-args: |
BUILD_DATE=${{ steps.args.outputs.date }}
file: docker/Dockerfile file: docker/Dockerfile
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+1 -1
View File
@@ -16,7 +16,7 @@ pacman -S --noconfirm --asdeps base-devel python-build python-flit python-instal
# optional dependencies # optional dependencies
if [[ -z $MINIMAL_INSTALL ]]; then if [[ -z $MINIMAL_INSTALL ]]; then
# web server # web server
pacman -S --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-aiohttp-sse-git python-cryptography python-jinja pacman -S --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-cryptography python-jinja
# additional features # additional features
pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync
fi fi
+2 -2
View File
@@ -1,9 +1,9 @@
version: 2 version: 2
build: build:
os: ubuntu-lts-latest os: ubuntu-20.04
tools: tools:
python: "3.13" python: "3.12"
apt_packages: apt_packages:
- graphviz - graphviz
+12 -13
View File
@@ -1,15 +1,13 @@
# build image # build image
FROM archlinux:base AS build FROM archlinux:base AS build
ARG BUILD_DATE
# install environment # install environment
## create build user ## create build user
RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build
## extract container creation date and set mirror for this timestamp, set PKGEXT and refresh database next ## extract container creation date and set mirror for this timestamp, set PKGEXT and refresh database next
RUN echo "Server = https://archive.archlinux.org/repos/${BUILD_DATE//-/\/}/\$repo/os/\$arch" > "/etc/pacman.d/mirrorlist" && \ RUN echo "Server = https://archive.archlinux.org/repos/$(stat -c "%y" "/var/lib/pacman" | cut -d " " -f 1 | sed "s,-,/,g")/\$repo/os/\$arch" > "/etc/pacman.d/mirrorlist" && \
pacman -Syyuu --noconfirm pacman -Sy
## setup package cache ## setup package cache
RUN runuser -u build -- mkdir "/tmp/pkg" && \ RUN runuser -u build -- mkdir "/tmp/pkg" && \
echo "PKGDEST=/tmp/pkg" >> "/etc/makepkg.conf" && \ echo "PKGDEST=/tmp/pkg" >> "/etc/makepkg.conf" && \
@@ -31,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 \
@@ -49,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 \
@@ -59,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
@@ -110,7 +109,7 @@ RUN cp "/etc/pacman.d/mirrorlist" "/etc/pacman.d/mirrorlist.orig" && \
echo "Server = file:///var/cache/pacman/pkg" > "/etc/pacman.d/mirrorlist" && \ echo "Server = file:///var/cache/pacman/pkg" > "/etc/pacman.d/mirrorlist" && \
cp "/etc/pacman.conf" "/etc/pacman.conf.orig" && \ cp "/etc/pacman.conf" "/etc/pacman.conf.orig" && \
sed -i "s/SigLevel *=.*/SigLevel = Optional/g" "/etc/pacman.conf" && \ sed -i "s/SigLevel *=.*/SigLevel = Optional/g" "/etc/pacman.conf" && \
pacman -Syyuu --noconfirm pacman -Sy
## install package and its optional dependencies ## install package and its optional dependencies
RUN pacman -S --noconfirm ahriman RUN pacman -S --noconfirm ahriman
RUN pacman -S --noconfirm --asdeps \ RUN pacman -S --noconfirm --asdeps \
+1 -3
View File
@@ -7,10 +7,8 @@ for PACKAGE in "$@"; do
# clone the remote source # clone the remote source
git clone https://aur.archlinux.org/"$PACKAGE".git "$BUILD_DIR" git clone https://aur.archlinux.org/"$PACKAGE".git "$BUILD_DIR"
cd "$BUILD_DIR" cd "$BUILD_DIR"
# FIXME monkey patch PKGBUILD for python
sed -i 's/python -m build/python -m build --skip-dependency-check/g' "PKGBUILD"
# checkout to the image date # checkout to the image date
git checkout "$(git rev-list -1 --before="$BUILD_DATE" master)" git checkout "$(git rev-list -1 --before="$(stat -c "%y" "/var/lib/pacman" | cut -d " " -f 1)" master)"
# build and install the package # build and install the package
makepkg --nocheck --noconfirm --install --rmdeps --syncdeps makepkg --nocheck --noconfirm --install --rmdeps --syncdeps
cd / cd /
-8
View File
@@ -164,14 +164,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
--------------------------------------- ---------------------------------------
-8
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
---------------------------------------- ----------------------------------------
-8
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
------------------------------------------------- -------------------------------------------------
-32
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
---------------------------------------- ----------------------------------------
@@ -260,14 +252,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 +332,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 +340,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
----------------------------------------- -----------------------------------------
-8
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
------------------------------------------- -------------------------------------------
-8
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
------------------------------------------ ------------------------------------------
-12
View File
@@ -235,18 +235,6 @@ 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
^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
+1 -6
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
----------------- -----------------
-25
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
-3
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
+3 -10
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,14 +17,13 @@ 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
@@ -39,7 +33,7 @@ export default tseslint.config(
// core // core
"curly": "error", "curly": "error",
"eqeqeq": "error", "eqeqeq": "error",
"no-console": ["warn", { allow: ["warn", "error"] }], "no-console": "error",
"no-eval": "error", "no-eval": "error",
// stylistic // stylistic
@@ -74,7 +68,6 @@ export default tseslint.config(
"@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-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error", "@typescript-eslint/prefer-optional-chain": "error",
+30 -32
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",
"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.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@mui/x-data-grid": "^8.27.4",
"@tanstack/react-query": "^5.90.21",
"chart.js": "^4.5.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4",
"react-syntax-highlighter": "^16.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^6.0.1",
"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.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
} }
+1 -4
View File
@@ -21,7 +21,6 @@ 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 { EventStreamProvider } from "contexts/EventStreamProvider";
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 { ThemeProvider } from "contexts/ThemeProvider";
@@ -30,8 +29,8 @@ import type React from "react";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: 1,
staleTime: 30_000, staleTime: 30_000,
retry: 1,
}, },
}, },
}); });
@@ -43,9 +42,7 @@ export default function App(): React.JSX.Element {
<ClientProvider> <ClientProvider>
<AuthProvider> <AuthProvider>
<RepositoryProvider> <RepositoryProvider>
<EventStreamProvider>
<AppLayout /> <AppLayout />
</EventStreamProvider>
</RepositoryProvider> </RepositoryProvider>
</AuthProvider> </AuthProvider>
</ClientProvider> </ClientProvider>
+1 -2
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}`);
-7
View File
@@ -24,7 +24,6 @@ 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";
@@ -43,12 +42,6 @@ export class FetchClient {
}); });
} }
async fetchPackageArtifacts(packageBase: string, repository: RepositoryId): Promise<Package[]> {
return this.client.request<Package[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/archives`, {
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.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
query: repository.toQuery(), query: repository.toQuery(),
+1 -1
View File
@@ -18,8 +18,8 @@
* 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>;
json?: unknown;
timeout?: number; timeout?: number;
} }
+8 -17
View File
@@ -23,7 +23,6 @@ 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 ServiceClient {
@@ -37,14 +36,6 @@ export class ServiceClient {
return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data }); return this.client.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.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
method: "DELETE", method: "DELETE",
@@ -67,14 +58,6 @@ export class ServiceClient {
}); });
} }
async servicePackageRollback(repository: RepositoryId, data: RollbackRequest): Promise<void> {
return this.client.request("/api/v1/service/rollback", {
method: "POST",
query: repository.toQuery(),
json: data,
});
}
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.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
} }
@@ -95,6 +78,14 @@ export class ServiceClient {
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data }); return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
} }
async servicePackageHoldUpdate(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 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.client.request("/api/v1/service/rebuild", {
method: "POST", method: "POST",
@@ -32,11 +32,11 @@ 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),
borderColor: blue[500],
backgroundColor: blue[200],
cubicInterpolationMode: "monotone" as const,
tension: 0.4, tension: 0.4,
}, },
], ],
@@ -29,19 +29,19 @@ 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],
data: [stats.bases ?? 0],
label: "bases", label: "bases",
data: [stats.bases ?? 0],
backgroundColor: indigo[300],
}, },
{ {
backgroundColor: blue[500],
data: [stats.packages ?? 0],
label: "archives", label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
}, },
], ],
labels: ["packages"],
}} }}
options={{ options={{
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -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 }} />;
@@ -0,0 +1,91 @@
/*
* 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 CheckIcon from "@mui/icons-material/Check";
import TimerIcon from "@mui/icons-material/Timer";
import TimerOffIcon from "@mui/icons-material/TimerOff";
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import React, { useState } from "react";
interface AutoRefreshControlProps {
intervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
export default function AutoRefreshControl({
intervals,
currentInterval,
onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (intervals.length === 0) {
return null;
}
const enabled = currentInterval > 0;
return <>
<Tooltip title="Auto-refresh">
<IconButton
size="small"
aria-label="Auto-refresh"
onClick={event => setAnchorEl(event.currentTarget)}
color={enabled ? "primary" : "default"}
>
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
<MenuItem
selected={!enabled}
onClick={() => {
onIntervalChange(0);
setAnchorEl(null);
}}
>
<ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>Off</ListItemText>
</MenuItem>
{intervals.map(interval =>
<MenuItem
key={interval.interval}
selected={enabled && interval.interval === currentInterval}
onClick={() => {
onIntervalChange(interval.interval);
setAnchorEl(null);
}}
>
<ListItemIcon>
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{interval.text}</ListItemText>
</MenuItem>,
)}
</Menu>
</>;
}
+10 -7
View File
@@ -46,24 +46,27 @@ export default function CodeBlock({
return <Box sx={{ position: "relative" }}> return <Box sx={{ position: "relative" }}>
<Box <Box
onScroll={onScroll}
ref={preRef} ref={preRef}
onScroll={onScroll}
sx={{ overflow: "auto", height }} sx={{ overflow: "auto", height }}
> >
<SyntaxHighlighter <SyntaxHighlighter
customStyle={{
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
padding: theme.spacing(2),
}}
language={language} language={language}
style={mode === "dark" ? vs2015 : githubGist} style={mode === "dark" ? vs2015 : githubGist}
wrapLongLines wrapLongLines
customStyle={{
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
minHeight: "100%",
}}
> >
{content} {content}
</SyntaxHighlighter> </SyntaxHighlighter>
</Box> </Box>
{content && <Box sx={{ position: "absolute", right: 8, top: 8 }}> {content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton text={content} /> <CopyButton text={content} />
</Box>} </Box>}
</Box>; </Box>;
@@ -40,7 +40,7 @@ export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element
}; };
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>;
@@ -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 />
@@ -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>;
}
@@ -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}`}
@@ -30,9 +30,9 @@ export default function RepositorySelect({
return <FormControl fullWidth margin="normal"> return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel> <InputLabel>repository</InputLabel>
<Select <Select
value={repositorySelect.selectedKey || (currentRepository?.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}>
@@ -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 { currentRepository } = useRepository();
const { data: status } = useQuery<InternalStatus>({ const { data: status } = useQuery<InternalStatus>({
enabled: open,
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"], queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
enabled: open,
}); });
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} 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={{ height: 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={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
<StatusPieChart counters={status.packages} /> <StatusPieChart counters={status.packages} />
</Box> </Box>
</Grid> </Grid>
@@ -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();
@@ -54,7 +54,7 @@ export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps)
onClose(); onClose();
}; };
const handleFetch = async (): Promise<void> => { const handleFetch: () => Promise<void> = async () => {
if (!fingerprint || !server) { if (!fingerprint || !server) {
return; return;
} }
@@ -67,7 +67,7 @@ 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;
} }
@@ -81,38 +81,38 @@ export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps)
} }
}; };
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}> return <Dialog open={open} onClose={handleClose} 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 content={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>;
} }
+12 -17
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} 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>;
} }
@@ -52,11 +52,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,9 +77,9 @@ 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.service.servicePackageSearch(debouncedSearch),
enabled: debouncedSearch.length >= 3,
}); });
const handleSubmit = async (action: "add" | "request"): Promise<void> => { const handleSubmit = async (action: "add" | "request"): Promise<void> => {
@@ -107,7 +107,7 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
} }
}; };
return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}> return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Add new packages Add new packages
</DialogHeader> </DialogHeader>
@@ -117,18 +117,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 (
<li {...props} key={option}>
{option}{pkg ? ` (${pkg.description})` : ""} {option}{pkg ? ` (${pkg.description})` : ""}
</li>; </li>
);
}} }}
renderInput={params =>
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
}
/> />
<FormControlLabel <FormControlLabel
@@ -138,50 +140,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 +186,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 handleSubmit("add")} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
<Button color="success" onClick={() => void handleSubmit("request")} startIcon={<AddIcon />} variant="contained">request</Button> <Button onClick={() => void handleSubmit("request")} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }
@@ -21,7 +21,6 @@ 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";
@@ -32,25 +31,30 @@ import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey"; 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 { 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 { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { Dependencies } from "models/Dependencies"; import type { Dependencies } from "models/Dependencies";
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 React, { useState } from "react"; import React, { useState } from "react";
import { StatusHeaderStyles } from "theme/StatusColors"; import { StatusHeaderStyles } from "theme/StatusColors";
import { defaultInterval } from "utils";
interface PackageInfoDialogProps { interface PackageInfoDialogProps {
onClose: () => void;
open: boolean;
packageBase: string | null; packageBase: string | null;
open: boolean;
onClose: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
} }
export default function PackageInfoDialog({ export default function PackageInfoDialog({
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 { currentRepository } = useRepository();
@@ -72,32 +76,35 @@ export default function PackageInfoDialog({
onClose(); onClose();
}; };
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({ const { data: packageData } = useQuery<PackageStatus[]>({
enabled: open, queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
queryFn: localPackageBase && currentRepository ? queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken, () => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"], enabled: open,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: dependencies } = useQuery<Dependencies>({ const { data: dependencies } = useQuery<Dependencies>({
enabled: open, queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
queryFn: localPackageBase && currentRepository ? queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken, () => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"], enabled: open,
}); });
const { data: patches = [] } = useQuery<Patch[]>({ const { data: patches = [] } = useQuery<Patch[]>({
enabled: open,
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"], queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
enabled: open,
}); });
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 (!localPackageBase || !currentRepository) {
return; return;
} }
@@ -110,7 +117,7 @@ export default function PackageInfoDialog({
} }
}; };
const handleRemove = async (): Promise<void> => { const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) { if (!localPackageBase || !currentRepository) {
return; return;
} }
@@ -123,20 +130,20 @@ export default function PackageInfoDialog({
} }
}; };
const handleHoldToggle = async (): Promise<void> => { const handleHoldToggle: () => Promise<void> = async () => {
if (!localPackageBase || !currentRepository) { if (!localPackageBase || !currentRepository) {
return; return;
} }
try { try {
const newHeldStatus = !(status?.is_held ?? false); const newHeldStatus = !(status?.is_held ?? false);
await client.service.servicePackageHold(localPackageBase, currentRepository, newHeldStatus); await client.service.servicePackageHoldUpdate(localPackageBase, currentRepository, newHeldStatus);
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) }); void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
} catch (exception) { } catch (exception) {
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
} }
}; };
const handleDeletePatch = async (key: string): Promise<void> => { const handleDeletePatch: (key: string) => Promise<void> = async key => {
if (!localPackageBase) { if (!localPackageBase) {
return; return;
} }
@@ -148,7 +155,7 @@ export default function PackageInfoDialog({
} }
}; };
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}> return <Dialog open={open} onClose={handleClose} 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()}`
@@ -158,16 +165,16 @@ export default function PackageInfoDialog({
<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={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}>
{tabs.map(({ key, label }) => <Tab key={key} label={label} value={key} />)} {tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)}
</Tabs> </Tabs>
</Box> </Box>
@@ -175,6 +182,7 @@ export default function PackageInfoDialog({
<BuildLogsTab <BuildLogsTab
packageBase={localPackageBase} packageBase={localPackageBase}
repository={currentRepository} repository={currentRepository}
refreshInterval={autoRefresh.interval}
/> />
} }
{activeTab === "changes" && localPackageBase && currentRepository && {activeTab === "changes" && localPackageBase && currentRepository &&
@@ -186,25 +194,21 @@ export default function PackageInfoDialog({
{activeTab === "events" && localPackageBase && currentRepository && {activeTab === "events" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} /> <EventsTab packageBase={localPackageBase} repository={currentRepository} />
} }
{activeTab === "artifacts" && localPackageBase && currentRepository &&
<ArtifactsTab
currentVersion={pkg.version}
packageBase={localPackageBase}
repository={currentRepository}
/>
}
</> </>
} }
</DialogContent> </DialogContent>
<PackageInfoActions <PackageInfoActions
isAuthorized={isAuthorized} isAuthorized={isAuthorized}
refreshDatabase={refreshDatabase}
onRefreshDatabaseChange={setRefreshDatabase}
isHeld={status?.is_held ?? false} isHeld={status?.is_held ?? false}
onHoldToggle={() => void handleHoldToggle()} onHoldToggle={() => void handleHoldToggle()}
onRefreshDatabaseChange={setRefreshDatabase}
onRemove={() => void handleRemove()}
onUpdate={() => void handleUpdate()} onUpdate={() => void handleUpdate()}
refreshDatabase={refreshDatabase} onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
/> />
</Dialog>; </Dialog>;
} }
@@ -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;
} }
@@ -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} 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>;
} }
+8 -6
View File
@@ -41,8 +41,8 @@ export default function AppLayout(): React.JSX.Element {
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.fetch.fetchServerInfo(),
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -55,9 +55,9 @@ 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://ahriman.readthedocs.io/" 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 />
@@ -69,15 +69,17 @@ export default function AppLayout(): React.JSX.Element {
</Tooltip> </Tooltip>
</Box> </Box>
<PackageTable /> <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>;
} }
+13 -13
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>
} }
+2 -2
View File
@@ -35,15 +35,15 @@ export default function Navbar(): React.JSX.Element | null {
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); setCurrentRepository(repository);
} }
}} }}
scrollButtons="auto"
value={currentIndex >= 0 ? currentIndex : 0}
variant="scrollable" variant="scrollable"
scrollButtons="auto"
> >
{repositories.map(repository => {repositories.map(repository =>
<Tab <Tab
@@ -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>;
}
@@ -23,22 +23,22 @@ import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
import CodeBlock from "components/common/CodeBlock"; import CodeBlock from "components/common/CodeBlock";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAutoScroll } from "hooks/useAutoScroll"; import { useAutoScroll } from "hooks/useAutoScroll";
import { useBuildLogStream } from "hooks/useBuildLogStream";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import type { LogRecord } from "models/LogRecord"; import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId"; 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;
repository: RepositoryId; repository: RepositoryId;
refreshInterval: number;
} }
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string { function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
@@ -51,16 +51,17 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
export default function BuildLogsTab({ export default function BuildLogsTab({
packageBase, packageBase,
repository, repository,
refreshInterval,
}: BuildLogsTabProps): React.JSX.Element { }: BuildLogsTabProps): React.JSX.Element {
const client = useClient(); const client = useClient();
useBuildLogStream(packageBase, repository);
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.fetch.fetchPackageLogs(packageBase, repository),
enabled: !!packageBase,
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
// Build version selectors from all logs // Build version selectors from all logs
@@ -83,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]);
@@ -109,13 +110,14 @@ export default function BuildLogsTab({
// Refresh active version logs // Refresh active version logs
const { data: versionLogs } = useQuery<LogRecord[]>({ const { data: versionLogs } = useQuery<LogRecord[]>({
placeholderData: keepPreviousData, queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
queryFn: activeVersion queryFn: activeVersion
? () => client.fetch.fetchPackageLogs( ? () => client.fetch.fetchPackageLogs(
packageBase, repository, activeVersion.version, activeVersion.processId, packageBase, repository, activeVersion.version, activeVersion.processId,
) )
: skipToken, : skipToken,
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""), placeholderData: keepPreviousData,
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
// Derive displayed logs: prefer fresh polled data when available // Derive displayed logs: prefer fresh polled data when available
@@ -141,25 +143,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>,
@@ -172,10 +174,10 @@ export default function BuildLogsTab({
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<CodeBlock <CodeBlock
preRef={preRef}
content={displayedLogs} content={displayedLogs}
height={400} height={400}
onScroll={handleScroll} onScroll={handleScroll}
preRef={preRef}
/> />
</Box> </Box>
</Box>; </Box>;
@@ -30,5 +30,5 @@ 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 data = usePackageChanges(packageBase, repository);
return <CodeBlock content={data?.changes ?? ""} height={400} language="diff" />; return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
} }
+20 -11
View File
@@ -27,7 +27,6 @@ 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 { useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface EventsTabProps { interface EventsTabProps {
packageBase: string; packageBase: string;
@@ -35,36 +34,46 @@ 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.fetch.fetchPackageEvents(repository, packageBase, 30),
enabled: !!packageBase,
}); });
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({ const rows = useMemo<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(),
event: event.event,
message: event.message ?? "",
})), [events]); })), [events]);
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]}
sx={{ height: 400, mt: 1 }}
disableRowSelectionOnClick
/>
</Box>; </Box>;
} }
@@ -23,11 +23,11 @@ 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 { 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})` : ""}`);
@@ -65,50 +65,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" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</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" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</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" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</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 noreferrer" 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">{aurUrl}</Link> <Link href={aurUrl} target="_blank" rel="noopener noreferrer" 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" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</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" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
</Grid> </Grid>
</>; </>;
} }
@@ -22,26 +22,34 @@ 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 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 type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type React from "react"; import type React from "react";
interface PackageInfoActionsProps { interface PackageInfoActionsProps {
isAuthorized: boolean; isAuthorized: boolean;
isHeld: boolean; isHeld: boolean;
onHoldToggle: () => 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({
isAuthorized, isAuthorized,
refreshDatabase,
onRefreshDatabaseChange,
isHeld, isHeld,
onHoldToggle, onHoldToggle,
onRefreshDatabaseChange,
onRemove,
onUpdate, onUpdate,
refreshDatabase, 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 &&
@@ -50,16 +58,21 @@ 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={onHoldToggle} variant="outlined" color="warning" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} size="small">
{isHeld ? "unhold" : "hold"} {isHeld ? "unhold" : "hold"}
</Button> </Button>
<Button color="success" onClick={onUpdate} size="small" startIcon={<PlayArrowIcon />} variant="contained"> <Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
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
intervals={autoRefreshIntervals}
currentInterval={autoRefreshInterval}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</DialogActions>; </DialogActions>;
} }
@@ -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" aria-label="Remove patch" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
} }
@@ -30,5 +30,5 @@ interface PkgbuildTabProps {
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element { export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository); const data = usePackageChanges(packageBase, repository);
return <CodeBlock content={data?.pkgbuild ?? ""} height={400} language="bash" />; return <CodeBlock language="bash" content={data?.pkgbuild ?? ""} height={400} />;
} }
+1 -2
View File
@@ -17,12 +17,11 @@
* 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 TabKey = "logs" | "changes" | "pkgbuild" | "events" | "artifacts"; export type TabKey = "logs" | "changes" | "pkgbuild" | "events";
export const tabs: { key: TabKey; label: string }[] = [ export const tabs: { key: TabKey; label: string }[] = [
{ key: "logs", label: "Build logs" }, { key: "logs", label: "Build logs" },
{ key: "changes", label: "Changes" }, { key: "changes", label: "Changes" },
{ key: "pkgbuild", label: "PKGBUILD" }, { key: "pkgbuild", label: "PKGBUILD" },
{ key: "events", label: "Events" }, { key: "events", label: "Events" },
{ key: "artifacts", label: "Artifacts" },
]; ];
+77 -53
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";
@@ -35,9 +36,16 @@ import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell"; import StatusCell from "components/table/StatusCell";
import { useDebounce } from "hooks/useDebounce"; import { useDebounce } from "hooks/useDebounce";
import { usePackageTable } from "hooks/usePackageTable"; import { usePackageTable } from "hooks/usePackageTable";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { PackageRow } from "models/PackageRow"; import type { PackageRow } from "models/PackageRow";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
interface PackageTableProps {
autoRefreshIntervals: AutoRefreshInterval[];
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function createListColumn( function createListColumn(
field: keyof PackageRow, field: keyof PackageRow,
headerName: string, headerName: string,
@@ -47,15 +55,15 @@ function createListColumn(
field, field,
headerName, headerName,
...options, ...options,
renderCell: params => valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) =>
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>, <Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
sortComparator: (left: string, right: string) => left.localeCompare(right), sortComparator: (left: string, right: string) => left.localeCompare(right),
valueGetter: (value: string[]) => (value ?? []).join(" "),
}; };
} }
export default function PackageTable(): React.JSX.Element { export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element {
const table = usePackageTable(); const table = usePackageTable(autoRefreshIntervals);
const apiRef = useGridApiRef(); const apiRef = useGridApiRef();
const debouncedSearch = useDebounce(table.searchText, 300); const debouncedSearch = useDebounce(table.searchText, 300);
@@ -71,30 +79,36 @@ export default function PackageTable(): React.JSX.Element {
() => [ () => [
{ {
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 noreferrer" 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} isHeld={params.row.isHeld} />,
}, },
], ],
[], [],
@@ -102,37 +116,56 @@ export default function PackageTable(): React.JSX.Element {
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}> return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<PackageTableToolbar <PackageTableToolbar
actions={{
onAddClick: () => table.setDialogOpen("add"),
onDashboardClick: () => table.setDialogOpen("dashboard"),
onExportClick: () => apiRef.current?.exportDataAsCsv(),
onKeyImportClick: () => table.setDialogOpen("keyImport"),
onRebuildClick: () => table.setDialogOpen("rebuild"),
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
onReloadClick: table.handleReload,
onRemoveClick: () => void table.handleRemove(),
onUpdateClick: () => void table.handleUpdate(),
}}
isAuthorized={table.isAuthorized}
hasSelection={table.selectionModel.length > 0} hasSelection={table.selectionModel.length > 0}
onSearchChange={table.setSearchText} isAuthorized={table.isAuthorized}
searchText={table.searchText}
status={table.status} status={table.status}
searchText={table.searchText}
onSearchChange={table.setSearchText}
autoRefresh={{
autoRefreshIntervals,
currentInterval: table.autoRefreshInterval,
onIntervalChange: table.onAutoRefreshIntervalChange,
}}
actions={{
onDashboardClick: () => table.setDialogOpen("dashboard"),
onAddClick: () => table.setDialogOpen("add"),
onUpdateClick: () => void table.handleUpdate(),
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
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) {
@@ -143,31 +176,22 @@ export default function PackageTable(): React.JSX.Element {
} }
table.setSelectedPackage(String(params.id)); table.setSelectedPackage(String(params.id));
}} }}
onColumnVisibilityModelChange={table.setColumnVisibility} sx={{
onFilterModelChange={table.setFilterModel} flex: 1,
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
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>;
} }
@@ -30,50 +30,60 @@ import ReplayIcon from "@mui/icons-material/Replay";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import VpnKeyIcon from "@mui/icons-material/VpnKey"; import VpnKeyIcon from "@mui/icons-material/VpnKey";
import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material"; import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import React, { useState } from "react"; import React, { useState } from "react";
import { StatusColors } from "theme/StatusColors"; import { StatusColors } from "theme/StatusColors";
export interface AutoRefreshProps {
autoRefreshIntervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
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;
onRefreshDatabaseClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
onExportClick: () => void;
} }
interface PackageTableToolbarProps { interface PackageTableToolbarProps {
actions: ToolbarActions;
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,
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,
}} }}
> >
@@ -84,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,52 +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
intervals={autoRefresh.autoRefreshIntervals}
currentInterval={autoRefresh.currentInterval}
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>
+2 -2
View File
@@ -24,11 +24,11 @@ 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;
isHeld?: boolean;
} }
export default function StatusCell({ isHeld, status }: StatusCellProps): React.JSX.Element { export default function StatusCell({ status, isHeld }: StatusCellProps): React.JSX.Element {
return <Chip return <Chip
icon={isHeld ? <PauseCircleIcon /> : undefined} icon={isHeld ? <PauseCircleIcon /> : undefined}
label={status} label={status}
+1 -1
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);
+4 -2
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 (
<ClientContext.Provider value={client}>
{children} {children}
</ClientContext.Provider>; </ClientContext.Provider>
);
} }
@@ -1,32 +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 { useQueryClient } from "@tanstack/react-query";
import { useEventStream } from "hooks/useEventStream";
import { useRepository } from "hooks/useRepository";
import type { ReactNode } from "react";
export function EventStreamProvider({ children }: { children: ReactNode }): ReactNode {
const queryClient = useQueryClient();
const { currentRepository } = useRepository();
useEventStream(queryClient, currentRepository);
return children;
}
+1 -1
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);
@@ -55,17 +55,17 @@ export function NotificationProvider({ children }: { children: ReactNode }): Rea
{children} {children}
<Box <Box
sx={{ sx={{
position: "fixed",
top: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: theme => theme.zIndex.snackbar,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 1, gap: 1,
left: "50%",
maxWidth: 500, maxWidth: 500,
pointerEvents: "none",
position: "fixed",
top: 16,
transform: "translateX(-50%)",
width: "100%", width: "100%",
zIndex: theme => theme.zIndex.snackbar, pointerEvents: "none",
}} }}
> >
{notifications.map(notification => {notifications.map(notification =>
+2 -2
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; currentRepository: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void; setRepositories: (repositories: RepositoryId[]) => void;
setCurrentRepository: (repository: RepositoryId) => void;
} }
export const RepositoryContext = createContext<RepositoryContextValue | null>(null); export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
+4 -2
View File
@@ -39,8 +39,10 @@ export function ThemeProvider({ children }: { children: React.ReactNode }): Reac
const theme = useMemo(() => createAppTheme(mode), [mode]); const theme = useMemo(() => createAppTheme(mode), [mode]);
useEffect(() => { useEffect(() => {
chartDefaults.color = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)"; const textColor = 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)"; const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
chartDefaults.color = textColor;
chartDefaults.borderColor = gridColor;
}, [mode]); }, [mode]);
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]); const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
-2
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,
+49
View File
@@ -0,0 +1,49 @@
/*
* 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 { useLocalStorage } from "hooks/useLocalStorage";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
interface AutoRefreshResult {
interval: number;
setInterval: Dispatch<SetStateAction<number>>;
setPaused: Dispatch<SetStateAction<boolean>>;
}
export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult {
const storageKey = `ahriman-${key}`;
const [interval, setInterval] = useLocalStorage<number>(storageKey, defaultInterval);
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 {
interval: effectiveInterval,
setInterval,
setPaused,
};
}
+3 -3
View File
@@ -20,10 +20,10 @@
import { type RefObject, useCallback, useRef } from "react"; import { type RefObject, useCallback, 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 {
@@ -59,5 +59,5 @@ export function useAutoScroll(): UseAutoScrollResult {
} }
}, []); }, []);
return { handleScroll, preRef, resetScroll, scrollToBottom }; return { preRef, handleScroll, scrollToBottom, resetScroll };
} }
-70
View File
@@ -1,70 +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 { useQueryClient } from "@tanstack/react-query";
import { buildEventStreamUrl } from "hooks/useEventStream";
import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId";
import { useEffect } from "react";
interface BuildLogEvent {
created: number;
message: string;
process_id: string;
version: string;
}
function appendLogRecord(existing: LogRecord[] | undefined, record: LogRecord): LogRecord[] {
return [...existing ?? [], record];
}
export function useBuildLogStream(packageBase: string, repository: RepositoryId): void {
const queryClient = useQueryClient();
useEffect(() => {
const source = new EventSource(buildEventStreamUrl(repository, ["build-log"], packageBase));
source.addEventListener("build-log", (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as BuildLogEvent;
const record: LogRecord = {
created: data.created,
message: data.message,
process_id: data.process_id,
version: data.version,
};
// Append to the all-logs cache
queryClient.setQueryData<LogRecord[]>(
["logs", repository.key, packageBase],
existing => appendLogRecord(existing, record),
);
// Append to the version-specific cache
queryClient.setQueryData<LogRecord[]>(
["logs", repository.key, packageBase, record.version, record.process_id],
existing => appendLogRecord(existing, record),
);
});
return () => {
source.close();
};
}, [queryClient, packageBase, repository]);
}
-101
View File
@@ -1,101 +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 type { QueryClient } from "@tanstack/react-query";
import type { RepositoryId } from "models/RepositoryId";
import { useEffect } from "react";
const GLOBAL_EVENT_TYPES = [
"package-held",
"package-outdated",
"package-removed",
"package-status-changed",
"package-update-failed",
"package-updated",
"service-status-changed",
] as const;
function invalidateForEvent(
queryClient: QueryClient,
repositoryKey: string,
eventType: string,
objectId?: string,
): void {
switch (eventType) {
case "package-status-changed":
case "package-updated":
case "package-removed":
case "package-held":
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
if (objectId) {
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey, objectId] });
void queryClient.invalidateQueries({ queryKey: ["events", repositoryKey, objectId] });
}
break;
case "service-status-changed":
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
break;
case "package-outdated":
case "package-update-failed":
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
if (objectId) {
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey, objectId] });
}
break;
}
}
export function buildEventStreamUrl(
repository: RepositoryId,
events?: readonly string[],
objectId?: string,
): string {
const params = new URLSearchParams(repository.toQuery());
if (events) {
for (const event of events) {
params.append("event", event);
}
}
if (objectId) {
params.set("object_id", objectId);
}
return `/api/v1/events/stream?${params.toString()}`;
}
export function useEventStream(queryClient: QueryClient, repository: RepositoryId | null): void {
useEffect(() => {
if (!repository) {
return;
}
const source = new EventSource(buildEventStreamUrl(repository, GLOBAL_EVENT_TYPES));
for (const eventType of GLOBAL_EVENT_TYPES) {
source.addEventListener(eventType, (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as { object_id?: string };
invalidateForEvent(queryClient, repository.key, eventType, data.object_id ?? undefined);
});
}
return () => {
source.close();
};
}, [queryClient, repository]);
}
+7 -7
View File
@@ -26,10 +26,10 @@ import { useRepository } from "hooks/useRepository";
import type { RepositoryId } from "models/RepositoryId"; 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>;
handleRefreshDatabase: () => Promise<void>;
handleRemove: () => Promise<void>;
} }
export function usePackageActions( export function usePackageActions(
@@ -63,7 +63,7 @@ export function usePackageActions(
} }
}; };
const handleReload = (): void => { const handleReload: () => void = () => {
if (currentRepository !== null) { if (currentRepository !== null) {
invalidate(currentRepository); invalidate(currentRepository);
} }
@@ -80,11 +80,11 @@ export function usePackageActions(
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => { const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
await client.service.servicePackageUpdate(repository, { await client.service.servicePackageUpdate(repository, {
packages: [],
refresh: true,
aur: false, aur: false,
local: false, local: false,
manual: false, manual: false,
packages: [],
refresh: true,
}); });
return "Pacman database update has been requested"; return "Pacman database update has been requested";
}, "Could not update pacman databases"); }, "Could not update pacman databases");
@@ -100,9 +100,9 @@ export function usePackageActions(
}; };
return { return {
handleRefreshDatabase,
handleReload, handleReload,
handleRemove,
handleUpdate, handleUpdate,
handleRefreshDatabase,
handleRemove,
}; };
} }
+2 -2
View File
@@ -27,9 +27,9 @@ export function usePackageChanges(packageBase: string, repository: RepositoryId)
const client = useClient(); const client = useClient();
const { data } = useQuery<Changes>({ const { data } = useQuery<Changes>({
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
queryKey: QueryKeys.changes(packageBase, repository), queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
}); });
return data; return data;
+15 -6
View File
@@ -20,40 +20,49 @@
import { skipToken, useQuery } from "@tanstack/react-query"; import { skipToken, useQuery } from "@tanstack/react-query";
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 { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository"; import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import { PackageRow } from "models/PackageRow"; import { PackageRow } from "models/PackageRow";
import { useMemo } from "react"; import { useMemo } from "react";
import { defaultInterval } from "utils";
export interface UsePackageDataResult { export interface UsePackageDataResult {
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(): UsePackageDataResult { export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { currentRepository } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
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: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"], queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"], queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]); const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
return { return {
rows,
isLoading, isLoading,
isAuthorized, isAuthorized,
rows,
status: status?.status.status, status: status?.status.status,
autoRefresh,
}; };
} }
+45 -24
View File
@@ -21,45 +21,66 @@ import type { GridFilterModel } from "@mui/x-data-grid";
import { usePackageActions } from "hooks/usePackageActions"; import { usePackageActions } from "hooks/usePackageActions";
import { usePackageData } from "hooks/usePackageData"; import { usePackageData } from "hooks/usePackageData";
import { useTableState } from "hooks/useTableState"; import { useTableState } from "hooks/useTableState";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import type { PackageRow } from "models/PackageRow"; import type { PackageRow } from "models/PackageRow";
import { useEffect } from "react";
export interface UsePackageTableResult { export interface UsePackageTableResult {
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;
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>;
handleRefreshDatabase: () => Promise<void>;
handleRemove: () => Promise<void>;
} }
export function usePackageTable(): UsePackageTableResult { export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
const { rows, isLoading, isAuthorized, status } = usePackageData(); const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals);
const tableState = useTableState(); const tableState = useTableState();
const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel); const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel);
// Pause auto-refresh when dialog is open
const isDialogOpen = tableState.dialogOpen !== null || tableState.selectedPackage !== null;
const setPaused = autoRefresh.setPaused;
useEffect(() => {
setPaused(isDialogOpen);
}, [isDialogOpen, setPaused]);
return { return {
rows,
isLoading, isLoading,
isAuthorized, isAuthorized,
rows,
status, status,
...actions,
...tableState, ...tableState,
autoRefreshInterval: autoRefresh.interval,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
...actions,
}; };
} }
+4 -4
View File
@@ -22,10 +22,10 @@ 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 {
@@ -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 };
} }
+29 -25
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,
}; };
} }
-7
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
FallbackComponent={ErrorFallback}
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
>
<App /> <App />
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );
+1 -1
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;
} }
@@ -17,7 +17,8 @@
* 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 interface RollbackRequest { export interface AutoRefreshInterval {
package: string; interval: number;
version: string; is_active: boolean;
text: string;
} }
+1 -1
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";
+4 -2
View File
@@ -18,12 +18,14 @@
* 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 { AuthInfo } from "models/AuthInfo"; import type { AuthInfo } from "models/AuthInfo";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
export interface InfoResponse { export interface InfoResponse {
auth: AuthInfo; auth: AuthInfo;
docs_enabled: boolean;
index_url?: string;
repositories: RepositoryId[]; repositories: RepositoryId[];
version: string; version: string;
autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean;
index_url?: string;
} }
+1 -1
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;
+1 -1
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;
} }
+1 -1
View File
@@ -21,7 +21,7 @@ import type { AlertColor } from "@mui/material";
export interface Notification { export interface Notification {
id: string; id: string;
title: string;
message: string; message: string;
severity: AlertColor; severity: AlertColor;
title: string;
} }
+3 -3
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;
} }
+16 -15
View File
@@ -21,31 +21,32 @@ 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;
isHeld: boolean;
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;
this.isHeld = descriptor.status.is_held ?? false;
} }
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] { private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
-1
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;
+1 -1
View File
@@ -20,7 +20,7 @@
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;
is_held?: boolean;
} }
+4 -4
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> = {
unknown: grey[600],
pending: amber[700],
building: orange[800], building: orange[800],
failed: red[700], failed: red[700],
pending: amber[700],
success: green[700], success: green[700],
unknown: grey[600],
}; };
const headerBase: Record<BuildStatus, string> = { const headerBase: Record<BuildStatus, string> = {
unknown: grey[600],
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;
+5 -7
View File
@@ -17,13 +17,11 @@
* 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 const DETAIL_TABLE_PROPS = { import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
density: "compact" as const,
disableColumnSorting: true, export function defaultInterval(intervals: AutoRefreshInterval[]): number {
disableRowSelectionOnClick: true, return intervals.find(interval => interval.is_active)?.interval ?? 0;
paginationModel: { page: 0, pageSize: 25 }, }
sx: { height: 400, mt: 1 },
};
declare global { declare global {
interface Array<T> { interface Array<T> {
+3 -3
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"]
+8 -8
View File
@@ -4,34 +4,34 @@ import { defineConfig, type Plugin } from "vite";
function rename(oldName: string, newName: string): Plugin { function rename(oldName: string, newName: string): Plugin {
return { return {
name: "rename",
enforce: "post", enforce: "post",
generateBundle(_, bundle) { generateBundle(_, bundle) {
if (bundle[oldName]) { if (bundle[oldName]) {
bundle[oldName].fileName = newName; bundle[oldName].fileName = newName;
} }
}, },
name: "rename",
}; };
} }
export default defineConfig({ export default defineConfig({
plugins: [react(), rename("index.html", "build-status.jinja2")],
base: "/", base: "/",
resolve: {
tsconfigPaths: true,
},
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",
+1 -1
View File
@@ -77,7 +77,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'
@@ -30,14 +30,6 @@ allow_read_only = yes
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh ; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
; by default. ; by default.
autorefresh_intervals = 5 1 10 30 60 autorefresh_intervals = 5 1 10 30 60
; Allowed CORS headers. By default everything is allowed.
;cors_allow_headers =
; Allowed CORS methods. By default everything is allowed.
;cors_allow_methods =
; Allowed CORS origins.
;cors_allow_origins = *
; Exposed CORS headers. By default everything is exposed.
;cors_expose_headers =
; Enable file upload endpoint used by some triggers. ; Enable file upload endpoint used by some triggers.
;enable_archive_upload = no ;enable_archive_upload = no
; Address to bind the server. ; Address to bind the server.
@@ -46,8 +38,6 @@ host = 127.0.0.1
;index_url = ;index_url =
; Max file size in bytes which can be uploaded to the server. Requires ${web:enable_archive_upload} to be enabled. ; Max file size in bytes which can be uploaded to the server. Requires ${web:enable_archive_upload} to be enabled.
;max_body_size = ;max_body_size =
; Max event queue size used for server sent event endpoints (0 is infinite)
;max_queue_size = 0
; Port to listen. Must be set, if the web service is enabled. ; Port to listen. Must be set, if the web service is enabled.
;port = ;port =
; Disable status (e.g. package status, logs, etc) endpoints. Useful for build only modes. ; Disable status (e.g. package status, logs, etc) endpoints. Useful for build only modes.
-1
View File
@@ -58,7 +58,6 @@ web = [
"aiohttp", "aiohttp",
"aiohttp_cors", "aiohttp_cors",
"aiohttp_jinja2", "aiohttp_jinja2",
"aiohttp_sse",
] ]
web-auth = [ web-auth = [
"ahriman[web]", "ahriman[web]",
@@ -156,14 +156,15 @@ class ApplicationRepository(ApplicationProperties):
result = Result() result = Result()
# process already built packages if any # process already built packages if any
if built_packages := self.repository.packages_built(): # speedup a bit built_packages = self.repository.packages_built()
if built_packages: # speedup a bit
build_result = self.repository.process_update(built_packages, packagers) build_result = self.repository.process_update(built_packages, packagers)
self.on_result(build_result) self.on_result(build_result)
result.merge(build_result) result.merge(build_result)
# filter packages which were prebuilt # filter packages which were prebuilt
succeeded = {package.base for package in build_result.success} succeeded = {package.base for package in build_result.success}
updates = [package for package in updates if package.base not in succeeded] updates = filter(lambda package: package.base not in succeeded, updates)
builder = Updater.load(self.repository_id, self.configuration, self.repository) builder = Updater.load(self.repository_id, self.configuration, self.repository)
+10 -9
View File
@@ -19,7 +19,6 @@
# #
import argparse import argparse
from dataclasses import replace
from pathlib import Path from pathlib import Path
from ahriman.application.application import Application from ahriman.application.application import Application
@@ -63,9 +62,9 @@ class Rollback(Handler):
application.reporter.package_hold_update(package.base, enabled=True) application.reporter.package_hold_update(package.base, enabled=True)
@staticmethod @staticmethod
def _set_package_rollback_parser(root: SubParserAction) -> argparse.ArgumentParser: def _set_package_archives_parser(root: SubParserAction) -> argparse.ArgumentParser:
""" """
add parser for package rollback subcommand add parser for package archives subcommand
Args: Args:
root(SubParserAction): subparsers for the commands root(SubParserAction): subparsers for the commands
@@ -81,14 +80,14 @@ class Rollback(Handler):
action=argparse.BooleanOptionalAction, default=True) action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-u", "--username", help="build as user", default=extract_user()) parser.add_argument("-u", "--username", help="build as user", default=extract_user())
parser.set_defaults(aur=False, changes=False, check_files=False, dependencies=False, dry_run=False, parser.set_defaults(aur=False, changes=False, check_files=False, dependencies=False, dry_run=False,
exit_code=True, increment=False, now=True, local=False, manual=False, refresh=False, exit_code=False, increment=False, now=True, local=False, manual=False, refresh=False,
source=PackageSource.Archive, variable=None, vcs=False) source=PackageSource.Archive, variable=None, vcs=False)
return parser return parser
@staticmethod @staticmethod
def package_artifacts(application: Application, package: Package) -> list[Path]: def package_artifacts(application: Application, package: Package) -> list[Path]:
""" """
look for requested package artifacts and return paths to them look for package artifacts and returns paths to them if any
Args: Args:
application(Application): application instance application(Application): application instance
@@ -103,13 +102,13 @@ class Rollback(Handler):
# lookup for built artifacts # lookup for built artifacts
artifacts = application.repository.package_archives_lookup(package) artifacts = application.repository.package_archives_lookup(package)
if not artifacts: if not artifacts:
raise UnknownPackageError(package.base) raise UnknownPackageError(package.base) from None
return artifacts return artifacts
@staticmethod @staticmethod
def package_load(application: Application, package_base: str, version: str) -> Package: def package_load(application: Application, package_base: str, version: str) -> Package:
""" """
load package from repository, while setting requested version load package from given arguments
Args: Args:
application(Application): application instance application(Application): application instance
@@ -124,8 +123,10 @@ class Rollback(Handler):
""" """
try: try:
package, _ = next(iter(application.reporter.package_get(package_base))) package, _ = next(iter(application.reporter.package_get(package_base)))
return replace(package, version=version) package.version = version
return package
except StopIteration: except StopIteration:
raise UnknownPackageError(package_base) from None raise UnknownPackageError(package_base) from None
arguments = [_set_package_rollback_parser] arguments = [_set_package_archives_parser]
+1 -1
View File
@@ -104,7 +104,7 @@ class PkgbuildParser(shlex.shlex):
# ignore substitution and extend bash symbols # ignore substitution and extend bash symbols
self.wordchars += "${}#:+-@!" self.wordchars += "${}#:+-@!"
# in case of default behavior, it will ignore, for example, segment part of url outside of quotes # in case of default behaviour, it will ignore, for example, segment part of url outside of quotes
self.commenters = "" self.commenters = ""
@staticmethod @staticmethod
+1 -1
View File
@@ -72,7 +72,7 @@ class AUR(Remote):
parse RPC response to package list parse RPC response to package list
Args: Args:
response(dict[str, Any]): RPC response JSON response(dict[str, Any]): RPC response json
Returns: Returns:
list[AURPackage]: list of parsed packages list[AURPackage]: list of parsed packages
+1 -1
View File
@@ -74,7 +74,7 @@ class Official(Remote):
parse RPC response to package list parse RPC response to package list
Args: Args:
response(dict[str, Any]): RPC response JSON response(dict[str, Any]): RPC response json
Returns: Returns:
list[AURPackage]: list of parsed packages list[AURPackage]: list of parsed packages
@@ -32,7 +32,7 @@ class OfficialSyncdb(Official):
updates. updates.
This approach also has limitations, because we don't require superuser rights (neither going to download database This approach also has limitations, because we don't require superuser rights (neither going to download database
separately), the database file might be outdated and must be handled manually (or kind of). This behavior might be separately), the database file might be outdated and must be handled manually (or kind of). This behaviour might be
changed in the future. changed in the future.
Still we leave search function based on the official repositories RPC. Still we leave search function based on the official repositories RPC.
+3 -3
View File
@@ -50,13 +50,13 @@ class Auth(LazyLogging):
@property @property
def auth_control(self) -> str: def auth_control(self) -> str:
""" """
This workaround is required to make different behavior for login interface. This workaround is required to make different behaviour for login interface.
In case of internal authentication it must provide an interface (modal form) to log in with button sends POST In case of internal authentication it must provide an interface (modal form) to log in with button sends POST
request. But for an external providers behavior can be different: e.g. OAuth provider requires sending GET request. But for an external providers behaviour can be different: e.g. OAuth provider requires sending GET
request to external resource request to external resource
Returns: Returns:
str: login control as HTML code to insert str: login control as html code to insert
""" """
return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>" return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>"
+1 -7
View File
@@ -30,13 +30,7 @@ except ImportError:
from typing import Any from typing import Any
__all__ = [ __all__ = ["authorized_userid", "check_authorized", "forget", "remember"]
"authorized_userid",
"check_authorized",
"forget",
"get_session",
"remember",
]
async def authorized_userid(*args: Any, **kwargs: Any) -> Any: async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
+2 -2
View File
@@ -62,10 +62,10 @@ class OAuth(Mapping):
@property @property
def auth_control(self) -> str: def auth_control(self) -> str:
""" """
get authorization HTML control get authorization html control
Returns: Returns:
str: login control as HTML code to insert str: login control as html code to insert
""" """
return "<a class=\"nav-link\" href=\"/api/v1/login\" title=\"login via OAuth2\"><i class=\"bi bi-box-arrow-in-right\"></i> login</a>" return "<a class=\"nav-link\" href=\"/api/v1/login\" title=\"login via OAuth2\"><i class=\"bi bi-box-arrow-in-right\"></i> login</a>"
+2 -6
View File
@@ -416,7 +416,7 @@ class Sources(LazyLogging):
else: else:
patch.write(sources_dir / "PKGBUILD") patch.write(sources_dir / "PKGBUILD")
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str | None: def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str:
""" """
read file content from the specified commit read file content from the specified commit
@@ -426,10 +426,6 @@ class Sources(LazyLogging):
path(Path): path to file inside the repository path(Path): path to file inside the repository
Returns: Returns:
str | None: file content at specified commit if available str: file content at specified commit
""" """
try:
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger) return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)
except CalledProcessError:
self.logger.exception("failed to read file %s at %s", path, commit_sha)
return None

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