diff --git a/.dockerignore b/.dockerignore
index b461b8d7..cd04d110 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -12,3 +12,6 @@ __pycache__/
*.pyc
*.pyd
*.pyo
+
+node_modules/
+package-lock.json
diff --git a/.github/workflows/setup.sh b/.github/workflows/setup.sh
index 6731e824..e61926cd 100755
--- a/.github/workflows/setup.sh
+++ b/.github/workflows/setup.sh
@@ -10,7 +10,7 @@ echo -e '[arcanisrepo]\nServer = https://repo.arcanis.me/$arch\nSigLevel = Never
# refresh the image
pacman -Syyu --noconfirm
# main dependencies
-pacman -S --noconfirm devtools git pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo
+pacman -S --noconfirm devtools git npm pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo
# make dependencies
pacman -S --noconfirm --asdeps base-devel python-build python-flit python-installer python-tox python-wheel
# optional dependencies
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 57781a0c..2ad62ad0 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -26,7 +26,7 @@ jobs:
- ${{ github.workspace }}:/build
steps:
- - run: pacman --noconfirm -Syu base-devel git python-tox
+ - run: pacman --noconfirm -Syu base-devel git npm python-tox
- run: git config --global --add safe.directory *
diff --git a/.gitignore b/.gitignore
index 1fa4cc9d..5a856dcf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,3 +99,9 @@ status_cache.json
*.db
docs/html/
+
+# Frontend
+node_modules/
+package-lock.json
+package/share/ahriman/templates/static/index.js
+package/share/ahriman/templates/static/index.css
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 74981a72..9f693d1d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -125,7 +125,7 @@ Again, the most checks can be performed by `tox` command, though some additional
def __hash__(self) -> int: ... # basically any magic (or look-alike) method
```
- Methods inside one group should be ordered alphabetically, the only exceptions are `__init__` (`__post_init__` for dataclasses), `__new__` and `__del__` methods which should be defined first. For test methods it is recommended to follow the order in which functions are defined.
+ Methods inside one group should be ordered alphabetically, the only exceptions are `__init__` (`__post_init__` for dataclasses), `__new__` and `__del__` methods which should be defined first. For test methods it is recommended to follow the order in which functions are defined. Same idea applies to frontend classes.
Though, we would like to highlight abstract methods (i.e. ones which raise `NotImplementedError`), we still keep in global order at the moment.
@@ -172,8 +172,9 @@ Again, the most checks can be performed by `tox` command, though some additional
)
```
-* One file should define only one class, exception is class satellites in case if file length remains less than 400 lines.
-* It is possible to create file which contains some functions (e.g. `ahriman.core.util`), but in this case you would need to define `__all__` attribute.
+* Imports goes in alphabetical order, no relative imports allowed. Same rule applies to frontend classes.
+* One file should define only one class, exception is class satellites in case if file length remains less than 400 lines. Same rule applies to frontend classes.
+* It is possible to create file which contains some functions (e.g. `ahriman.core.utils`), but in this case you would need to define `__all__` attribute.
* The file size mentioned above must be applicable in general. In case of big classes consider splitting them into traits. Note, however, that `pylint` includes comments and docstrings into counter, thus you need to check file size by other tools.
* No global variable is allowed outside of `ahriman` module. `ahriman.core.context` is also special case.
* Single quotes are not allowed. The reason behind this restriction is the fact that docstrings must be written by using double quotes only, and we would like to make style consistent.
@@ -226,6 +227,8 @@ Again, the most checks can be performed by `tox` command, though some additional
The projects also uses typing checks (provided by `mypy`) and some linter checks provided by `pylint` and `bandit`. Those checks must be passed successfully for any open pull requests.
+Frontend checks normally are performed by `eslint` (e.g. `npx run eslint`).
+
## Developers how to
### Run automated checks
diff --git a/docker/Dockerfile b/docker/Dockerfile
index fa5eed5c..061f9ff9 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -23,6 +23,7 @@ COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
RUN pacman -S --noconfirm --asdeps \
devtools \
git \
+ npm \
pyalpm \
python-bcrypt \
python-filelock \
diff --git a/docs/ahriman.web.rst b/docs/ahriman.web.rst
index c175f4ac..3164f3f4 100644
--- a/docs/ahriman.web.rst
+++ b/docs/ahriman.web.rst
@@ -39,6 +39,14 @@ ahriman.web.routes module
:no-undoc-members:
:show-inheritance:
+ahriman.web.server\_info module
+-------------------------------
+
+.. automodule:: ahriman.web.server_info
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.web module
----------------------
diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst
index 1900afaf..425ae914 100644
--- a/docs/ahriman.web.schemas.rst
+++ b/docs/ahriman.web.schemas.rst
@@ -20,6 +20,14 @@ ahriman.web.schemas.aur\_package\_schema module
:no-undoc-members:
:show-inheritance:
+ahriman.web.schemas.auth\_info\_schema module
+---------------------------------------------
+
+.. automodule:: ahriman.web.schemas.auth_info_schema
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.schemas.auth\_schema module
---------------------------------------
@@ -28,6 +36,14 @@ ahriman.web.schemas.auth\_schema module
:no-undoc-members:
:show-inheritance:
+ahriman.web.schemas.auto\_refresh\_interval\_schema module
+----------------------------------------------------------
+
+.. automodule:: ahriman.web.schemas.auto_refresh_interval_schema
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.schemas.build\_options\_schema module
-------------------------------------------------
@@ -108,6 +124,14 @@ ahriman.web.schemas.info\_schema module
:no-undoc-members:
:show-inheritance:
+ahriman.web.schemas.info\_v2\_schema module
+-------------------------------------------
+
+.. automodule:: ahriman.web.schemas.info_v2_schema
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.schemas.internal\_status\_schema module
---------------------------------------------------
diff --git a/docs/ahriman.web.views.v2.rst b/docs/ahriman.web.views.v2.rst
index 0d06409a..f5e97f6c 100644
--- a/docs/ahriman.web.views.v2.rst
+++ b/docs/ahriman.web.views.v2.rst
@@ -8,6 +8,7 @@ Subpackages
:maxdepth: 4
ahriman.web.views.v2.packages
+ ahriman.web.views.v2.status
Module contents
---------------
diff --git a/docs/ahriman.web.views.v2.status.rst b/docs/ahriman.web.views.v2.status.rst
new file mode 100644
index 00000000..65174688
--- /dev/null
+++ b/docs/ahriman.web.views.v2.status.rst
@@ -0,0 +1,21 @@
+ahriman.web.views.v2.status package
+===================================
+
+Submodules
+----------
+
+ahriman.web.views.v2.status.info module
+---------------------------------------
+
+.. automodule:: ahriman.web.views.v2.status.info
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: ahriman.web.views.v2.status
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 71446cc6..2b11d3a8 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -118,7 +118,6 @@ Base authorization settings. ``OAuth`` provider requires ``aioauth-client`` libr
* ``cookie_secret_key`` - secret key which will be used for cookies encryption, string, optional. It must be 32 bytes URL-safe base64-encoded and can be generated as following ``base64.urlsafe_b64encode(os.urandom(32)).decode("utf8")``. If not set, it will be generated automatically; note, however, that in this case, all sessions will be automatically invalidated during the service restart.
* ``full_access_group`` - name of the secondary group (e.g. ``wheel``) to be used as admin group in the service, string, required in case if ``pam`` is used.
* ``max_age`` - parameter which controls both cookie expiration and token expiration inside the service in seconds, integer, optional, default is 7 days.
-* ``oauth_icon`` - OAuth2 login button icon, string, optional, default is ``google``. Must be valid `Bootstrap icon `__ name.
* ``oauth_provider`` - OAuth2 provider class name as is in ``aioauth-client`` (e.g. ``GoogleClient``, ``GithubClient`` etc), string, required in case if ``oauth`` is used.
* ``oauth_scopes`` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. ``https://www.googleapis.com/auth/userinfo.email`` for ``GoogleClient`` or ``user:email`` for ``GithubClient``, space separated list of strings, required in case if ``oauth`` is used.
* ``permit_root_login`` - allow login as root user, boolean, optional, default ``no``.
@@ -188,6 +187,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``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.
+* ``template`` - Jinja2 template name for the index page, string, optional, default ``build-status.jinja2``.
* ``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.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 00000000..94c510ab
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,67 @@
+import js from "@eslint/js";
+import stylistic from "@stylistic/eslint-plugin";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import simpleImportSort from "eslint-plugin-simple-import-sort";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ { ignores: ["dist"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
+ files: ["src/**/*.{ts,tsx}"],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ "simple-import-sort": simpleImportSort,
+ "@stylistic": stylistic,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+
+ // imports
+ "simple-import-sort/imports": "error",
+ "simple-import-sort/exports": "error",
+
+ // brackets
+ "curly": "error",
+ "@stylistic/brace-style": ["error", "1tbs"],
+
+ // stylistic
+ "@stylistic/array-bracket-spacing": ["error", "never"],
+ "@stylistic/arrow-parens": ["error", "as-needed"],
+ "@stylistic/comma-dangle": ["error", "always-multiline"],
+ "@stylistic/comma-spacing": ["error", { before: false, after: true }],
+ "@stylistic/eol-last": ["error", "always"],
+ "@stylistic/indent": ["error", 4],
+ "@stylistic/jsx-quotes": ["error", "prefer-double"],
+ "@stylistic/max-len": ["error", {
+ code: 120,
+ ignoreComments: true,
+ ignoreStrings: true,
+ ignoreTemplateLiterals: true,
+ ignoreUrls: true,
+ }],
+ "@stylistic/no-extra-parens": ["error", "all"],
+ "@stylistic/no-multi-spaces": "error",
+ "@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
+ "@stylistic/no-trailing-spaces": "error",
+ "@stylistic/object-curly-spacing": ["error", "always"],
+ "@stylistic/quotes": ["error", "double"],
+ "@stylistic/semi": ["error", "always"],
+
+ // typescript
+ "@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-unused-vars": ["error", { argsIgnorePattern: "^_" }],
+ },
+ },
+);
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 00000000..7246ce66
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ ahriman
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 00000000..6033833a
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "ahriman-frontend",
+ "private": true,
+ "type": "module",
+ "version": "2.20.0-rc4",
+ "scripts": {
+ "build": "tsc && vite build",
+ "dev": "vite",
+ "lint": "eslint src/",
+ "lint:fix": "eslint --fix src/",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@emotion/react": "^11.11.0",
+ "@emotion/styled": "^11.11.0",
+ "@mui/icons-material": "^7.3.8",
+ "@mui/material": "^7.3.8",
+ "@mui/x-data-grid": "^8.27.3",
+ "@tanstack/react-query": "^5.0.0",
+ "chart.js": "^4.5.0",
+ "react": "^19.2.4",
+ "react-chartjs-2": "^5.2.0",
+ "react-dom": "^19.2.4",
+ "react-syntax-highlighter": "^16.1.1"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.3",
+ "@stylistic/eslint-plugin": "^5.9.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "@vitejs/plugin-react": "^5.0.0",
+ "eslint": "^9.39.3",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "eslint-plugin-simple-import-sort": "^12.1.1",
+ "typescript": "^5.3.0",
+ "typescript-eslint": "^8.56.1",
+ "vite": "^7.3.1",
+ "vite-tsconfig-paths": "^6.1.1"
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 00000000..7ec3be07
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,57 @@
+/*
+ * 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 .
+ */
+import CssBaseline from "@mui/material/CssBaseline";
+import { ThemeProvider } from "@mui/material/styles";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import AppLayout from "components/layout/AppLayout";
+import { AuthProvider } from "contexts/AuthProvider";
+import { ClientProvider } from "contexts/ClientProvider";
+import { NotificationProvider } from "contexts/NotificationProvider";
+import { RepositoryProvider } from "contexts/RepositoryProvider";
+import type React from "react";
+import Theme from "theme/Theme";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ retry: 1,
+ },
+ },
+});
+
+export default function App(): React.JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/api/client/AhrimanClient.ts b/frontend/src/api/client/AhrimanClient.ts
new file mode 100644
index 00000000..ff2275ff
--- /dev/null
+++ b/frontend/src/api/client/AhrimanClient.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+import { Client } from "api/client/Client";
+import { FetchClient } from "api/client/FetchClient";
+import { ServiceClient } from "api/client/ServiceClient";
+import type { LoginRequest } from "models/LoginRequest";
+
+export class AhrimanClient {
+
+ protected client = new Client();
+
+ readonly fetch = new FetchClient(this.client);
+ readonly service = new ServiceClient(this.client);
+
+ async login(data: LoginRequest): Promise {
+ return this.client.request("/api/v1/login", { method: "POST", json: data });
+ }
+
+ async logout(): Promise {
+ return this.client.request("/api/v1/logout", { method: "POST" });
+ }
+}
diff --git a/frontend/src/api/client/ApiError.ts b/frontend/src/api/client/ApiError.ts
new file mode 100644
index 00000000..1462d48b
--- /dev/null
+++ b/frontend/src/api/client/ApiError.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+export class ApiError extends Error {
+ status: number;
+ statusText: string;
+ body: string;
+
+ constructor(status: number, statusText: string, body: string) {
+ super(`${status} ${statusText}`);
+ this.status = status;
+ this.statusText = statusText;
+ this.body = body;
+ }
+
+ get detail(): string {
+ try {
+ const parsed = JSON.parse(this.body) as Record;
+ return parsed.error ?? (this.body || this.message);
+ } catch {
+ return this.body || this.message;
+ }
+ }
+
+ static errorDetail(exception: unknown): string {
+ return exception instanceof ApiError ? exception.detail : String(exception);
+ }
+}
diff --git a/frontend/src/api/client/Client.ts b/frontend/src/api/client/Client.ts
new file mode 100644
index 00000000..e305c28b
--- /dev/null
+++ b/frontend/src/api/client/Client.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 .
+ */
+import { ApiError } from "api/client/ApiError";
+import type { RequestOptions } from "api/client/RequestOptions";
+
+export class Client {
+
+ async request(url: string, options: RequestOptions = {}): Promise {
+ const { method, query, json } = options;
+
+ let fullUrl = url;
+ if (query) {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== undefined && value !== null) {
+ params.set(key, String(value));
+ }
+ }
+ fullUrl += `?${params.toString()}`;
+ }
+
+ const headers: Record = {
+ Accept: "application/json",
+ };
+ if (json !== undefined) {
+ headers["Content-Type"] = "application/json";
+ }
+
+ const requestInit: RequestInit = {
+ method: method || (json ? "POST" : "GET"),
+ headers,
+ };
+
+ if (json !== undefined) {
+ requestInit.body = JSON.stringify(json);
+ }
+
+ const response = await fetch(fullUrl, requestInit);
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new ApiError(response.status, response.statusText, body);
+ }
+
+ const contentType = response.headers.get("Content-Type") ?? "";
+ if (contentType.includes("application/json")) {
+ return await response.json() as T;
+ }
+ return await response.text() as T;
+ }
+}
diff --git a/frontend/src/api/client/FetchClient.ts b/frontend/src/api/client/FetchClient.ts
new file mode 100644
index 00000000..28de08c3
--- /dev/null
+++ b/frontend/src/api/client/FetchClient.ts
@@ -0,0 +1,109 @@
+/*
+ * 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 .
+ */
+import type { Client } from "api/client/Client";
+import type { Changes } from "models/Changes";
+import type { Dependencies } from "models/Dependencies";
+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 { PackageStatus } from "models/PackageStatus";
+import type { Patch } from "models/Patch";
+import { RepositoryId } from "models/RepositoryId";
+
+export class FetchClient {
+
+ protected client: Client;
+
+ constructor(client: Client) {
+ this.client = client;
+ }
+
+ async fetchPackage(packageBase: string, repository: RepositoryId): Promise {
+ return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}`, {
+ query: repository.toQuery(),
+ });
+ }
+
+ async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise {
+ return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
+ query: repository.toQuery(),
+ });
+ }
+
+ async fetchPackageDependencies(packageBase: string, repository: RepositoryId): Promise {
+ return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/dependencies`, {
+ query: repository.toQuery(),
+ });
+ }
+
+ async fetchPackageEvents(repository: RepositoryId, objectId?: string, limit?: number): Promise {
+ const query: Record = repository.toQuery();
+ if (objectId) {
+ query.object_id = objectId;
+ }
+ if (limit) {
+ query.limit = limit;
+ }
+ return this.client.request("/api/v1/events", { query });
+ }
+
+ async fetchPackageLogs(
+ packageBase: string,
+ repository: RepositoryId,
+ version?: string,
+ processId?: string,
+ head?: boolean,
+ ): Promise {
+ const query: Record = { ...repository.toQuery() };
+ if (version) {
+ query.version = version;
+ }
+ if (processId) {
+ query.process_id = processId;
+ }
+ if (head) {
+ query.head = true;
+ }
+ return this.client.request(`/api/v2/packages/${encodeURIComponent(packageBase)}/logs`, { query });
+ }
+
+ async fetchPackagePatches(packageBase: string): Promise {
+ return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches`);
+ }
+
+ async fetchPackages(repository: RepositoryId): Promise {
+ return this.client.request("/api/v1/packages", { query: repository.toQuery() });
+ }
+
+ async fetchServerInfo(): Promise {
+ const info = await this.client.request("/api/v2/info");
+ return {
+ ...info,
+ repositories: info.repositories.map(repo =>
+ new RepositoryId(repo.architecture, repo.repository),
+ ),
+ };
+ }
+
+ async fetchServerStatus(repository: RepositoryId): Promise {
+ return this.client.request("/api/v1/status", { query: repository.toQuery() });
+ }
+}
diff --git a/frontend/src/api/client/RequestOptions.ts b/frontend/src/api/client/RequestOptions.ts
new file mode 100644
index 00000000..ae4f3f8b
--- /dev/null
+++ b/frontend/src/api/client/RequestOptions.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 .
+ */
+export interface RequestOptions {
+ method?: string;
+ query?: Record;
+ json?: unknown;
+}
diff --git a/frontend/src/api/client/ServiceClient.ts b/frontend/src/api/client/ServiceClient.ts
new file mode 100644
index 00000000..21006840
--- /dev/null
+++ b/frontend/src/api/client/ServiceClient.ts
@@ -0,0 +1,88 @@
+/*
+ * 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 .
+ */
+import type { Client } from "api/client/Client";
+import type { AURPackage } from "models/AURPackage";
+import type { PackageActionRequest } from "models/PackageActionRequest";
+import type { PGPKey } from "models/PGPKey";
+import type { PGPKeyRequest } from "models/PGPKeyRequest";
+import type { RepositoryId } from "models/RepositoryId";
+
+export class ServiceClient {
+
+ protected client: Client;
+
+ constructor(client: Client) {
+ this.client = client;
+ }
+
+ async servicePackageAdd(repository: RepositoryId, data: PackageActionRequest): Promise {
+ return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data });
+ }
+
+ async servicePackagePatchRemove(packageBase: string, key: string): Promise {
+ return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
+ method: "DELETE",
+ });
+ }
+
+ async servicePackageRemove(repository: RepositoryId, packages: string[]): Promise {
+ return this.client.request("/api/v1/service/remove", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: { packages },
+ });
+ }
+
+ async servicePackageRequest(repository: RepositoryId, data: PackageActionRequest): Promise {
+ return this.client.request("/api/v1/service/request", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: data,
+ });
+ }
+
+ async servicePackageSearch(query: string): Promise {
+ return this.client.request("/api/v1/service/search", { query: { for: query } });
+ }
+
+ async servicePackageUpdate(repository: RepositoryId, data: PackageActionRequest): Promise {
+ return this.client.request("/api/v1/service/update", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: data,
+ });
+ }
+
+ async servicePGPFetch(key: string, server: string): Promise {
+ return this.client.request("/api/v1/service/pgp", { query: { key, server } });
+ }
+
+ async servicePGPImport(data: PGPKeyRequest): Promise {
+ return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
+ }
+
+ async serviceRebuild(repository: RepositoryId, packages: string[]): Promise {
+ return this.client.request("/api/v1/service/rebuild", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: { packages },
+ });
+ }
+}
diff --git a/frontend/src/chartSetup.ts b/frontend/src/chartSetup.ts
new file mode 100644
index 00000000..f2463226
--- /dev/null
+++ b/frontend/src/chartSetup.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 .
+ */
+import {
+ ArcElement,
+ BarElement,
+ CategoryScale,
+ Chart as ChartJS,
+ Legend,
+ LinearScale,
+ LineElement,
+ PointElement,
+ Tooltip,
+} from "chart.js";
+
+ChartJS.register(
+ ArcElement,
+ BarElement,
+ CategoryScale,
+ Legend,
+ LinearScale,
+ LineElement,
+ PointElement,
+ Tooltip,
+);
diff --git a/frontend/src/components/charts/EventDurationLineChart.tsx b/frontend/src/components/charts/EventDurationLineChart.tsx
new file mode 100644
index 00000000..15768204
--- /dev/null
+++ b/frontend/src/components/charts/EventDurationLineChart.tsx
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+import type { Event } from "models/Event";
+import type React from "react";
+import { Line } from "react-chartjs-2";
+
+interface EventDurationLineChartProps {
+ events: Event[];
+}
+
+export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element {
+ const updateEvents = events.filter(event => event.event === "package-updated");
+ const data = {
+ labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
+ datasets: [
+ {
+ label: "update duration, s",
+ data: updateEvents.map(event => event.data?.took ?? 0),
+ cubicInterpolationMode: "monotone" as const,
+ tension: 0.4,
+ },
+ ],
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/charts/PackageCountBarChart.tsx b/frontend/src/components/charts/PackageCountBarChart.tsx
new file mode 100644
index 00000000..22f542a9
--- /dev/null
+++ b/frontend/src/components/charts/PackageCountBarChart.tsx
@@ -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 .
+ */
+import { blue, indigo } from "@mui/material/colors";
+import type { RepositoryStats } from "models/RepositoryStats";
+import type React from "react";
+import { Bar } from "react-chartjs-2";
+
+interface PackageCountBarChartProps {
+ stats: RepositoryStats;
+}
+
+export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
+ return ;
+}
diff --git a/frontend/src/components/charts/StatusPieChart.tsx b/frontend/src/components/charts/StatusPieChart.tsx
new file mode 100644
index 00000000..6f8827c3
--- /dev/null
+++ b/frontend/src/components/charts/StatusPieChart.tsx
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+import type { BuildStatus } from "models/BuildStatus.ts";
+import type { Counters } from "models/Counters";
+import type React from "react";
+import { Pie } from "react-chartjs-2";
+import { StatusColors } from "theme/StatusColors";
+
+interface StatusPieChartProps {
+ counters: Counters;
+}
+
+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]),
+ },
+ ],
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/common/AutoRefreshControl.tsx b/frontend/src/components/common/AutoRefreshControl.tsx
new file mode 100644
index 00000000..aa872554
--- /dev/null
+++ b/frontend/src/components/common/AutoRefreshControl.tsx
@@ -0,0 +1,90 @@
+/*
+ * 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 .
+ */
+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(null);
+
+ if (intervals.length === 0) {
+ return null;
+ }
+
+ const enabled = currentInterval > 0;
+
+ return <>
+
+ setAnchorEl(event.currentTarget)}
+ color={enabled ? "primary" : "default"}
+ >
+ {enabled ? : }
+
+
+
+ >;
+}
diff --git a/frontend/src/components/common/CodeBlock.tsx b/frontend/src/components/common/CodeBlock.tsx
new file mode 100644
index 00000000..56917000
--- /dev/null
+++ b/frontend/src/components/common/CodeBlock.tsx
@@ -0,0 +1,63 @@
+/*
+ * 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 .
+ */
+import { Box } from "@mui/material";
+import CopyButton from "components/common/CopyButton";
+import React, { type RefObject } from "react";
+
+interface CodeBlockProps {
+ preRef?: RefObject;
+ getText: () => string;
+ height?: number | string;
+ onScroll?: () => void;
+ wordBreak?: boolean;
+}
+
+export default function CodeBlock({
+ preRef,
+ getText,
+ height,
+ onScroll,
+ wordBreak,
+}: CodeBlockProps): React.JSX.Element {
+ return
+
+
+ {getText()}
+
+
+
+
+
+ ;
+}
diff --git a/frontend/src/components/common/CopyButton.tsx b/frontend/src/components/common/CopyButton.tsx
new file mode 100644
index 00000000..2f68feb2
--- /dev/null
+++ b/frontend/src/components/common/CopyButton.tsx
@@ -0,0 +1,47 @@
+/*
+ * 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 .
+ */
+import CheckIcon from "@mui/icons-material/Check";
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
+import { IconButton, Tooltip } from "@mui/material";
+import React, { useEffect, useRef, useState } from "react";
+
+interface CopyButtonProps {
+ getText: () => string;
+}
+
+export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
+ const [copied, setCopied] = useState(false);
+ const timer = useRef>(undefined);
+
+ useEffect(() => () => clearTimeout(timer.current), []);
+
+ const handleCopy: () => Promise = async () => {
+ await navigator.clipboard.writeText(getText());
+ setCopied(true);
+ clearTimeout(timer.current);
+ timer.current = setTimeout(() => setCopied(false), 2000);
+ };
+
+ return
+ void handleCopy()}>
+ {copied ? : }
+
+ ;
+}
diff --git a/frontend/src/components/common/DialogHeader.tsx b/frontend/src/components/common/DialogHeader.tsx
new file mode 100644
index 00000000..f4271df3
--- /dev/null
+++ b/frontend/src/components/common/DialogHeader.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 .
+ */
+import CloseIcon from "@mui/icons-material/Close";
+import { DialogTitle, IconButton, type SxProps, type Theme } from "@mui/material";
+import type React from "react";
+
+interface DialogHeaderProps {
+ children: React.ReactNode;
+ onClose: () => void;
+ sx?: SxProps;
+}
+
+export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
+ return
+ {children}
+
+
+
+ ;
+}
diff --git a/frontend/src/components/common/RepositorySelect.tsx b/frontend/src/components/common/RepositorySelect.tsx
new file mode 100644
index 00000000..b80783f2
--- /dev/null
+++ b/frontend/src/components/common/RepositorySelect.tsx
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
+import { useRepository } from "hooks/useRepository";
+import type { SelectedRepositoryResult } from "hooks/useSelectedRepository";
+import type React from "react";
+
+export default function RepositorySelect({
+ repositorySelect,
+}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
+ const { repositories, current } = useRepository();
+
+ return
+ repository
+
+ ;
+}
diff --git a/frontend/src/components/common/StringList.tsx b/frontend/src/components/common/StringList.tsx
new file mode 100644
index 00000000..85550619
--- /dev/null
+++ b/frontend/src/components/common/StringList.tsx
@@ -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 .
+ */
+import React from "react";
+
+interface StringListProps {
+ items: string[];
+}
+
+export default function StringList({ items }: StringListProps): React.JSX.Element {
+ return <>{items.join("\n")}>;
+}
diff --git a/frontend/src/components/dialogs/DashboardDialog.tsx b/frontend/src/components/dialogs/DashboardDialog.tsx
new file mode 100644
index 00000000..8fd42f9d
--- /dev/null
+++ b/frontend/src/components/dialogs/DashboardDialog.tsx
@@ -0,0 +1,103 @@
+/*
+ * 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 .
+ */
+import { Box, Dialog, DialogContent, Grid, Typography } from "@mui/material";
+import { skipToken, useQuery } from "@tanstack/react-query";
+import PackageCountBarChart from "components/charts/PackageCountBarChart";
+import StatusPieChart from "components/charts/StatusPieChart";
+import DialogHeader from "components/common/DialogHeader";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import { useRepository } from "hooks/useRepository";
+import type { InternalStatus } from "models/InternalStatus";
+import type React from "react";
+import { StatusHeaderStyles } from "theme/StatusColors";
+
+interface DashboardDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { current } = useRepository();
+
+ const { data: status } = useQuery({
+ queryKey: current ? QueryKeys.status(current) : ["status"],
+ queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
+ enabled: open,
+ });
+
+ const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/KeyImportDialog.tsx b/frontend/src/components/dialogs/KeyImportDialog.tsx
new file mode 100644
index 00000000..5db78803
--- /dev/null
+++ b/frontend/src/components/dialogs/KeyImportDialog.tsx
@@ -0,0 +1,118 @@
+/*
+ * 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 .
+ */
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ TextField,
+} from "@mui/material";
+import { ApiError } from "api/client/ApiError";
+import CodeBlock from "components/common/CodeBlock";
+import DialogHeader from "components/common/DialogHeader";
+import { useClient } from "hooks/useClient";
+import { useNotification } from "hooks/useNotification";
+import React, { useState } from "react";
+
+interface KeyImportDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { showSuccess, showError } = useNotification();
+
+ const [fingerprint, setFingerprint] = useState("");
+ const [server, setServer] = useState("keyserver.ubuntu.com");
+ const [keyBody, setKeyBody] = useState("");
+
+ const handleClose = (): void => {
+ setFingerprint("");
+ setServer("keyserver.ubuntu.com");
+ setKeyBody("");
+ onClose();
+ };
+
+ const handleFetch: () => Promise = async () => {
+ if (!fingerprint || !server) {
+ return;
+ }
+ try {
+ const result = await client.service.servicePGPFetch(fingerprint, server);
+ setKeyBody(result.key);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Could not fetch key: ${detail}`);
+ }
+ };
+
+ const handleImport: () => Promise = async () => {
+ if (!fingerprint || !server) {
+ return;
+ }
+ try {
+ await client.service.servicePGPImport({ key: fingerprint, server });
+ handleClose();
+ showSuccess("Success", `Key ${fingerprint} has been imported`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/LoginDialog.tsx b/frontend/src/components/dialogs/LoginDialog.tsx
new file mode 100644
index 00000000..8cf47eaa
--- /dev/null
+++ b/frontend/src/components/dialogs/LoginDialog.tsx
@@ -0,0 +1,118 @@
+/*
+ * 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 .
+ */
+import PersonIcon from "@mui/icons-material/Person";
+import VisibilityIcon from "@mui/icons-material/Visibility";
+import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ IconButton,
+ InputAdornment,
+ TextField,
+} from "@mui/material";
+import { ApiError } from "api/client/ApiError";
+import DialogHeader from "components/common/DialogHeader";
+import { useAuth } from "hooks/useAuth";
+import { useNotification } from "hooks/useNotification";
+import React, { useState } from "react";
+
+interface LoginDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+ const { login } = useAuth();
+ const { showSuccess, showError } = useNotification();
+
+ const handleClose = (): void => {
+ setUsername("");
+ setPassword("");
+ setShowPassword(false);
+ onClose();
+ };
+
+ const handleSubmit: () => Promise = async () => {
+ if (!username || !password) {
+ return;
+ }
+ try {
+ await login(username, password);
+ handleClose();
+ showSuccess("Logged in", `Successfully logged in as ${username}`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ if (username === "admin" && password === "admin") {
+ showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
+ } else {
+ showError("Login error", `Could not login as ${username}: ${detail}`);
+ }
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/PackageAddDialog.tsx b/frontend/src/components/dialogs/PackageAddDialog.tsx
new file mode 100644
index 00000000..4c008565
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageAddDialog.tsx
@@ -0,0 +1,193 @@
+/*
+ * 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 .
+ */
+import AddIcon from "@mui/icons-material/Add";
+import DeleteIcon from "@mui/icons-material/Delete";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import {
+ Autocomplete,
+ Box,
+ Button,
+ Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ FormControlLabel,
+ IconButton,
+ TextField,
+} from "@mui/material";
+import { useQuery } from "@tanstack/react-query";
+import { ApiError } from "api/client/ApiError";
+import DialogHeader from "components/common/DialogHeader";
+import RepositorySelect from "components/common/RepositorySelect";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import { useDebounce } from "hooks/useDebounce";
+import { useNotification } from "hooks/useNotification";
+import { useSelectedRepository } from "hooks/useSelectedRepository";
+import type { AURPackage } from "models/AURPackage";
+import type { PackageActionRequest } from "models/PackageActionRequest";
+import React, { useRef, useState } from "react";
+
+interface EnvironmentVariable {
+ id: number;
+ key: string;
+ value: string;
+}
+
+interface PackageAddDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { showSuccess, showError } = useNotification();
+ const repositorySelect = useSelectedRepository();
+
+ const [packageName, setPackageName] = useState("");
+ const [refreshDatabase, setRefreshDatabase] = useState(true);
+ const [environmentVariables, setEnvironmentVariables] = useState([]);
+ const variableIdCounter = useRef(0);
+
+ const handleClose = (): void => {
+ setPackageName("");
+ repositorySelect.reset();
+ setRefreshDatabase(true);
+ setEnvironmentVariables([]);
+ onClose();
+ };
+
+ const debouncedSearch = useDebounce(packageName, 500);
+
+ const { data: searchResults = [] } = useQuery({
+ queryKey: QueryKeys.search(debouncedSearch),
+ queryFn: () => client.service.servicePackageSearch(debouncedSearch),
+ enabled: debouncedSearch.length >= 3,
+ });
+
+ const handleSubmit = async (action: "add" | "request"): Promise => {
+ if (!packageName) {
+ return;
+ }
+ const repository = repositorySelect.selectedRepository;
+ if (!repository) {
+ return;
+ }
+ try {
+ const patches = environmentVariables.filter(variable => variable.key);
+ const request: PackageActionRequest = { packages: [packageName], patches };
+ if (action === "add") {
+ request.refresh = refreshDatabase;
+ await client.service.servicePackageAdd(repository, request);
+ } else {
+ await client.service.servicePackageRequest(repository, request);
+ }
+ handleClose();
+ showSuccess("Success", `Packages ${packageName} have been ${action === "add" ? "added" : "requested"}`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Package ${action} failed: ${detail}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/PackageInfoDialog.tsx b/frontend/src/components/dialogs/PackageInfoDialog.tsx
new file mode 100644
index 00000000..efc56145
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageInfoDialog.tsx
@@ -0,0 +1,194 @@
+/*
+ * 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 .
+ */
+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 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 { 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[];
+}
+
+export default function PackageInfoDialog({
+ packageBase,
+ open,
+ onClose,
+ autoRefreshIntervals,
+}: PackageInfoDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { current } = useRepository();
+ const { isAuthorized } = useAuth();
+ const { showSuccess, showError } = useNotification();
+ const queryClient = useQueryClient();
+
+ const [localPackageBase, setLocalPackageBase] = useState(packageBase);
+ if (packageBase !== null && packageBase !== localPackageBase) {
+ setLocalPackageBase(packageBase);
+ }
+
+ const [tabIndex, setTabIndex] = useState(0);
+ const [refreshDatabase, setRefreshDatabase] = useState(true);
+
+ const handleClose = (): void => {
+ setTabIndex(0);
+ setRefreshDatabase(true);
+ onClose();
+ };
+
+ const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
+
+ const { data: packageData } = useQuery({
+ 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,
+ });
+
+ const { data: dependencies } = useQuery({
+ queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"],
+ queryFn: localPackageBase && current
+ ? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken,
+ enabled: open,
+ });
+
+ const { data: patches = [] } = useQuery({
+ queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
+ queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
+ enabled: open,
+ });
+
+ const description: PackageStatus | undefined = packageData?.[0];
+ const pkg = description?.package;
+ const status = description?.status;
+ const headerStyle = status ? StatusHeaderStyles[status.status] : {};
+
+ const handleUpdate: () => Promise = async () => {
+ if (!localPackageBase || !current) {
+ return;
+ }
+ try {
+ await client.service.servicePackageAdd(current, { 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 = async () => {
+ if (!localPackageBase || !current) {
+ return;
+ }
+ try {
+ await client.service.servicePackageRemove(current, [localPackageBase]);
+ showSuccess("Success", `Packages ${localPackageBase} have been removed`);
+ onClose();
+ } catch (exception) {
+ showError("Action failed", `Could not remove package: ${ApiError.errorDetail(exception)}`);
+ }
+ };
+
+ const handleDeletePatch: (key: string) => Promise = async key => {
+ if (!localPackageBase) {
+ return;
+ }
+ try {
+ await client.service.servicePackagePatchRemove(localPackageBase, key);
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(localPackageBase) });
+ } catch (exception) {
+ showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(exception)}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/PackageRebuildDialog.tsx b/frontend/src/components/dialogs/PackageRebuildDialog.tsx
new file mode 100644
index 00000000..f7863d29
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageRebuildDialog.tsx
@@ -0,0 +1,88 @@
+/*
+ * 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 .
+ */
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import { Button, Dialog, DialogActions, DialogContent, TextField } from "@mui/material";
+import { ApiError } from "api/client/ApiError";
+import DialogHeader from "components/common/DialogHeader";
+import RepositorySelect from "components/common/RepositorySelect";
+import { useClient } from "hooks/useClient";
+import { useNotification } from "hooks/useNotification";
+import { useSelectedRepository } from "hooks/useSelectedRepository";
+import React, { useState } from "react";
+
+interface PackageRebuildDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { showSuccess, showError } = useNotification();
+ const repositorySelect = useSelectedRepository();
+
+ const [dependency, setDependency] = useState("");
+
+ const handleClose = (): void => {
+ setDependency("");
+ repositorySelect.reset();
+ onClose();
+ };
+
+ const handleRebuild: () => Promise = async () => {
+ if (!dependency) {
+ return;
+ }
+ const repository = repositorySelect.selectedRepository;
+ if (!repository) {
+ return;
+ }
+ try {
+ await client.service.serviceRebuild(repository, [dependency]);
+ handleClose();
+ showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Repository rebuild failed: ${detail}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
new file mode 100644
index 00000000..0b53caa5
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -0,0 +1,76 @@
+/*
+ * 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 .
+ */
+import { Box, Container } from "@mui/material";
+import { useQuery } from "@tanstack/react-query";
+import LoginDialog from "components/dialogs/LoginDialog";
+import Footer from "components/layout/Footer";
+import Navbar from "components/layout/Navbar";
+import PackageTable from "components/table/PackageTable";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useAuth } from "hooks/useAuth";
+import { useClient } from "hooks/useClient";
+import { useRepository } from "hooks/useRepository";
+import type { InfoResponse } from "models/InfoResponse";
+import React, { useEffect, useState } from "react";
+
+export default function AppLayout(): React.JSX.Element {
+ const client = useClient();
+ const { setAuthState } = useAuth();
+ const { setRepositories } = useRepository();
+ const [loginOpen, setLoginOpen] = useState(false);
+
+ const { data: info } = useQuery({
+ queryKey: QueryKeys.info,
+ queryFn: () => client.fetch.fetchServerInfo(),
+ staleTime: Infinity,
+ });
+
+ // Sync info to contexts when loaded
+ useEffect(() => {
+ if (info) {
+ setAuthState({ enabled: info.auth.enabled, username: info.auth.username ?? null });
+ setRepositories(info.repositories);
+ }
+ }, [info, setAuthState, setRepositories]);
+
+ return
+
+
+
+
+
+
+
+
+
+
+
+ ;
+}
diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx
new file mode 100644
index 00000000..6f44d5d4
--- /dev/null
+++ b/frontend/src/components/layout/Footer.tsx
@@ -0,0 +1,92 @@
+/*
+ * 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 .
+ */
+import GitHubIcon from "@mui/icons-material/GitHub";
+import HomeIcon from "@mui/icons-material/Home";
+import LoginIcon from "@mui/icons-material/Login";
+import LogoutIcon from "@mui/icons-material/Logout";
+import { Box, Button, Link, Typography } from "@mui/material";
+import { useAuth } from "hooks/useAuth";
+import type React from "react";
+
+interface FooterProps {
+ version: string;
+ docsEnabled: boolean;
+ indexUrl?: string;
+ onLoginClick: () => void;
+}
+
+export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
+ const { enabled: authEnabled, username, logout } = useAuth();
+
+ return
+
+
+
+ ahriman {version}
+
+
+ releases
+
+
+ report a bug
+
+ {docsEnabled &&
+
+ api
+
+ }
+
+
+ {indexUrl &&
+
+
+
+ repo index
+
+
+ }
+
+ {authEnabled &&
+
+ {username ?
+ } onClick={() => void logout()} sx={{ textTransform: "none" }}>
+ logout ({username})
+
+ :
+ } onClick={onLoginClick} sx={{ textTransform: "none" }}>
+ login
+
+ }
+
+ }
+ ;
+}
diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx
new file mode 100644
index 00000000..9275c651
--- /dev/null
+++ b/frontend/src/components/layout/Navbar.tsx
@@ -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 .
+ */
+import { Box, Tab, Tabs } from "@mui/material";
+import { useRepository } from "hooks/useRepository";
+import type React from "react";
+
+export default function Navbar(): React.JSX.Element | null {
+ const { repositories, current, setCurrent } = useRepository();
+
+ if (repositories.length === 0 || !current) {
+ return null;
+ }
+
+ const currentIndex = repositories.findIndex(repository =>
+ repository.architecture === current.architecture && repository.repository === current.repository,
+ );
+
+ return
+ = 0 ? currentIndex : 0}
+ onChange={(_, newValue: number) => {
+ const repository = repositories[newValue];
+ if (repository) {
+ setCurrent(repository);
+ }
+ }}
+ variant="scrollable"
+ scrollButtons="auto"
+ >
+ {repositories.map(repository =>
+ ,
+ )}
+
+ ;
+}
diff --git a/frontend/src/components/package/BuildLogsTab.tsx b/frontend/src/components/package/BuildLogsTab.tsx
new file mode 100644
index 00000000..58964609
--- /dev/null
+++ b/frontend/src/components/package/BuildLogsTab.tsx
@@ -0,0 +1,185 @@
+/*
+ * 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 .
+ */
+import ListIcon from "@mui/icons-material/List";
+import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
+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 { 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;
+}
+
+interface BuildLogsTabProps {
+ packageBase: string;
+ repository: RepositoryId;
+ refreshInterval: number;
+}
+
+function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
+ const filtered = filter ? records.filter(filter) : records;
+ return filtered
+ .map(record => `[${new Date(record.created * 1000).toISOString()}] ${record.message}`)
+ .join("\n");
+}
+
+export default function BuildLogsTab({
+ packageBase,
+ repository,
+ refreshInterval,
+}: BuildLogsTabProps): React.JSX.Element {
+ const client = useClient();
+ const [selectedVersionKey, setSelectedVersionKey] = useState(null);
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const { data: allLogs } = useQuery({
+ queryKey: QueryKeys.logs(packageBase, repository),
+ queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
+ enabled: !!packageBase,
+ refetchInterval: refreshInterval > 0 ? refreshInterval : false,
+ });
+
+ // Build version selectors from all logs
+ const versions = useMemo(() => {
+ if (!allLogs || allLogs.length === 0) {
+ return [];
+ }
+
+ const grouped: Record = {};
+ for (const record of allLogs) {
+ const key = `${record.version}-${record.process_id}`;
+ const existing = grouped[key];
+ if (!existing) {
+ grouped[key] = { ...record, minCreated: record.created };
+ } else {
+ existing.minCreated = Math.min(existing.minCreated, record.created);
+ }
+ }
+
+ 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,
+ ),
+ }));
+ }, [allLogs]);
+
+ // Compute active index from selected version key, defaulting to newest (index 0)
+ const activeIndex = useMemo(() => {
+ if (selectedVersionKey) {
+ const index = versions.findIndex(record => `${record.version}-${record.processId}` === selectedVersionKey);
+ if (index >= 0) {
+ return index;
+ }
+ }
+ return 0;
+ }, [versions, selectedVersionKey]);
+
+ const activeVersion = versions[activeIndex];
+ const activeVersionKey = activeVersion ? `${activeVersion.version}-${activeVersion.processId}` : null;
+
+ // Refresh active version logs
+ const { data: versionLogs } = useQuery({
+ queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
+ queryFn: activeVersion
+ ? () => client.fetch.fetchPackageLogs(
+ packageBase, repository, activeVersion.version, activeVersion.processId,
+ )
+ : skipToken,
+ placeholderData: keepPreviousData,
+ refetchInterval: refreshInterval > 0 ? refreshInterval : false,
+ });
+
+ // Derive displayed logs: prefer fresh polled data when available
+ const displayedLogs = useMemo(() => {
+ if (versionLogs && versionLogs.length > 0) {
+ return convertLogs(versionLogs);
+ }
+ return activeVersion?.logs ?? "";
+ }, [versionLogs, activeVersion]);
+
+ const { preRef, handleScroll, scrollToBottom, resetScroll } = useAutoScroll();
+
+ // Reset scroll tracking when active version changes
+ useEffect(() => {
+ resetScroll();
+ }, [activeVersionKey, resetScroll]);
+
+ // Scroll to bottom on new logs
+ useEffect(() => {
+ scrollToBottom();
+ }, [displayedLogs, scrollToBottom]);
+
+ return
+
+ }
+ onClick={event => setAnchorEl(event.currentTarget)}
+ />
+
+
+
+
+ displayedLogs}
+ height={400}
+ onScroll={handleScroll}
+ wordBreak
+ />
+
+ ;
+}
diff --git a/frontend/src/components/package/ChangesTab.tsx b/frontend/src/components/package/ChangesTab.tsx
new file mode 100644
index 00000000..ff754aa5
--- /dev/null
+++ b/frontend/src/components/package/ChangesTab.tsx
@@ -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 .
+ */
+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 type { Changes } from "models/Changes";
+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 } from "react-syntax-highlighter/dist/esm/styles/hljs";
+
+SyntaxHighlighter.registerLanguage("diff", diff);
+
+interface ChangesTabProps {
+ packageBase: string;
+ repository: RepositoryId;
+}
+
+export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
+ const client = useClient();
+
+ const { data } = useQuery({
+ queryKey: QueryKeys.changes(packageBase, repository),
+ queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
+ enabled: !!packageBase,
+ });
+
+ const changesText = data?.changes ?? "";
+
+ return
+
+ {changesText}
+
+
+ changesText} />
+
+ ;
+}
diff --git a/frontend/src/components/package/EventsTab.tsx b/frontend/src/components/package/EventsTab.tsx
new file mode 100644
index 00000000..340f6850
--- /dev/null
+++ b/frontend/src/components/package/EventsTab.tsx
@@ -0,0 +1,78 @@
+/*
+ * 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 .
+ */
+import { Box } from "@mui/material";
+import { DataGrid, type GridColDef } from "@mui/x-data-grid";
+import { useQuery } from "@tanstack/react-query";
+import EventDurationLineChart from "components/charts/EventDurationLineChart";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import type { Event } from "models/Event";
+import type { RepositoryId } from "models/RepositoryId";
+import type React from "react";
+
+interface EventsTabProps {
+ packageBase: string;
+ repository: RepositoryId;
+}
+
+interface EventRow {
+ id: number;
+ timestamp: string;
+ event: string;
+ message: string;
+}
+
+const columns: GridColDef[] = [
+ { field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
+ { field: "event", headerName: "event", flex: 1 },
+ { field: "message", headerName: "description", flex: 2 },
+];
+
+export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
+ const client = useClient();
+
+ const { data: events = [] } = useQuery({
+ queryKey: QueryKeys.events(repository, packageBase),
+ queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
+ enabled: !!packageBase,
+ });
+
+ const rows: EventRow[] = events.map((event, index) => ({
+ id: index,
+ timestamp: new Date(event.created * 1000).toISOStringShort(),
+ event: event.event,
+ message: event.message ?? "",
+ }));
+
+ return
+
+
+ ;
+}
diff --git a/frontend/src/components/package/PackageDetailsGrid.tsx b/frontend/src/components/package/PackageDetailsGrid.tsx
new file mode 100644
index 00000000..0ad683cb
--- /dev/null
+++ b/frontend/src/components/package/PackageDetailsGrid.tsx
@@ -0,0 +1,115 @@
+/*
+ * 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 .
+ */
+import { Grid, Link, Typography } from "@mui/material";
+import StringList from "components/common/StringList";
+import type { Dependencies } from "models/Dependencies";
+import type { Package } from "models/Package";
+import React from "react";
+
+interface PackageDetailsGridProps {
+ pkg: Package;
+ dependencies?: Dependencies;
+}
+
+export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
+ const packagesList = Object.entries(pkg.packages)
+ .map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
+
+ const groups = Object.values(pkg.packages)
+ .flatMap(properties => properties.groups ?? []);
+
+ const licenses = Object.values(pkg.packages)
+ .flatMap(properties => properties.licenses ?? []);
+
+ const upstreamUrls = Object.values(pkg.packages)
+ .map(properties => properties.url)
+ .filter((url): url is string => !!url)
+ .unique();
+
+ const aurUrl = pkg.remote.web_url;
+
+ const pkgNames = Object.keys(pkg.packages);
+ const pkgValues = Object.values(pkg.packages);
+ const deps = pkgValues
+ .flatMap(properties => (properties.depends ?? []).filter(dep => !pkgNames.includes(dep)))
+ .unique();
+ const makeDeps = pkgValues
+ .flatMap(properties => (properties.make_depends ?? []).filter(dep => !pkgNames.includes(dep)))
+ .map(dep => `${dep} (make)`)
+ .unique();
+ const optDeps = pkgValues
+ .flatMap(properties => (properties.opt_depends ?? []).filter(dep => !pkgNames.includes(dep)))
+ .map(dep => `${dep} (optional)`)
+ .unique();
+ const allDepends = [...deps, ...makeDeps, ...optDeps];
+
+ const implicitDepends = dependencies
+ ? Object.values(dependencies.paths).flat()
+ : [];
+
+ return <>
+
+ packages
+
+ version
+ {pkg.version}
+
+
+
+ packager
+ {pkg.packager ?? ""}
+
+
+
+
+
+ groups
+
+ licenses
+
+
+
+
+ upstream
+
+ {upstreamUrls.map(url =>
+
+ {url}
+ ,
+ )}
+
+ AUR
+
+
+ {aurUrl &&
+ AUR link
+ }
+
+
+
+
+
+ depends
+
+ implicitly depends
+
+
+ >;
+}
diff --git a/frontend/src/components/package/PackageInfoActions.tsx b/frontend/src/components/package/PackageInfoActions.tsx
new file mode 100644
index 00000000..eabbdac7
--- /dev/null
+++ b/frontend/src/components/package/PackageInfoActions.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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 .
+ */
+import DeleteIcon from "@mui/icons-material/Delete";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+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;
+ onRefreshDatabaseChange: (checked: boolean) => void;
+ onUpdate: () => void;
+ onRemove: () => void;
+ autoRefreshIntervals: AutoRefreshInterval[];
+ autoRefreshInterval: number;
+ onAutoRefreshIntervalChange: (interval: number) => void;
+}
+
+export default function PackageInfoActions({
+ isAuthorized,
+ refreshDatabase,
+ onRefreshDatabaseChange,
+ onUpdate,
+ onRemove,
+ autoRefreshIntervals,
+ autoRefreshInterval,
+ onAutoRefreshIntervalChange,
+}: PackageInfoActionsProps): React.JSX.Element {
+ return
+ {isAuthorized &&
+ <>
+ onRefreshDatabaseChange(checked)} size="small" />}
+ label="update pacman databases"
+ />
+ } size="small">
+ update
+
+ } size="small">
+ remove
+
+ >
+ }
+
+ ;
+}
diff --git a/frontend/src/components/package/PackagePatchesList.tsx b/frontend/src/components/package/PackagePatchesList.tsx
new file mode 100644
index 00000000..823610ba
--- /dev/null
+++ b/frontend/src/components/package/PackagePatchesList.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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 .
+ */
+import DeleteIcon from "@mui/icons-material/Delete";
+import { Box, IconButton, TextField, Typography } from "@mui/material";
+import type { Patch } from "models/Patch";
+import type React from "react";
+
+interface PackagePatchesListProps {
+ patches: Patch[];
+ editable: boolean;
+ onDelete: (key: string) => void;
+}
+
+export default function PackagePatchesList({
+ patches,
+ editable,
+ onDelete,
+}: PackagePatchesListProps): React.JSX.Element | null {
+ if (patches.length === 0) {
+ return null;
+ }
+
+ return
+ Environment variables
+ {patches.map(patch =>
+
+
+ =
+
+ {editable &&
+ onDelete(patch.key)}>
+
+
+ }
+ ,
+ )}
+ ;
+}
diff --git a/frontend/src/components/table/PackageTable.tsx b/frontend/src/components/table/PackageTable.tsx
new file mode 100644
index 00000000..0fbde6dd
--- /dev/null
+++ b/frontend/src/components/table/PackageTable.tsx
@@ -0,0 +1,197 @@
+/*
+ * 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 .
+ */
+import { Box, Link } from "@mui/material";
+import {
+ DataGrid,
+ GRID_CHECKBOX_SELECTION_COL_DEF,
+ type GridColDef,
+ type GridFilterModel,
+ type GridRenderCellParams,
+ type GridRowId,
+ useGridApiRef,
+} from "@mui/x-data-grid";
+import StringList from "components/common/StringList";
+import DashboardDialog from "components/dialogs/DashboardDialog";
+import KeyImportDialog from "components/dialogs/KeyImportDialog";
+import PackageAddDialog from "components/dialogs/PackageAddDialog";
+import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
+import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
+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,
+ options: { flex?: number; minWidth?: number; width?: number },
+): GridColDef {
+ return {
+ field,
+ headerName,
+ ...options,
+ valueGetter: (value: string[]) => (value ?? []).join(" "),
+ renderCell: (params: GridRenderCellParams) =>
+ ,
+ sortComparator: (left: string, right: string) => left.localeCompare(right),
+ };
+}
+
+export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element {
+ const table = usePackageTable(autoRefreshIntervals);
+ const apiRef = useGridApiRef();
+ const debouncedSearch = useDebounce(table.searchText, 300);
+
+ const effectiveFilterModel: GridFilterModel = useMemo(
+ () => ({
+ ...table.filterModel,
+ quickFilterValues: debouncedSearch ? debouncedSearch.split(/\s+/) : undefined,
+ }),
+ [table.filterModel, debouncedSearch],
+ );
+
+ const columns: GridColDef[] = useMemo(
+ () => [
+ {
+ field: "base",
+ headerName: "package base",
+ flex: 1,
+ minWidth: 150,
+ renderCell: (params: GridRenderCellParams) =>
+ params.row.webUrl ?
+
+ {params.value as string}
+
+ : params.value as string,
+ },
+ { field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
+ createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
+ createListColumn("groups", "groups", { width: 150 }),
+ createListColumn("licenses", "licenses", { width: 150 }),
+ { field: "packager", headerName: "packager", width: 150 },
+ {
+ field: "timestamp",
+ headerName: "last update",
+ width: 180,
+ align: "right",
+ headerAlign: "right",
+ },
+ {
+ field: "status",
+ headerName: "status",
+ width: 120,
+ align: "center",
+ headerAlign: "center",
+ renderCell: (params: GridRenderCellParams) => ,
+ },
+ ],
+ [],
+ );
+
+ return
+ 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,
+ onExportClick: () => apiRef.current?.exportDataAsCsv(),
+ }}
+ />
+
+ "auto"}
+ checkboxSelection
+ disableRowSelectionOnClick
+ rowSelectionModel={{ type: "include", ids: new Set(table.selectionModel) }}
+ onRowSelectionModelChange={model => {
+ if (model.type === "exclude") {
+ const excludeIds = new Set([...model.ids].map(String));
+ table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
+ } else {
+ table.setSelectionModel([...model.ids].map(String));
+ }
+ }}
+ paginationModel={table.paginationModel}
+ onPaginationModelChange={table.setPaginationModel}
+ pageSizeOptions={PAGE_SIZE_OPTIONS}
+ columnVisibilityModel={table.columnVisibility}
+ onColumnVisibilityModelChange={table.setColumnVisibility}
+ filterModel={effectiveFilterModel}
+ onFilterModelChange={table.setFilterModel}
+ initialState={{
+ sorting: { sortModel: [{ field: "base", sort: "asc" }] },
+ }}
+ onCellClick={(params, event) => {
+ // Don't open info dialog when clicking checkbox or link
+ if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
+ return;
+ }
+ if ((event.target as HTMLElement).closest("a")) {
+ return;
+ }
+ table.setSelectedPackage(String(params.id));
+ }}
+ autoHeight
+ sx={{
+ "& .MuiDataGrid-row": { cursor: "pointer" },
+ }}
+ density="compact"
+ />
+
+ table.setDialogOpen(null)} />
+ table.setDialogOpen(null)} />
+ table.setDialogOpen(null)} />
+ table.setDialogOpen(null)} />
+ table.setSelectedPackage(null)}
+ autoRefreshIntervals={autoRefreshIntervals}
+ />
+ ;
+}
diff --git a/frontend/src/components/table/PackageTableToolbar.tsx b/frontend/src/components/table/PackageTableToolbar.tsx
new file mode 100644
index 00000000..cd1239d0
--- /dev/null
+++ b/frontend/src/components/table/PackageTableToolbar.tsx
@@ -0,0 +1,185 @@
+/*
+ * 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 .
+ */
+import AddIcon from "@mui/icons-material/Add";
+import ClearIcon from "@mui/icons-material/Clear";
+import DeleteIcon from "@mui/icons-material/Delete";
+import DownloadIcon from "@mui/icons-material/Download";
+import FileDownloadIcon from "@mui/icons-material/FileDownload";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import InventoryIcon from "@mui/icons-material/Inventory";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import RefreshIcon from "@mui/icons-material/Refresh";
+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;
+ onExportClick: () => void;
+}
+
+interface PackageTableToolbarProps {
+ hasSelection: boolean;
+ isAuthorized: boolean;
+ status?: BuildStatus;
+ searchText: string;
+ onSearchChange: (text: string) => void;
+ autoRefresh: AutoRefreshProps;
+ actions: ToolbarActions;
+}
+
+export default function PackageTableToolbar({
+ hasSelection,
+ isAuthorized,
+ status,
+ searchText,
+ onSearchChange,
+ autoRefresh,
+ actions,
+}: PackageTableToolbarProps): React.JSX.Element {
+ const [packagesAnchorEl, setPackagesAnchorEl] = useState(null);
+
+ return
+
+
+
+
+
+
+ {isAuthorized &&
+ <>
+ }
+ onClick={event => setPackagesAnchorEl(event.currentTarget)}
+ >
+ packages
+
+
+
+ } onClick={actions.onKeyImportClick}>
+ import key
+
+ >
+ }
+
+ } onClick={actions.onReloadClick}>
+ reload
+
+
+
+
+
+
+ onSearchChange(event.target.value)}
+ slotProps={{
+ input: {
+ startAdornment:
+
+
+
+ ,
+ endAdornment: searchText ?
+
+ onSearchChange("")}>
+
+
+
+ : undefined,
+ },
+ }}
+ sx={{ minWidth: 200 }}
+ />
+
+
+
+
+
+
+ ;
+}
diff --git a/frontend/src/components/table/StatusCell.tsx b/frontend/src/components/table/StatusCell.tsx
new file mode 100644
index 00000000..f74c12e8
--- /dev/null
+++ b/frontend/src/components/table/StatusCell.tsx
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+import { Chip } from "@mui/material";
+import type { BuildStatus } from "models/BuildStatus";
+import type React from "react";
+import { StatusColors } from "theme/StatusColors";
+
+interface StatusCellProps {
+ status: BuildStatus;
+}
+
+export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
+ return ;
+}
diff --git a/frontend/src/contexts/AuthContext.ts b/frontend/src/contexts/AuthContext.ts
new file mode 100644
index 00000000..87bfa1a7
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.ts
@@ -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 .
+ */
+import { createContext } from "react";
+
+interface AuthState {
+ enabled: boolean;
+ username: string | null;
+}
+
+export interface AuthContextValue extends AuthState {
+ isAuthorized: boolean;
+ setAuthState: (state: AuthState) => void;
+ login: (username: string, password: string) => Promise;
+ logout: () => Promise;
+}
+
+export const AuthContext = createContext(null);
diff --git a/frontend/src/contexts/AuthProvider.tsx b/frontend/src/contexts/AuthProvider.tsx
new file mode 100644
index 00000000..207fb4b2
--- /dev/null
+++ b/frontend/src/contexts/AuthProvider.tsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { AuthContext } from "contexts/AuthContext";
+import { useClient } from "hooks/useClient";
+import React, { type ReactNode, useCallback, useMemo, useState } from "react";
+
+export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const client = useClient();
+ const [state, setState] = useState({ enabled: true, username: null as string | null });
+
+ const login = useCallback(async (username: string, password: string) => {
+ await client.login({ username, password });
+ setState(prev => ({ ...prev, username }));
+ }, [client]);
+
+ const doLogout = useCallback(async () => {
+ await client.logout();
+ setState(prev => ({ ...prev, username: null }));
+ }, [client]);
+
+ const isAuthorized = useMemo(() => !state.enabled || state.username !== null, [state.enabled, state.username]);
+
+ const value = useMemo(() => ({
+ ...state, isAuthorized, setAuthState: setState, login, logout: doLogout,
+ }), [state, isAuthorized, login, doLogout]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/contexts/ClientContext.ts b/frontend/src/contexts/ClientContext.ts
new file mode 100644
index 00000000..b8f89174
--- /dev/null
+++ b/frontend/src/contexts/ClientContext.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 .
+ */
+import type { AhrimanClient } from "api/client/AhrimanClient";
+import { createContext } from "react";
+
+export const ClientContext = createContext(null);
diff --git a/frontend/src/contexts/ClientProvider.tsx b/frontend/src/contexts/ClientProvider.tsx
new file mode 100644
index 00000000..af71dfc9
--- /dev/null
+++ b/frontend/src/contexts/ClientProvider.tsx
@@ -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 .
+ */
+import { AhrimanClient } from "api/client/AhrimanClient";
+import { ClientContext } from "contexts/ClientContext";
+import React, { type ReactNode, useMemo } from "react";
+
+export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const client = useMemo(() => new AhrimanClient(), []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/contexts/Notification.ts b/frontend/src/contexts/Notification.ts
new file mode 100644
index 00000000..b5b5c61d
--- /dev/null
+++ b/frontend/src/contexts/Notification.ts
@@ -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 .
+ */
+import type { AlertColor } from "@mui/material";
+
+export interface Notification {
+ id: number;
+ title: string;
+ message: string;
+ severity: AlertColor;
+}
diff --git a/frontend/src/contexts/NotificationContext.ts b/frontend/src/contexts/NotificationContext.ts
new file mode 100644
index 00000000..0e5ed5f7
--- /dev/null
+++ b/frontend/src/contexts/NotificationContext.ts
@@ -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 .
+ */
+import { createContext } from "react";
+
+export interface NotificationContextValue {
+ showSuccess: (title: string, message: string) => void;
+ showError: (title: string, message: string) => void;
+}
+
+export const NotificationContext = createContext(null);
diff --git a/frontend/src/contexts/NotificationItem.tsx b/frontend/src/contexts/NotificationItem.tsx
new file mode 100644
index 00000000..91194cfa
--- /dev/null
+++ b/frontend/src/contexts/NotificationItem.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 .
+ */
+import { Alert, Slide } from "@mui/material";
+import type { Notification } from "contexts/Notification";
+import React, { useEffect, useState } from "react";
+
+interface NotificationItemProps {
+ notification: Notification;
+ onClose: (id: number) => void;
+}
+
+export default function NotificationItem({ notification, onClose }: NotificationItemProps): React.JSX.Element {
+ const [show, setShow] = useState(true);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setShow(false), 5000);
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+ onClose(notification.id)}>
+ setShow(false)}
+ severity={notification.severity}
+ variant="filled"
+ sx={{ width: "100%", pointerEvents: "auto" }}
+ >
+ {notification.title}
+ {notification.message && ` - ${notification.message}`}
+
+
+ );
+}
diff --git a/frontend/src/contexts/NotificationProvider.tsx b/frontend/src/contexts/NotificationProvider.tsx
new file mode 100644
index 00000000..37211a05
--- /dev/null
+++ b/frontend/src/contexts/NotificationProvider.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 .
+ */
+import { type AlertColor, Box } from "@mui/material";
+import type { Notification } from "contexts/Notification";
+import { NotificationContext } from "contexts/NotificationContext";
+import NotificationItem from "contexts/NotificationItem";
+import React, { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
+
+export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const nextId = useRef(0);
+ const [notifications, setNotifications] = useState([]);
+
+ const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
+ const id = nextId.current++;
+ setNotifications(prev => [...prev, { id, title, message, severity }]);
+ }, []);
+
+ const removeNotification = useCallback((id: number) => {
+ setNotifications(prev => prev.filter(notification => notification.id !== id));
+ }, []);
+
+ const showSuccess = useCallback(
+ (title: string, message: string) => addNotification(title, message, "success"),
+ [addNotification],
+ );
+ const showError = useCallback(
+ (title: string, message: string) => addNotification(title, message, "error"),
+ [addNotification],
+ );
+
+ const value = useMemo(() => ({ showSuccess, showError }), [showSuccess, showError]);
+
+ return (
+
+ {children}
+ theme.zIndex.snackbar,
+ display: "flex",
+ flexDirection: "column",
+ gap: 1,
+ maxWidth: 500,
+ width: "100%",
+ pointerEvents: "none",
+ }}
+ >
+ {notifications.map(notification =>
+ ,
+ )}
+
+
+ );
+}
diff --git a/frontend/src/contexts/RepositoryContext.ts b/frontend/src/contexts/RepositoryContext.ts
new file mode 100644
index 00000000..1f25ad2a
--- /dev/null
+++ b/frontend/src/contexts/RepositoryContext.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 .
+ */
+import type { RepositoryId } from "models/RepositoryId";
+import { createContext } from "react";
+
+export interface RepositoryContextValue {
+ repositories: RepositoryId[];
+ current: RepositoryId | null;
+ setRepositories: (repositories: RepositoryId[]) => void;
+ setCurrent: (repository: RepositoryId) => void;
+}
+
+export const RepositoryContext = createContext(null);
diff --git a/frontend/src/contexts/RepositoryProvider.tsx b/frontend/src/contexts/RepositoryProvider.tsx
new file mode 100644
index 00000000..dc87419b
--- /dev/null
+++ b/frontend/src/contexts/RepositoryProvider.tsx
@@ -0,0 +1,53 @@
+/*
+ * 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 .
+ */
+import { RepositoryContext } from "contexts/RepositoryContext";
+import type { RepositoryId } from "models/RepositoryId";
+import React, { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from "react";
+
+function subscribeToHash(callback: () => void): () => void {
+ window.addEventListener("hashchange", callback);
+ return () => window.removeEventListener("hashchange", callback);
+}
+
+function getHashSnapshot(): string {
+ return window.location.hash.replace("#", "");
+}
+
+export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const [repositories, setRepositories] = useState([]);
+ const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
+
+ const current = useMemo(() => {
+ if (repositories.length === 0) {
+ return null;
+ }
+ return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
+ }, [repositories, hash]);
+
+ const setCurrent = useCallback((repository: RepositoryId) => {
+ window.location.hash = repository.key;
+ }, []);
+
+ const value = useMemo(() => ({
+ repositories, current, setRepositories, setCurrent,
+ }), [repositories, current, setCurrent]);
+
+ return {children};
+}
diff --git a/frontend/src/hooks/QueryKeys.ts b/frontend/src/hooks/QueryKeys.ts
new file mode 100644
index 00000000..a9e78613
--- /dev/null
+++ b/frontend/src/hooks/QueryKeys.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 .
+ */
+import type { RepositoryId } from "models/RepositoryId";
+
+export const QueryKeys = {
+
+ changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
+
+ dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,
+
+ events: (repository: RepositoryId, objectId?: string) => ["events", repository.key, objectId] as const,
+
+ info: ["info"] as const,
+
+ logs: (packageBase: string, repository: RepositoryId) => ["logs", repository.key, packageBase] as const,
+
+ logsVersion: (packageBase: string, repository: RepositoryId, version: string, processId: string) =>
+ ["logs", repository.key, packageBase, version, processId] as const,
+
+ package: (packageBase: string, repository: RepositoryId) => ["packages", repository.key, packageBase] as const,
+
+ packages: (repository: RepositoryId) => ["packages", repository.key] as const,
+
+ patches: (packageBase: string) => ["patches", packageBase] as const,
+
+ search: (query: string) => ["search", query] as const,
+
+ status: (repository: RepositoryId) => ["status", repository.key] as const,
+};
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
new file mode 100644
index 00000000..c36f05fb
--- /dev/null
+++ b/frontend/src/hooks/useAuth.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+import { AuthContext, type AuthContextValue } from "contexts/AuthContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useAuth(): AuthContextValue {
+ return useContextNotNull(AuthContext);
+}
diff --git a/frontend/src/hooks/useAutoRefresh.ts b/frontend/src/hooks/useAutoRefresh.ts
new file mode 100644
index 00000000..f9ec1a11
--- /dev/null
+++ b/frontend/src/hooks/useAutoRefresh.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { useLocalStorage } from "hooks/useLocalStorage";
+import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
+
+interface AutoRefreshResult {
+ interval: number;
+ setInterval: Dispatch>;
+ setPaused: Dispatch>;
+}
+
+export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult {
+ const storageKey = `ahriman-${key}`;
+ const [interval, setInterval] = useLocalStorage(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,
+ };
+}
diff --git a/frontend/src/hooks/useAutoScroll.ts b/frontend/src/hooks/useAutoScroll.ts
new file mode 100644
index 00000000..957fb36f
--- /dev/null
+++ b/frontend/src/hooks/useAutoScroll.ts
@@ -0,0 +1,63 @@
+/*
+ * 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 .
+ */
+import { type RefObject, useRef } from "react";
+
+interface UseAutoScrollResult {
+ preRef: RefObject;
+ handleScroll: () => void;
+ scrollToBottom: () => void;
+ resetScroll: () => void;
+}
+
+export function useAutoScroll(): UseAutoScrollResult {
+ const preRef = useRef(null);
+ const initialScrollDone = useRef(false);
+ const wasAtBottom = useRef(true);
+
+ const handleScroll: () => void = () => {
+ if (preRef.current) {
+ const element = preRef.current;
+ wasAtBottom.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 50;
+ }
+ };
+
+ const resetScroll: () => void = () => {
+ initialScrollDone.current = false;
+ };
+
+ // scroll to bottom on initial load, then only if already near bottom and no active text selection
+ const scrollToBottom: () => void = () => {
+ if (!preRef.current) {
+ return;
+ }
+ const element = preRef.current;
+ if (!initialScrollDone.current) {
+ element.scrollTop = element.scrollHeight;
+ initialScrollDone.current = true;
+ } else {
+ const hasSelection = !document.getSelection()?.isCollapsed;
+ if (wasAtBottom.current && !hasSelection) {
+ element.scrollTop = element.scrollHeight;
+ }
+ }
+ };
+
+ return { preRef, handleScroll, scrollToBottom, resetScroll };
+}
diff --git a/frontend/src/hooks/useClient.ts b/frontend/src/hooks/useClient.ts
new file mode 100644
index 00000000..cb07d833
--- /dev/null
+++ b/frontend/src/hooks/useClient.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+import type { AhrimanClient } from "api/client/AhrimanClient";
+import { ClientContext } from "contexts/ClientContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useClient(): AhrimanClient {
+ return useContextNotNull(ClientContext);
+}
diff --git a/frontend/src/hooks/useContextNotNull.ts b/frontend/src/hooks/useContextNotNull.ts
new file mode 100644
index 00000000..01130edd
--- /dev/null
+++ b/frontend/src/hooks/useContextNotNull.ts
@@ -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 .
+ */
+import { type Context, useContext } from "react";
+
+export function useContextNotNull(context: Context): T {
+ const ctx = useContext(context);
+ if (ctx === null) {
+ throw new Error("must be used within a Provider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts
new file mode 100644
index 00000000..ac0b22b1
--- /dev/null
+++ b/frontend/src/hooks/useDebounce.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+import { useEffect, useState } from "react";
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(handler);
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts
new file mode 100644
index 00000000..c7e8d8ff
--- /dev/null
+++ b/frontend/src/hooks/useLocalStorage.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+import { type Dispatch, type SetStateAction, useCallback, useState } from "react";
+
+export function useLocalStorage(key: string, initialValue: T): [T, Dispatch>] {
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(key);
+ return item !== null ? (JSON.parse(item) as T) : initialValue;
+ } catch {
+ return initialValue;
+ }
+ });
+
+ const setValue: Dispatch> = useCallback(
+ value => {
+ setStoredValue(prev => {
+ const nextValue = value instanceof Function ? value(prev) : value;
+ window.localStorage.setItem(key, JSON.stringify(nextValue));
+ return nextValue;
+ });
+ },
+ [key],
+ );
+
+ return [storedValue, setValue];
+}
diff --git a/frontend/src/hooks/useNotification.ts b/frontend/src/hooks/useNotification.ts
new file mode 100644
index 00000000..80e22ccb
--- /dev/null
+++ b/frontend/src/hooks/useNotification.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+import { NotificationContext, type NotificationContextValue } from "contexts/NotificationContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useNotification(): NotificationContextValue {
+ return useContextNotNull(NotificationContext);
+}
diff --git a/frontend/src/hooks/usePackageActions.ts b/frontend/src/hooks/usePackageActions.ts
new file mode 100644
index 00000000..02140871
--- /dev/null
+++ b/frontend/src/hooks/usePackageActions.ts
@@ -0,0 +1,108 @@
+/*
+ * 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 .
+ */
+import { useQueryClient } from "@tanstack/react-query";
+import { ApiError } from "api/client/ApiError";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import { useNotification } from "hooks/useNotification";
+import { useRepository } from "hooks/useRepository";
+import type { RepositoryId } from "models/RepositoryId";
+
+export interface UsePackageActionsResult {
+ handleReload: () => void;
+ handleUpdate: () => Promise;
+ handleRefreshDatabase: () => Promise;
+ handleRemove: () => Promise;
+}
+
+export function usePackageActions(
+ selectionModel: string[],
+ setSelectionModel: (model: string[]) => void,
+): UsePackageActionsResult {
+ const client = useClient();
+ const { current } = useRepository();
+ const { showSuccess, showError } = useNotification();
+ const queryClient = useQueryClient();
+
+ const invalidate = (repository: RepositoryId): void => {
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(repository) });
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.status(repository) });
+ };
+
+ const performAction = async (
+ action: (repository: RepositoryId) => Promise,
+ errorMessage: string,
+ ): Promise => {
+ if (!current) {
+ return;
+ }
+ try {
+ const successMessage = await action(current);
+ showSuccess("Success", successMessage);
+ invalidate(current);
+ setSelectionModel([]);
+ } catch (exception) {
+ showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`);
+ }
+ };
+
+ const handleReload: () => void = () => {
+ if (current !== null) {
+ invalidate(current);
+ }
+ };
+
+ const handleUpdate = (): Promise => performAction(async (repository): Promise => {
+ if (selectionModel.length === 0) {
+ await client.service.servicePackageUpdate(repository, { packages: [] });
+ return "Repository update has been run";
+ }
+ await client.service.servicePackageAdd(repository, { packages: selectionModel });
+ return `Run update for packages ${selectionModel.join(", ")}`;
+ }, "Packages update failed");
+
+ const handleRefreshDatabase = (): Promise => performAction(async (repository): Promise => {
+ await client.service.servicePackageUpdate(repository, {
+ packages: [],
+ refresh: true,
+ aur: false,
+ local: false,
+ manual: false,
+ });
+ return "Pacman database update has been requested";
+ }, "Could not update pacman databases");
+
+ const handleRemove = (): Promise => {
+ if (selectionModel.length === 0) {
+ return Promise.resolve();
+ }
+ return performAction(async (repository): Promise => {
+ await client.service.servicePackageRemove(repository, selectionModel);
+ return `Packages ${selectionModel.join(", ")} have been removed`;
+ }, "Could not remove packages");
+ };
+
+ return {
+ handleReload,
+ handleUpdate,
+ handleRefreshDatabase,
+ handleRemove,
+ };
+}
diff --git a/frontend/src/hooks/usePackageData.ts b/frontend/src/hooks/usePackageData.ts
new file mode 100644
index 00000000..7fbb7449
--- /dev/null
+++ b/frontend/src/hooks/usePackageData.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 .
+ */
+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;
+ status: BuildStatus | undefined;
+ autoRefresh: ReturnType;
+}
+
+export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
+ const client = useClient();
+ const { current } = 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,
+ });
+
+ const { data: status } = useQuery({
+ queryKey: current ? QueryKeys.status(current) : ["status"],
+ queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
+ refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
+ });
+
+ const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
+
+ return {
+ rows,
+ isLoading,
+ isAuthorized,
+ status: status?.status.status,
+ autoRefresh,
+ };
+}
diff --git a/frontend/src/hooks/usePackageTable.ts b/frontend/src/hooks/usePackageTable.ts
new file mode 100644
index 00000000..a7f32e12
--- /dev/null
+++ b/frontend/src/hooks/usePackageTable.ts
@@ -0,0 +1,86 @@
+/*
+ * 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 .
+ */
+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;
+ setColumnVisibility: (model: Record) => void;
+ filterModel: GridFilterModel;
+ setFilterModel: (model: GridFilterModel) => void;
+ searchText: string;
+ setSearchText: (text: string) => void;
+
+ autoRefreshInterval: number;
+ onAutoRefreshIntervalChange: (interval: number) => void;
+
+ handleReload: () => void;
+ handleUpdate: () => Promise;
+ handleRefreshDatabase: () => Promise;
+ handleRemove: () => Promise;
+}
+
+export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
+ const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals);
+ 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,
+ status,
+
+ ...tableState,
+
+ autoRefreshInterval: autoRefresh.interval,
+ onAutoRefreshIntervalChange: autoRefresh.setInterval,
+
+ ...actions,
+ };
+}
diff --git a/frontend/src/hooks/useRepository.ts b/frontend/src/hooks/useRepository.ts
new file mode 100644
index 00000000..52afb0f8
--- /dev/null
+++ b/frontend/src/hooks/useRepository.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+import { RepositoryContext, type RepositoryContextValue } from "contexts/RepositoryContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useRepository(): RepositoryContextValue {
+ return useContextNotNull(RepositoryContext);
+}
diff --git a/frontend/src/hooks/useSelectedRepository.ts b/frontend/src/hooks/useSelectedRepository.ts
new file mode 100644
index 00000000..a9405cc4
--- /dev/null
+++ b/frontend/src/hooks/useSelectedRepository.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+import { useRepository } from "hooks/useRepository";
+import type { RepositoryId } from "models/RepositoryId";
+import { useState } from "react";
+
+export interface SelectedRepositoryResult {
+ selectedKey: string;
+ setSelectedKey: (key: string) => void;
+ selectedRepository: RepositoryId | null;
+ reset: () => void;
+}
+
+export function useSelectedRepository(): SelectedRepositoryResult {
+ const { repositories, current } = useRepository();
+ const [selectedKey, setSelectedKey] = useState("");
+
+ let selectedRepository: RepositoryId | null = current;
+ if (selectedKey) {
+ const repository = repositories.find(repository => repository.key === selectedKey);
+ if (repository) {
+ selectedRepository = repository;
+ }
+ }
+
+ const reset: () => void = () => {
+ setSelectedKey("");
+ };
+
+ return { selectedKey, setSelectedKey, selectedRepository, reset };
+}
diff --git a/frontend/src/hooks/useTableState.ts b/frontend/src/hooks/useTableState.ts
new file mode 100644
index 00000000..4e28a4be
--- /dev/null
+++ b/frontend/src/hooks/useTableState.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 .
+ */
+import type { GridFilterModel } from "@mui/x-data-grid";
+import { useLocalStorage } from "hooks/useLocalStorage";
+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;
+ setColumnVisibility: (model: Record) => void;
+ filterModel: GridFilterModel;
+ setFilterModel: (model: GridFilterModel) => void;
+ searchText: string;
+ setSearchText: (text: string) => void;
+}
+
+export function useTableState(): UseTableStateResult {
+ const [selectionModel, setSelectionModel] = useState([]);
+ const [dialogOpen, setDialogOpen] = useState(null);
+ const [selectedPackage, setSelectedPackage] = useState(null);
+ const [searchText, setSearchText] = useState("");
+
+ const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
+ pageSize: 10,
+ page: 0,
+ });
+ const [columnVisibility, setColumnVisibility] = useLocalStorage>(
+ "ahriman-packages-columns",
+ { groups: false, licenses: false, packager: false },
+ );
+ const [filterModel, setFilterModel] = useLocalStorage(
+ "ahriman-packages-filters",
+ { items: [] },
+ );
+
+ return {
+ selectionModel,
+ setSelectionModel,
+
+ dialogOpen,
+ setDialogOpen,
+ selectedPackage,
+ setSelectedPackage,
+
+ paginationModel,
+ setPaginationModel,
+ columnVisibility,
+ setColumnVisibility,
+ filterModel,
+ setFilterModel,
+ searchText,
+ setSearchText,
+ };
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 00000000..6e74ebae
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+import "chartSetup";
+import "utils";
+
+import App from "App";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/frontend/src/models/AURPackage.ts b/frontend/src/models/AURPackage.ts
new file mode 100644
index 00000000..a7523f3e
--- /dev/null
+++ b/frontend/src/models/AURPackage.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 .
+ */
+export interface AURPackage {
+ package: string;
+ description: string;
+}
diff --git a/frontend/src/models/AuthInfo.ts b/frontend/src/models/AuthInfo.ts
new file mode 100644
index 00000000..58dd1f0b
--- /dev/null
+++ b/frontend/src/models/AuthInfo.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+export interface AuthInfo {
+ control: string;
+ enabled: boolean;
+ external: boolean;
+ username?: string;
+}
diff --git a/frontend/src/models/AutoRefreshInterval.ts b/frontend/src/models/AutoRefreshInterval.ts
new file mode 100644
index 00000000..bc9f43f1
--- /dev/null
+++ b/frontend/src/models/AutoRefreshInterval.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 .
+ */
+export interface AutoRefreshInterval {
+ interval: number;
+ is_active: boolean;
+ text: string;
+}
diff --git a/frontend/src/models/BuildStatus.ts b/frontend/src/models/BuildStatus.ts
new file mode 100644
index 00000000..a0aa37a3
--- /dev/null
+++ b/frontend/src/models/BuildStatus.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 .
+ */
+export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";
diff --git a/frontend/src/models/Changes.ts b/frontend/src/models/Changes.ts
new file mode 100644
index 00000000..28e479ff
--- /dev/null
+++ b/frontend/src/models/Changes.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 .
+ */
+export interface Changes {
+ changes?: string;
+ last_commit_sha?: string;
+}
diff --git a/frontend/src/models/Counters.ts b/frontend/src/models/Counters.ts
new file mode 100644
index 00000000..717c694c
--- /dev/null
+++ b/frontend/src/models/Counters.ts
@@ -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 .
+ */
+export interface Counters {
+ building: number;
+ failed: number;
+ pending: number;
+ success: number;
+ total: number;
+ unknown: number;
+}
diff --git a/frontend/src/models/Dependencies.ts b/frontend/src/models/Dependencies.ts
new file mode 100644
index 00000000..e02c02cc
--- /dev/null
+++ b/frontend/src/models/Dependencies.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 .
+ */
+export interface Dependencies {
+ paths: Record;
+}
diff --git a/frontend/src/models/Event.ts b/frontend/src/models/Event.ts
new file mode 100644
index 00000000..ac28d93b
--- /dev/null
+++ b/frontend/src/models/Event.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+export interface Event {
+ created: number;
+ data?: Record;
+ event: string;
+ message?: string;
+ object_id: string;
+}
diff --git a/frontend/src/models/InfoResponse.ts b/frontend/src/models/InfoResponse.ts
new file mode 100644
index 00000000..a323fa77
--- /dev/null
+++ b/frontend/src/models/InfoResponse.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+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;
+}
diff --git a/frontend/src/models/InternalStatus.ts b/frontend/src/models/InternalStatus.ts
new file mode 100644
index 00000000..3b25bc4d
--- /dev/null
+++ b/frontend/src/models/InternalStatus.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 .
+ */
+import type { Counters } from "models/Counters";
+import type { RepositoryStats } from "models/RepositoryStats";
+import type { Status } from "models/Status";
+
+export interface InternalStatus {
+ architecture: string;
+ repository: string;
+ packages: Counters;
+ stats: RepositoryStats;
+ status: Status;
+ version: string;
+}
diff --git a/frontend/src/models/LogRecord.ts b/frontend/src/models/LogRecord.ts
new file mode 100644
index 00000000..92f3971e
--- /dev/null
+++ b/frontend/src/models/LogRecord.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+export interface LogRecord {
+ created: number;
+ message: string;
+ process_id: string;
+ version: string;
+}
diff --git a/frontend/src/models/LoginRequest.ts b/frontend/src/models/LoginRequest.ts
new file mode 100644
index 00000000..53ef5fe1
--- /dev/null
+++ b/frontend/src/models/LoginRequest.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 .
+ */
+export interface LoginRequest {
+ username: string;
+ password: string;
+}
diff --git a/frontend/src/models/PGPKey.ts b/frontend/src/models/PGPKey.ts
new file mode 100644
index 00000000..c8cb510a
--- /dev/null
+++ b/frontend/src/models/PGPKey.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 .
+ */
+export interface PGPKey {
+ key: string;
+}
diff --git a/frontend/src/models/PGPKeyRequest.ts b/frontend/src/models/PGPKeyRequest.ts
new file mode 100644
index 00000000..01d189a2
--- /dev/null
+++ b/frontend/src/models/PGPKeyRequest.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 .
+ */
+export interface PGPKeyRequest {
+ key: string;
+ server: string;
+}
diff --git a/frontend/src/models/Package.ts b/frontend/src/models/Package.ts
new file mode 100644
index 00000000..aca2518a
--- /dev/null
+++ b/frontend/src/models/Package.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 .
+ */
+import type { PackageProperties } from "models/PackageProperties";
+import type { Remote } from "models/Remote";
+
+export interface Package {
+ base: string;
+ packager?: string;
+ packages: Record;
+ remote: Remote;
+ version: string;
+}
diff --git a/frontend/src/models/PackageActionRequest.ts b/frontend/src/models/PackageActionRequest.ts
new file mode 100644
index 00000000..d84134e5
--- /dev/null
+++ b/frontend/src/models/PackageActionRequest.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 .
+ */
+import type { Patch } from "models/Patch";
+
+export interface PackageActionRequest {
+ packages: string[];
+ patches?: Patch[];
+ refresh?: boolean;
+ aur?: boolean;
+ local?: boolean;
+ manual?: boolean;
+}
diff --git a/frontend/src/models/PackageProperties.ts b/frontend/src/models/PackageProperties.ts
new file mode 100644
index 00000000..021af495
--- /dev/null
+++ b/frontend/src/models/PackageProperties.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+export interface PackageProperties {
+ architecture?: string;
+ archive_size?: number;
+ build_date?: number;
+ check_depends?: string[];
+ depends?: string[];
+ description?: string;
+ filename?: string;
+ groups?: string[];
+ installed_size?: number;
+ licenses?: string[];
+ make_depends?: string[];
+ opt_depends?: string[];
+ provides?: string[];
+ url?: string;
+}
diff --git a/frontend/src/models/PackageRow.ts b/frontend/src/models/PackageRow.ts
new file mode 100644
index 00000000..0012cea4
--- /dev/null
+++ b/frontend/src/models/PackageRow.ts
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+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[];
+ licenses: string[];
+ packager: string;
+ timestamp: string;
+ timestampValue: number;
+ status: BuildStatus;
+
+ 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.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.status = descriptor.status.status;
+ }
+
+ private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
+ return [
+ ...new Set(
+ Object.values(pkg.packages)
+ .flatMap(properties => properties[property] ?? []),
+ ),
+ ].sort();
+ }
+}
diff --git a/frontend/src/models/PackageSource.ts b/frontend/src/models/PackageSource.ts
new file mode 100644
index 00000000..1221fad9
--- /dev/null
+++ b/frontend/src/models/PackageSource.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 .
+ */
+export type PackageSource = "auto" | "archive" | "aur" | "directory" | "local" | "remote" | "repository";
diff --git a/frontend/src/models/PackageStatus.ts b/frontend/src/models/PackageStatus.ts
new file mode 100644
index 00000000..ce516aa7
--- /dev/null
+++ b/frontend/src/models/PackageStatus.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 .
+ */
+import type { Package } from "models/Package";
+import type { Status } from "models/Status";
+
+export interface PackageStatus {
+ package: Package;
+ status: Status;
+}
diff --git a/frontend/src/models/Patch.ts b/frontend/src/models/Patch.ts
new file mode 100644
index 00000000..350750f2
--- /dev/null
+++ b/frontend/src/models/Patch.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 .
+ */
+export interface Patch {
+ key: string;
+ value: string;
+}
diff --git a/frontend/src/models/Remote.ts b/frontend/src/models/Remote.ts
new file mode 100644
index 00000000..b2e2dd82
--- /dev/null
+++ b/frontend/src/models/Remote.ts
@@ -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 .
+ */
+import type { PackageSource } from "models/PackageSource";
+
+export interface Remote {
+ branch?: string;
+ git_url?: string;
+ path?: string;
+ source: PackageSource;
+ web_url?: string;
+}
diff --git a/frontend/src/models/RepositoryId.ts b/frontend/src/models/RepositoryId.ts
new file mode 100644
index 00000000..1d0547fc
--- /dev/null
+++ b/frontend/src/models/RepositoryId.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 .
+ */
+export class RepositoryId {
+ readonly architecture: string;
+ readonly repository: string;
+
+ constructor(architecture: string, repository: string) {
+ this.architecture = architecture;
+ this.repository = repository;
+ }
+
+ get key(): string {
+ return `${this.architecture}-${this.repository}`;
+ }
+
+ get label(): string {
+ return `${this.repository} (${this.architecture})`;
+ }
+
+ toQuery(): Record {
+ return { architecture: this.architecture, repository: this.repository };
+ }
+}
diff --git a/frontend/src/models/RepositoryStats.ts b/frontend/src/models/RepositoryStats.ts
new file mode 100644
index 00000000..42e97613
--- /dev/null
+++ b/frontend/src/models/RepositoryStats.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+export interface RepositoryStats {
+ archive_size?: number;
+ bases?: number;
+ installed_size?: number;
+ packages?: number;
+}
diff --git a/frontend/src/models/Status.ts b/frontend/src/models/Status.ts
new file mode 100644
index 00000000..01715863
--- /dev/null
+++ b/frontend/src/models/Status.ts
@@ -0,0 +1,25 @@
+/*
+ * 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 .
+ */
+import type { BuildStatus } from "models/BuildStatus";
+
+export interface Status {
+ status: BuildStatus;
+ timestamp: number;
+}
diff --git a/frontend/src/theme/StatusColors.ts b/frontend/src/theme/StatusColors.ts
new file mode 100644
index 00000000..fa900fb4
--- /dev/null
+++ b/frontend/src/theme/StatusColors.ts
@@ -0,0 +1,43 @@
+/*
+ * 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 .
+ */
+import { amber, green, grey, orange, red } from "@mui/material/colors";
+import type { BuildStatus } from "models/BuildStatus";
+
+const base: Record = {
+ unknown: grey[800],
+ pending: amber[900],
+ building: orange[900],
+ failed: red[900],
+ success: green[800],
+};
+
+const headerBase: Record = {
+ unknown: grey[800],
+ pending: amber[700],
+ building: orange[600],
+ failed: red[500],
+ success: green[600],
+};
+
+export const StatusColors = base;
+
+export const StatusHeaderStyles: Record = Object.fromEntries(
+ Object.entries(headerBase).map(([key, value]) => [key, { backgroundColor: value, color: "#fff" }]),
+) as Record;
diff --git a/frontend/src/theme/Theme.ts b/frontend/src/theme/Theme.ts
new file mode 100644
index 00000000..d08acc1b
--- /dev/null
+++ b/frontend/src/theme/Theme.ts
@@ -0,0 +1,33 @@
+/*
+ * 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 .
+ */
+import { createTheme } from "@mui/material/styles";
+
+const Theme = createTheme({
+ components: {
+ MuiDialog: {
+ defaultProps: {
+ maxWidth: "lg",
+ fullWidth: true,
+ },
+ },
+ },
+});
+
+export default Theme;
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
new file mode 100644
index 00000000..15eb4ab2
--- /dev/null
+++ b/frontend/src/utils.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+
+export function defaultInterval(intervals: AutoRefreshInterval[]): number {
+ return intervals.find(interval => interval.is_active)?.interval ?? 0;
+}
+
+declare global {
+ interface Array {
+ unique(): T[];
+ }
+
+ interface Date {
+ toISOStringShort(): string;
+ }
+}
+
+Array.prototype.unique = function (): T[] {
+ return [...new Set(this)].sort();
+};
+
+// custom formatter to print pretty date, because there is no builtin for this
+Date.prototype.toISOStringShort = function (): string {
+ const pad: (num: number) => string = num => String(num).padStart(2, "0");
+ return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`;
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 00000000..018317b7
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "allowImportingTsExtensions": true,
+ "baseUrl": "src",
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2020",
+ "useDefineForClassFields": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 00000000..65a11cb1
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,38 @@
+import { defineConfig, type Plugin } from "vite";
+import react from "@vitejs/plugin-react";
+import tsconfigPaths from "vite-tsconfig-paths";
+import path from "path";
+
+function renameHtml(newName: string): Plugin {
+ return {
+ name: "rename-html",
+ enforce: "post",
+ generateBundle(_, bundle) {
+ if (bundle["index.html"]) {
+ bundle["index.html"].fileName = newName;
+ }
+ },
+ };
+}
+
+export default defineConfig({
+ plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")],
+ base: "/",
+ build: {
+ chunkSizeWarningLimit: 10000,
+ emptyOutDir: false,
+ outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
+ rollupOptions: {
+ output: {
+ entryFileNames: "static/[name].js",
+ chunkFileNames: "static/[name].js",
+ assetFileNames: "static/[name].[ext]",
+ },
+ },
+ },
+ server: {
+ proxy: {
+ "/api": "http://localhost:8080",
+ },
+ },
+});
diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD
index ecc7907e..c60ea2f8 100644
--- a/package/archlinux/PKGBUILD
+++ b/package/archlinux/PKGBUILD
@@ -9,7 +9,7 @@ arch=('any')
url="https://ahriman.readthedocs.io/"
license=('GPL-3.0-or-later')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
-makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
+makedepends=('npm' 'python-build' 'python-flit' 'python-installer' 'python-wheel')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
"$pkgbase.sysusers"
"$pkgbase.tmpfiles")
@@ -18,6 +18,10 @@ build() {
cd "$pkgbase-$pkgver"
python -m build --wheel --no-isolation
+
+ cd "frontend"
+ npm install --cache "$srcdir/npm-cache"
+ npm run build
}
package_ahriman() {
diff --git a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini
index 804584a3..d20b8da6 100644
--- a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini
+++ b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini
@@ -13,8 +13,6 @@ allow_read_only = yes
;full_access_group = wheel
; Authentication cookie expiration in seconds.
;max_age = 604800
-; OAuth2 provider icon for the web interface.
-;oauth_icon = google
; OAuth2 provider class name, one of provided by aioauth-client. Required if oauth is used.
;oauth_provider = GoogleClient
; Scopes list for OAuth2 provider. Required if oauth is used.
@@ -46,6 +44,8 @@ host = 127.0.0.1
;service_only = no
; Path to directory with static files.
static_path = ${templates}/static
+; Jinja2 template name for the index page.
+;template = build-status.jinja2
; List of directories with templates.
templates[] = ${prefix}/share/ahriman/templates
; Path to unix socket. If none set, unix socket will be disabled.
diff --git a/package/share/ahriman/templates/build-status-legacy.jinja2 b/package/share/ahriman/templates/build-status-legacy.jinja2
new file mode 100644
index 00000000..a1a62940
--- /dev/null
+++ b/package/share/ahriman/templates/build-status-legacy.jinja2
@@ -0,0 +1,191 @@
+
+
+
+ ahriman
+
+
+
+ {% include "utils/style.jinja2" %}
+ {% include "user-style.jinja2" ignore missing %}
+
+
+
+
+ {% include "utils/bootstrap-scripts.jinja2" %}
+
+
+
+
+
+
+
+
+
+
+
+ {% if not auth.enabled or auth.username is not none %}
+
+
-
- {% if auth.enabled %}
- {% include "build-status/login-modal.jinja2" %}
- {% endif %}
-
- {% include "build-status/alerts.jinja2" %}
-
- {% include "build-status/dashboard.jinja2" %}
- {% include "build-status/package-add-modal.jinja2" %}
- {% include "build-status/package-rebuild-modal.jinja2" %}
- {% include "build-status/key-import-modal.jinja2" %}
-
- {% include "build-status/package-info-modal.jinja2" %}
-
- {% include "build-status/table.jinja2" %}
-
-
+
+
diff --git a/pyproject.toml b/pyproject.toml
index dc82e6ca..aae0ba62 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -118,11 +118,14 @@ include = [
"CONTRIBUTING.md",
"SECURITY.md",
"package",
+ "frontend",
"subpackages.py",
"web.png",
]
exclude = [
"package/archlinux",
+ "frontend/node_modules",
+ "frontend/package-lock.json",
]
[tool.flit.external-data]
diff --git a/recipes/oauth/service.ini b/recipes/oauth/service.ini
index 96003669..b4767c35 100644
--- a/recipes/oauth/service.ini
+++ b/recipes/oauth/service.ini
@@ -3,7 +3,6 @@ target = oauth
client_id = $AHRIMAN_OAUTH_CLIENT_ID
client_secret = $AHRIMAN_OAUTH_CLIENT_SECRET
-oauth_icon = github
oauth_provider = GithubClient
oauth_scopes = read:user
diff --git a/src/ahriman/core/auth/auth.py b/src/ahriman/core/auth/auth.py
index 21bf458a..19f78efd 100644
--- a/src/ahriman/core/auth/auth.py
+++ b/src/ahriman/core/auth/auth.py
@@ -60,6 +60,16 @@ class Auth(LazyLogging):
"""
return """"""
+ @property
+ def is_external(self) -> bool:
+ """
+ check if the provider is external (e.g. OAuth)
+
+ Returns:
+ bool: ``True`` in case if external provider is used and ``False`` otherwise
+ """
+ return False
+
@staticmethod
def load(configuration: Configuration, database: SQLite) -> Auth:
"""
diff --git a/src/ahriman/core/auth/oauth.py b/src/ahriman/core/auth/oauth.py
index 8172e29a..e14355de 100644
--- a/src/ahriman/core/auth/oauth.py
+++ b/src/ahriman/core/auth/oauth.py
@@ -36,7 +36,6 @@ class OAuth(Mapping):
Attributes:
client_id(str): application client id
client_secret(str): application client secret key
- icon(str): icon to be used in login control
provider(aioauth_client.OAuth2Client): provider class, should be one of aiohttp-client provided classes
redirect_uri(str): redirect URI registered in provider
scopes(str): list of scopes required by the application
@@ -59,7 +58,6 @@ class OAuth(Mapping):
self.provider = self.get_provider(configuration.get("auth", "oauth_provider"))
# it is list, but we will have to convert to string it anyway
self.scopes = configuration.get("auth", "oauth_scopes")
- self.icon = configuration.get("auth", "oauth_icon", fallback="google")
@property
def auth_control(self) -> str:
@@ -69,8 +67,17 @@ class OAuth(Mapping):
Returns:
str: login control as html code to insert
"""
- return f" login"
+ return " login"
+
+ @property
+ def is_external(self) -> bool:
+ """
+ check if the provider is external (e.g. OAuth)
+
+ Returns:
+ bool: ``True`` in case if external provider is used and ``False`` otherwise
+ """
+ return True
@staticmethod
def get_provider(name: str) -> type[aioauth_client.OAuth2Client]:
diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py
index d1284915..13551a1d 100644
--- a/src/ahriman/core/configuration/schema.py
+++ b/src/ahriman/core/configuration/schema.py
@@ -161,10 +161,6 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"coerce": "integer",
"min": 0,
},
- "oauth_icon": {
- "type": "string",
- "empty": False,
- },
"oauth_provider": {
"type": "string",
"empty": False,
@@ -398,6 +394,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"path_exists": True,
"path_type": "dir",
},
+ "template": {
+ "type": "string",
+ "empty": False,
+ },
"templates": {
"type": "list",
"coerce": "list",
diff --git a/src/ahriman/core/utils.py b/src/ahriman/core/utils.py
index 6ac48ce8..79b71424 100644
--- a/src/ahriman/core/utils.py
+++ b/src/ahriman/core/utils.py
@@ -35,7 +35,7 @@ from enum import Enum
from filelock import FileLock
from pathlib import Path
from pwd import getpwuid
-from typing import Any, IO, TypeVar
+from typing import Any, IO, TypeVar, cast
from ahriman.core.exceptions import CalledProcessError, OptionError, UnsafeRunError
from ahriman.core.types import Comparable
@@ -285,16 +285,17 @@ def filelock(path: Path) -> Iterator[FileLock]:
lock_path.unlink(missing_ok=True)
-def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]:
+def filter_json(source: T, known_fields: Iterable[str] | None = None) -> T:
"""
- filter json object by fields used for json-to-object conversion
+ recursively filter json object removing ``None`` values and optionally filtering by known fields
Args:
- source(dict[str, Any]): raw json object
- known_fields(Iterable[str]): list of fields which have to be known for the target object
+ source(T): raw json object (dict, list, or scalar)
+ known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
+ (Default value = None)
Returns:
- dict[str, Any]: json object without unknown and empty fields
+ T: json without ``None`` values
Examples:
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
@@ -306,7 +307,15 @@ def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str
>>> properties = filter_json(dump, known_fields)
>>> package = Package(**properties)
"""
- return {key: value for key, value in source.items() if key in known_fields and value is not None}
+ if isinstance(source, dict):
+ return cast(T, {
+ key: filter_json(value)
+ for key, value in source.items()
+ if value is not None and (known_fields is None or key in known_fields)
+ })
+ if isinstance(source, list):
+ return cast(T, [filter_json(value) for value in source])
+ return source
def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:
diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py
index 2710f174..b04d2621 100644
--- a/src/ahriman/web/schemas/__init__.py
+++ b/src/ahriman/web/schemas/__init__.py
@@ -19,7 +19,9 @@
#
from ahriman.web.schemas.any_schema import AnySchema
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
+from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
from ahriman.web.schemas.auth_schema import AuthSchema
+from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
from ahriman.web.schemas.changes_schema import ChangesSchema
from ahriman.web.schemas.configuration_schema import ConfigurationSchema
@@ -30,6 +32,7 @@ from ahriman.web.schemas.event_schema import EventSchema
from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.info_schema import InfoSchema
+from ahriman.web.schemas.info_v2_schema import InfoV2Schema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.login_schema import LoginSchema
diff --git a/src/ahriman/web/schemas/auth_info_schema.py b/src/ahriman/web/schemas/auth_info_schema.py
new file mode 100644
index 00000000..7a3de497
--- /dev/null
+++ b/src/ahriman/web/schemas/auth_info_schema.py
@@ -0,0 +1,39 @@
+#
+# 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 .
+#
+from ahriman.web.apispec import Schema, fields
+
+
+class AuthInfoSchema(Schema):
+ """
+ authorization information schema
+ """
+
+ control = fields.String(required=True, metadata={
+ "description": "HTML control for login interface",
+ })
+ enabled = fields.Boolean(required=True, metadata={
+ "description": "Whether authentication is enabled or not",
+ })
+ external = fields.Boolean(required=True, metadata={
+ "description": "Whether authorization provider is external (e.g. OAuth)",
+ })
+ username = fields.String(metadata={
+ "description": "Currently authenticated username if any",
+ })
diff --git a/src/ahriman/web/schemas/auto_refresh_interval_schema.py b/src/ahriman/web/schemas/auto_refresh_interval_schema.py
new file mode 100644
index 00000000..83d01b86
--- /dev/null
+++ b/src/ahriman/web/schemas/auto_refresh_interval_schema.py
@@ -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 .
+#
+from ahriman.web.apispec import Schema, fields
+
+
+class AutoRefreshIntervalSchema(Schema):
+ """
+ auto refresh interval schema
+ """
+
+ interval = fields.Integer(required=True, metadata={
+ "description": "Auto refresh interval in milliseconds",
+ })
+ is_active = fields.Boolean(required=True, metadata={
+ "description": "Whether this interval is the default active one",
+ })
+ text = fields.String(required=True, metadata={
+ "description": "Human readable interval description",
+ })
diff --git a/src/ahriman/web/schemas/info_schema.py b/src/ahriman/web/schemas/info_schema.py
index 877d9f2c..5e8bca46 100644
--- a/src/ahriman/web/schemas/info_schema.py
+++ b/src/ahriman/web/schemas/info_schema.py
@@ -27,7 +27,7 @@ class InfoSchema(Schema):
response service information schema
"""
- auth = fields.Boolean(dump_default=False, required=True, metadata={
+ auth = fields.Boolean(required=True, metadata={
"description": "Whether authentication is enabled or not",
})
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
diff --git a/src/ahriman/web/schemas/info_v2_schema.py b/src/ahriman/web/schemas/info_v2_schema.py
new file mode 100644
index 00000000..ea8f7007
--- /dev/null
+++ b/src/ahriman/web/schemas/info_v2_schema.py
@@ -0,0 +1,50 @@
+#
+# 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 .
+#
+from ahriman import __version__
+from ahriman.web.apispec import Schema, fields
+from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
+from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
+from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
+
+
+class InfoV2Schema(Schema):
+ """
+ response service information schema
+ """
+
+ auth = fields.Nested(AuthInfoSchema(), required=True, metadata={
+ "description": "Authorization descriptor",
+ })
+ autorefresh_intervals = fields.Nested(AutoRefreshIntervalSchema(many=True), metadata={
+ "description": "Available auto refresh intervals",
+ })
+ docs_enabled = fields.Boolean(metadata={
+ "description": "Whether API documentation is enabled",
+ })
+ index_url = fields.String(metadata={
+ "description": "URL to the repository index page",
+ })
+ repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
+ "description": "List of loaded repositories",
+ })
+ version = fields.String(required=True, metadata={
+ "description": "Service version",
+ "example": __version__,
+ })
diff --git a/src/ahriman/web/schemas/repository_id_schema.py b/src/ahriman/web/schemas/repository_id_schema.py
index e3dff167..5a0606cb 100644
--- a/src/ahriman/web/schemas/repository_id_schema.py
+++ b/src/ahriman/web/schemas/repository_id_schema.py
@@ -29,6 +29,10 @@ class RepositoryIdSchema(Schema):
"description": "Repository architecture",
"example": "x86_64",
})
+ id = fields.String(metadata={
+ "description": "Unique repository identifier",
+ "example": "aur-x86_64",
+ })
repository = fields.String(metadata={
"description": "Repository name",
"example": "aur",
diff --git a/src/ahriman/web/server_info.py b/src/ahriman/web/server_info.py
new file mode 100644
index 00000000..6ee2075b
--- /dev/null
+++ b/src/ahriman/web/server_info.py
@@ -0,0 +1,73 @@
+#
+# 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 .
+#
+from collections.abc import Callable
+from typing import Any
+
+from ahriman import __version__
+from ahriman.core.auth.helpers import authorized_userid
+from ahriman.core.types import Comparable
+from ahriman.core.utils import pretty_interval
+from ahriman.web.apispec import aiohttp_apispec
+from ahriman.web.views.base import BaseView
+
+
+__all__ = ["server_info"]
+
+
+async def server_info(view: BaseView) -> dict[str, Any]:
+ """
+ generate server info which can be used in responses directly
+
+ Args:
+ view(BaseView): view of the request
+
+ Returns:
+ dict[str, Any]: server info as a json response
+ """
+ autorefresh_intervals = [
+ {
+ "interval": interval * 1000, # milliseconds
+ "is_active": index == 0, # first element is always default
+ "text": pretty_interval(interval),
+ }
+ for index, interval in enumerate(view.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
+ if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
+ ]
+ comparator: Callable[[dict[str, Any]], Comparable] = lambda interval: interval["interval"]
+
+ return {
+ "auth": {
+ "control": view.validator.auth_control,
+ "enabled": view.validator.enabled,
+ "external": view.validator.is_external,
+ "username": await authorized_userid(view.request),
+ },
+ "autorefresh_intervals": sorted(autorefresh_intervals, key=comparator),
+ "docs_enabled": aiohttp_apispec is not None,
+ "index_url": view.configuration.get("web", "index_url", fallback=None),
+ "repositories": [
+ {
+ "id": repository_id.id,
+ **repository_id.view(),
+ }
+ for repository_id in sorted(view.services)
+ ],
+ "version": __version__,
+ }
diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py
index 4a28e44a..5a3f271f 100644
--- a/src/ahriman/web/views/base.py
+++ b/src/ahriman/web/views/base.py
@@ -17,10 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, Response, StreamResponse, View, json_response
from aiohttp_cors import CorsViewMixin
from collections.abc import Awaitable, Callable
-from typing import ClassVar, TypeVar
+from typing import Any, ClassVar, TypeVar
from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration
@@ -29,6 +29,7 @@ from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.sign.gpg import GPG
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
+from ahriman.core.utils import filter_json
from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
@@ -162,6 +163,20 @@ class BaseView(View, CorsViewMixin):
raise KeyError(f"Key {key} is missing or empty") from None
return value
+ @staticmethod
+ def json_response(data: dict[str, Any] | list[Any], **kwargs: Any) -> Response:
+ """
+ filter and convert data and return :class:`aiohttp.web.Response` object
+
+ Args:
+ data(dict[str, Any] | list[Any]): response in json format
+ **kwargs(Any): keyword arguments for :func:`aiohttp.web.json_response` function
+
+ Returns:
+ Response: generated response object
+ """
+ return json_response(filter_json(data), **kwargs)
+
# pylint: disable=not-callable,protected-access
async def head(self) -> StreamResponse:
"""
diff --git a/src/ahriman/web/views/index.py b/src/ahriman/web/views/index.py
index ec9dfd07..b2046441 100644
--- a/src/ahriman/web/views/index.py
+++ b/src/ahriman/web/views/index.py
@@ -19,12 +19,11 @@
#
import aiohttp_jinja2
-from typing import Any, ClassVar
+from aiohttp.web import Response
+from typing import ClassVar
-from ahriman.core.auth.helpers import authorized_userid
-from ahriman.core.utils import pretty_interval
from ahriman.models.user_access import UserAccess
-from ahriman.web.apispec import aiohttp_apispec
+from ahriman.web.server_info import server_info
from ahriman.web.views.base import BaseView
@@ -48,6 +47,7 @@ class IndexView(BaseView):
* id - unique repository identifier, string, required
* repository - repository name, string, required
* architecture - repository architecture, string, required
+ * version - service version, string, required
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
@@ -56,41 +56,14 @@ class IndexView(BaseView):
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/", "/index.html"]
- @aiohttp_jinja2.template("build-status.jinja2")
- async def get(self) -> dict[str, Any]:
+ async def get(self) -> Response:
"""
process get request. No parameters supported here
Returns:
- dict[str, Any]: parameters for jinja template
+ Response: 200 with rendered index page
"""
- auth_username = await authorized_userid(self.request)
- auth = {
- "control": self.validator.auth_control,
- "enabled": self.validator.enabled,
- "username": auth_username,
- }
+ context = await server_info(self)
- autorefresh_intervals = [
- {
- "interval": interval * 1000, # milliseconds
- "is_active": index == 0, # first element is always default
- "text": pretty_interval(interval),
- }
- for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
- if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
- ]
-
- return {
- "auth": auth,
- "autorefresh_intervals": sorted(autorefresh_intervals, key=lambda interval: interval["interval"]),
- "docs_enabled": aiohttp_apispec is not None,
- "index_url": self.configuration.get("web", "index_url", fallback=None),
- "repositories": [
- {
- "id": repository.id,
- **repository.view(),
- }
- for repository in sorted(self.services)
- ]
- }
+ template = self.configuration.get("web", "template", fallback="build-status.jinja2")
+ return aiohttp_jinja2.render_template(template, self.request, context)
diff --git a/src/ahriman/web/views/v1/auditlog/events.py b/src/ahriman/web/views/v1/auditlog/events.py
index 53a945a0..968e174c 100644
--- a/src/ahriman/web/views/v1/auditlog/events.py
+++ b/src/ahriman/web/views/v1/auditlog/events.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.event import Event
@@ -70,7 +70,7 @@ class EventsView(BaseView):
events = self.service().event_get(event, object_id, from_date, to_date, limit, offset)
response = [event.view() for event in events]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Audit log"],
diff --git a/src/ahriman/web/views/v1/distributed/workers.py b/src/ahriman/web/views/v1/distributed/workers.py
index 56bb1cdf..d5de4f27 100644
--- a/src/ahriman/web/views/v1/distributed/workers.py
+++ b/src/ahriman/web/views/v1/distributed/workers.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from collections.abc import Callable
from typing import ClassVar
@@ -78,7 +78,7 @@ class WorkersView(BaseView):
comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
response = [worker.view() for worker in sorted(workers, key=comparator)]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Distributed"],
diff --git a/src/ahriman/web/views/v1/packages/changes.py b/src/ahriman/web/views/v1/packages/changes.py
index 22b504ea..00b3f619 100644
--- a/src/ahriman/web/views/v1/packages/changes.py
+++ b/src/ahriman/web/views/v1/packages/changes.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.changes import Changes
@@ -65,7 +65,7 @@ class ChangesView(StatusViewGuard, BaseView):
changes = self.service(package_base=package_base).package_changes_get(package_base)
- return json_response(changes.view())
+ return self.json_response(changes.view())
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/dependencies.py b/src/ahriman/web/views/v1/packages/dependencies.py
index 611f0167..9d4de53e 100644
--- a/src/ahriman/web/views/v1/packages/dependencies.py
+++ b/src/ahriman/web/views/v1/packages/dependencies.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.dependencies import Dependencies
@@ -65,7 +65,7 @@ class DependenciesView(StatusViewGuard, BaseView):
dependencies = self.service(package_base=package_base).package_dependencies_get(package_base)
- return json_response(dependencies.view())
+ return self.json_response(dependencies.view())
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/logs.py b/src/ahriman/web/views/v1/packages/logs.py
index 5d712d83..26f4bf10 100644
--- a/src/ahriman/web/views/v1/packages/logs.py
+++ b/src/ahriman/web/views/v1/packages/logs.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
@@ -99,7 +99,7 @@ class LogsView(StatusViewGuard, BaseView):
"status": status.view(),
"logs": "\n".join(f"[{pretty_datetime(log_record.created)}] {log_record.message}" for log_record in logs)
}
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/package.py b/src/ahriman/web/views/v1/packages/package.py
index d119c8cd..79e2546b 100644
--- a/src/ahriman/web/views/v1/packages/package.py
+++ b/src/ahriman/web/views/v1/packages/package.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
@@ -105,7 +105,7 @@ class PackageView(StatusViewGuard, BaseView):
"repository": repository_id.view(),
}
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/packages.py b/src/ahriman/web/views/v1/packages/packages.py
index e914d54d..a93d82b9 100644
--- a/src/ahriman/web/views/v1/packages/packages.py
+++ b/src/ahriman/web/views/v1/packages/packages.py
@@ -19,7 +19,7 @@
#
import itertools
-from aiohttp.web import HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPNoContent, Response
from collections.abc import Callable
from typing import ClassVar
@@ -78,7 +78,7 @@ class PackagesView(StatusViewGuard, BaseView):
} for package, status in itertools.islice(sorted(packages, key=comparator), offset, stop)
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/patch.py b/src/ahriman/web/views/v1/packages/patch.py
index 8850f5e8..9ec13bd4 100644
--- a/src/ahriman/web/views/v1/packages/patch.py
+++ b/src/ahriman/web/views/v1/packages/patch.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -89,4 +89,4 @@ class PatchView(StatusViewGuard, BaseView):
if selected is None:
raise HTTPNotFound(reason=f"Patch {variable} is unknown")
- return json_response(selected.view())
+ return self.json_response(selected.view())
diff --git a/src/ahriman/web/views/v1/packages/patches.py b/src/ahriman/web/views/v1/packages/patches.py
index 031af8b5..dd8346e0 100644
--- a/src/ahriman/web/views/v1/packages/patches.py
+++ b/src/ahriman/web/views/v1/packages/patches.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -60,7 +60,7 @@ class PatchesView(StatusViewGuard, BaseView):
patches = self.service().package_patches_get(package_base, None)
response = [patch.view() for patch in patches]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/service/add.py b/src/ahriman/web/views/v1/service/add.py
index 9307bb64..a0356f77 100644
--- a/src/ahriman/web/views/v1/service/add.py
+++ b/src/ahriman/web/views/v1/service/add.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -78,4 +78,4 @@ class AddView(BaseView):
refresh=data.get("refresh", False),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/config.py b/src/ahriman/web/views/v1/service/config.py
index bdb74920..0801bce4 100644
--- a/src/ahriman/web/views/v1/service/config.py
+++ b/src/ahriman/web/views/v1/service/config.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPNoContent, Response
from typing import ClassVar
from ahriman.core.formatters import ConfigurationPrinter
@@ -64,7 +64,7 @@ class ConfigView(BaseView):
for key, value in values.items()
if key not in ConfigurationPrinter.HIDE_KEYS
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Actions"],
diff --git a/src/ahriman/web/views/v1/service/pgp.py b/src/ahriman/web/views/v1/service/pgp.py
index fd7b1b98..7c682d91 100644
--- a/src/ahriman/web/views/v1/service/pgp.py
+++ b/src/ahriman/web/views/v1/service/pgp.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -71,7 +71,7 @@ class PGPView(BaseView):
except Exception:
raise HTTPNotFound(reason=f"Key {key} is unknown")
- return json_response({"key": key})
+ return self.json_response({"key": key})
@apidocs(
tags=["Actions"],
@@ -100,4 +100,4 @@ class PGPView(BaseView):
process_id = self.spawner.key_import(key, data.get("server"))
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/process.py b/src/ahriman/web/views/v1/service/process.py
index 6e32f685..bd8d42e1 100644
--- a/src/ahriman/web/views/v1/service/process.py
+++ b/src/ahriman/web/views/v1/service/process.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -66,4 +66,4 @@ class ProcessView(BaseView):
"is_alive": is_alive,
}
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/service/rebuild.py b/src/ahriman/web/views/v1/service/rebuild.py
index a0469b45..0e88749d 100644
--- a/src/ahriman/web/views/v1/service/rebuild.py
+++ b/src/ahriman/web/views/v1/service/rebuild.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -74,4 +74,4 @@ class RebuildView(BaseView):
increment=data.get("increment", True),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/remove.py b/src/ahriman/web/views/v1/service/remove.py
index b2e1668e..8a21589a 100644
--- a/src/ahriman/web/views/v1/service/remove.py
+++ b/src/ahriman/web/views/v1/service/remove.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -67,4 +67,4 @@ class RemoveView(BaseView):
repository_id = self.repository_id()
process_id = self.spawner.packages_remove(repository_id, packages)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/request.py b/src/ahriman/web/views/v1/service/request.py
index 93ae3f38..55eb2e28 100644
--- a/src/ahriman/web/views/v1/service/request.py
+++ b/src/ahriman/web/views/v1/service/request.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -78,4 +78,4 @@ class RequestView(BaseView):
refresh=False, # refresh doesn't work here
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/search.py b/src/ahriman/web/views/v1/service/search.py
index 08b958e8..bd8ffb41 100644
--- a/src/ahriman/web/views/v1/service/search.py
+++ b/src/ahriman/web/views/v1/service/search.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
from collections.abc import Callable
from typing import ClassVar
@@ -83,4 +83,4 @@ class SearchView(BaseView):
"description": package.description,
} for package in sorted(packages, key=comparator)
]
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/service/update.py b/src/ahriman/web/views/v1/service/update.py
index 5eb14eaa..bc95b457 100644
--- a/src/ahriman/web/views/v1/service/update.py
+++ b/src/ahriman/web/views/v1/service/update.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -75,4 +75,4 @@ class UpdateView(BaseView):
refresh=data.get("refresh", False),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/status/info.py b/src/ahriman/web/views/v1/status/info.py
index 7e92cd63..e1635f91 100644
--- a/src/ahriman/web/views/v1/status/info.py
+++ b/src/ahriman/web/views/v1/status/info.py
@@ -17,13 +17,13 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from typing import ClassVar
-from ahriman import __version__
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import InfoSchema
+from ahriman.web.server_info import server_info
from ahriman.web.views.base import BaseView
@@ -52,13 +52,11 @@ class InfoView(BaseView):
Returns:
Response: 200 with service information object
"""
+ info = await server_info(self)
response = {
- "auth": self.validator.enabled,
- "repositories": [
- repository_id.view()
- for repository_id in sorted(self.services)
- ],
- "version": __version__,
+ "auth": info["auth"]["enabled"],
+ "repositories": info["repositories"],
+ "version": info["version"],
}
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/status/repositories.py b/src/ahriman/web/views/v1/status/repositories.py
index 5e3ecc6a..81e333e3 100644
--- a/src/ahriman/web/views/v1/status/repositories.py
+++ b/src/ahriman/web/views/v1/status/repositories.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -56,4 +56,4 @@ class RepositoriesView(BaseView):
for repository_id in sorted(self.services)
]
- return json_response(repositories)
+ return self.json_response(repositories)
diff --git a/src/ahriman/web/views/v1/status/status.py b/src/ahriman/web/views/v1/status/status.py
index bc72f709..55a851a0 100644
--- a/src/ahriman/web/views/v1/status/status.py
+++ b/src/ahriman/web/views/v1/status/status.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman import __version__
@@ -75,7 +75,7 @@ class StatusView(StatusViewGuard, BaseView):
version=__version__,
)
- return json_response(status.view())
+ return self.json_response(status.view())
@apidocs(
tags=["Status"],
diff --git a/src/ahriman/web/views/v2/packages/logs.py b/src/ahriman/web/views/v2/packages/logs.py
index b2ab43a1..231e7091 100644
--- a/src/ahriman/web/views/v2/packages/logs.py
+++ b/src/ahriman/web/views/v2/packages/logs.py
@@ -19,7 +19,7 @@
#
import itertools
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from dataclasses import replace
from typing import ClassVar
@@ -31,8 +31,7 @@ from ahriman.web.views.status_view_guard import StatusViewGuard
class LogsView(StatusViewGuard, BaseView):
- """ else:
-
+ """
package logs web view
Attributes:
@@ -80,4 +79,4 @@ class LogsView(StatusViewGuard, BaseView):
]
response = [log_record.view() for log_record in logs]
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v2/status/__init__.py b/src/ahriman/web/views/v2/status/__init__.py
new file mode 100644
index 00000000..cddc28d6
--- /dev/null
+++ b/src/ahriman/web/views/v2/status/__init__.py
@@ -0,0 +1,19 @@
+#
+# 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 .
+#
diff --git a/src/ahriman/web/views/v2/status/info.py b/src/ahriman/web/views/v2/status/info.py
new file mode 100644
index 00000000..c486173b
--- /dev/null
+++ b/src/ahriman/web/views/v2/status/info.py
@@ -0,0 +1,56 @@
+#
+# 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 .
+#
+from aiohttp.web import Response
+from typing import ClassVar
+
+from ahriman.models.user_access import UserAccess
+from ahriman.web.apispec.decorators import apidocs
+from ahriman.web.schemas import InfoV2Schema
+from ahriman.web.server_info import server_info
+from ahriman.web.views.base import BaseView
+
+
+class InfoView(BaseView):
+ """
+ web service information view
+
+ Attributes:
+ GET_PERMISSION(UserAccess): (class attribute) get permissions of self
+ """
+
+ GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
+ ROUTES = ["/api/v2/info"]
+
+ @apidocs(
+ tags=["Status"],
+ summary="Service information",
+ description="Perform basic service health check and returns its information",
+ permission=GET_PERMISSION,
+ schema=InfoV2Schema,
+ )
+ async def get(self) -> Response:
+ """
+ get service information
+
+ Returns:
+ Response: 200 with service information object
+ """
+ response = await server_info(self)
+ return self.json_response(response)
diff --git a/subpackages.py b/subpackages.py
index ec871c39..e5ba144c 100644
--- a/subpackages.py
+++ b/subpackages.py
@@ -45,10 +45,12 @@ SUBPACKAGES = {
prefix / "lib" / "systemd" / "system" / "ahriman-web.service",
prefix / "lib" / "systemd" / "system" / "ahriman-web@.service",
prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-web.ini",
- prefix / "share" / "ahriman" / "templates" / "api.jinja2",
prefix / "share" / "ahriman" / "templates" / "build-status",
prefix / "share" / "ahriman" / "templates" / "build-status.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "build-status-legacy.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "api.jinja2",
prefix / "share" / "ahriman" / "templates" / "error.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "static",
site_packages / "ahriman" / "application" / "handlers" / "web.py",
site_packages / "ahriman" / "core" / "auth",
site_packages / "ahriman" / "web",
diff --git a/tests/ahriman/core/auth/test_auth.py b/tests/ahriman/core/auth/test_auth.py
index f0a73796..1cb17246 100644
--- a/tests/ahriman/core/auth/test_auth.py
+++ b/tests/ahriman/core/auth/test_auth.py
@@ -16,6 +16,13 @@ def test_auth_control(auth: Auth) -> None:
assert "button" in auth.auth_control # I think it should be a button
+def test_is_external(auth: Auth) -> None:
+ """
+ must not be external provider
+ """
+ assert not auth.is_external
+
+
def test_load_dummy(configuration: Configuration, database: SQLite) -> None:
"""
must load dummy validator if authorization is not enabled
diff --git a/tests/ahriman/core/auth/test_oauth.py b/tests/ahriman/core/auth/test_oauth.py
index 1bb67943..86b605be 100644
--- a/tests/ahriman/core/auth/test_oauth.py
+++ b/tests/ahriman/core/auth/test_oauth.py
@@ -15,6 +15,13 @@ def test_auth_control(oauth: OAuth) -> None:
assert " None:
+ """
+ must be external provider
+ """
+ assert oauth.is_external
+
+
def test_get_provider() -> None:
"""
must return valid provider type
diff --git a/tests/ahriman/core/test_utils.py b/tests/ahriman/core/test_utils.py
index 10a84aea..ee9566bb 100644
--- a/tests/ahriman/core/test_utils.py
+++ b/tests/ahriman/core/test_utils.py
@@ -583,6 +583,7 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "web" / "templates" / "utils" / "style.jinja2",
resource_path_root / "web" / "templates" / "api.jinja2",
resource_path_root / "web" / "templates" / "build-status.jinja2",
+ resource_path_root / "web" / "templates" / "build-status-legacy.jinja2",
resource_path_root / "web" / "templates" / "email-index.jinja2",
resource_path_root / "web" / "templates" / "error.jinja2",
resource_path_root / "web" / "templates" / "repo-index.jinja2",
@@ -590,5 +591,5 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "web" / "templates" / "shell",
resource_path_root / "web" / "templates" / "telegram-index.jinja2",
])
- local_files = list(sorted(walk(resource_path_root)))
+ local_files = list(path for path in sorted(walk(resource_path_root)) if path.name not in ("index.js", "index.css"))
assert local_files == expected
diff --git a/tests/ahriman/web/schemas/test_auth_info_schema.py b/tests/ahriman/web/schemas/test_auth_info_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_auth_info_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py b/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_info_v2_schema.py b/tests/ahriman/web/schemas/test_info_v2_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_info_v2_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/test_server_info.py b/tests/ahriman/web/test_server_info.py
new file mode 100644
index 00000000..508158dd
--- /dev/null
+++ b/tests/ahriman/web/test_server_info.py
@@ -0,0 +1,27 @@
+import pytest
+
+from aiohttp.web import Application
+
+from ahriman import __version__
+from ahriman.models.repository_id import RepositoryId
+from ahriman.web.server_info import server_info
+from ahriman.web.views.index import IndexView
+
+
+async def test_server_info(application: Application, repository_id: RepositoryId) -> None:
+ """
+ must generate server info
+ """
+ request = pytest.helpers.request(application, "", "GET")
+ view = IndexView(request)
+ result = await server_info(view)
+
+ assert result["repositories"] == [{"id": repository_id.id, **repository_id.view()}]
+ assert not result["auth"]["enabled"]
+ assert not result["auth"]["external"]
+ assert result["auth"]["username"] is None
+ assert result["auth"]["control"]
+ assert result["version"] == __version__
+ assert result["autorefresh_intervals"] == []
+ assert result["docs_enabled"]
+ assert result["index_url"] is None
diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py
index ff741482..14bc82b0 100644
--- a/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py
+++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py
@@ -35,6 +35,6 @@ async def test_get(client: TestClient, repository_id: RepositoryId) -> None:
json = await response.json()
assert not response_schema.validate(json)
- assert json["repositories"] == [repository_id.view()]
+ assert json["repositories"] == [{"id": repository_id.id, **repository_id.view()}]
assert not json["auth"]
assert json["version"] == __version__
diff --git a/tests/ahriman/web/views/v2/status/test_view_v2_status_info.py b/tests/ahriman/web/views/v2/status/test_view_v2_status_info.py
new file mode 100644
index 00000000..b77ed5e5
--- /dev/null
+++ b/tests/ahriman/web/views/v2/status/test_view_v2_status_info.py
@@ -0,0 +1,43 @@
+import pytest
+
+from aiohttp.test_utils import TestClient
+
+from ahriman import __version__
+from ahriman.models.repository_id import RepositoryId
+from ahriman.models.user_access import UserAccess
+from ahriman.web.views.v2.status.info import InfoView
+
+
+async def test_get_permission() -> None:
+ """
+ must return correct permission for the request
+ """
+ for method in ("GET",):
+ request = pytest.helpers.request("", "", method)
+ assert await InfoView.get_permission(request) == UserAccess.Unauthorized
+
+
+def test_routes() -> None:
+ """
+ must return correct routes
+ """
+ assert InfoView.ROUTES == ["/api/v2/info"]
+
+
+async def test_get(client: TestClient, repository_id: RepositoryId) -> None:
+ """
+ must return service information
+ """
+ response_schema = pytest.helpers.schema_response(InfoView.get)
+
+ response = await client.get("/api/v2/info")
+ assert response.ok
+ json = await response.json()
+ assert not response_schema.validate(json)
+
+ assert json["repositories"] == [{"id": repository_id.id, **repository_id.view()}]
+ assert not json["auth"]["enabled"]
+ assert json["auth"]["control"]
+ assert json["version"] == __version__
+ assert json["autorefresh_intervals"] == []
+ assert json["docs_enabled"]
diff --git a/tox.toml b/tox.toml
index 390f5c84..b59f572a 100644
--- a/tox.toml
+++ b/tox.toml
@@ -9,6 +9,16 @@ labels.release = [
"publish",
]
+[commands]
+npm_install = [
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "install",
+ "--cache", "{envtmpdir}/npm-cache",
+ ],
+]
+
[flags]
autopep8 = [
"--max-line-length", "120",
@@ -32,6 +42,9 @@ mypy = [
"--allow-untyped-decorators",
"--allow-subclassing-any",
]
+npm = [
+ "--prefix", "frontend",
+]
pydeps = [
"--no-config",
"--cluster",
@@ -78,6 +91,9 @@ commands = [
[env.check]
description = "Run common checks like linter, mypy, etc"
+allowlist_externals = [
+ "npm",
+]
dependency_groups = [
"check",
]
@@ -123,6 +139,13 @@ commands = [
"--non-interactive",
"--package", "{[project]name}",
],
+ { replace = "ref", of = ["commands", "npm_install"], extend = true },
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "run",
+ "lint",
+ ],
]
[env.docs]
@@ -193,6 +216,21 @@ commands = [
],
]
+[env.frontend]
+description = "Build frontend HTML and JS"
+allowlist_externals = [
+ "npm",
+]
+commands = [
+ { replace = "ref", of = ["commands", "npm_install"], extend = true },
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "run",
+ "build",
+ ],
+]
+
[env.html]
description = "Generate html documentation"
dependency_groups = [
@@ -278,6 +316,7 @@ commands = [
[env.version]
description = "Bump package version"
allowlist_externals = [
+ "npm",
"sed",
]
deps = [
@@ -295,6 +334,12 @@ commands = [
"s/^__version__ = .*/__version__ = \"{posargs}\"/",
"src/ahriman/__init__.py",
],
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "version",
+ "{posargs}",
+ ],
[
"sed",
"--in-place",