Compare commits

..

33 Commits

Author SHA1 Message Date
arcanis a713c67948 zz 2026-04-27 13:01:56 +03:00
arcanis 4934205b9e fix typo 2026-04-27 13:01:56 +03:00
arcanis 97c121731b update configs 2026-04-27 13:01:56 +03:00
arcanis 578bb797ef review fixes 2026-04-27 13:01:56 +03:00
arcanis 8623b26a04 docs update 2026-04-27 13:01:56 +03:00
arcanis 0903378a64 update tests 2026-04-27 13:01:56 +03:00
arcanis 47cc99292f event bus implementation 2026-04-27 13:01:56 +03:00
arcanis 6ee8b26bd5 feat: show aur url in interface 2026-04-22 06:56:28 +03:00
arcanis fce49f22c9 type: fix mypy warnings in updated version 2026-04-03 13:36:41 +03:00
arcanis af8e2c9e9b build: update rtd.io image 2026-03-30 19:20:03 +03:00
arcanis 1c312bb528 feat: add error boundary 2026-03-24 01:17:42 +02:00
arcanis e39194e9f6 feat: etag support 2026-03-23 23:58:56 +02:00
arcanis 21cc029c18 refactor: use usedforsecurity flag for md5 calculations 2026-03-23 23:07:31 +02:00
arcanis 40671b99d5 feat: allow to configure cors 2026-03-23 16:39:07 +02:00
arcanis 3ad2c494af docs: update docstrings 2026-03-22 17:23:16 +02:00
arcanis 34014d1cdd build: add more rules for typescript 2026-03-22 17:23:16 +02:00
arcanis 93ed2b864b docs: describe rollback procedure in docs 2026-03-22 17:23:16 +02:00
arcanis cca931ccd0 fix: handle errors on pkgbuild reading 2026-03-22 13:10:09 +02:00
arcanis 5e090cebdb refactor: reorder arguments in web ui 2026-03-22 04:03:41 +02:00
arcanis d7984c12f0 feat: package rollback support (#161)
* implement support of rollback handler

* react interface for the rollback
2026-03-22 00:36:48 +02:00
arcanis 96684d7ddd refactor: move package archive lockup to package info trait 2026-03-18 18:48:27 +02:00
arcanis 2e9837b70d build: update frontend dependencies 2026-03-15 19:18:58 +02:00
arcanis ac4a8fb2cd fix: hide ignore_list because it is ambiguous now 2026-03-15 19:06:53 +02:00
arcanis 1db8eb0ac4 fix: preserve hold status on status updates 2026-03-15 19:03:05 +02:00
arcanis dc394f7df9 feat: dynamic package hold (#160)
* add dynamic hold implementation to backend

* update frontend to support new status

* force reporter loader

* handle missing packages explicitly

* handle missing packages explicitly
2026-03-15 18:47:02 +02:00
arcanis 058f784b05 fix: center icons on buttons 2026-03-15 17:07:39 +02:00
arcanis f688768ca7 fix: fallback to timestamp in case if crypto context is not available
Previous tests have been done in either secure (HTTPS) or loopback
(localhost) environments, so I missed the case, that crypto methods
might not be available

Fixes #159
2026-03-13 17:00:06 +02:00
arcanis a09ad7617d feat: support archive listing 2026-03-12 22:26:35 +02:00
arcanis 81aeb56ba3 refactor: lightweight Package.from_archive method 2026-03-11 23:00:55 +02:00
arcanis 2cd4ef5e86 refactor: separate package_archives method from trigger 2026-03-11 13:36:59 +02:00
arcanis 998ed48dde feat: add pkgbuild subscommands 2026-03-11 13:31:19 +02:00
arcanis 021d88dc4c feat: store and show pkgbuild (implements #157) 2026-03-11 01:05:06 +02:00
arcanis 9012ee7144 feat: add dark theme support and increase contrast 2026-03-08 22:59:31 +02:00
256 changed files with 5368 additions and 1708 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
version: 2
build:
os: ubuntu-20.04
os: ubuntu-lts-latest
tools:
python: "3.12"
apt_packages:
+8 -9
View File
@@ -29,17 +29,16 @@ RUN pacman -S --noconfirm --asdeps \
python-filelock \
python-inflection \
python-pyelftools \
python-requests \
&& \
pacman -S --noconfirm --asdeps \
python-requests
RUN pacman -S --noconfirm --asdeps \
base-devel \
python-build \
python-flit \
python-installer \
python-setuptools \
python-tox \
python-wheel \
&& \
pacman -S --noconfirm --asdeps \
python-wheel
RUN pacman -S --noconfirm --asdeps \
git \
python-aiohttp \
python-aiohttp-openmetrics \
@@ -48,9 +47,8 @@ RUN pacman -S --noconfirm --asdeps \
python-cryptography \
python-jinja \
python-systemd \
rsync \
&& \
runuser -u build -- install-aur-package \
rsync
RUN runuser -u build -- install-aur-package \
python-aioauth-client \
python-sphinx-typlog-theme \
python-webargs \
@@ -59,6 +57,7 @@ RUN pacman -S --noconfirm --asdeps \
python-aiohttp-jinja2 \
python-aiohttp-session \
python-aiohttp-security \
python-aiohttp-sse-git \
python-requests-unixsocket2
# install ahriman
+32
View File
@@ -12,6 +12,14 @@ ahriman.application.handlers.add module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.archives module
--------------------------------------------
.. automodule:: ahriman.application.handlers.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.backup module
------------------------------------------
@@ -76,6 +84,14 @@ ahriman.application.handlers.help module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.hold module
----------------------------------------
.. automodule:: ahriman.application.handlers.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.key\_import module
-----------------------------------------------
@@ -92,6 +108,14 @@ ahriman.application.handlers.patch module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.pkgbuild module
--------------------------------------------
.. automodule:: ahriman.application.handlers.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rebuild module
-------------------------------------------
@@ -140,6 +164,14 @@ ahriman.application.handlers.restore module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rollback module
--------------------------------------------
.. automodule:: ahriman.application.handlers.rollback
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.run module
---------------------------------------
+8
View File
@@ -28,6 +28,14 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pacman\_handle module
---------------------------------------
.. automodule:: ahriman.core.alpm.pacman_handle
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pkgbuild\_parser module
-----------------------------------------
+16
View File
@@ -140,6 +140,22 @@ ahriman.core.database.migrations.m016\_archive module
:no-undoc-members:
:show-inheritance:
ahriman.core.database.migrations.m017\_pkgbuild module
------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m017_pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.database.migrations.m018\_package\_hold module
-----------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m018_package_hold
:members:
:no-undoc-members:
:show-inheritance:
Module contents
---------------
+8
View File
@@ -76,6 +76,14 @@ ahriman.core.formatters.patch\_printer module
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.pkgbuild\_printer module
------------------------------------------------
.. automodule:: ahriman.core.formatters.pkgbuild_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.printer module
--------------------------------------
+8
View File
@@ -12,6 +12,14 @@ ahriman.core.status.client module
:no-undoc-members:
: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
----------------------------------------
+8
View File
@@ -12,6 +12,14 @@ ahriman.web.middlewares.auth\_handler module
:no-undoc-members:
: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
-------------------------------------------------
+40
View File
@@ -92,6 +92,14 @@ ahriman.web.schemas.error\_schema module
:no-undoc-members:
: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
----------------------------------------
@@ -116,6 +124,14 @@ ahriman.web.schemas.file\_schema module
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.hold\_schema module
---------------------------------------
.. automodule:: ahriman.web.schemas.hold_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.info\_schema module
---------------------------------------
@@ -244,6 +260,14 @@ ahriman.web.schemas.package\_version\_schema module
:no-undoc-members:
: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
---------------------------------------------
@@ -324,6 +348,14 @@ ahriman.web.schemas.repository\_stats\_schema module
:no-undoc-members:
: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
-----------------------------------------
@@ -332,6 +364,14 @@ ahriman.web.schemas.search\_schema module
:no-undoc-members:
: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
-----------------------------------------
+8
View File
@@ -4,6 +4,14 @@ ahriman.web.views.v1.auditlog package
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
-------------------------------------------
+16
View File
@@ -4,6 +4,14 @@ ahriman.web.views.v1.packages package
Submodules
----------
ahriman.web.views.v1.packages.archives module
---------------------------------------------
.. automodule:: ahriman.web.views.v1.packages.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.changes module
--------------------------------------------
@@ -20,6 +28,14 @@ ahriman.web.views.v1.packages.dependencies module
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.hold module
-----------------------------------------
.. automodule:: ahriman.web.views.v1.packages.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.logs module
-----------------------------------------
+8
View File
@@ -68,6 +68,14 @@ ahriman.web.views.v1.service.request module
:no-undoc-members:
: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
------------------------------------------
+12
View File
@@ -235,6 +235,18 @@ Remove packages
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
^^^^^^^^^^^^^^^^^^^^^^^
+6 -1
View File
@@ -180,10 +180,15 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used.
* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default.
* ``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``.
* ``host`` - host to bind, 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_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.
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
* ``static_path`` - path to directory with static files, string, required.
@@ -191,7 +196,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``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_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.
* ``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.
``archive`` group
-----------------
+25
View File
@@ -33,3 +33,28 @@ The service provides several commands aim to do easy repository backup and resto
.. code-block:: shell
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,10 +7,13 @@ aiohttp==3.11.18
# ahriman (pyproject.toml)
# aiohttp-cors
# aiohttp-jinja2
# aiohttp-sse
aiohttp-cors==0.8.1
# via ahriman (pyproject.toml)
aiohttp-jinja2==1.6
# via ahriman (pyproject.toml)
aiohttp-sse==2.2.0
# via ahriman (pyproject.toml)
aiosignal==1.3.2
# via aiohttp
alabaster==1.0.0
+10 -3
View File
@@ -1,5 +1,6 @@
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import simpleImportSort from "eslint-plugin-simple-import-sort";
@@ -8,7 +9,11 @@ import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
extends: [
js.configs.recommended,
react.configs.flat.recommended,
...tseslint.configs.recommendedTypeChecked,
],
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
@@ -17,13 +22,14 @@ export default tseslint.config(
},
},
plugins: {
"@stylistic": stylistic,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"simple-import-sort": simpleImportSort,
"@stylistic": stylistic,
},
rules: {
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// imports
@@ -33,7 +39,7 @@ export default tseslint.config(
// core
"curly": "error",
"eqeqeq": "error",
"no-console": "error",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-eval": "error",
// stylistic
@@ -68,6 +74,7 @@ export default tseslint.config(
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
+32 -31
View File
@@ -1,8 +1,36 @@
{
"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",
"private": true,
"type": "module",
"version": "2.20.1",
"scripts": {
"build": "tsc && vite build",
"dev": "vite",
@@ -10,33 +38,6 @@
"lint:fix": "eslint --fix src/",
"preview": "vite preview"
},
"dependencies": {
"@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": "^5.1.4",
"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": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1"
}
"type": "module",
"version": "2.20.0"
}
+5 -2
View File
@@ -21,6 +21,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppLayout from "components/layout/AppLayout";
import { AuthProvider } from "contexts/AuthProvider";
import { ClientProvider } from "contexts/ClientProvider";
import { EventStreamProvider } from "contexts/EventStreamProvider";
import { NotificationProvider } from "contexts/NotificationProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider";
import { ThemeProvider } from "contexts/ThemeProvider";
@@ -29,8 +30,8 @@ import type React from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
staleTime: 30_000,
},
},
});
@@ -42,7 +43,9 @@ export default function App(): React.JSX.Element {
<ClientProvider>
<AuthProvider>
<RepositoryProvider>
<AppLayout />
<EventStreamProvider>
<AppLayout />
</EventStreamProvider>
</RepositoryProvider>
</AuthProvider>
</ClientProvider>
+2 -1
View File
@@ -18,9 +18,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export class ApiError extends Error {
body: string;
status: number;
statusText: string;
body: string;
constructor(status: number, statusText: string, body: string) {
super(`${status} ${statusText}`);
+7
View File
@@ -24,6 +24,7 @@ import type { Event } from "models/Event";
import type { InfoResponse } from "models/InfoResponse";
import type { InternalStatus } from "models/InternalStatus";
import type { LogRecord } from "models/LogRecord";
import type { Package } from "models/Package";
import type { PackageStatus } from "models/PackageStatus";
import type { Patch } from "models/Patch";
import { RepositoryId } from "models/RepositoryId";
@@ -42,6 +43,12 @@ 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> {
return this.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
query: repository.toQuery(),
+1 -1
View File
@@ -18,8 +18,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export interface RequestOptions {
json?: unknown;
method?: string;
query?: Record<string, string | number | boolean>;
json?: unknown;
timeout?: number;
}
+17
View File
@@ -23,6 +23,7 @@ import type { PackageActionRequest } from "models/PackageActionRequest";
import type { PGPKey } from "models/PGPKey";
import type { PGPKeyRequest } from "models/PGPKeyRequest";
import type { RepositoryId } from "models/RepositoryId";
import type { RollbackRequest } from "models/RollbackRequest";
export class ServiceClient {
@@ -36,6 +37,14 @@ export class ServiceClient {
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> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
method: "DELETE",
@@ -58,6 +67,14 @@ 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[]> {
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
}
@@ -32,11 +32,11 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
datasets: [
{
label: "update duration, s",
data: updateEvents.map(event => event.data?.took ?? 0),
borderColor: blue[500],
backgroundColor: blue[200],
borderColor: blue[500],
cubicInterpolationMode: "monotone" as const,
data: updateEvents.map(event => event.data?.took ?? 0),
label: "update duration, s",
tension: 0.4,
},
],
@@ -29,19 +29,19 @@ interface PackageCountBarChartProps {
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
return <Bar
data={{
labels: ["packages"],
datasets: [
{
label: "bases",
data: [stats.bases ?? 0],
backgroundColor: indigo[300],
data: [stats.bases ?? 0],
label: "bases",
},
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
data: [stats.packages ?? 0],
label: "archives",
},
],
labels: ["packages"],
}}
options={{
maintainAspectRatio: false,
@@ -30,14 +30,14 @@ interface StatusPieChartProps {
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
const data = {
labels: labels,
datasets: [
{
label: "packages in status",
data: labels.map(label => counters[label]),
backgroundColor: labels.map(label => StatusColors[label]),
data: labels.map(label => counters[label]),
label: "packages in status",
},
],
labels: labels,
};
return <Pie data={data} options={{ responsive: true }} />;
@@ -1,91 +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 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>
</>;
}
+32 -25
View File
@@ -17,47 +17,54 @@
* 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 } from "@mui/material";
import "components/common/syntaxLanguages";
import { Box, useTheme } from "@mui/material";
import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
import React, { type RefObject } from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface CodeBlockProps {
preRef?: RefObject<HTMLElement | null>;
getText: () => string;
content: string;
height?: number | string;
language?: string;
onScroll?: () => void;
wordBreak?: boolean;
preRef?: RefObject<HTMLElement | null>;
}
export default function CodeBlock({
preRef,
getText,
content,
height,
language = "text",
onScroll,
wordBreak,
preRef,
}: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}>
<Box
ref={preRef}
component="pre"
onScroll={onScroll}
sx={{
backgroundColor: "action.hover",
p: 2,
borderRadius: 1,
overflow: "auto",
height,
fontSize: "0.8rem",
fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
}}
ref={preRef}
sx={{ overflow: "auto", height }}
>
<code>
{getText()}
</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
<SyntaxHighlighter
customStyle={{
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
padding: theme.spacing(2),
}}
language={language}
style={mode === "dark" ? vs2015 : githubGist}
wrapLongLines
>
{content}
</SyntaxHighlighter>
</Box>
{content && <Box sx={{ position: "absolute", right: 8, top: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>;
}
@@ -23,24 +23,24 @@ import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react";
interface CopyButtonProps {
getText: () => string;
text: string;
}
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(getText());
await navigator.clipboard.writeText(text);
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000);
};
return <Tooltip title={copied ? "Copied!" : "Copy"}>
<IconButton size="small" aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()}>
<IconButton aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()} size="small">
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Tooltip>;
@@ -28,7 +28,7 @@ interface DialogHeaderProps {
}
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
return <DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", ...sx }}>
return <DialogTitle sx={{ alignItems: "center", display: "flex", justifyContent: "space-between", ...sx }}>
{children}
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
<CloseIcon />
@@ -0,0 +1,55 @@
/*
* 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 (
<Slide direction="down" in={show} mountOnEnter unmountOnExit onExited={() => onClose(notification.id)}>
<Slide direction="down" in={show} mountOnEnter onExited={() => onClose(notification.id)} unmountOnExit>
<Alert
onClose={() => setShow(false)}
severity={notification.severity}
variant="filled"
sx={{ width: "100%", pointerEvents: "auto" }}
variant="filled"
>
<strong>{notification.title}</strong>
{notification.message && ` - ${notification.message}`}
@@ -25,14 +25,14 @@ import type React from "react";
export default function RepositorySelect({
repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, current } = useRepository();
const { repositories, currentRepository } = useRepository();
return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={repositorySelect.selectedKey || (current?.key ?? "")}
label="repository"
onChange={event => repositorySelect.setSelectedKey(event.target.value)}
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
>
{repositories.map(repository =>
<MenuItem key={repository.key} value={repository.key}>
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext";
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("text", plaintext);
@@ -30,23 +30,23 @@ import type React from "react";
import { StatusHeaderStyles } from "theme/StatusColors";
interface DashboardDialogProps {
open: boolean;
onClose: () => void;
open: boolean;
}
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
export default function DashboardDialog({ onClose, open }: DashboardDialogProps): React.JSX.Element {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
enabled: open,
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
});
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
return <Dialog fullWidth maxWidth="lg" onClose={onClose} open={open}>
<DialogHeader onClose={onClose} sx={headerStyle}>
System health
</DialogHeader>
@@ -55,43 +55,43 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
{status &&
<>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
<Grid size={{ md: 3, xs: 6 }}>
<Typography align="right" color="text.secondary" variant="body2">Repository name</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{status.repository}</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
<Grid size={{ md: 3, xs: 6 }}>
<Typography align="right" color="text.secondary" variant="body2">Repository architecture</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{status.architecture}</Typography>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
<Grid size={{ md: 3, xs: 6 }}>
<Typography align="right" color="text.secondary" variant="body2">Current status</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{status.status.status}</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
<Grid size={{ md: 3, xs: 6 }}>
<Typography align="right" color="text.secondary" variant="body2">Updated at</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ xs: 12, md: 6 }}>
<Grid size={{ md: 6, xs: 12 }}>
<Box sx={{ height: 300 }}>
<PackageCountBarChart stats={status.stats} />
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
<Grid size={{ md: 6, xs: 12 }}>
<Box sx={{ alignItems: "center", display: "flex", height: 300, justifyContent: "center" }}>
<StatusPieChart counters={status.packages} />
</Box>
</Grid>
@@ -35,11 +35,11 @@ import { useNotification } from "hooks/useNotification";
import React, { useState } from "react";
interface KeyImportDialogProps {
open: boolean;
onClose: () => void;
open: boolean;
}
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps): React.JSX.Element {
const client = useClient();
const { showSuccess, showError } = useNotification();
@@ -54,7 +54,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
onClose();
};
const handleFetch: () => Promise<void> = async () => {
const handleFetch = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
@@ -67,7 +67,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
}
};
const handleImport: () => Promise<void> = async () => {
const handleImport = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
@@ -81,38 +81,38 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}>
Import key from PGP server
</DialogHeader>
<DialogContent>
<TextField
label="fingerprint"
placeholder="PGP key fingerprint"
fullWidth
label="fingerprint"
margin="normal"
value={fingerprint}
onChange={event => setFingerprint(event.target.value)}
placeholder="PGP key fingerprint"
value={fingerprint}
/>
<TextField
label="key server"
placeholder="PGP key server"
fullWidth
label="key server"
margin="normal"
value={server}
onChange={event => setServer(event.target.value)}
placeholder="PGP key server"
value={server}
/>
{keyBody &&
<Box sx={{ mt: 2 }}>
<CodeBlock getText={() => keyBody} height={300} />
<CodeBlock height={300} content={keyBody} />
</Box>
}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
<Button onClick={() => void handleImport()} startIcon={<PlayArrowIcon />} variant="contained">import</Button>
<Button color="success" onClick={() => void handleFetch()} startIcon={<RefreshIcon />} variant="contained">fetch</Button>
</DialogActions>
</Dialog>;
}
+19 -14
View File
@@ -36,11 +36,11 @@ import { useNotification } from "hooks/useNotification";
import React, { useState } from "react";
interface LoginDialogProps {
open: boolean;
onClose: () => void;
open: boolean;
}
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
export default function LoginDialog({ onClose, open }: LoginDialogProps): React.JSX.Element {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@@ -54,7 +54,7 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
onClose();
};
const handleSubmit: () => Promise<void> = async () => {
const handleSubmit = async (): Promise<void> => {
if (!username || !password) {
return;
}
@@ -72,26 +72,24 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
return <Dialog fullWidth maxWidth="xs" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}>
Login
</DialogHeader>
<DialogContent>
<TextField
label="username"
fullWidth
margin="normal"
value={username}
onChange={event => setUsername(event.target.value)}
autoFocus
fullWidth
label="username"
margin="normal"
onChange={event => setUsername(event.target.value)}
value={username}
/>
<TextField
label="password"
fullWidth
label="password"
margin="normal"
type={showPassword ? "text" : "password"}
value={password}
onChange={event => setPassword(event.target.value)}
onKeyDown={event => {
if (event.key === "Enter") {
@@ -102,17 +100,24 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
input: {
endAdornment:
<InputAdornment position="end">
<IconButton aria-label={showPassword ? "Hide password" : "Show password"} onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
<IconButton
aria-label={showPassword ? "Hide password" : "Show password"}
edge="end"
onClick={() => setShowPassword(!showPassword)}
size="small"
>
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>,
},
}}
type={showPassword ? "text" : "password"}
value={password}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
<Button onClick={() => void handleSubmit()} startIcon={<PersonIcon />} variant="contained">login</Button>
</DialogActions>
</Dialog>;
}
@@ -52,11 +52,11 @@ interface EnvironmentVariable {
}
interface PackageAddDialogProps {
open: boolean;
onClose: () => void;
open: boolean;
}
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
export default function PackageAddDialog({ onClose, open }: PackageAddDialogProps): React.JSX.Element {
const client = useClient();
const { showSuccess, showError } = useNotification();
const repositorySelect = useSelectedRepository();
@@ -77,9 +77,9 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
const debouncedSearch = useDebounce(packageName, 500);
const { data: searchResults = [] } = useQuery<AURPackage[]>({
queryKey: QueryKeys.search(debouncedSearch),
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
enabled: debouncedSearch.length >= 3,
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
queryKey: QueryKeys.search(debouncedSearch),
});
const handleSubmit = async (action: "add" | "request"): Promise<void> => {
@@ -107,7 +107,7 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}>
Add new packages
</DialogHeader>
@@ -117,20 +117,18 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
<Autocomplete
freeSolo
options={searchResults.map(pkg => pkg.package)}
inputValue={packageName}
onInputChange={(_, value) => setPackageName(value)}
options={searchResults.map(pkg => pkg.package)}
renderInput={params =>
<TextField {...params} label="package" margin="normal" placeholder="AUR package" />
}
renderOption={(props, option) => {
const pkg = searchResults.find(pkg => pkg.package === option);
return (
<li {...props} key={option}>
{option}{pkg ? ` (${pkg.description})` : ""}
</li>
);
return <li {...props} key={option}>
{option}{pkg ? ` (${pkg.description})` : ""}
</li>;
}}
renderInput={params =>
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
}
/>
<FormControlLabel
@@ -140,45 +138,50 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
const id = variableIdCounter.current++;
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
}}
startIcon={<AddIcon />}
sx={{ mt: 1 }}
variant="outlined"
>
add environment variable
</Button>
{environmentVariables.map(variable =>
<Box key={variable.id} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
<Box key={variable.id} sx={{ alignItems: "center", display: "flex", gap: 1, mt: 1 }}>
<TextField
size="small"
placeholder="name"
value={variable.key}
onChange={event => {
const newKey = event.target.value;
setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
);
}}
placeholder="name"
size="small"
sx={{ flex: 1 }}
value={variable.key}
/>
<Box>=</Box>
<TextField
size="small"
placeholder="value"
value={variable.value}
onChange={event => {
const newValue = event.target.value;
setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
);
}}
size="small"
sx={{ flex: 1 }}
value={variable.value}
/>
<IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}>
<IconButton
aria-label="Remove variable"
color="error"
onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}
size="small"
>
<DeleteIcon />
</IconButton>
</Box>,
@@ -186,8 +189,8 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
</DialogContent>
<DialogActions>
<Button onClick={() => void handleSubmit("add")} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
<Button onClick={() => void handleSubmit("request")} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
<Button onClick={() => void handleSubmit("add")} startIcon={<PlayArrowIcon />} variant="contained">add</Button>
<Button color="success" onClick={() => void handleSubmit("request")} startIcon={<AddIcon />} variant="contained">request</Button>
</DialogActions>
</Dialog>;
}
@@ -21,41 +21,39 @@ import { Box, Dialog, DialogContent, Tab, Tabs } from "@mui/material";
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "api/client/ApiError";
import DialogHeader from "components/common/DialogHeader";
import ArtifactsTab from "components/package/ArtifactsTab";
import BuildLogsTab from "components/package/BuildLogsTab";
import ChangesTab from "components/package/ChangesTab";
import EventsTab from "components/package/EventsTab";
import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackageInfoActions from "components/package/PackageInfoActions";
import PackagePatchesList from "components/package/PackagePatchesList";
import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { Dependencies } from "models/Dependencies";
import type { PackageStatus } from "models/PackageStatus";
import type { Patch } from "models/Patch";
import React, { useState } from "react";
import { StatusHeaderStyles } from "theme/StatusColors";
import { defaultInterval } from "utils";
interface PackageInfoDialogProps {
packageBase: string | null;
open: boolean;
onClose: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
open: boolean;
packageBase: string | null;
}
export default function PackageInfoDialog({
packageBase,
open,
onClose,
autoRefreshIntervals,
open,
packageBase,
}: PackageInfoDialogProps): React.JSX.Element {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
@@ -65,60 +63,59 @@ export default function PackageInfoDialog({
setLocalPackageBase(packageBase);
}
const [tabIndex, setTabIndex] = useState(0);
const [activeTab, setActiveTab] = useState<TabKey>("logs");
const [refreshDatabase, setRefreshDatabase] = useState(true);
const handleClose = (): void => {
setTabIndex(0);
setActiveTab("logs");
setRefreshDatabase(true);
onClose();
};
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: localPackageBase && current ? QueryKeys.package(localPackageBase, current) : ["packages"],
queryFn: localPackageBase && current ? () => client.fetch.fetchPackage(localPackageBase, current) : skipToken,
enabled: open,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
});
const { data: dependencies } = useQuery<Dependencies>({
queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"],
queryFn: localPackageBase && current
? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken,
enabled: open,
queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
});
const { data: patches = [] } = useQuery<Patch[]>({
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
enabled: open,
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
});
const description: PackageStatus | undefined = packageData?.[0];
const description = packageData?.[0];
const pkg = description?.package;
const status = description?.status;
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate: () => Promise<void> = async () => {
if (!localPackageBase || !current) {
const handleUpdate = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) {
return;
}
try {
await client.service.servicePackageAdd(current, { packages: [localPackageBase], refresh: refreshDatabase });
await client.service.servicePackageAdd(
currentRepository, { packages: [localPackageBase], refresh: refreshDatabase });
showSuccess("Success", `Run update for packages ${localPackageBase}`);
} catch (exception) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
}
};
const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !current) {
const handleRemove = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) {
return;
}
try {
await client.service.servicePackageRemove(current, [localPackageBase]);
await client.service.servicePackageRemove(currentRepository, [localPackageBase]);
showSuccess("Success", `Packages ${localPackageBase} have been removed`);
onClose();
} catch (exception) {
@@ -126,7 +123,20 @@ export default function PackageInfoDialog({
}
};
const handleDeletePatch: (key: string) => Promise<void> = async key => {
const handleHoldToggle = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) {
return;
}
try {
const newHeldStatus = !(status?.is_held ?? false);
await client.service.servicePackageHold(localPackageBase, currentRepository, newHeldStatus);
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
} catch (exception) {
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
}
};
const handleDeletePatch = async (key: string): Promise<void> => {
if (!localPackageBase) {
return;
}
@@ -138,7 +148,7 @@ export default function PackageInfoDialog({
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose} sx={headerStyle}>
{pkg && status
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
@@ -148,33 +158,40 @@ export default function PackageInfoDialog({
<DialogContent>
{pkg &&
<>
<PackageDetailsGrid pkg={pkg} dependencies={dependencies} />
<PackageDetailsGrid dependencies={dependencies} pkg={pkg} />
<PackagePatchesList
patches={patches}
editable={isAuthorized}
onDelete={key => void handleDeletePatch(key)}
patches={patches}
/>
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={tabIndex} onChange={(_, index: number) => setTabIndex(index)}>
<Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
<Tabs onChange={(_, tab: TabKey) => setActiveTab(tab)} value={activeTab}>
{tabs.map(({ key, label }) => <Tab key={key} label={label} value={key} />)}
</Tabs>
</Box>
{tabIndex === 0 && localPackageBase && current &&
{activeTab === "logs" && localPackageBase && currentRepository &&
<BuildLogsTab
packageBase={localPackageBase}
repository={current}
refreshInterval={autoRefresh.interval}
repository={currentRepository}
/>
}
{tabIndex === 1 && localPackageBase && current &&
<ChangesTab packageBase={localPackageBase} repository={current} />
{activeTab === "changes" && localPackageBase && currentRepository &&
<ChangesTab packageBase={localPackageBase} repository={currentRepository} />
}
{tabIndex === 2 && localPackageBase && current &&
<EventsTab packageBase={localPackageBase} repository={current} />
{activeTab === "pkgbuild" && localPackageBase && currentRepository &&
<PkgbuildTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "events" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "artifacts" && localPackageBase && currentRepository &&
<ArtifactsTab
currentVersion={pkg.version}
packageBase={localPackageBase}
repository={currentRepository}
/>
}
</>
}
@@ -182,13 +199,12 @@ export default function PackageInfoDialog({
<PackageInfoActions
isAuthorized={isAuthorized}
refreshDatabase={refreshDatabase}
isHeld={status?.is_held ?? false}
onHoldToggle={() => void handleHoldToggle()}
onRefreshDatabaseChange={setRefreshDatabase}
onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
onUpdate={() => void handleUpdate()}
refreshDatabase={refreshDatabase}
/>
</Dialog>;
}
@@ -28,11 +28,11 @@ import { useSelectedRepository } from "hooks/useSelectedRepository";
import React, { useState } from "react";
interface PackageRebuildDialogProps {
open: boolean;
onClose: () => void;
open: boolean;
}
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDialogProps): React.JSX.Element {
const client = useClient();
const { showSuccess, showError } = useNotification();
const repositorySelect = useSelectedRepository();
@@ -45,7 +45,7 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
onClose();
};
const handleRebuild: () => Promise<void> = async () => {
const handleRebuild = async (): Promise<void> => {
if (!dependency) {
return;
}
@@ -63,7 +63,7 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}>
Rebuild depending packages
</DialogHeader>
@@ -72,17 +72,17 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
<RepositorySelect repositorySelect={repositorySelect} />
<TextField
label="dependency"
placeholder="packages dependency"
fullWidth
label="dependency"
margin="normal"
value={dependency}
placeholder="packages dependency"
onChange={event => setDependency(event.target.value)}
value={dependency}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
<Button onClick={() => void handleRebuild()} startIcon={<PlayArrowIcon />} variant="contained">rebuild</Button>
</DialogActions>
</Dialog>;
}
+6 -8
View File
@@ -41,8 +41,8 @@ export default function AppLayout(): React.JSX.Element {
const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({
queryKey: QueryKeys.info,
queryFn: () => client.fetch.fetchServerInfo(),
queryKey: QueryKeys.info,
staleTime: Infinity,
});
@@ -55,9 +55,9 @@ export default function AppLayout(): React.JSX.Element {
}, [info, setAuthState, setRepositories]);
return <Container maxWidth="xl">
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
<Box sx={{ alignItems: "center", display: "flex", gap: 1, py: 1 }}>
<a href="https://ahriman.readthedocs.io/" title="logo">
<img src="/static/logo.svg" width={30} height={30} alt="" />
<img alt="" height={30} src="/static/logo.svg" width={30} />
</a>
<Box sx={{ flex: 1 }}>
<Navbar />
@@ -69,17 +69,15 @@ export default function AppLayout(): React.JSX.Element {
</Tooltip>
</Box>
<PackageTable
autoRefreshIntervals={info?.autorefresh_intervals ?? []}
/>
<PackageTable />
<Footer
version={info?.version ?? ""}
docsEnabled={info?.docs_enabled ?? false}
indexUrl={info?.index_url}
onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)}
version={info?.version ?? ""}
/>
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
<LoginDialog onClose={() => setLoginOpen(false)} open={loginOpen} />
</Container>;
}
+13 -13
View File
@@ -26,41 +26,41 @@ import { useAuth } from "hooks/useAuth";
import type React from "react";
interface FooterProps {
version: string;
docsEnabled: boolean;
indexUrl?: string;
onLoginClick: () => void;
version: string;
}
export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
export default function Footer({ docsEnabled, indexUrl, onLoginClick, version }: FooterProps): React.JSX.Element {
const { enabled: authEnabled, username, logout } = useAuth();
return <Box
component="footer"
sx={{
alignItems: "center",
borderColor: "divider",
borderTop: 1,
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
alignItems: "center",
borderTop: 1,
borderColor: "divider",
mt: 2,
py: 1,
}}
>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Box sx={{ alignItems: "center", display: "flex", gap: 2 }}>
<Link color="inherit" href="https://github.com/arcan1s/ahriman" sx={{ alignItems: "center", display: "flex", gap: 0.5 }} underline="hover">
<GitHubIcon fontSize="small" />
<Typography variant="body2">ahriman {version}</Typography>
</Link>
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
<Link color="text.secondary" href="https://github.com/arcan1s/ahriman/releases" underline="hover" variant="body2">
releases
</Link>
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
<Link color="text.secondary" href="https://github.com/arcan1s/ahriman/issues" underline="hover" variant="body2">
report a bug
</Link>
{docsEnabled &&
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
<Link color="text.secondary" href="/api-docs" underline="hover" variant="body2">
api
</Link>
}
@@ -68,7 +68,7 @@ export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }:
{indexUrl &&
<Box>
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Link color="inherit" href={indexUrl} underline="hover" sx={{ alignItems: "center", display: "flex", gap: 0.5 }}>
<HomeIcon fontSize="small" />
<Typography variant="body2">repo index</Typography>
</Link>
@@ -78,11 +78,11 @@ export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }:
{authEnabled &&
<Box>
{username ?
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void logout()} sx={{ textTransform: "none" }}>
<Button onClick={() => void logout()} size="small" startIcon={<LogoutIcon />} sx={{ textTransform: "none" }}>
logout ({username})
</Button>
:
<Button size="small" startIcon={<LoginIcon />} onClick={onLoginClick} sx={{ textTransform: "none" }}>
<Button onClick={onLoginClick} size="small" startIcon={<LoginIcon />} sx={{ textTransform: "none" }}>
login
</Button>
}
+7 -6
View File
@@ -22,27 +22,28 @@ import { useRepository } from "hooks/useRepository";
import type React from "react";
export default function Navbar(): React.JSX.Element | null {
const { repositories, current, setCurrent } = useRepository();
const { repositories, currentRepository, setCurrentRepository } = useRepository();
if (repositories.length === 0 || !current) {
if (repositories.length === 0 || !currentRepository) {
return null;
}
const currentIndex = repositories.findIndex(repository =>
repository.architecture === current.architecture && repository.repository === current.repository,
repository.architecture === currentRepository.architecture &&
repository.repository === currentRepository.repository,
);
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={currentIndex >= 0 ? currentIndex : 0}
onChange={(_, newValue: number) => {
const repository = repositories[newValue];
if (repository) {
setCurrent(repository);
setCurrentRepository(repository);
}
}}
variant="scrollable"
scrollButtons="auto"
value={currentIndex >= 0 ? currentIndex : 0}
variant="scrollable"
>
{repositories.map(repository =>
<Tab
@@ -0,0 +1,119 @@
/*
* 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 { QueryKeys } from "hooks/QueryKeys";
import { useAutoScroll } from "hooks/useAutoScroll";
import { useBuildLogStream } from "hooks/useBuildLogStream";
import { useClient } from "hooks/useClient";
import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId";
import React, { useEffect, useMemo, useState } from "react";
interface Logs {
version: string;
processId: string;
created: number;
logs: string;
processId: string;
version: string;
}
interface BuildLogsTabProps {
packageBase: string;
repository: RepositoryId;
refreshInterval: number;
}
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
@@ -51,17 +51,16 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
export default function BuildLogsTab({
packageBase,
repository,
refreshInterval,
}: BuildLogsTabProps): React.JSX.Element {
const client = useClient();
useBuildLogStream(packageBase, repository);
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const { data: allLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logs(packageBase, repository),
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
enabled: !!packageBase,
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
queryKey: QueryKeys.logs(packageBase, repository),
});
// Build version selectors from all logs
@@ -84,13 +83,13 @@ export default function BuildLogsTab({
return Object.values(grouped)
.sort((left, right) => right.minCreated - left.minCreated)
.map(record => ({
version: record.version,
processId: record.process_id,
created: record.minCreated,
logs: convertLogs(
allLogs,
right => record.version === right.version && record.process_id === right.process_id,
),
processId: record.process_id,
version: record.version,
}));
}, [allLogs]);
@@ -110,14 +109,13 @@ export default function BuildLogsTab({
// Refresh active version logs
const { data: versionLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
placeholderData: keepPreviousData,
queryFn: activeVersion
? () => client.fetch.fetchPackageLogs(
packageBase, repository, activeVersion.version, activeVersion.processId,
)
: skipToken,
placeholderData: keepPreviousData,
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
});
// Derive displayed logs: prefer fresh polled data when available
@@ -143,25 +141,25 @@ export default function BuildLogsTab({
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box>
<Button
size="small"
aria-label="Select version"
startIcon={<ListIcon />}
onClick={event => setAnchorEl(event.currentTarget)}
size="small"
startIcon={<ListIcon />}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)}
>
{versions.map((logs, index) =>
<MenuItem
key={`${logs.version}-${logs.processId}`}
selected={index === activeIndex}
onClick={() => {
setSelectedVersionKey(`${logs.version}-${logs.processId}`);
setAnchorEl(null);
resetScroll();
}}
selected={index === activeIndex}
>
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
</MenuItem>,
@@ -174,11 +172,10 @@ export default function BuildLogsTab({
<Box sx={{ flex: 1 }}>
<CodeBlock
preRef={preRef}
getText={() => displayedLogs}
content={displayedLogs}
height={400}
onScroll={handleScroll}
wordBreak
preRef={preRef}
/>
</Box>
</Box>;
+4 -42
View File
@@ -17,20 +17,10 @@
* 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 } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import CopyButton from "components/common/CopyButton";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import { useThemeMode } from "hooks/useThemeMode";
import type { Changes } from "models/Changes";
import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
import React from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
SyntaxHighlighter.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
@@ -38,35 +28,7 @@ interface ChangesTabProps {
}
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const client = useClient();
const { mode } = useThemeMode();
const data = usePackageChanges(packageBase, repository);
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
return <Box sx={{ position: "relative", mt: 1 }}>
<SyntaxHighlighter
language="diff"
style={mode === "dark" ? vs2015 : githubGist}
customStyle={{
padding: "16px",
borderRadius: "4px",
overflow: "auto",
height: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
}}
>
{changesText}
</SyntaxHighlighter>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => changesText} />
</Box>
</Box>;
return <CodeBlock content={data?.changes ?? ""} height={400} language="diff" />;
}
+11 -20
View File
@@ -27,6 +27,7 @@ import type { Event } from "models/Event";
import type { RepositoryId } from "models/RepositoryId";
import type React from "react";
import { useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface EventsTabProps {
packageBase: string;
@@ -34,46 +35,36 @@ interface EventsTabProps {
}
interface EventRow {
id: number;
timestamp: string;
event: string;
id: number;
message: string;
timestamp: string;
}
const columns: GridColDef<EventRow>[] = [
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
{ field: "event", headerName: "event", flex: 1 },
{ field: "message", headerName: "description", flex: 2 },
{ align: "right", field: "timestamp", headerAlign: "right", headerName: "date", width: 180 },
{ field: "event", flex: 1, headerName: "event" },
{ field: "message", flex: 2, headerName: "description" },
];
export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
const client = useClient();
const { data: events = [] } = useQuery<Event[]>({
queryKey: QueryKeys.events(repository, packageBase),
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
queryKey: QueryKeys.events(repository, packageBase),
});
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
id: index,
timestamp: new Date(event.created * 1000).toISOStringShort(),
event: event.event,
id: index,
message: event.message ?? "",
timestamp: new Date(event.created * 1000).toISOStringShort(),
})), [events]);
return <Box sx={{ mt: 1 }}>
<EventDurationLineChart events={events} />
<DataGrid
rows={rows}
columns={columns}
density="compact"
initialState={{
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
}}
pageSizeOptions={[10, 25]}
sx={{ height: 400, mt: 1 }}
disableRowSelectionOnClick
/>
<DataGrid columns={columns} rows={rows} {...DETAIL_TABLE_PROPS} />
</Box>;
}
@@ -23,11 +23,11 @@ import type { Package } from "models/Package";
import React from "react";
interface PackageDetailsGridProps {
pkg: Package;
dependencies?: Dependencies;
pkg: Package;
}
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetailsGridProps): React.JSX.Element {
const packagesList = Object.entries(pkg.packages)
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
@@ -65,50 +65,50 @@ export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetails
return <>
<Grid container spacing={1} sx={{ mt: 1 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packages</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><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={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }} />
<Grid size={{ xs: 8, md: 5 }} />
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packager</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }} />
<Grid size={{ md: 5, xs: 8 }} />
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">groups</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><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={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">upstream</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}>
{upstreamUrls.map(url =>
<Link key={url} href={url} target="_blank" rel="noopener noreferrer" underline="hover" display="block" variant="body2">
<Link display="block" href={url} key={url} rel="noopener noreferrer" target="_blank" underline="hover" variant="body2">
{url}
</Link>,
)}
</Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">AUR</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}>
<Typography variant="body2">
{aurUrl &&
<Link href={aurUrl} target="_blank" rel="noopener noreferrer" underline="hover">AUR link</Link>
<Link href={aurUrl} rel="noopener noreferrer" target="_blank" underline="hover">{aurUrl}</Link>
}
</Typography>
</Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">depends</Typography></Grid>
<Grid size={{ md: 5, xs: 8 }}><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={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
</Grid>
</>;
}
@@ -18,32 +18,30 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import DeleteIcon from "@mui/icons-material/Delete";
import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
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";
interface PackageInfoActionsProps {
isAuthorized: boolean;
refreshDatabase: boolean;
isHeld: boolean;
onHoldToggle: () => void;
onRefreshDatabaseChange: (checked: boolean) => void;
onUpdate: () => void;
onRemove: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
autoRefreshInterval: number;
onAutoRefreshIntervalChange: (interval: number) => void;
onUpdate: () => void;
refreshDatabase: boolean;
}
export default function PackageInfoActions({
isAuthorized,
refreshDatabase,
isHeld,
onHoldToggle,
onRefreshDatabaseChange,
onUpdate,
onRemove,
autoRefreshIntervals,
autoRefreshInterval,
onAutoRefreshIntervalChange,
onUpdate,
refreshDatabase,
}: PackageInfoActionsProps): React.JSX.Element {
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{isAuthorized &&
@@ -52,18 +50,16 @@ export default function PackageInfoActions({
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
label="update pacman databases"
/>
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
<Button color="warning" onClick={onHoldToggle} size="small" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} variant="outlined">
{isHeld ? "unhold" : "hold"}
</Button>
<Button color="success" onClick={onUpdate} size="small" startIcon={<PlayArrowIcon />} variant="contained">
update
</Button>
<Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
<Button color="error" onClick={onRemove} size="small" startIcon={<DeleteIcon />} variant="contained">
remove
</Button>
</>
}
<AutoRefreshControl
intervals={autoRefreshIntervals}
currentInterval={autoRefreshInterval}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</DialogActions>;
}
@@ -23,39 +23,39 @@ import type { Patch } from "models/Patch";
import type React from "react";
interface PackagePatchesListProps {
patches: Patch[];
editable: boolean;
onDelete: (key: string) => void;
patches: Patch[];
}
export default function PackagePatchesList({
patches,
editable,
onDelete,
patches,
}: PackagePatchesListProps): React.JSX.Element | null {
if (patches.length === 0) {
return null;
}
return <Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>Environment variables</Typography>
<Typography gutterBottom variant="h6">Environment variables</Typography>
{patches.map(patch =>
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<Box key={patch.key} sx={{ alignItems: "center", display: "flex", gap: 1, mb: 0.5 }}>
<TextField
size="small"
value={patch.key}
disabled
size="small"
sx={{ flex: 1 }}
value={patch.key}
/>
<Box>=</Box>
<TextField
size="small"
value={JSON.stringify(patch.value)}
disabled
value={JSON.stringify(patch.value)}
size="small"
sx={{ flex: 1 }}
/>
{editable &&
<IconButton size="small" color="error" aria-label="Remove patch" onClick={() => onDelete(patch.key)}>
<IconButton aria-label="Remove patch" color="error" onClick={() => onDelete(patch.key)} size="small">
<DeleteIcon fontSize="small" />
</IconButton>
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
import React from "react";
interface PkgbuildTabProps {
packageBase: string;
repository: RepositoryId;
}
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository);
return <CodeBlock content={data?.pkgbuild ?? ""} height={400} language="bash" />;
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type TabKey = "logs" | "changes" | "pkgbuild" | "events" | "artifacts";
export const tabs: { key: TabKey; label: string }[] = [
{ key: "logs", label: "Build logs" },
{ key: "changes", label: "Changes" },
{ key: "pkgbuild", label: "PKGBUILD" },
{ key: "events", label: "Events" },
{ key: "artifacts", label: "Artifacts" },
];
+51 -74
View File
@@ -23,7 +23,6 @@ import {
GRID_CHECKBOX_SELECTION_COL_DEF,
type GridColDef,
type GridFilterModel,
type GridRenderCellParams,
type GridRowId,
useGridApiRef,
} from "@mui/x-data-grid";
@@ -36,16 +35,9 @@ import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell";
import { useDebounce } from "hooks/useDebounce";
import { usePackageTable } from "hooks/usePackageTable";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { PackageRow } from "models/PackageRow";
import React, { useMemo } from "react";
interface PackageTableProps {
autoRefreshIntervals: AutoRefreshInterval[];
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function createListColumn(
field: keyof PackageRow,
headerName: string,
@@ -55,15 +47,15 @@ function createListColumn(
field,
headerName,
...options,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) =>
renderCell: params =>
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
sortComparator: (left: string, right: string) => left.localeCompare(right),
valueGetter: (value: string[]) => (value ?? []).join(" "),
};
}
export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element {
const table = usePackageTable(autoRefreshIntervals);
export default function PackageTable(): React.JSX.Element {
const table = usePackageTable();
const apiRef = useGridApiRef();
const debouncedSearch = useDebounce(table.searchText, 300);
@@ -79,35 +71,30 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
() => [
{
field: "base",
headerName: "package base",
flex: 1,
headerName: "package base",
minWidth: 150,
renderCell: (params: GridRenderCellParams<PackageRow>) =>
renderCell: params =>
params.row.webUrl ?
<Link href={params.row.webUrl} target="_blank" rel="noopener noreferrer" underline="hover">
<Link href={params.row.webUrl} rel="noopener noreferrer" target="_blank" underline="hover">
{params.value as string}
</Link>
: params.value as string,
},
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
{ align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 },
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
createListColumn("groups", "groups", { width: 150 }),
createListColumn("licenses", "licenses", { width: 150 }),
{ field: "packager", headerName: "packager", width: 150 },
{ align: "right", field: "timestamp", headerName: "last update", headerAlign: "right", width: 180 },
{
field: "timestamp",
headerName: "last update",
width: 180,
align: "right",
headerAlign: "right",
},
{
field: "status",
headerName: "status",
width: 120,
align: "center",
field: "status",
headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
headerName: "status",
renderCell: params =>
<StatusCell isHeld={params.row.isHeld} status={params.row.status} />,
width: 120,
},
],
[],
@@ -115,56 +102,37 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<PackageTableToolbar
hasSelection={table.selectionModel.length > 0}
isAuthorized={table.isAuthorized}
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,
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}
onSearchChange={table.setSearchText}
searchText={table.searchText}
status={table.status}
/>
<DataGrid
apiRef={apiRef}
rows={table.rows}
columns={columns}
loading={table.isLoading}
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}
columns={columns}
density="compact"
disableRowSelectionOnClick
filterModel={effectiveFilterModel}
onFilterModelChange={table.setFilterModel}
getRowHeight={() => "auto"}
initialState={{
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
}}
loading={table.isLoading}
onCellClick={(params, event) => {
// Don't open info dialog when clicking checkbox or link
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
@@ -175,22 +143,31 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
}
table.setSelectedPackage(String(params.id));
}}
sx={{
flex: 1,
"& .MuiDataGrid-row": { cursor: "pointer" },
onColumnVisibilityModelChange={table.setColumnVisibility}
onFilterModelChange={table.setFilterModel}
onPaginationModelChange={table.setPaginationModel}
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));
}
}}
density="compact"
paginationModel={table.paginationModel}
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
rows={table.rows}
sx={{ flex: 1 }}
/>
<DashboardDialog open={table.dialogOpen === "dashboard"} onClose={() => table.setDialogOpen(null)} />
<PackageAddDialog open={table.dialogOpen === "add"} onClose={() => table.setDialogOpen(null)} />
<PackageRebuildDialog open={table.dialogOpen === "rebuild"} onClose={() => table.setDialogOpen(null)} />
<KeyImportDialog open={table.dialogOpen === "keyImport"} onClose={() => table.setDialogOpen(null)} />
<DashboardDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "dashboard"} />
<PackageAddDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "add"} />
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
<PackageInfoDialog
packageBase={table.selectedPackage}
open={table.selectedPackage !== null}
onClose={() => table.setSelectedPackage(null)}
autoRefreshIntervals={autoRefreshIntervals}
open={table.selectedPackage !== null}
packageBase={table.selectedPackage}
/>
</Box>;
}
@@ -30,60 +30,50 @@ import ReplayIcon from "@mui/icons-material/Replay";
import SearchIcon from "@mui/icons-material/Search";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
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 React, { useState } from "react";
import { StatusColors } from "theme/StatusColors";
export interface AutoRefreshProps {
autoRefreshIntervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
export interface ToolbarActions {
onDashboardClick: () => void;
onAddClick: () => void;
onUpdateClick: () => void;
onRefreshDatabaseClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
onDashboardClick: () => void;
onExportClick: () => void;
onKeyImportClick: () => void;
onRebuildClick: () => void;
onRefreshDatabaseClick: () => void;
onReloadClick: () => void;
onRemoveClick: () => void;
onUpdateClick: () => void;
}
interface PackageTableToolbarProps {
actions: ToolbarActions;
hasSelection: boolean;
isAuthorized: boolean;
status?: BuildStatus;
searchText: string;
onSearchChange: (text: string) => void;
autoRefresh: AutoRefreshProps;
actions: ToolbarActions;
searchText: string;
status?: BuildStatus;
}
export default function PackageTableToolbar({
actions,
hasSelection,
isAuthorized,
status,
searchText,
onSearchChange,
autoRefresh,
actions,
searchText,
status,
}: PackageTableToolbarProps): React.JSX.Element {
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null);
return <Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
return <Box sx={{ alignItems: "center", display: "flex", flexWrap: "wrap", gap: 1, mb: 1 }}>
<Tooltip title="System health">
<IconButton
aria-label="System health"
onClick={actions.onDashboardClick}
sx={{
borderColor: status ? StatusColors[status] : undefined,
borderWidth: 1,
borderStyle: "solid",
borderWidth: 1,
color: status ? StatusColors[status] : undefined,
}}
>
@@ -94,16 +84,16 @@ export default function PackageTableToolbar({
{isAuthorized &&
<>
<Button
variant="contained"
startIcon={<InventoryIcon />}
onClick={event => setPackagesAnchorEl(event.currentTarget)}
startIcon={<InventoryIcon />}
variant="contained"
>
packages
</Button>
<Menu
anchorEl={packagesAnchorEl}
open={Boolean(packagesAnchorEl)}
onClose={() => setPackagesAnchorEl(null)}
open={Boolean(packagesAnchorEl)}
>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onAddClick();
@@ -126,58 +116,52 @@ export default function PackageTableToolbar({
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
</MenuItem>
<Divider />
<MenuItem onClick={() => {
<MenuItem disabled={!hasSelection} onClick={() => {
setPackagesAnchorEl(null); actions.onRemoveClick();
}} disabled={!hasSelection}>
}}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
</MenuItem>
</Menu>
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={actions.onKeyImportClick}>
<Button color="info" onClick={actions.onKeyImportClick} startIcon={<VpnKeyIcon />} variant="contained">
import key
</Button>
</>
}
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={actions.onReloadClick}>
<Button color="secondary" onClick={actions.onReloadClick} startIcon={<RefreshIcon />} variant="outlined">
reload
</Button>
<AutoRefreshControl
intervals={autoRefresh.autoRefreshIntervals}
currentInterval={autoRefresh.currentInterval}
onIntervalChange={autoRefresh.onIntervalChange}
/>
<Box sx={{ flexGrow: 1 }} />
<TextField
size="small"
aria-label="Search packages"
placeholder="search packages..."
value={searchText}
onChange={event => onSearchChange(event.target.value)}
placeholder="search packages..."
size="small"
slotProps={{
input: {
endAdornment: searchText ?
<InputAdornment position="end">
<IconButton aria-label="Clear search" onClick={() => onSearchChange("")} size="small">
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
: undefined,
startAdornment:
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
,
endAdornment: searchText ?
<InputAdornment position="end">
<IconButton size="small" aria-label="Clear search" onClick={() => onSearchChange("")}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
: undefined,
},
}}
sx={{ minWidth: 200 }}
value={searchText}
/>
<Tooltip title="Export CSV">
<IconButton size="small" aria-label="Export CSV" onClick={actions.onExportClick}>
<IconButton aria-label="Export CSV" onClick={actions.onExportClick} size="small">
<FileDownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
+4 -1
View File
@@ -17,17 +17,20 @@
* 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 PauseCircleIcon from "@mui/icons-material/PauseCircle";
import { Chip } from "@mui/material";
import type { BuildStatus } from "models/BuildStatus";
import type React from "react";
import { StatusColors } from "theme/StatusColors";
interface StatusCellProps {
isHeld?: boolean;
status: BuildStatus;
}
export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
export default function StatusCell({ isHeld, status }: StatusCellProps): React.JSX.Element {
return <Chip
icon={isHeld ? <PauseCircleIcon /> : undefined}
label={status}
size="small"
sx={{
+1 -1
View File
@@ -26,9 +26,9 @@ interface AuthState {
export interface AuthContextValue extends AuthState {
isAuthorized: boolean;
setAuthState: (state: AuthState) => void;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
setAuthState: (state: AuthState) => void;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
+3 -5
View File
@@ -24,9 +24,7 @@ import React, { type ReactNode, useMemo } from "react";
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
const client = useMemo(() => new AhrimanClient(), []);
return (
<ClientContext.Provider value={client}>
{children}
</ClientContext.Provider>
);
return <ClientContext.Provider value={client}>
{children}
</ClientContext.Provider>;
}
@@ -0,0 +1,32 @@
/*
* 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";
export interface NotificationContextValue {
showSuccess: (title: string, message: string) => void;
showError: (title: string, message: string) => void;
showSuccess: (title: string, message: string) => void;
}
export const NotificationContext = createContext<NotificationContextValue | null>(null);
@@ -55,17 +55,17 @@ export function NotificationProvider({ children }: { children: ReactNode }): Rea
{children}
<Box
sx={{
position: "fixed",
top: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: theme => theme.zIndex.snackbar,
display: "flex",
flexDirection: "column",
gap: 1,
left: "50%",
maxWidth: 500,
width: "100%",
pointerEvents: "none",
position: "fixed",
top: 16,
transform: "translateX(-50%)",
width: "100%",
zIndex: theme => theme.zIndex.snackbar,
}}
>
{notifications.map(notification =>
+2 -2
View File
@@ -21,10 +21,10 @@ import type { RepositoryId } from "models/RepositoryId";
import { createContext } from "react";
export interface RepositoryContextValue {
currentRepository: RepositoryId | null;
repositories: RepositoryId[];
current: RepositoryId | null;
setCurrentRepository: (repository: RepositoryId) => void;
setRepositories: (repositories: RepositoryId[]) => void;
setCurrent: (repository: RepositoryId) => void;
}
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
+4 -4
View File
@@ -34,20 +34,20 @@ export function RepositoryProvider({ children }: { children: ReactNode }): React
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const current = useMemo(() => {
const currentRepository = useMemo(() => {
if (repositories.length === 0) {
return null;
}
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]);
const setCurrent = useCallback((repository: RepositoryId) => {
const setCurrentRepository = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key;
}, []);
const value = useMemo(() => ({
repositories, current, setRepositories, setCurrent,
}), [repositories, current, setCurrent]);
repositories, currentRepository, setRepositories, setCurrentRepository,
}), [repositories, currentRepository, setCurrentRepository]);
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
}
+2 -4
View File
@@ -39,10 +39,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }): Reac
const theme = useMemo(() => createAppTheme(mode), [mode]);
useEffect(() => {
const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
chartDefaults.color = textColor;
chartDefaults.borderColor = gridColor;
chartDefaults.color = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
chartDefaults.borderColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
}, [mode]);
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
+2
View File
@@ -21,6 +21,8 @@ import type { RepositoryId } from "models/RepositoryId";
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,
dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,
-49
View File
@@ -1,49 +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 { 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";
interface UseAutoScrollResult {
preRef: RefObject<HTMLElement | null>;
handleScroll: () => void;
scrollToBottom: () => void;
preRef: RefObject<HTMLElement | null>;
resetScroll: () => void;
scrollToBottom: () => void;
}
export function useAutoScroll(): UseAutoScrollResult {
@@ -59,5 +59,5 @@ export function useAutoScroll(): UseAutoScrollResult {
}
}, []);
return { preRef, handleScroll, scrollToBottom, resetScroll };
return { handleScroll, preRef, resetScroll, scrollToBottom };
}
+70
View File
@@ -0,0 +1,70 @@
/*
* 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
@@ -0,0 +1,101 @@
/*
* 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]);
}
+13 -13
View File
@@ -26,10 +26,10 @@ import { useRepository } from "hooks/useRepository";
import type { RepositoryId } from "models/RepositoryId";
export interface UsePackageActionsResult {
handleReload: () => void;
handleUpdate: () => Promise<void>;
handleRefreshDatabase: () => Promise<void>;
handleReload: () => void;
handleRemove: () => Promise<void>;
handleUpdate: () => Promise<void>;
}
export function usePackageActions(
@@ -37,7 +37,7 @@ export function usePackageActions(
setSelectionModel: (model: string[]) => void,
): UsePackageActionsResult {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
@@ -50,22 +50,22 @@ export function usePackageActions(
action: (repository: RepositoryId) => Promise<string>,
errorMessage: string,
): Promise<void> => {
if (!current) {
if (!currentRepository) {
return;
}
try {
const successMessage = await action(current);
const successMessage = await action(currentRepository);
showSuccess("Success", successMessage);
invalidate(current);
invalidate(currentRepository);
setSelectionModel([]);
} catch (exception) {
showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`);
}
};
const handleReload: () => void = () => {
if (current !== null) {
invalidate(current);
const handleReload = (): void => {
if (currentRepository !== null) {
invalidate(currentRepository);
}
};
@@ -80,11 +80,11 @@ export function usePackageActions(
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
await client.service.servicePackageUpdate(repository, {
packages: [],
refresh: true,
aur: false,
local: false,
manual: false,
packages: [],
refresh: true,
});
return "Pacman database update has been requested";
}, "Could not update pacman databases");
@@ -100,9 +100,9 @@ export function usePackageActions(
};
return {
handleReload,
handleUpdate,
handleRefreshDatabase,
handleReload,
handleRemove,
handleUpdate,
};
}
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId";
export function usePackageChanges(packageBase: string, repository: RepositoryId): Changes | undefined {
const client = useClient();
const { data } = useQuery<Changes>({
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
queryKey: QueryKeys.changes(packageBase, repository),
});
return data;
}
+9 -18
View File
@@ -20,49 +20,40 @@
import { skipToken, useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus";
import { PackageRow } from "models/PackageRow";
import { useMemo } from "react";
import { defaultInterval } from "utils";
export interface UsePackageDataResult {
rows: PackageRow[];
isLoading: boolean;
isAuthorized: boolean;
isLoading: boolean;
rows: PackageRow[];
status: BuildStatus | undefined;
autoRefresh: ReturnType<typeof useAutoRefresh>;
}
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
export function usePackageData(): UsePackageDataResult {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({
queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: current ? () => client.fetch.fetchPackages(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
});
const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
});
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
return {
rows,
isLoading,
isAuthorized,
rows,
status: status?.status.status,
autoRefresh,
};
}
+22 -43
View File
@@ -21,66 +21,45 @@ import type { GridFilterModel } from "@mui/x-data-grid";
import { usePackageActions } from "hooks/usePackageActions";
import { usePackageData } from "hooks/usePackageData";
import { useTableState } from "hooks/useTableState";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus";
import type { PackageRow } from "models/PackageRow";
import { useEffect } from "react";
export interface UsePackageTableResult {
rows: PackageRow[];
isLoading: boolean;
isAuthorized: boolean;
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;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
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>;
handleReload: () => void;
handleRemove: () => Promise<void>;
handleUpdate: () => Promise<void>;
isAuthorized: boolean;
isLoading: boolean;
paginationModel: { page: number; pageSize: number };
rows: PackageRow[];
searchText: string;
selectedPackage: string | null;
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;
}
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals);
export function usePackageTable(): UsePackageTableResult {
const { rows, isLoading, isAuthorized, status } = usePackageData();
const tableState = useTableState();
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 {
rows,
isLoading,
isAuthorized,
rows,
status,
...tableState,
autoRefreshInterval: autoRefresh.interval,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
...actions,
...tableState,
};
}
+7 -7
View File
@@ -22,17 +22,17 @@ import type { RepositoryId } from "models/RepositoryId";
import { useState } from "react";
export interface SelectedRepositoryResult {
selectedKey: string;
setSelectedKey: (key: string) => void;
selectedRepository: RepositoryId | null;
reset: () => void;
selectedKey: string;
selectedRepository: RepositoryId | null;
setSelectedKey: (key: string) => void;
}
export function useSelectedRepository(): SelectedRepositoryResult {
const { repositories, current } = useRepository();
const { repositories, currentRepository } = useRepository();
const [selectedKey, setSelectedKey] = useState("");
let selectedRepository: RepositoryId | null = current;
let selectedRepository: RepositoryId | null = currentRepository;
if (selectedKey) {
const repository = repositories.find(repository => repository.key === selectedKey);
if (repository) {
@@ -40,9 +40,9 @@ export function useSelectedRepository(): SelectedRepositoryResult {
}
}
const reset: () => void = () => {
const reset = (): void => {
setSelectedKey("");
};
return { selectedKey, setSelectedKey, selectedRepository, reset };
return { reset, selectedKey, selectedRepository, setSelectedKey };
}
+21 -25
View File
@@ -24,22 +24,20 @@ import { useState } from "react";
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
export interface UseTableStateResult {
selectionModel: string[];
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;
dialogOpen: DialogType | null;
filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void;
paginationModel: { pageSize: number; page: number };
searchText: string;
selectedPackage: string | null;
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;
}
export function useTableState(): UseTableStateResult {
@@ -49,8 +47,8 @@ export function useTableState(): UseTableStateResult {
const [searchText, setSearchText] = useState("");
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
pageSize: 10,
page: 0,
pageSize: 25,
});
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
"ahriman-packages-columns",
@@ -62,21 +60,19 @@ export function useTableState(): UseTableStateResult {
);
return {
selectionModel,
setSelectionModel,
dialogOpen,
setDialogOpen,
selectedPackage,
setSelectedPackage,
paginationModel,
setPaginationModel,
columnVisibility,
setColumnVisibility,
dialogOpen,
filterModel,
setFilterModel,
paginationModel,
searchText,
selectedPackage,
selectionModel,
setColumnVisibility,
setDialogOpen,
setFilterModel,
setPaginationModel,
setSearchText,
setSelectedPackage,
setSelectionModel,
};
}
+8 -1
View File
@@ -21,11 +21,18 @@ import "chartSetup";
import "utils";
import App from "App";
import ErrorFallback from "components/common/ErrorBoundary";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ErrorBoundary } from "react-error-boundary";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
>
<App />
</ErrorBoundary>
</StrictMode>,
);
+1 -1
View File
@@ -18,6 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export interface AURPackage {
package: string;
description: string;
package: string;
}
+1 -1
View File
@@ -17,4 +17,4 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";
export type BuildStatus = "building" | "failed" | "pending" | "success" | "unknown";
+1
View File
@@ -20,4 +20,5 @@
export interface Changes {
changes?: string;
last_commit_sha?: string;
pkgbuild?: string;
}
+2 -4
View File
@@ -18,14 +18,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { AuthInfo } from "models/AuthInfo";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { RepositoryId } from "models/RepositoryId";
export interface InfoResponse {
auth: AuthInfo;
repositories: RepositoryId[];
version: string;
autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean;
index_url?: string;
repositories: RepositoryId[];
version: string;
}
+1 -1
View File
@@ -23,8 +23,8 @@ import type { Status } from "models/Status";
export interface InternalStatus {
architecture: string;
repository: string;
packages: Counters;
repository: string;
stats: RepositoryStats;
status: Status;
version: string;
+1 -1
View File
@@ -18,6 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export interface LoginRequest {
username: string;
password: string;
username: string;
}
+1 -1
View File
@@ -21,7 +21,7 @@ import type { AlertColor } from "@mui/material";
export interface Notification {
id: string;
title: string;
message: string;
severity: AlertColor;
title: string;
}
+3 -3
View File
@@ -20,10 +20,10 @@
import type { Patch } from "models/Patch";
export interface PackageActionRequest {
packages: string[];
patches?: Patch[];
refresh?: boolean;
aur?: boolean;
local?: boolean;
manual?: boolean;
packages: string[];
patches?: Patch[];
refresh?: boolean;
}
+13 -12
View File
@@ -21,30 +21,31 @@ import type { BuildStatus } from "models/BuildStatus";
import type { PackageStatus } from "models/PackageStatus";
export class PackageRow {
id: string;
base: string;
webUrl?: string;
version: string;
packages: string[];
groups: string[];
id: string;
isHeld: boolean;
licenses: string[];
packager: string;
timestamp: string;
timestampValue: number;
packages: string[];
status: BuildStatus;
timestamp: string;
version: string;
webUrl?: string;
constructor(descriptor: PackageStatus) {
this.id = descriptor.package.base;
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.id = descriptor.package.base;
this.isHeld = descriptor.status.is_held ?? false;
this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses");
this.packager = descriptor.package.packager ?? "";
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
this.timestampValue = descriptor.status.timestamp;
this.packages = Object.keys(descriptor.package.packages).sort();
this.status = descriptor.status.status;
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
this.version = descriptor.package.version;
this.webUrl = descriptor.package.remote.web_url ?? undefined;
}
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
+1
View File
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export class RepositoryId {
readonly architecture: string;
readonly repository: string;
@@ -17,8 +17,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export interface AutoRefreshInterval {
interval: number;
is_active: boolean;
text: string;
export interface RollbackRequest {
package: string;
version: string;
}
+1
View File
@@ -20,6 +20,7 @@
import type { BuildStatus } from "models/BuildStatus";
export interface Status {
is_held?: boolean;
status: BuildStatus;
timestamp: number;
}
+4 -4
View File
@@ -21,19 +21,19 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
import type { BuildStatus } from "models/BuildStatus";
const base: Record<BuildStatus, string> = {
unknown: grey[600],
pending: amber[700],
building: orange[800],
failed: red[700],
pending: amber[700],
success: green[700],
unknown: grey[600],
};
const headerBase: Record<BuildStatus, string> = {
unknown: grey[600],
pending: amber[700],
building: orange[600],
failed: red[500],
pending: amber[700],
success: green[600],
unknown: grey[600],
};
export const StatusColors = base;
+12 -4
View File
@@ -21,16 +21,24 @@ import { createTheme, type Theme } from "@mui/material/styles";
export function createAppTheme(mode: "light" | "dark"): Theme {
return createTheme({
palette: {
mode,
},
components: {
MuiButton: {
styleOverrides: {
startIcon: {
alignItems: "center",
display: "flex",
},
},
},
MuiDialog: {
defaultProps: {
maxWidth: "lg",
fullWidth: true,
maxWidth: "lg",
},
},
},
palette: {
mode,
},
});
}
+7 -5
View File
@@ -17,11 +17,13 @@
* 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 { AutoRefreshInterval } from "models/AutoRefreshInterval";
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
return intervals.find(interval => interval.is_active)?.interval ?? 0;
}
export const DETAIL_TABLE_PROPS = {
density: "compact" as const,
disableColumnSorting: true,
disableRowSelectionOnClick: true,
paginationModel: { page: 0, pageSize: 25 },
sx: { height: 400, mt: 1 },
};
declare global {
interface Array<T> {
+3 -3
View File
@@ -4,18 +4,18 @@
"baseUrl": "src",
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true
},
"include": ["src"]
+8 -6
View File
@@ -1,35 +1,37 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
function rename(oldName: string, newName: string): Plugin {
return {
name: "rename",
enforce: "post",
generateBundle(_, bundle) {
if (bundle[oldName]) {
bundle[oldName].fileName = newName;
}
},
name: "rename",
};
}
export default defineConfig({
plugins: [react(), tsconfigPaths(), rename("index.html", "build-status.jinja2")],
base: "/",
build: {
chunkSizeWarningLimit: 10000,
emptyOutDir: false,
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
rollupOptions: {
rolldownOptions: {
output: {
entryFileNames: "static/[name].js",
chunkFileNames: "static/[name].js",
assetFileNames: "static/[name].[ext]",
chunkFileNames: "static/[name].js",
entryFileNames: "static/[name].js",
},
},
},
plugins: [react(), rename("index.html", "build-status.jinja2")],
resolve: {
tsconfigPaths: true,
},
server: {
proxy: {
"/api": "http://localhost:8080",
+2 -2
View File
@@ -2,7 +2,7 @@
pkgbase='ahriman'
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
pkgver=2.20.1
pkgver=2.20.0
pkgrel=1
pkgdesc="ArcH linux ReposItory MANager"
arch=('any')
@@ -77,7 +77,7 @@ package_ahriman-triggers() {
package_ahriman-web() {
pkgname='ahriman-web'
pkgdesc="ArcH linux ReposItory MANager, web server"
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2')
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2' 'python-aiohttp-sse-git')
optdepends=('python-aioauth-client: OAuth2 authorization support'
'python-aiohttp-apispec>=3.0.0: autogenerated API documentation'
'python-aiohttp-openmetrics: HTTP metrics support'
@@ -30,6 +30,14 @@ 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
; by default.
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_archive_upload = no
; Address to bind the server.
@@ -38,6 +46,8 @@ host = 127.0.0.1
;index_url =
; Max file size in bytes which can be uploaded to the server. Requires ${web:enable_archive_upload} to be enabled.
;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 =
; Disable status (e.g. package status, logs, etc) endpoints. Useful for build only modes.
+1 -1
View File
@@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2026\-03\-13" "ahriman 2.20.1" "ArcH linux ReposItory MANager"
.TH AHRIMAN "1" "2026\-03\-08" "ahriman 2.20.0" "ArcH linux ReposItory MANager"
.SH NAME
ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS
+1
View File
@@ -58,6 +58,7 @@ web = [
"aiohttp",
"aiohttp_cors",
"aiohttp_jinja2",
"aiohttp_sse",
]
web-auth = [
"ahriman[web]",
+1 -1
View File
@@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__version__ = "2.20.1"
__version__ = "2.20.0"
@@ -154,13 +154,13 @@ class Application(ApplicationPackages, ApplicationRepository):
for package_name, packager in missing.items():
if (source_dir := self.repository.paths.cache_for(package_name)).is_dir():
# there is local cache, load package from it
leaf = Package.from_build(source_dir, self.repository.architecture, packager)
leaf = Package.from_build(source_dir, self.repository.repository_id.architecture, packager)
else:
leaf = Package.from_aur(package_name, packager, include_provides=True)
portion[leaf.base] = leaf
# register package in the database
self.repository.reporter.set_unknown(leaf)
self.reporter.set_unknown(leaf)
return portion
@@ -45,8 +45,8 @@ class ApplicationRepository(ApplicationProperties):
if last_commit_sha is None:
continue # skip check in case if we can't calculate diff
changes = self.repository.package_changes(package, last_commit_sha)
self.repository.reporter.package_changes_update(package.base, changes)
if (changes := self.repository.package_changes(package, last_commit_sha)) is not None:
self.reporter.package_changes_update(package.base, changes)
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:
"""
@@ -156,12 +156,15 @@ class ApplicationRepository(ApplicationProperties):
result = Result()
# process already built packages if any
built_packages = self.repository.packages_built()
if built_packages: # speedup a bit
if built_packages := self.repository.packages_built(): # speedup a bit
build_result = self.repository.process_update(built_packages, packagers)
self.on_result(build_result)
result.merge(build_result)
# filter packages which were prebuilt
succeeded = {package.base for package in build_result.success}
updates = [package for package in updates if package.base not in succeeded]
builder = Updater.load(self.repository_id, self.configuration, self.repository)
# ok so for now we split all packages into chunks and process each chunk accordingly
+25 -24
View File
@@ -21,10 +21,10 @@ import argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.application.handlers.update import Update
from ahriman.core.configuration import Configuration
from ahriman.core.utils import enum_values, extract_user
from ahriman.models.package_source import PackageSource
from ahriman.models.packagers import Packagers
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId
@@ -48,26 +48,7 @@ class Add(Handler):
"""
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
application.on_start()
application.add(args.package, args.source, args.username)
patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
for package in args.package: # for each requested package insert patch
for patch in patches:
application.reporter.package_patches_update(package, patch)
if not args.now:
return
packages = application.updates(args.package, aur=False, local=False, manual=True, vcs=False, check_files=False)
if args.changes: # generate changes if requested
application.changes(packages)
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
packagers = Packagers(args.username, {package.base: package.packager for package in packages})
application.print_updates(packages, log_fn=application.logger.info)
result = application.update(packages, packagers, bump_pkgrel=args.increment)
Add.check_status(args.exit_code, not result.is_empty)
Add.perform_action(application, args)
@staticmethod
def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
@@ -103,14 +84,34 @@ class Add(Handler):
parser.add_argument("--increment", help="increment package release (pkgrel) version on duplicate",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-n", "--now", help="run update function after", action="store_true")
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=False)
parser.add_argument("-s", "--source", help="explicitly specify the package source for this command",
type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto)
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build",
action="append")
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=False)
parser.set_defaults(aur=False, check_files=False, dry_run=False, local=False, manual=True, vcs=False)
return parser
@staticmethod
def perform_action(application: Application, args: argparse.Namespace) -> None:
"""
perform add action
Args:
application(Application): application instance
args(argparse.Namespace): command line args
"""
application.add(args.package, args.source, args.username)
patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
for package in args.package: # for each requested package insert patch
for patch in patches:
application.reporter.package_patches_update(package, patch)
if not args.now:
return
Update.perform_action(application, args)
arguments = [_set_package_add_parser]

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