mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 11:03:37 +00:00
Compare commits
13 Commits
a04b6c3b9c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fce49f22c9 | |||
| af8e2c9e9b | |||
| 1c312bb528 | |||
| e39194e9f6 | |||
| 21cc029c18 | |||
| 40671b99d5 | |||
| 3ad2c494af | |||
| 34014d1cdd | |||
| 93ed2b864b | |||
| cca931ccd0 | |||
| 5e090cebdb | |||
| d7984c12f0 | |||
| 96684d7ddd |
@@ -1,7 +1,7 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
build:
|
build:
|
||||||
os: ubuntu-20.04
|
os: ubuntu-lts-latest
|
||||||
tools:
|
tools:
|
||||||
python: "3.12"
|
python: "3.12"
|
||||||
apt_packages:
|
apt_packages:
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ ahriman.application.handlers.restore module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.application.handlers.rollback module
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.application.handlers.rollback
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.application.handlers.run module
|
ahriman.application.handlers.run module
|
||||||
---------------------------------------
|
---------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ ahriman.web.middlewares.auth\_handler module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.web.middlewares.etag\_handler module
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.middlewares.etag_handler
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.web.middlewares.exception\_handler module
|
ahriman.web.middlewares.exception\_handler module
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,14 @@ ahriman.web.schemas.package\_version\_schema module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.web.schemas.packager\_schema module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.schemas.packager_schema
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.web.schemas.pagination\_schema module
|
ahriman.web.schemas.pagination\_schema module
|
||||||
---------------------------------------------
|
---------------------------------------------
|
||||||
|
|
||||||
@@ -332,6 +340,14 @@ ahriman.web.schemas.repository\_stats\_schema module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.web.schemas.rollback\_schema module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.schemas.rollback_schema
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.web.schemas.search\_schema module
|
ahriman.web.schemas.search\_schema module
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ ahriman.web.views.v1.service.request module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.web.views.v1.service.rollback module
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.views.v1.service.rollback
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.web.views.v1.service.search module
|
ahriman.web.views.v1.service.search module
|
||||||
------------------------------------------
|
------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,18 @@ Remove packages
|
|||||||
|
|
||||||
This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods.
|
This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods.
|
||||||
|
|
||||||
|
Rollback packages
|
||||||
|
^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
This flow restores a package to a previously built version:
|
||||||
|
|
||||||
|
#. Load the current package definition from the repository database.
|
||||||
|
#. Replace its version with the requested rollback target.
|
||||||
|
#. Search the archive directory for built artifacts (packages and signatures) matching the target version.
|
||||||
|
#. Add the found artifacts to the repository via the same path as ``package-add`` with ``PackageSource.Archive``.
|
||||||
|
#. Trigger an immediate update to process the added packages.
|
||||||
|
#. If ``--hold`` is enabled (the default), mark the package as held in the database to prevent automatic updates from overriding the rollback.
|
||||||
|
|
||||||
Check outdated packages
|
Check outdated packages
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
|
|||||||
|
|
||||||
* ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used.
|
* ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used.
|
||||||
* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default.
|
* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default.
|
||||||
|
* ``cors_allow_headers`` - allowed CORS headers, space separated list of strings, optional.
|
||||||
|
* ``cors_allow_methods`` - allowed CORS methods, space separated list of strings, optional.
|
||||||
|
* ``cors_allow_origins`` - allowed CORS origins, space separated list of strings, optional, default ``*``.
|
||||||
|
* ``cors_expose_headers`` - exposed CORS headers, space separated list of strings, optional.
|
||||||
* ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``.
|
* ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``.
|
||||||
* ``host`` - host to bind, string, optional.
|
* ``host`` - host to bind, string, optional.
|
||||||
* ``index_url`` - full URL of the repository index page, string, optional.
|
* ``index_url`` - full URL of the repository index page, string, optional.
|
||||||
|
|||||||
@@ -33,3 +33,28 @@ The service provides several commands aim to do easy repository backup and resto
|
|||||||
.. code-block:: shell
|
.. code-block:: shell
|
||||||
|
|
||||||
sudo -u ahriman ahriman repo-rebuild --from-database
|
sudo -u ahriman ahriman repo-rebuild --from-database
|
||||||
|
|
||||||
|
Package rollback
|
||||||
|
================
|
||||||
|
|
||||||
|
If the ``archive.keep_built_packages`` option is enabled, the service keeps previously built package files in the archive directory. These archives can be used to rollback a package to a previous successfully built version.
|
||||||
|
|
||||||
|
#.
|
||||||
|
List available archive versions for a package:
|
||||||
|
|
||||||
|
.. code-block:: shell
|
||||||
|
|
||||||
|
ahriman package-archives ahriman
|
||||||
|
|
||||||
|
#.
|
||||||
|
Rollback the package to the desired version:
|
||||||
|
|
||||||
|
.. code-block:: shell
|
||||||
|
|
||||||
|
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1
|
||||||
|
|
||||||
|
By default, the ``--hold`` flag is enabled, which prevents the package from being automatically updated on subsequent ``repo-update`` runs. To rollback without holding the package use:
|
||||||
|
|
||||||
|
.. code-block:: shell
|
||||||
|
|
||||||
|
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1 --no-hold
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import stylistic from "@stylistic/eslint-plugin";
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
|
import react from "eslint-plugin-react";
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||||
@@ -8,7 +9,11 @@ import tseslint from "typescript-eslint";
|
|||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist"] },
|
{ ignores: ["dist"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
react.configs.flat.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
],
|
||||||
files: ["src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
@@ -17,13 +22,14 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
"@stylistic": stylistic,
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
"simple-import-sort": simpleImportSort,
|
"simple-import-sort": simpleImportSort,
|
||||||
"@stylistic": stylistic,
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
|
||||||
// imports
|
// imports
|
||||||
@@ -33,7 +39,7 @@ export default tseslint.config(
|
|||||||
// core
|
// core
|
||||||
"curly": "error",
|
"curly": "error",
|
||||||
"eqeqeq": "error",
|
"eqeqeq": "error",
|
||||||
"no-console": "error",
|
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||||
"no-eval": "error",
|
"no-eval": "error",
|
||||||
|
|
||||||
// stylistic
|
// stylistic
|
||||||
@@ -68,6 +74,7 @@ export default tseslint.config(
|
|||||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||||
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
||||||
"@typescript-eslint/no-deprecated": "error",
|
"@typescript-eslint/no-deprecated": "error",
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||||
"@typescript-eslint/prefer-optional-chain": "error",
|
"@typescript-eslint/prefer-optional-chain": "error",
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
{
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": ">=11.14.0 <11.15.0",
|
||||||
|
"@emotion/styled": ">=11.14.0 <11.15.0",
|
||||||
|
"@mui/icons-material": ">=7.3.0 <7.4.0",
|
||||||
|
"@mui/material": ">=7.3.0 <7.4.0",
|
||||||
|
"@mui/x-data-grid": ">=8.28.0 <8.29.0",
|
||||||
|
"@tanstack/react-query": ">=5.94.0 <5.95.0",
|
||||||
|
"chart.js": ">=4.5.0 <4.6.0",
|
||||||
|
"react": ">=19.2.0 <19.3.0",
|
||||||
|
"react-chartjs-2": ">=5.3.0 <5.4.0",
|
||||||
|
"react-dom": ">=19.2.0 <19.3.0",
|
||||||
|
"react-error-boundary": ">=6.1.0 <6.2.0",
|
||||||
|
"react-syntax-highlighter": ">=16.1.0 <16.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": ">=9.39.0 <9.40.0",
|
||||||
|
"@stylistic/eslint-plugin": ">=5.10.0 <5.11.0",
|
||||||
|
"@types/react": ">=19.2.0 <19.3.0",
|
||||||
|
"@types/react-dom": ">=19.2.0 <19.3.0",
|
||||||
|
"@types/react-syntax-highlighter": ">=15.5.0 <15.6.0",
|
||||||
|
"@vitejs/plugin-react": ">=6.0.0 <6.1.0",
|
||||||
|
"eslint": ">=9.39.0 <9.40.0",
|
||||||
|
"eslint-plugin-react": ">=7.37.0 <7.38.0",
|
||||||
|
"eslint-plugin-react-hooks": ">=7.0.0 <7.1.0",
|
||||||
|
"eslint-plugin-react-refresh": ">=0.5.0 <0.6.0",
|
||||||
|
"eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0",
|
||||||
|
"typescript": ">=5.9.0 <5.10.0",
|
||||||
|
"typescript-eslint": ">=8.57.0 <8.58.0",
|
||||||
|
"vite": ">=8.0.0 <8.1.0"
|
||||||
|
},
|
||||||
"name": "ahriman-frontend",
|
"name": "ahriman-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
|
||||||
"version": "2.20.0",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -10,32 +38,6 @@
|
|||||||
"lint:fix": "eslint --fix src/",
|
"lint:fix": "eslint --fix src/",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"type": "module",
|
||||||
"@emotion/react": "^11.14.0",
|
"version": "2.20.0"
|
||||||
"@emotion/styled": "^11.14.1",
|
|
||||||
"@mui/icons-material": "^7.3.9",
|
|
||||||
"@mui/material": "^7.3.9",
|
|
||||||
"@mui/x-data-grid": "^8.27.4",
|
|
||||||
"@tanstack/react-query": "^5.90.21",
|
|
||||||
"chart.js": "^4.5.1",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-chartjs-2": "^5.3.1",
|
|
||||||
"react-dom": "^19.2.4",
|
|
||||||
"react-syntax-highlighter": "^16.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.39.3",
|
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"eslint": "^9.39.3",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"typescript-eslint": "^8.56.1",
|
|
||||||
"vite": "^8.0.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import type React from "react";
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,10 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|
||||||
|
body: string;
|
||||||
status: number;
|
status: number;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
body: string;
|
|
||||||
|
|
||||||
constructor(status: number, statusText: string, body: string) {
|
constructor(status: number, statusText: string, body: string) {
|
||||||
super(`${status} ${statusText}`);
|
super(`${status} ${statusText}`);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { Event } from "models/Event";
|
|||||||
import type { InfoResponse } from "models/InfoResponse";
|
import type { InfoResponse } from "models/InfoResponse";
|
||||||
import type { InternalStatus } from "models/InternalStatus";
|
import type { InternalStatus } from "models/InternalStatus";
|
||||||
import type { LogRecord } from "models/LogRecord";
|
import type { LogRecord } from "models/LogRecord";
|
||||||
|
import type { Package } from "models/Package";
|
||||||
import type { PackageStatus } from "models/PackageStatus";
|
import type { PackageStatus } from "models/PackageStatus";
|
||||||
import type { Patch } from "models/Patch";
|
import type { Patch } from "models/Patch";
|
||||||
import { RepositoryId } from "models/RepositoryId";
|
import { RepositoryId } from "models/RepositoryId";
|
||||||
@@ -42,6 +43,12 @@ export class FetchClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchPackageArtifacts(packageBase: string, repository: RepositoryId): Promise<Package[]> {
|
||||||
|
return this.client.request<Package[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/archives`, {
|
||||||
|
query: repository.toQuery(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise<Changes> {
|
async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise<Changes> {
|
||||||
return this.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
|
return this.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
|
||||||
query: repository.toQuery(),
|
query: repository.toQuery(),
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
|
json?: unknown;
|
||||||
method?: string;
|
method?: string;
|
||||||
query?: Record<string, string | number | boolean>;
|
query?: Record<string, string | number | boolean>;
|
||||||
json?: unknown;
|
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { PackageActionRequest } from "models/PackageActionRequest";
|
|||||||
import type { PGPKey } from "models/PGPKey";
|
import type { PGPKey } from "models/PGPKey";
|
||||||
import type { PGPKeyRequest } from "models/PGPKeyRequest";
|
import type { PGPKeyRequest } from "models/PGPKeyRequest";
|
||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
|
import type { RollbackRequest } from "models/RollbackRequest";
|
||||||
|
|
||||||
export class ServiceClient {
|
export class ServiceClient {
|
||||||
|
|
||||||
@@ -36,6 +37,14 @@ export class ServiceClient {
|
|||||||
return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data });
|
return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async servicePackageHold(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
|
||||||
|
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
|
||||||
|
method: "POST",
|
||||||
|
query: repository.toQuery(),
|
||||||
|
json: { is_held: isHeld },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async servicePackagePatchRemove(packageBase: string, key: string): Promise<void> {
|
async servicePackagePatchRemove(packageBase: string, key: string): Promise<void> {
|
||||||
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
|
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -58,6 +67,14 @@ export class ServiceClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async servicePackageRollback(repository: RepositoryId, data: RollbackRequest): Promise<void> {
|
||||||
|
return this.client.request("/api/v1/service/rollback", {
|
||||||
|
method: "POST",
|
||||||
|
query: repository.toQuery(),
|
||||||
|
json: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async servicePackageSearch(query: string): Promise<AURPackage[]> {
|
async servicePackageSearch(query: string): Promise<AURPackage[]> {
|
||||||
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
|
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
|
||||||
}
|
}
|
||||||
@@ -78,14 +95,6 @@ export class ServiceClient {
|
|||||||
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
|
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
async servicePackageHoldUpdate(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
|
|
||||||
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
|
|
||||||
method: "POST",
|
|
||||||
query: repository.toQuery(),
|
|
||||||
json: { is_held: isHeld },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
|
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
|
||||||
return this.client.request("/api/v1/service/rebuild", {
|
return this.client.request("/api/v1/service/rebuild", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
|
|||||||
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
|
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "update duration, s",
|
|
||||||
data: updateEvents.map(event => event.data?.took ?? 0),
|
|
||||||
borderColor: blue[500],
|
|
||||||
backgroundColor: blue[200],
|
backgroundColor: blue[200],
|
||||||
|
borderColor: blue[500],
|
||||||
cubicInterpolationMode: "monotone" as const,
|
cubicInterpolationMode: "monotone" as const,
|
||||||
|
data: updateEvents.map(event => event.data?.took ?? 0),
|
||||||
|
label: "update duration, s",
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,19 +29,19 @@ interface PackageCountBarChartProps {
|
|||||||
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
||||||
return <Bar
|
return <Bar
|
||||||
data={{
|
data={{
|
||||||
labels: ["packages"],
|
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "bases",
|
|
||||||
data: [stats.bases ?? 0],
|
|
||||||
backgroundColor: indigo[300],
|
backgroundColor: indigo[300],
|
||||||
|
data: [stats.bases ?? 0],
|
||||||
|
label: "bases",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "archives",
|
|
||||||
data: [stats.packages ?? 0],
|
|
||||||
backgroundColor: blue[500],
|
backgroundColor: blue[500],
|
||||||
|
data: [stats.packages ?? 0],
|
||||||
|
label: "archives",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
labels: ["packages"],
|
||||||
}}
|
}}
|
||||||
options={{
|
options={{
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ interface StatusPieChartProps {
|
|||||||
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
|
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
|
||||||
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
|
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
|
||||||
const data = {
|
const data = {
|
||||||
labels: labels,
|
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "packages in status",
|
|
||||||
data: labels.map(label => counters[label]),
|
|
||||||
backgroundColor: labels.map(label => StatusColors[label]),
|
backgroundColor: labels.map(label => StatusColors[label]),
|
||||||
|
data: labels.map(label => counters[label]),
|
||||||
|
label: "packages in status",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
labels: labels,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Pie data={data} options={{ responsive: true }} />;
|
return <Pie data={data} options={{ responsive: true }} />;
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface AutoRefreshControlProps {
|
interface AutoRefreshControlProps {
|
||||||
intervals: AutoRefreshInterval[];
|
|
||||||
currentInterval: number;
|
currentInterval: number;
|
||||||
|
intervals: AutoRefreshInterval[];
|
||||||
onIntervalChange: (interval: number) => void;
|
onIntervalChange: (interval: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutoRefreshControl({
|
export default function AutoRefreshControl({
|
||||||
intervals,
|
|
||||||
currentInterval,
|
currentInterval,
|
||||||
|
intervals,
|
||||||
onIntervalChange,
|
onIntervalChange,
|
||||||
}: AutoRefreshControlProps): React.JSX.Element | null {
|
}: AutoRefreshControlProps): React.JSX.Element | null {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
@@ -46,25 +46,25 @@ export default function AutoRefreshControl({
|
|||||||
return <>
|
return <>
|
||||||
<Tooltip title="Auto-refresh">
|
<Tooltip title="Auto-refresh">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
|
||||||
aria-label="Auto-refresh"
|
aria-label="Auto-refresh"
|
||||||
onClick={event => setAnchorEl(event.currentTarget)}
|
|
||||||
color={enabled ? "primary" : "default"}
|
color={enabled ? "primary" : "default"}
|
||||||
|
onClick={event => setAnchorEl(event.currentTarget)}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
|
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
onClose={() => setAnchorEl(null)}
|
onClose={() => setAnchorEl(null)}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
selected={!enabled}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onIntervalChange(0);
|
onIntervalChange(0);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
}}
|
}}
|
||||||
|
selected={!enabled}
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{!enabled && <CheckIcon fontSize="small" />}
|
{!enabled && <CheckIcon fontSize="small" />}
|
||||||
@@ -74,11 +74,11 @@ export default function AutoRefreshControl({
|
|||||||
{intervals.map(interval =>
|
{intervals.map(interval =>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={interval.interval}
|
key={interval.interval}
|
||||||
selected={enabled && interval.interval === currentInterval}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onIntervalChange(interval.interval);
|
onIntervalChange(interval.interval);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
}}
|
}}
|
||||||
|
selected={enabled && interval.interval === currentInterval}
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}
|
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}
|
||||||
|
|||||||
@@ -46,27 +46,24 @@ export default function CodeBlock({
|
|||||||
|
|
||||||
return <Box sx={{ position: "relative" }}>
|
return <Box sx={{ position: "relative" }}>
|
||||||
<Box
|
<Box
|
||||||
ref={preRef}
|
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
|
ref={preRef}
|
||||||
sx={{ overflow: "auto", height }}
|
sx={{ overflow: "auto", height }}
|
||||||
>
|
>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
|
customStyle={{
|
||||||
|
borderRadius: `${theme.shape.borderRadius}px`,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
}}
|
||||||
language={language}
|
language={language}
|
||||||
style={mode === "dark" ? vs2015 : githubGist}
|
style={mode === "dark" ? vs2015 : githubGist}
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
customStyle={{
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
borderRadius: `${theme.shape.borderRadius}px`,
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
margin: 0,
|
|
||||||
minHeight: "100%",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
</Box>
|
</Box>
|
||||||
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
{content && <Box sx={{ position: "absolute", right: 8, top: 8 }}>
|
||||||
<CopyButton text={content} />
|
<CopyButton text={content} />
|
||||||
</Box>}
|
</Box>}
|
||||||
</Box>;
|
</Box>;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element
|
|||||||
};
|
};
|
||||||
|
|
||||||
return <Tooltip title={copied ? "Copied!" : "Copy"}>
|
return <Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||||
<IconButton size="small" aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()}>
|
<IconButton aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()} size="small">
|
||||||
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface DialogHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
|
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
|
||||||
return <DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", ...sx }}>
|
return <DialogTitle sx={{ alignItems: "center", display: "flex", justifyContent: "space-between", ...sx }}>
|
||||||
{children}
|
{children}
|
||||||
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
|
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
|
|||||||
55
frontend/src/components/common/ErrorBoundary.tsx
Normal file
55
frontend/src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2021-2026 ahriman team.
|
||||||
|
*
|
||||||
|
* This file is part of ahriman
|
||||||
|
* (see https://github.com/arcan1s/ahriman).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { Box, Button, Typography } from "@mui/material";
|
||||||
|
import type React from "react";
|
||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
|
interface ErrorDetails {
|
||||||
|
message: string;
|
||||||
|
stack: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorFallback({ error }: FallbackProps): React.JSX.Element {
|
||||||
|
|
||||||
|
const details: ErrorDetails = error instanceof Error
|
||||||
|
? { message: error.message, stack: error.stack }
|
||||||
|
: { message: String(error), stack: undefined };
|
||||||
|
|
||||||
|
return <Box role="alert" sx={{ color: "text.primary", minHeight: "100vh", p: 6 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700 }} variant="h4">
|
||||||
|
Something went wrong
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography color="error" sx={{ fontFamily: "monospace", mt: 2 }}>
|
||||||
|
{details.message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{details.stack && <Typography
|
||||||
|
component="pre"
|
||||||
|
sx={{ color: "text.secondary", fontFamily: "monospace", fontSize: "0.75rem", mt: 3, whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{details.stack}
|
||||||
|
</Typography>}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, mt: 4 }}>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outlined">Reload page</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>;
|
||||||
|
}
|
||||||
@@ -35,12 +35,12 @@ export default function NotificationItem({ notification, onClose }: Notification
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slide direction="down" in={show} mountOnEnter unmountOnExit onExited={() => onClose(notification.id)}>
|
<Slide direction="down" in={show} mountOnEnter onExited={() => onClose(notification.id)} unmountOnExit>
|
||||||
<Alert
|
<Alert
|
||||||
onClose={() => setShow(false)}
|
onClose={() => setShow(false)}
|
||||||
severity={notification.severity}
|
severity={notification.severity}
|
||||||
variant="filled"
|
|
||||||
sx={{ width: "100%", pointerEvents: "auto" }}
|
sx={{ width: "100%", pointerEvents: "auto" }}
|
||||||
|
variant="filled"
|
||||||
>
|
>
|
||||||
<strong>{notification.title}</strong>
|
<strong>{notification.title}</strong>
|
||||||
{notification.message && ` - ${notification.message}`}
|
{notification.message && ` - ${notification.message}`}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export default function RepositorySelect({
|
|||||||
return <FormControl fullWidth margin="normal">
|
return <FormControl fullWidth margin="normal">
|
||||||
<InputLabel>repository</InputLabel>
|
<InputLabel>repository</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
|
|
||||||
label="repository"
|
label="repository"
|
||||||
onChange={event => repositorySelect.setSelectedKey(event.target.value)}
|
onChange={event => repositorySelect.setSelectedKey(event.target.value)}
|
||||||
|
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
|
||||||
>
|
>
|
||||||
{repositories.map(repository =>
|
{repositories.map(repository =>
|
||||||
<MenuItem key={repository.key} value={repository.key}>
|
<MenuItem key={repository.key} value={repository.key}>
|
||||||
|
|||||||
@@ -30,23 +30,23 @@ import type React from "react";
|
|||||||
import { StatusHeaderStyles } from "theme/StatusColors";
|
import { StatusHeaderStyles } from "theme/StatusColors";
|
||||||
|
|
||||||
interface DashboardDialogProps {
|
interface DashboardDialogProps {
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
|
export default function DashboardDialog({ onClose, open }: DashboardDialogProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { currentRepository } = useRepository();
|
const { currentRepository } = useRepository();
|
||||||
|
|
||||||
const { data: status } = useQuery<InternalStatus>({
|
const { data: status } = useQuery<InternalStatus>({
|
||||||
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
|
||||||
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
|
||||||
enabled: open,
|
enabled: open,
|
||||||
|
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
||||||
|
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
|
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
|
||||||
|
|
||||||
return <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
return <Dialog fullWidth maxWidth="lg" onClose={onClose} open={open}>
|
||||||
<DialogHeader onClose={onClose} sx={headerStyle}>
|
<DialogHeader onClose={onClose} sx={headerStyle}>
|
||||||
System health
|
System health
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -55,43 +55,43 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
|
|||||||
{status &&
|
{status &&
|
||||||
<>
|
<>
|
||||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
|
<Typography align="right" color="text.secondary" variant="body2">Repository name</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2">{status.repository}</Typography>
|
<Typography variant="body2">{status.repository}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
|
<Typography align="right" color="text.secondary" variant="body2">Repository architecture</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2">{status.architecture}</Typography>
|
<Typography variant="body2">{status.architecture}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
|
<Typography align="right" color="text.secondary" variant="body2">Current status</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2">{status.status.status}</Typography>
|
<Typography variant="body2">{status.status.status}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
|
<Typography align="right" color="text.secondary" variant="body2">Updated at</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<Grid size={{ md: 3, xs: 6 }}>
|
||||||
<Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
|
<Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ md: 6, xs: 12 }}>
|
||||||
<Box sx={{ height: 300 }}>
|
<Box sx={{ height: 300 }}>
|
||||||
<PackageCountBarChart stats={status.stats} />
|
<PackageCountBarChart stats={status.stats} />
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ md: 6, xs: 12 }}>
|
||||||
<Box sx={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
|
<Box sx={{ alignItems: "center", display: "flex", height: 300, justifyContent: "center" }}>
|
||||||
<StatusPieChart counters={status.packages} />
|
<StatusPieChart counters={status.packages} />
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ import { useNotification } from "hooks/useNotification";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface KeyImportDialogProps {
|
interface KeyImportDialogProps {
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
|
export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFetch: () => Promise<void> = async () => {
|
const handleFetch = async (): Promise<void> => {
|
||||||
if (!fingerprint || !server) {
|
if (!fingerprint || !server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport: () => Promise<void> = async () => {
|
const handleImport = async (): Promise<void> => {
|
||||||
if (!fingerprint || !server) {
|
if (!fingerprint || !server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,38 +81,38 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}>
|
||||||
<DialogHeader onClose={handleClose}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Import key from PGP server
|
Import key from PGP server
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
label="fingerprint"
|
|
||||||
placeholder="PGP key fingerprint"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
label="fingerprint"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
value={fingerprint}
|
|
||||||
onChange={event => setFingerprint(event.target.value)}
|
onChange={event => setFingerprint(event.target.value)}
|
||||||
|
placeholder="PGP key fingerprint"
|
||||||
|
value={fingerprint}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="key server"
|
|
||||||
placeholder="PGP key server"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
label="key server"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
value={server}
|
|
||||||
onChange={event => setServer(event.target.value)}
|
onChange={event => setServer(event.target.value)}
|
||||||
|
placeholder="PGP key server"
|
||||||
|
value={server}
|
||||||
/>
|
/>
|
||||||
{keyBody &&
|
{keyBody &&
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<CodeBlock content={keyBody} height={300} />
|
<CodeBlock height={300} content={keyBody} />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
|
<Button onClick={() => void handleImport()} startIcon={<PlayArrowIcon />} variant="contained">import</Button>
|
||||||
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
|
<Button color="success" onClick={() => void handleFetch()} startIcon={<RefreshIcon />} variant="contained">fetch</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ import { useNotification } from "hooks/useNotification";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
|
export default function LoginDialog({ onClose, open }: LoginDialogProps): React.JSX.Element {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -54,7 +54,7 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit: () => Promise<void> = async () => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,26 +72,24 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
return <Dialog fullWidth maxWidth="xs" onClose={handleClose} open={open}>
|
||||||
<DialogHeader onClose={handleClose}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Login
|
Login
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
label="username"
|
|
||||||
fullWidth
|
|
||||||
margin="normal"
|
|
||||||
value={username}
|
|
||||||
onChange={event => setUsername(event.target.value)}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label="username"
|
||||||
|
margin="normal"
|
||||||
|
onChange={event => setUsername(event.target.value)}
|
||||||
|
value={username}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="password"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
label="password"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
onChange={event => setPassword(event.target.value)}
|
onChange={event => setPassword(event.target.value)}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
@@ -102,17 +100,24 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
|
|||||||
input: {
|
input: {
|
||||||
endAdornment:
|
endAdornment:
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton aria-label={showPassword ? "Hide password" : "Show password"} onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
|
<IconButton
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
edge="end"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>,
|
</InputAdornment>,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
|
<Button onClick={() => void handleSubmit()} startIcon={<PersonIcon />} variant="contained">login</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ interface EnvironmentVariable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PackageAddDialogProps {
|
interface PackageAddDialogProps {
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
|
export default function PackageAddDialog({ onClose, open }: PackageAddDialogProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const repositorySelect = useSelectedRepository();
|
const repositorySelect = useSelectedRepository();
|
||||||
@@ -77,9 +77,9 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
|
|||||||
const debouncedSearch = useDebounce(packageName, 500);
|
const debouncedSearch = useDebounce(packageName, 500);
|
||||||
|
|
||||||
const { data: searchResults = [] } = useQuery<AURPackage[]>({
|
const { data: searchResults = [] } = useQuery<AURPackage[]>({
|
||||||
queryKey: QueryKeys.search(debouncedSearch),
|
|
||||||
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
|
|
||||||
enabled: debouncedSearch.length >= 3,
|
enabled: debouncedSearch.length >= 3,
|
||||||
|
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
|
||||||
|
queryKey: QueryKeys.search(debouncedSearch),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (action: "add" | "request"): Promise<void> => {
|
const handleSubmit = async (action: "add" | "request"): Promise<void> => {
|
||||||
@@ -107,7 +107,7 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}>
|
||||||
<DialogHeader onClose={handleClose}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Add new packages
|
Add new packages
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -117,20 +117,18 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
|
|||||||
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
freeSolo
|
freeSolo
|
||||||
options={searchResults.map(pkg => pkg.package)}
|
|
||||||
inputValue={packageName}
|
inputValue={packageName}
|
||||||
onInputChange={(_, value) => setPackageName(value)}
|
onInputChange={(_, value) => setPackageName(value)}
|
||||||
|
options={searchResults.map(pkg => pkg.package)}
|
||||||
|
renderInput={params =>
|
||||||
|
<TextField {...params} label="package" margin="normal" placeholder="AUR package" />
|
||||||
|
}
|
||||||
renderOption={(props, option) => {
|
renderOption={(props, option) => {
|
||||||
const pkg = searchResults.find(pkg => pkg.package === option);
|
const pkg = searchResults.find(pkg => pkg.package === option);
|
||||||
return (
|
return <li {...props} key={option}>
|
||||||
<li {...props} key={option}>
|
|
||||||
{option}{pkg ? ` (${pkg.description})` : ""}
|
{option}{pkg ? ` (${pkg.description})` : ""}
|
||||||
</li>
|
</li>;
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
renderInput={params =>
|
|
||||||
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -140,45 +138,50 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const id = variableIdCounter.current++;
|
const id = variableIdCounter.current++;
|
||||||
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
|
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
|
||||||
}}
|
}}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
sx={{ mt: 1 }}
|
sx={{ mt: 1 }}
|
||||||
|
variant="outlined"
|
||||||
>
|
>
|
||||||
add environment variable
|
add environment variable
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{environmentVariables.map(variable =>
|
{environmentVariables.map(variable =>
|
||||||
<Box key={variable.id} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
|
<Box key={variable.id} sx={{ alignItems: "center", display: "flex", gap: 1, mt: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
|
||||||
placeholder="name"
|
|
||||||
value={variable.key}
|
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
const newKey = event.target.value;
|
const newKey = event.target.value;
|
||||||
setEnvironmentVariables(prev =>
|
setEnvironmentVariables(prev =>
|
||||||
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
|
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
placeholder="name"
|
||||||
|
size="small"
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
|
value={variable.key}
|
||||||
/>
|
/>
|
||||||
<Box>=</Box>
|
<Box>=</Box>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
value={variable.value}
|
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
const newValue = event.target.value;
|
const newValue = event.target.value;
|
||||||
setEnvironmentVariables(prev =>
|
setEnvironmentVariables(prev =>
|
||||||
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
|
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
|
value={variable.value}
|
||||||
/>
|
/>
|
||||||
<IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}>
|
<IconButton
|
||||||
|
aria-label="Remove variable"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>,
|
</Box>,
|
||||||
@@ -186,8 +189,8 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleSubmit("add")} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
|
<Button onClick={() => void handleSubmit("add")} startIcon={<PlayArrowIcon />} variant="contained">add</Button>
|
||||||
<Button onClick={() => void handleSubmit("request")} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
|
<Button color="success" onClick={() => void handleSubmit("request")} startIcon={<AddIcon />} variant="contained">request</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Box, Dialog, DialogContent, Tab, Tabs } from "@mui/material";
|
|||||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ApiError } from "api/client/ApiError";
|
import { ApiError } from "api/client/ApiError";
|
||||||
import DialogHeader from "components/common/DialogHeader";
|
import DialogHeader from "components/common/DialogHeader";
|
||||||
|
import ArtifactsTab from "components/package/ArtifactsTab";
|
||||||
import BuildLogsTab from "components/package/BuildLogsTab";
|
import BuildLogsTab from "components/package/BuildLogsTab";
|
||||||
import ChangesTab from "components/package/ChangesTab";
|
import ChangesTab from "components/package/ChangesTab";
|
||||||
import EventsTab from "components/package/EventsTab";
|
import EventsTab from "components/package/EventsTab";
|
||||||
@@ -44,17 +45,17 @@ import { StatusHeaderStyles } from "theme/StatusColors";
|
|||||||
import { defaultInterval } from "utils";
|
import { defaultInterval } from "utils";
|
||||||
|
|
||||||
interface PackageInfoDialogProps {
|
interface PackageInfoDialogProps {
|
||||||
packageBase: string | null;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
autoRefreshIntervals: AutoRefreshInterval[];
|
autoRefreshIntervals: AutoRefreshInterval[];
|
||||||
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
|
packageBase: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageInfoDialog({
|
export default function PackageInfoDialog({
|
||||||
packageBase,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
autoRefreshIntervals,
|
autoRefreshIntervals,
|
||||||
|
onClose,
|
||||||
|
open,
|
||||||
|
packageBase,
|
||||||
}: PackageInfoDialogProps): React.JSX.Element {
|
}: PackageInfoDialogProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { currentRepository } = useRepository();
|
const { currentRepository } = useRepository();
|
||||||
@@ -79,32 +80,32 @@ export default function PackageInfoDialog({
|
|||||||
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
|
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
|
||||||
|
|
||||||
const { data: packageData } = useQuery<PackageStatus[]>({
|
const { data: packageData } = useQuery<PackageStatus[]>({
|
||||||
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
|
enabled: open,
|
||||||
queryFn: localPackageBase && currentRepository ?
|
queryFn: localPackageBase && currentRepository ?
|
||||||
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
|
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
|
||||||
enabled: open,
|
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
|
||||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: dependencies } = useQuery<Dependencies>({
|
const { data: dependencies } = useQuery<Dependencies>({
|
||||||
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
|
enabled: open,
|
||||||
queryFn: localPackageBase && currentRepository ?
|
queryFn: localPackageBase && currentRepository ?
|
||||||
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
|
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
|
||||||
enabled: open,
|
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: patches = [] } = useQuery<Patch[]>({
|
const { data: patches = [] } = useQuery<Patch[]>({
|
||||||
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
|
|
||||||
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
|
|
||||||
enabled: open,
|
enabled: open,
|
||||||
|
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
|
||||||
|
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const description: PackageStatus | undefined = packageData?.[0];
|
const description = packageData?.[0];
|
||||||
const pkg = description?.package;
|
const pkg = description?.package;
|
||||||
const status = description?.status;
|
const status = description?.status;
|
||||||
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
||||||
|
|
||||||
const handleUpdate: () => Promise<void> = async () => {
|
const handleUpdate = async (): Promise<void> => {
|
||||||
if (!localPackageBase || !currentRepository) {
|
if (!localPackageBase || !currentRepository) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,7 +118,7 @@ export default function PackageInfoDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove: () => Promise<void> = async () => {
|
const handleRemove = async (): Promise<void> => {
|
||||||
if (!localPackageBase || !currentRepository) {
|
if (!localPackageBase || !currentRepository) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -130,20 +131,20 @@ export default function PackageInfoDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHoldToggle: () => Promise<void> = async () => {
|
const handleHoldToggle = async (): Promise<void> => {
|
||||||
if (!localPackageBase || !currentRepository) {
|
if (!localPackageBase || !currentRepository) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const newHeldStatus = !(status?.is_held ?? false);
|
const newHeldStatus = !(status?.is_held ?? false);
|
||||||
await client.service.servicePackageHoldUpdate(localPackageBase, currentRepository, newHeldStatus);
|
await client.service.servicePackageHold(localPackageBase, currentRepository, newHeldStatus);
|
||||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
|
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePatch: (key: string) => Promise<void> = async key => {
|
const handleDeletePatch = async (key: string): Promise<void> => {
|
||||||
if (!localPackageBase) {
|
if (!localPackageBase) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -155,7 +156,7 @@ export default function PackageInfoDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}>
|
||||||
<DialogHeader onClose={handleClose} sx={headerStyle}>
|
<DialogHeader onClose={handleClose} sx={headerStyle}>
|
||||||
{pkg && status
|
{pkg && status
|
||||||
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
|
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
|
||||||
@@ -165,24 +166,24 @@ export default function PackageInfoDialog({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
{pkg &&
|
{pkg &&
|
||||||
<>
|
<>
|
||||||
<PackageDetailsGrid pkg={pkg} dependencies={dependencies} />
|
<PackageDetailsGrid dependencies={dependencies} pkg={pkg} />
|
||||||
<PackagePatchesList
|
<PackagePatchesList
|
||||||
patches={patches}
|
|
||||||
editable={isAuthorized}
|
editable={isAuthorized}
|
||||||
onDelete={key => void handleDeletePatch(key)}
|
onDelete={key => void handleDeletePatch(key)}
|
||||||
|
patches={patches}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
||||||
<Tabs value={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}>
|
<Tabs onChange={(_, tab: TabKey) => setActiveTab(tab)} value={activeTab}>
|
||||||
{tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)}
|
{tabs.map(({ key, label }) => <Tab key={key} label={label} value={key} />)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{activeTab === "logs" && localPackageBase && currentRepository &&
|
{activeTab === "logs" && localPackageBase && currentRepository &&
|
||||||
<BuildLogsTab
|
<BuildLogsTab
|
||||||
packageBase={localPackageBase}
|
packageBase={localPackageBase}
|
||||||
repository={currentRepository}
|
|
||||||
refreshInterval={autoRefresh.interval}
|
refreshInterval={autoRefresh.interval}
|
||||||
|
repository={currentRepository}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{activeTab === "changes" && localPackageBase && currentRepository &&
|
{activeTab === "changes" && localPackageBase && currentRepository &&
|
||||||
@@ -194,21 +195,28 @@ export default function PackageInfoDialog({
|
|||||||
{activeTab === "events" && localPackageBase && currentRepository &&
|
{activeTab === "events" && localPackageBase && currentRepository &&
|
||||||
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
|
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
|
||||||
}
|
}
|
||||||
|
{activeTab === "artifacts" && localPackageBase && currentRepository &&
|
||||||
|
<ArtifactsTab
|
||||||
|
currentVersion={pkg.version}
|
||||||
|
packageBase={localPackageBase}
|
||||||
|
repository={currentRepository}
|
||||||
|
/>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<PackageInfoActions
|
<PackageInfoActions
|
||||||
isAuthorized={isAuthorized}
|
|
||||||
refreshDatabase={refreshDatabase}
|
|
||||||
onRefreshDatabaseChange={setRefreshDatabase}
|
|
||||||
isHeld={status?.is_held ?? false}
|
|
||||||
onHoldToggle={() => void handleHoldToggle()}
|
|
||||||
onUpdate={() => void handleUpdate()}
|
|
||||||
onRemove={() => void handleRemove()}
|
|
||||||
autoRefreshIntervals={autoRefreshIntervals}
|
|
||||||
autoRefreshInterval={autoRefresh.interval}
|
autoRefreshInterval={autoRefresh.interval}
|
||||||
|
autoRefreshIntervals={autoRefreshIntervals}
|
||||||
|
isAuthorized={isAuthorized}
|
||||||
|
isHeld={status?.is_held ?? false}
|
||||||
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
||||||
|
onHoldToggle={() => void handleHoldToggle()}
|
||||||
|
onRefreshDatabaseChange={setRefreshDatabase}
|
||||||
|
onRemove={() => void handleRemove()}
|
||||||
|
onUpdate={() => void handleUpdate()}
|
||||||
|
refreshDatabase={refreshDatabase}
|
||||||
/>
|
/>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import { useSelectedRepository } from "hooks/useSelectedRepository";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface PackageRebuildDialogProps {
|
interface PackageRebuildDialogProps {
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
|
export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDialogProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const repositorySelect = useSelectedRepository();
|
const repositorySelect = useSelectedRepository();
|
||||||
@@ -45,7 +45,7 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRebuild: () => Promise<void> = async () => {
|
const handleRebuild = async (): Promise<void> => {
|
||||||
if (!dependency) {
|
if (!dependency) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}>
|
||||||
<DialogHeader onClose={handleClose}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Rebuild depending packages
|
Rebuild depending packages
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -72,17 +72,17 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
|
|||||||
<RepositorySelect repositorySelect={repositorySelect} />
|
<RepositorySelect repositorySelect={repositorySelect} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="dependency"
|
|
||||||
placeholder="packages dependency"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
|
label="dependency"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
value={dependency}
|
placeholder="packages dependency"
|
||||||
onChange={event => setDependency(event.target.value)}
|
onChange={event => setDependency(event.target.value)}
|
||||||
|
value={dependency}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
|
<Button onClick={() => void handleRebuild()} startIcon={<PlayArrowIcon />} variant="contained">rebuild</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default function AppLayout(): React.JSX.Element {
|
|||||||
const [loginOpen, setLoginOpen] = useState(false);
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
|
||||||
const { data: info } = useQuery<InfoResponse>({
|
const { data: info } = useQuery<InfoResponse>({
|
||||||
queryKey: QueryKeys.info,
|
|
||||||
queryFn: () => client.fetch.fetchServerInfo(),
|
queryFn: () => client.fetch.fetchServerInfo(),
|
||||||
|
queryKey: QueryKeys.info,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,9 +55,9 @@ export default function AppLayout(): React.JSX.Element {
|
|||||||
}, [info, setAuthState, setRepositories]);
|
}, [info, setAuthState, setRepositories]);
|
||||||
|
|
||||||
return <Container maxWidth="xl">
|
return <Container maxWidth="xl">
|
||||||
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
|
<Box sx={{ alignItems: "center", display: "flex", gap: 1, py: 1 }}>
|
||||||
<a href="https://ahriman.readthedocs.io/" title="logo">
|
<a href="https://ahriman.readthedocs.io/" title="logo">
|
||||||
<img src="/static/logo.svg" width={30} height={30} alt="" />
|
<img alt="" height={30} src="/static/logo.svg" width={30} />
|
||||||
</a>
|
</a>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -69,17 +69,15 @@ export default function AppLayout(): React.JSX.Element {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<PackageTable
|
<PackageTable autoRefreshIntervals={info?.autorefresh_intervals ?? []} />
|
||||||
autoRefreshIntervals={info?.autorefresh_intervals ?? []}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Footer
|
<Footer
|
||||||
version={info?.version ?? ""}
|
|
||||||
docsEnabled={info?.docs_enabled ?? false}
|
docsEnabled={info?.docs_enabled ?? false}
|
||||||
indexUrl={info?.index_url}
|
indexUrl={info?.index_url}
|
||||||
onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)}
|
onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)}
|
||||||
|
version={info?.version ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
|
<LoginDialog onClose={() => setLoginOpen(false)} open={loginOpen} />
|
||||||
</Container>;
|
</Container>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,41 +26,41 @@ import { useAuth } from "hooks/useAuth";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
version: string;
|
|
||||||
docsEnabled: boolean;
|
docsEnabled: boolean;
|
||||||
indexUrl?: string;
|
indexUrl?: string;
|
||||||
onLoginClick: () => void;
|
onLoginClick: () => void;
|
||||||
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
|
export default function Footer({ docsEnabled, indexUrl, onLoginClick, version }: FooterProps): React.JSX.Element {
|
||||||
const { enabled: authEnabled, username, logout } = useAuth();
|
const { enabled: authEnabled, username, logout } = useAuth();
|
||||||
|
|
||||||
return <Box
|
return <Box
|
||||||
component="footer"
|
component="footer"
|
||||||
sx={{
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
borderColor: "divider",
|
||||||
|
borderTop: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
|
||||||
borderTop: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
mt: 2,
|
mt: 2,
|
||||||
py: 1,
|
py: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
|
<Box sx={{ alignItems: "center", display: "flex", gap: 2 }}>
|
||||||
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
<Link color="inherit" href="https://github.com/arcan1s/ahriman" sx={{ alignItems: "center", display: "flex", gap: 0.5 }} underline="hover">
|
||||||
<GitHubIcon fontSize="small" />
|
<GitHubIcon fontSize="small" />
|
||||||
<Typography variant="body2">ahriman {version}</Typography>
|
<Typography variant="body2">ahriman {version}</Typography>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
|
<Link color="text.secondary" href="https://github.com/arcan1s/ahriman/releases" underline="hover" variant="body2">
|
||||||
releases
|
releases
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
|
<Link color="text.secondary" href="https://github.com/arcan1s/ahriman/issues" underline="hover" variant="body2">
|
||||||
report a bug
|
report a bug
|
||||||
</Link>
|
</Link>
|
||||||
{docsEnabled &&
|
{docsEnabled &&
|
||||||
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
|
<Link color="text.secondary" href="/api-docs" underline="hover" variant="body2">
|
||||||
api
|
api
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }:
|
|||||||
|
|
||||||
{indexUrl &&
|
{indexUrl &&
|
||||||
<Box>
|
<Box>
|
||||||
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
<Link color="inherit" href={indexUrl} underline="hover" sx={{ alignItems: "center", display: "flex", gap: 0.5 }}>
|
||||||
<HomeIcon fontSize="small" />
|
<HomeIcon fontSize="small" />
|
||||||
<Typography variant="body2">repo index</Typography>
|
<Typography variant="body2">repo index</Typography>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -78,11 +78,11 @@ export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }:
|
|||||||
{authEnabled &&
|
{authEnabled &&
|
||||||
<Box>
|
<Box>
|
||||||
{username ?
|
{username ?
|
||||||
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void logout()} sx={{ textTransform: "none" }}>
|
<Button onClick={() => void logout()} size="small" startIcon={<LogoutIcon />} sx={{ textTransform: "none" }}>
|
||||||
logout ({username})
|
logout ({username})
|
||||||
</Button>
|
</Button>
|
||||||
:
|
:
|
||||||
<Button size="small" startIcon={<LoginIcon />} onClick={onLoginClick} sx={{ textTransform: "none" }}>
|
<Button onClick={onLoginClick} size="small" startIcon={<LoginIcon />} sx={{ textTransform: "none" }}>
|
||||||
login
|
login
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,15 @@ export default function Navbar(): React.JSX.Element | null {
|
|||||||
|
|
||||||
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={currentIndex >= 0 ? currentIndex : 0}
|
|
||||||
onChange={(_, newValue: number) => {
|
onChange={(_, newValue: number) => {
|
||||||
const repository = repositories[newValue];
|
const repository = repositories[newValue];
|
||||||
if (repository) {
|
if (repository) {
|
||||||
setCurrentRepository(repository);
|
setCurrentRepository(repository);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
variant="scrollable"
|
|
||||||
scrollButtons="auto"
|
scrollButtons="auto"
|
||||||
|
value={currentIndex >= 0 ? currentIndex : 0}
|
||||||
|
variant="scrollable"
|
||||||
>
|
>
|
||||||
{repositories.map(repository =>
|
{repositories.map(repository =>
|
||||||
<Tab
|
<Tab
|
||||||
|
|||||||
119
frontend/src/components/package/ArtifactsTab.tsx
Normal file
119
frontend/src/components/package/ArtifactsTab.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2021-2026 ahriman team.
|
||||||
|
*
|
||||||
|
* This file is part of ahriman
|
||||||
|
* (see https://github.com/arcan1s/ahriman).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import RestoreIcon from "@mui/icons-material/Restore";
|
||||||
|
import { Box, IconButton, Tooltip } from "@mui/material";
|
||||||
|
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import { QueryKeys } from "hooks/QueryKeys";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useClient } from "hooks/useClient";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { DETAIL_TABLE_PROPS } from "utils";
|
||||||
|
|
||||||
|
interface ArtifactsTabProps {
|
||||||
|
currentVersion: string;
|
||||||
|
packageBase: string;
|
||||||
|
repository: RepositoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtifactRow {
|
||||||
|
id: string;
|
||||||
|
packager: string;
|
||||||
|
packages: string[];
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticColumns: GridColDef<ArtifactRow>[] = [
|
||||||
|
{ align: "right", field: "version", flex: 1, headerAlign: "right", headerName: "version" },
|
||||||
|
{
|
||||||
|
field: "packages",
|
||||||
|
flex: 2,
|
||||||
|
headerName: "packages",
|
||||||
|
renderCell: params =>
|
||||||
|
<Box sx={{ whiteSpace: "pre-line" }}>{params.row.packages.join("\n")}</Box>,
|
||||||
|
},
|
||||||
|
{ field: "packager", flex: 1, headerName: "packager" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ArtifactsTab({
|
||||||
|
currentVersion,
|
||||||
|
packageBase,
|
||||||
|
repository,
|
||||||
|
}: ArtifactsTabProps): React.JSX.Element {
|
||||||
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { isAuthorized } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const { data: rows = [] } = useQuery<ArtifactRow[]>({
|
||||||
|
enabled: !!packageBase,
|
||||||
|
queryFn: async () => {
|
||||||
|
const packages = await client.fetch.fetchPackageArtifacts(packageBase, repository);
|
||||||
|
return packages.map(artifact => ({
|
||||||
|
id: artifact.version,
|
||||||
|
packager: artifact.packager ?? "",
|
||||||
|
packages: Object.keys(artifact.packages).sort(),
|
||||||
|
version: artifact.version,
|
||||||
|
})).reverse();
|
||||||
|
},
|
||||||
|
queryKey: QueryKeys.artifacts(packageBase, repository),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRollback = useCallback(async (version: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await client.service.servicePackageRollback(repository, { package: packageBase, version });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.artifacts(packageBase, repository) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repository) });
|
||||||
|
showSuccess("Success", `Rollback ${packageBase} to ${version} has been started`);
|
||||||
|
} catch (exception) {
|
||||||
|
showError("Action failed", `Rollback failed: ${ApiError.errorDetail(exception)}`);
|
||||||
|
}
|
||||||
|
}, [client, repository, packageBase, queryClient, showSuccess, showError]);
|
||||||
|
|
||||||
|
const columns = useMemo<GridColDef<ArtifactRow>[]>(() => [
|
||||||
|
...staticColumns,
|
||||||
|
...isAuthorized ? [{
|
||||||
|
field: "actions",
|
||||||
|
filterable: false,
|
||||||
|
headerName: "",
|
||||||
|
renderCell: params =>
|
||||||
|
<Tooltip title={params.row.version === currentVersion ? "Current version" : "Rollback to this version"}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disabled={params.row.version === currentVersion}
|
||||||
|
onClick={() => void handleRollback(params.row.version)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<RestoreIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>,
|
||||||
|
width: 60,
|
||||||
|
} satisfies GridColDef<ArtifactRow>] : [],
|
||||||
|
], [isAuthorized, currentVersion, handleRollback]);
|
||||||
|
|
||||||
|
return <Box sx={{ mt: 1 }}>
|
||||||
|
<DataGrid columns={columns} getRowHeight={() => "auto"} rows={rows} {...DETAIL_TABLE_PROPS} />
|
||||||
|
</Box>;
|
||||||
|
}
|
||||||
@@ -29,16 +29,16 @@ import type { RepositoryId } from "models/RepositoryId";
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
interface Logs {
|
interface Logs {
|
||||||
version: string;
|
|
||||||
processId: string;
|
|
||||||
created: number;
|
created: number;
|
||||||
logs: string;
|
logs: string;
|
||||||
|
processId: string;
|
||||||
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BuildLogsTabProps {
|
interface BuildLogsTabProps {
|
||||||
packageBase: string;
|
packageBase: string;
|
||||||
repository: RepositoryId;
|
|
||||||
refreshInterval: number;
|
refreshInterval: number;
|
||||||
|
repository: RepositoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
|
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
|
||||||
@@ -50,17 +50,17 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
|
|||||||
|
|
||||||
export default function BuildLogsTab({
|
export default function BuildLogsTab({
|
||||||
packageBase,
|
packageBase,
|
||||||
repository,
|
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
|
repository,
|
||||||
}: BuildLogsTabProps): React.JSX.Element {
|
}: BuildLogsTabProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
|
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const { data: allLogs } = useQuery<LogRecord[]>({
|
const { data: allLogs } = useQuery<LogRecord[]>({
|
||||||
queryKey: QueryKeys.logs(packageBase, repository),
|
|
||||||
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
|
|
||||||
enabled: !!packageBase,
|
enabled: !!packageBase,
|
||||||
|
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
|
||||||
|
queryKey: QueryKeys.logs(packageBase, repository),
|
||||||
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,13 +84,13 @@ export default function BuildLogsTab({
|
|||||||
return Object.values(grouped)
|
return Object.values(grouped)
|
||||||
.sort((left, right) => right.minCreated - left.minCreated)
|
.sort((left, right) => right.minCreated - left.minCreated)
|
||||||
.map(record => ({
|
.map(record => ({
|
||||||
version: record.version,
|
|
||||||
processId: record.process_id,
|
|
||||||
created: record.minCreated,
|
created: record.minCreated,
|
||||||
logs: convertLogs(
|
logs: convertLogs(
|
||||||
allLogs,
|
allLogs,
|
||||||
right => record.version === right.version && record.process_id === right.process_id,
|
right => record.version === right.version && record.process_id === right.process_id,
|
||||||
),
|
),
|
||||||
|
processId: record.process_id,
|
||||||
|
version: record.version,
|
||||||
}));
|
}));
|
||||||
}, [allLogs]);
|
}, [allLogs]);
|
||||||
|
|
||||||
@@ -110,13 +110,13 @@ export default function BuildLogsTab({
|
|||||||
|
|
||||||
// Refresh active version logs
|
// Refresh active version logs
|
||||||
const { data: versionLogs } = useQuery<LogRecord[]>({
|
const { data: versionLogs } = useQuery<LogRecord[]>({
|
||||||
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
|
placeholderData: keepPreviousData,
|
||||||
queryFn: activeVersion
|
queryFn: activeVersion
|
||||||
? () => client.fetch.fetchPackageLogs(
|
? () => client.fetch.fetchPackageLogs(
|
||||||
packageBase, repository, activeVersion.version, activeVersion.processId,
|
packageBase, repository, activeVersion.version, activeVersion.processId,
|
||||||
)
|
)
|
||||||
: skipToken,
|
: skipToken,
|
||||||
placeholderData: keepPreviousData,
|
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
|
||||||
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,25 +143,25 @@ export default function BuildLogsTab({
|
|||||||
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
|
||||||
aria-label="Select version"
|
aria-label="Select version"
|
||||||
startIcon={<ListIcon />}
|
|
||||||
onClick={event => setAnchorEl(event.currentTarget)}
|
onClick={event => setAnchorEl(event.currentTarget)}
|
||||||
|
size="small"
|
||||||
|
startIcon={<ListIcon />}
|
||||||
/>
|
/>
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
onClose={() => setAnchorEl(null)}
|
onClose={() => setAnchorEl(null)}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
>
|
>
|
||||||
{versions.map((logs, index) =>
|
{versions.map((logs, index) =>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={`${logs.version}-${logs.processId}`}
|
key={`${logs.version}-${logs.processId}`}
|
||||||
selected={index === activeIndex}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedVersionKey(`${logs.version}-${logs.processId}`);
|
setSelectedVersionKey(`${logs.version}-${logs.processId}`);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
resetScroll();
|
resetScroll();
|
||||||
}}
|
}}
|
||||||
|
selected={index === activeIndex}
|
||||||
>
|
>
|
||||||
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
|
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
@@ -174,10 +174,10 @@ export default function BuildLogsTab({
|
|||||||
|
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
preRef={preRef}
|
|
||||||
content={displayedLogs}
|
content={displayedLogs}
|
||||||
height={400}
|
height={400}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
|
preRef={preRef}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>;
|
</Box>;
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ interface ChangesTabProps {
|
|||||||
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
|
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
|
||||||
const data = usePackageChanges(packageBase, repository);
|
const data = usePackageChanges(packageBase, repository);
|
||||||
|
|
||||||
return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
|
return <CodeBlock content={data?.changes ?? ""} height={400} language="diff" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { Event } from "models/Event";
|
|||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { DETAIL_TABLE_PROPS } from "utils";
|
||||||
|
|
||||||
interface EventsTabProps {
|
interface EventsTabProps {
|
||||||
packageBase: string;
|
packageBase: string;
|
||||||
@@ -34,46 +35,36 @@ interface EventsTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EventRow {
|
interface EventRow {
|
||||||
id: number;
|
|
||||||
timestamp: string;
|
|
||||||
event: string;
|
event: string;
|
||||||
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: GridColDef<EventRow>[] = [
|
const columns: GridColDef<EventRow>[] = [
|
||||||
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
|
{ align: "right", field: "timestamp", headerAlign: "right", headerName: "date", width: 180 },
|
||||||
{ field: "event", headerName: "event", flex: 1 },
|
{ field: "event", flex: 1, headerName: "event" },
|
||||||
{ field: "message", headerName: "description", flex: 2 },
|
{ field: "message", flex: 2, headerName: "description" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
|
export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const { data: events = [] } = useQuery<Event[]>({
|
const { data: events = [] } = useQuery<Event[]>({
|
||||||
queryKey: QueryKeys.events(repository, packageBase),
|
|
||||||
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
|
|
||||||
enabled: !!packageBase,
|
enabled: !!packageBase,
|
||||||
|
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
|
||||||
|
queryKey: QueryKeys.events(repository, packageBase),
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
|
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
|
||||||
id: index,
|
|
||||||
timestamp: new Date(event.created * 1000).toISOStringShort(),
|
|
||||||
event: event.event,
|
event: event.event,
|
||||||
|
id: index,
|
||||||
message: event.message ?? "",
|
message: event.message ?? "",
|
||||||
|
timestamp: new Date(event.created * 1000).toISOStringShort(),
|
||||||
})), [events]);
|
})), [events]);
|
||||||
|
|
||||||
return <Box sx={{ mt: 1 }}>
|
return <Box sx={{ mt: 1 }}>
|
||||||
<EventDurationLineChart events={events} />
|
<EventDurationLineChart events={events} />
|
||||||
<DataGrid
|
<DataGrid columns={columns} rows={rows} {...DETAIL_TABLE_PROPS} />
|
||||||
rows={rows}
|
|
||||||
columns={columns}
|
|
||||||
density="compact"
|
|
||||||
initialState={{
|
|
||||||
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
|
|
||||||
}}
|
|
||||||
pageSizeOptions={[10, 25]}
|
|
||||||
sx={{ height: 400, mt: 1 }}
|
|
||||||
disableRowSelectionOnClick
|
|
||||||
/>
|
|
||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import type { Package } from "models/Package";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface PackageDetailsGridProps {
|
interface PackageDetailsGridProps {
|
||||||
pkg: Package;
|
|
||||||
dependencies?: Dependencies;
|
dependencies?: Dependencies;
|
||||||
|
pkg: Package;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
|
export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetailsGridProps): React.JSX.Element {
|
||||||
const packagesList = Object.entries(pkg.packages)
|
const packagesList = Object.entries(pkg.packages)
|
||||||
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
|
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
|
||||||
|
|
||||||
@@ -65,50 +65,50 @@ export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetails
|
|||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packages</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</Typography></Grid>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">version</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packager</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
||||||
<Grid size={{ xs: 4, md: 1 }} />
|
<Grid size={{ md: 1, xs: 4 }} />
|
||||||
<Grid size={{ xs: 8, md: 5 }} />
|
<Grid size={{ md: 5, xs: 8 }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">groups</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</Typography></Grid>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">licenses</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">upstream</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}>
|
<Grid size={{ md: 5, xs: 8 }}>
|
||||||
{upstreamUrls.map(url =>
|
{upstreamUrls.map(url =>
|
||||||
<Link key={url} href={url} target="_blank" rel="noopener noreferrer" underline="hover" display="block" variant="body2">
|
<Link display="block" href={url} key={url} rel="noopener noreferrer" target="_blank" underline="hover" variant="body2">
|
||||||
{url}
|
{url}
|
||||||
</Link>,
|
</Link>,
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">AUR</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}>
|
<Grid size={{ md: 5, xs: 8 }}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
{aurUrl &&
|
{aurUrl &&
|
||||||
<Link href={aurUrl} target="_blank" rel="noopener noreferrer" underline="hover">AUR link</Link>
|
<Link href={aurUrl} rel="noopener noreferrer" target="_blank" underline="hover">AUR link</Link>
|
||||||
}
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">depends</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</Typography></Grid>
|
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</Typography></Grid>
|
||||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
|
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">implicitly depends</Typography></Grid>
|
||||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,29 +27,29 @@ import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
interface PackageInfoActionsProps {
|
interface PackageInfoActionsProps {
|
||||||
|
autoRefreshInterval: number;
|
||||||
|
autoRefreshIntervals: AutoRefreshInterval[];
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
isHeld: boolean;
|
isHeld: boolean;
|
||||||
onHoldToggle: () => void;
|
|
||||||
refreshDatabase: boolean;
|
|
||||||
onRefreshDatabaseChange: (checked: boolean) => void;
|
|
||||||
onUpdate: () => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
autoRefreshIntervals: AutoRefreshInterval[];
|
|
||||||
autoRefreshInterval: number;
|
|
||||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||||
|
onHoldToggle: () => void;
|
||||||
|
onRefreshDatabaseChange: (checked: boolean) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
refreshDatabase: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageInfoActions({
|
export default function PackageInfoActions({
|
||||||
isAuthorized,
|
|
||||||
refreshDatabase,
|
|
||||||
onRefreshDatabaseChange,
|
|
||||||
isHeld,
|
|
||||||
onHoldToggle,
|
|
||||||
onUpdate,
|
|
||||||
onRemove,
|
|
||||||
autoRefreshIntervals,
|
|
||||||
autoRefreshInterval,
|
autoRefreshInterval,
|
||||||
|
autoRefreshIntervals,
|
||||||
|
isAuthorized,
|
||||||
|
isHeld,
|
||||||
onAutoRefreshIntervalChange,
|
onAutoRefreshIntervalChange,
|
||||||
|
onHoldToggle,
|
||||||
|
onRefreshDatabaseChange,
|
||||||
|
onRemove,
|
||||||
|
onUpdate,
|
||||||
|
refreshDatabase,
|
||||||
}: PackageInfoActionsProps): React.JSX.Element {
|
}: PackageInfoActionsProps): React.JSX.Element {
|
||||||
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||||
{isAuthorized &&
|
{isAuthorized &&
|
||||||
@@ -58,20 +58,20 @@ export default function PackageInfoActions({
|
|||||||
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
|
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
|
||||||
label="update pacman databases"
|
label="update pacman databases"
|
||||||
/>
|
/>
|
||||||
<Button onClick={onHoldToggle} variant="outlined" color="warning" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} size="small">
|
<Button color="warning" onClick={onHoldToggle} size="small" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} variant="outlined">
|
||||||
{isHeld ? "unhold" : "hold"}
|
{isHeld ? "unhold" : "hold"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
|
<Button color="success" onClick={onUpdate} size="small" startIcon={<PlayArrowIcon />} variant="contained">
|
||||||
update
|
update
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
|
<Button color="error" onClick={onRemove} size="small" startIcon={<DeleteIcon />} variant="contained">
|
||||||
remove
|
remove
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
<AutoRefreshControl
|
<AutoRefreshControl
|
||||||
intervals={autoRefreshIntervals}
|
|
||||||
currentInterval={autoRefreshInterval}
|
currentInterval={autoRefreshInterval}
|
||||||
|
intervals={autoRefreshIntervals}
|
||||||
onIntervalChange={onAutoRefreshIntervalChange}
|
onIntervalChange={onAutoRefreshIntervalChange}
|
||||||
/>
|
/>
|
||||||
</DialogActions>;
|
</DialogActions>;
|
||||||
|
|||||||
@@ -23,39 +23,39 @@ import type { Patch } from "models/Patch";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
interface PackagePatchesListProps {
|
interface PackagePatchesListProps {
|
||||||
patches: Patch[];
|
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
onDelete: (key: string) => void;
|
onDelete: (key: string) => void;
|
||||||
|
patches: Patch[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackagePatchesList({
|
export default function PackagePatchesList({
|
||||||
patches,
|
|
||||||
editable,
|
editable,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
patches,
|
||||||
}: PackagePatchesListProps): React.JSX.Element | null {
|
}: PackagePatchesListProps): React.JSX.Element | null {
|
||||||
if (patches.length === 0) {
|
if (patches.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Box sx={{ mt: 2 }}>
|
return <Box sx={{ mt: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>Environment variables</Typography>
|
<Typography gutterBottom variant="h6">Environment variables</Typography>
|
||||||
{patches.map(patch =>
|
{patches.map(patch =>
|
||||||
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
<Box key={patch.key} sx={{ alignItems: "center", display: "flex", gap: 1, mb: 0.5 }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
|
||||||
value={patch.key}
|
|
||||||
disabled
|
disabled
|
||||||
|
size="small"
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
|
value={patch.key}
|
||||||
/>
|
/>
|
||||||
<Box>=</Box>
|
<Box>=</Box>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
|
||||||
value={JSON.stringify(patch.value)}
|
|
||||||
disabled
|
disabled
|
||||||
|
value={JSON.stringify(patch.value)}
|
||||||
|
size="small"
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
{editable &&
|
{editable &&
|
||||||
<IconButton size="small" color="error" aria-label="Remove patch" onClick={() => onDelete(patch.key)}>
|
<IconButton aria-label="Remove patch" color="error" onClick={() => onDelete(patch.key)} size="small">
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ interface PkgbuildTabProps {
|
|||||||
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
|
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
|
||||||
const data = usePackageChanges(packageBase, repository);
|
const data = usePackageChanges(packageBase, repository);
|
||||||
|
|
||||||
return <CodeBlock language="bash" content={data?.pkgbuild ?? ""} height={400} />;
|
return <CodeBlock content={data?.pkgbuild ?? ""} height={400} language="bash" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,12 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export type TabKey = "logs" | "changes" | "pkgbuild" | "events";
|
export type TabKey = "logs" | "changes" | "pkgbuild" | "events" | "artifacts";
|
||||||
|
|
||||||
export const tabs: { key: TabKey; label: string }[] = [
|
export const tabs: { key: TabKey; label: string }[] = [
|
||||||
{ key: "logs", label: "Build logs" },
|
{ key: "logs", label: "Build logs" },
|
||||||
{ key: "changes", label: "Changes" },
|
{ key: "changes", label: "Changes" },
|
||||||
{ key: "pkgbuild", label: "PKGBUILD" },
|
{ key: "pkgbuild", label: "PKGBUILD" },
|
||||||
{ key: "events", label: "Events" },
|
{ key: "events", label: "Events" },
|
||||||
|
{ key: "artifacts", label: "Artifacts" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
GRID_CHECKBOX_SELECTION_COL_DEF,
|
GRID_CHECKBOX_SELECTION_COL_DEF,
|
||||||
type GridColDef,
|
type GridColDef,
|
||||||
type GridFilterModel,
|
type GridFilterModel,
|
||||||
type GridRenderCellParams,
|
|
||||||
type GridRowId,
|
type GridRowId,
|
||||||
useGridApiRef,
|
useGridApiRef,
|
||||||
} from "@mui/x-data-grid";
|
} from "@mui/x-data-grid";
|
||||||
@@ -44,8 +43,6 @@ interface PackageTableProps {
|
|||||||
autoRefreshIntervals: AutoRefreshInterval[];
|
autoRefreshIntervals: AutoRefreshInterval[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
|
||||||
|
|
||||||
function createListColumn(
|
function createListColumn(
|
||||||
field: keyof PackageRow,
|
field: keyof PackageRow,
|
||||||
headerName: string,
|
headerName: string,
|
||||||
@@ -55,10 +52,10 @@ function createListColumn(
|
|||||||
field,
|
field,
|
||||||
headerName,
|
headerName,
|
||||||
...options,
|
...options,
|
||||||
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
renderCell: params =>
|
||||||
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
|
||||||
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
|
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
|
||||||
sortComparator: (left: string, right: string) => left.localeCompare(right),
|
sortComparator: (left: string, right: string) => left.localeCompare(right),
|
||||||
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,36 +76,30 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: "base",
|
field: "base",
|
||||||
headerName: "package base",
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
headerName: "package base",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
renderCell: params =>
|
||||||
params.row.webUrl ?
|
params.row.webUrl ?
|
||||||
<Link href={params.row.webUrl} target="_blank" rel="noopener noreferrer" underline="hover">
|
<Link href={params.row.webUrl} rel="noopener noreferrer" target="_blank" underline="hover">
|
||||||
{params.value as string}
|
{params.value as string}
|
||||||
</Link>
|
</Link>
|
||||||
: params.value as string,
|
: params.value as string,
|
||||||
},
|
},
|
||||||
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
|
{ align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 },
|
||||||
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
|
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
|
||||||
createListColumn("groups", "groups", { width: 150 }),
|
createListColumn("groups", "groups", { width: 150 }),
|
||||||
createListColumn("licenses", "licenses", { width: 150 }),
|
createListColumn("licenses", "licenses", { width: 150 }),
|
||||||
{ field: "packager", headerName: "packager", width: 150 },
|
{ field: "packager", headerName: "packager", width: 150 },
|
||||||
|
{ align: "right", field: "timestamp", headerName: "last update", headerAlign: "right", width: 180 },
|
||||||
{
|
{
|
||||||
field: "timestamp",
|
|
||||||
headerName: "last update",
|
|
||||||
width: 180,
|
|
||||||
align: "right",
|
|
||||||
headerAlign: "right",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "status",
|
|
||||||
headerName: "status",
|
|
||||||
width: 120,
|
|
||||||
align: "center",
|
align: "center",
|
||||||
|
field: "status",
|
||||||
headerAlign: "center",
|
headerAlign: "center",
|
||||||
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
headerName: "status",
|
||||||
<StatusCell status={params.row.status} isHeld={params.row.isHeld} />,
|
renderCell: params =>
|
||||||
|
<StatusCell isHeld={params.row.isHeld} status={params.row.status} />,
|
||||||
|
width: 120,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@@ -116,56 +107,42 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
|
|||||||
|
|
||||||
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||||
<PackageTableToolbar
|
<PackageTableToolbar
|
||||||
hasSelection={table.selectionModel.length > 0}
|
actions={{
|
||||||
isAuthorized={table.isAuthorized}
|
onAddClick: () => table.setDialogOpen("add"),
|
||||||
status={table.status}
|
onDashboardClick: () => table.setDialogOpen("dashboard"),
|
||||||
searchText={table.searchText}
|
onExportClick: () => apiRef.current?.exportDataAsCsv(),
|
||||||
onSearchChange={table.setSearchText}
|
onKeyImportClick: () => table.setDialogOpen("keyImport"),
|
||||||
|
onRebuildClick: () => table.setDialogOpen("rebuild"),
|
||||||
|
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
|
||||||
|
onReloadClick: table.handleReload,
|
||||||
|
onRemoveClick: () => void table.handleRemove(),
|
||||||
|
onUpdateClick: () => void table.handleUpdate(),
|
||||||
|
}}
|
||||||
autoRefresh={{
|
autoRefresh={{
|
||||||
autoRefreshIntervals,
|
autoRefreshIntervals,
|
||||||
currentInterval: table.autoRefreshInterval,
|
currentInterval: table.autoRefreshInterval,
|
||||||
onIntervalChange: table.onAutoRefreshIntervalChange,
|
onIntervalChange: table.onAutoRefreshIntervalChange,
|
||||||
}}
|
}}
|
||||||
actions={{
|
isAuthorized={table.isAuthorized}
|
||||||
onDashboardClick: () => table.setDialogOpen("dashboard"),
|
hasSelection={table.selectionModel.length > 0}
|
||||||
onAddClick: () => table.setDialogOpen("add"),
|
onSearchChange={table.setSearchText}
|
||||||
onUpdateClick: () => void table.handleUpdate(),
|
searchText={table.searchText}
|
||||||
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
|
status={table.status}
|
||||||
onRebuildClick: () => table.setDialogOpen("rebuild"),
|
|
||||||
onRemoveClick: () => void table.handleRemove(),
|
|
||||||
onKeyImportClick: () => table.setDialogOpen("keyImport"),
|
|
||||||
onReloadClick: table.handleReload,
|
|
||||||
onExportClick: () => apiRef.current?.exportDataAsCsv(),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
apiRef={apiRef}
|
apiRef={apiRef}
|
||||||
rows={table.rows}
|
|
||||||
columns={columns}
|
|
||||||
loading={table.isLoading}
|
|
||||||
getRowHeight={() => "auto"}
|
|
||||||
checkboxSelection
|
checkboxSelection
|
||||||
disableRowSelectionOnClick
|
|
||||||
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
|
|
||||||
onRowSelectionModelChange={model => {
|
|
||||||
if (model.type === "exclude") {
|
|
||||||
const excludeIds = new Set([...model.ids].map(String));
|
|
||||||
table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
|
|
||||||
} else {
|
|
||||||
table.setSelectionModel([...model.ids].map(String));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
paginationModel={table.paginationModel}
|
|
||||||
onPaginationModelChange={table.setPaginationModel}
|
|
||||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
|
||||||
columnVisibilityModel={table.columnVisibility}
|
columnVisibilityModel={table.columnVisibility}
|
||||||
onColumnVisibilityModelChange={table.setColumnVisibility}
|
columns={columns}
|
||||||
|
density="compact"
|
||||||
|
disableRowSelectionOnClick
|
||||||
filterModel={effectiveFilterModel}
|
filterModel={effectiveFilterModel}
|
||||||
onFilterModelChange={table.setFilterModel}
|
getRowHeight={() => "auto"}
|
||||||
initialState={{
|
initialState={{
|
||||||
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
||||||
}}
|
}}
|
||||||
|
loading={table.isLoading}
|
||||||
onCellClick={(params, event) => {
|
onCellClick={(params, event) => {
|
||||||
// Don't open info dialog when clicking checkbox or link
|
// Don't open info dialog when clicking checkbox or link
|
||||||
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
|
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
|
||||||
@@ -176,22 +153,32 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
|
|||||||
}
|
}
|
||||||
table.setSelectedPackage(String(params.id));
|
table.setSelectedPackage(String(params.id));
|
||||||
}}
|
}}
|
||||||
sx={{
|
onColumnVisibilityModelChange={table.setColumnVisibility}
|
||||||
flex: 1,
|
onFilterModelChange={table.setFilterModel}
|
||||||
"& .MuiDataGrid-row": { cursor: "pointer" },
|
onPaginationModelChange={table.setPaginationModel}
|
||||||
|
onRowSelectionModelChange={model => {
|
||||||
|
if (model.type === "exclude") {
|
||||||
|
const excludeIds = new Set([...model.ids].map(String));
|
||||||
|
table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
|
||||||
|
} else {
|
||||||
|
table.setSelectionModel([...model.ids].map(String));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
density="compact"
|
paginationModel={table.paginationModel}
|
||||||
|
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
|
||||||
|
rows={table.rows}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardDialog open={table.dialogOpen === "dashboard"} onClose={() => table.setDialogOpen(null)} />
|
<DashboardDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "dashboard"} />
|
||||||
<PackageAddDialog open={table.dialogOpen === "add"} onClose={() => table.setDialogOpen(null)} />
|
<PackageAddDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "add"} />
|
||||||
<PackageRebuildDialog open={table.dialogOpen === "rebuild"} onClose={() => table.setDialogOpen(null)} />
|
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
|
||||||
<KeyImportDialog open={table.dialogOpen === "keyImport"} onClose={() => table.setDialogOpen(null)} />
|
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
|
||||||
<PackageInfoDialog
|
<PackageInfoDialog
|
||||||
packageBase={table.selectedPackage}
|
|
||||||
open={table.selectedPackage !== null}
|
|
||||||
onClose={() => table.setSelectedPackage(null)}
|
|
||||||
autoRefreshIntervals={autoRefreshIntervals}
|
autoRefreshIntervals={autoRefreshIntervals}
|
||||||
|
onClose={() => table.setSelectedPackage(null)}
|
||||||
|
open={table.selectedPackage !== null}
|
||||||
|
packageBase={table.selectedPackage}
|
||||||
/>
|
/>
|
||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,47 +43,47 @@ export interface AutoRefreshProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolbarActions {
|
export interface ToolbarActions {
|
||||||
onDashboardClick: () => void;
|
|
||||||
onAddClick: () => void;
|
onAddClick: () => void;
|
||||||
onUpdateClick: () => void;
|
onDashboardClick: () => void;
|
||||||
onRefreshDatabaseClick: () => void;
|
|
||||||
onRebuildClick: () => void;
|
|
||||||
onRemoveClick: () => void;
|
|
||||||
onKeyImportClick: () => void;
|
|
||||||
onReloadClick: () => void;
|
|
||||||
onExportClick: () => void;
|
onExportClick: () => void;
|
||||||
|
onKeyImportClick: () => void;
|
||||||
|
onRebuildClick: () => void;
|
||||||
|
onRefreshDatabaseClick: () => void;
|
||||||
|
onReloadClick: () => void;
|
||||||
|
onRemoveClick: () => void;
|
||||||
|
onUpdateClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackageTableToolbarProps {
|
interface PackageTableToolbarProps {
|
||||||
|
actions: ToolbarActions;
|
||||||
|
autoRefresh: AutoRefreshProps;
|
||||||
hasSelection: boolean;
|
hasSelection: boolean;
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
status?: BuildStatus;
|
|
||||||
searchText: string;
|
|
||||||
onSearchChange: (text: string) => void;
|
onSearchChange: (text: string) => void;
|
||||||
autoRefresh: AutoRefreshProps;
|
searchText: string;
|
||||||
actions: ToolbarActions;
|
status?: BuildStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageTableToolbar({
|
export default function PackageTableToolbar({
|
||||||
|
actions,
|
||||||
|
autoRefresh,
|
||||||
hasSelection,
|
hasSelection,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
status,
|
|
||||||
searchText,
|
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
autoRefresh,
|
searchText,
|
||||||
actions,
|
status,
|
||||||
}: PackageTableToolbarProps): React.JSX.Element {
|
}: PackageTableToolbarProps): React.JSX.Element {
|
||||||
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null);
|
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
return <Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
|
return <Box sx={{ alignItems: "center", display: "flex", flexWrap: "wrap", gap: 1, mb: 1 }}>
|
||||||
<Tooltip title="System health">
|
<Tooltip title="System health">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="System health"
|
aria-label="System health"
|
||||||
onClick={actions.onDashboardClick}
|
onClick={actions.onDashboardClick}
|
||||||
sx={{
|
sx={{
|
||||||
borderColor: status ? StatusColors[status] : undefined,
|
borderColor: status ? StatusColors[status] : undefined,
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
color: status ? StatusColors[status] : undefined,
|
color: status ? StatusColors[status] : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -94,16 +94,16 @@ export default function PackageTableToolbar({
|
|||||||
{isAuthorized &&
|
{isAuthorized &&
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
|
||||||
startIcon={<InventoryIcon />}
|
|
||||||
onClick={event => setPackagesAnchorEl(event.currentTarget)}
|
onClick={event => setPackagesAnchorEl(event.currentTarget)}
|
||||||
|
startIcon={<InventoryIcon />}
|
||||||
|
variant="contained"
|
||||||
>
|
>
|
||||||
packages
|
packages
|
||||||
</Button>
|
</Button>
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={packagesAnchorEl}
|
anchorEl={packagesAnchorEl}
|
||||||
open={Boolean(packagesAnchorEl)}
|
|
||||||
onClose={() => setPackagesAnchorEl(null)}
|
onClose={() => setPackagesAnchorEl(null)}
|
||||||
|
open={Boolean(packagesAnchorEl)}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={() => {
|
<MenuItem onClick={() => {
|
||||||
setPackagesAnchorEl(null); actions.onAddClick();
|
setPackagesAnchorEl(null); actions.onAddClick();
|
||||||
@@ -126,58 +126,58 @@ export default function PackageTableToolbar({
|
|||||||
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
<MenuItem onClick={() => {
|
<MenuItem disabled={!hasSelection} onClick={() => {
|
||||||
setPackagesAnchorEl(null); actions.onRemoveClick();
|
setPackagesAnchorEl(null); actions.onRemoveClick();
|
||||||
}} disabled={!hasSelection}>
|
}}>
|
||||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={actions.onKeyImportClick}>
|
<Button color="info" onClick={actions.onKeyImportClick} startIcon={<VpnKeyIcon />} variant="contained">
|
||||||
import key
|
import key
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={actions.onReloadClick}>
|
<Button color="secondary" onClick={actions.onReloadClick} startIcon={<RefreshIcon />} variant="outlined">
|
||||||
reload
|
reload
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<AutoRefreshControl
|
<AutoRefreshControl
|
||||||
intervals={autoRefresh.autoRefreshIntervals}
|
|
||||||
currentInterval={autoRefresh.currentInterval}
|
currentInterval={autoRefresh.currentInterval}
|
||||||
|
intervals={autoRefresh.autoRefreshIntervals}
|
||||||
onIntervalChange={autoRefresh.onIntervalChange}
|
onIntervalChange={autoRefresh.onIntervalChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
|
||||||
aria-label="Search packages"
|
aria-label="Search packages"
|
||||||
placeholder="search packages..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={event => onSearchChange(event.target.value)}
|
onChange={event => onSearchChange(event.target.value)}
|
||||||
|
placeholder="search packages..."
|
||||||
|
size="small"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
|
endAdornment: searchText ?
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton aria-label="Clear search" onClick={() => onSearchChange("")} size="small">
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
: undefined,
|
||||||
startAdornment:
|
startAdornment:
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<SearchIcon fontSize="small" />
|
<SearchIcon fontSize="small" />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
,
|
,
|
||||||
endAdornment: searchText ?
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton size="small" aria-label="Clear search" onClick={() => onSearchChange("")}>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ minWidth: 200 }}
|
sx={{ minWidth: 200 }}
|
||||||
|
value={searchText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip title="Export CSV">
|
<Tooltip title="Export CSV">
|
||||||
<IconButton size="small" aria-label="Export CSV" onClick={actions.onExportClick}>
|
<IconButton aria-label="Export CSV" onClick={actions.onExportClick} size="small">
|
||||||
<FileDownloadIcon fontSize="small" />
|
<FileDownloadIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import type React from "react";
|
|||||||
import { StatusColors } from "theme/StatusColors";
|
import { StatusColors } from "theme/StatusColors";
|
||||||
|
|
||||||
interface StatusCellProps {
|
interface StatusCellProps {
|
||||||
status: BuildStatus;
|
|
||||||
isHeld?: boolean;
|
isHeld?: boolean;
|
||||||
|
status: BuildStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatusCell({ status, isHeld }: StatusCellProps): React.JSX.Element {
|
export default function StatusCell({ isHeld, status }: StatusCellProps): React.JSX.Element {
|
||||||
return <Chip
|
return <Chip
|
||||||
icon={isHeld ? <PauseCircleIcon /> : undefined}
|
icon={isHeld ? <PauseCircleIcon /> : undefined}
|
||||||
label={status}
|
label={status}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ interface AuthState {
|
|||||||
|
|
||||||
export interface AuthContextValue extends AuthState {
|
export interface AuthContextValue extends AuthState {
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
setAuthState: (state: AuthState) => void;
|
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
setAuthState: (state: AuthState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ import React, { type ReactNode, useMemo } from "react";
|
|||||||
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
const client = useMemo(() => new AhrimanClient(), []);
|
const client = useMemo(() => new AhrimanClient(), []);
|
||||||
|
|
||||||
return (
|
return <ClientContext.Provider value={client}>
|
||||||
<ClientContext.Provider value={client}>
|
|
||||||
{children}
|
{children}
|
||||||
</ClientContext.Provider>
|
</ClientContext.Provider>;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
export interface NotificationContextValue {
|
export interface NotificationContextValue {
|
||||||
showSuccess: (title: string, message: string) => void;
|
|
||||||
showError: (title: string, message: string) => void;
|
showError: (title: string, message: string) => void;
|
||||||
|
showSuccess: (title: string, message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ export function NotificationProvider({ children }: { children: ReactNode }): Rea
|
|||||||
{children}
|
{children}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "fixed",
|
|
||||||
top: 16,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
zIndex: theme => theme.zIndex.snackbar,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
left: "50%",
|
||||||
maxWidth: 500,
|
maxWidth: 500,
|
||||||
width: "100%",
|
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
|
position: "fixed",
|
||||||
|
top: 16,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: "100%",
|
||||||
|
zIndex: theme => theme.zIndex.snackbar,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{notifications.map(notification =>
|
{notifications.map(notification =>
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import type { RepositoryId } from "models/RepositoryId";
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
export interface RepositoryContextValue {
|
export interface RepositoryContextValue {
|
||||||
repositories: RepositoryId[];
|
|
||||||
currentRepository: RepositoryId | null;
|
currentRepository: RepositoryId | null;
|
||||||
setRepositories: (repositories: RepositoryId[]) => void;
|
repositories: RepositoryId[];
|
||||||
setCurrentRepository: (repository: RepositoryId) => void;
|
setCurrentRepository: (repository: RepositoryId) => void;
|
||||||
|
setRepositories: (repositories: RepositoryId[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
|
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
|
||||||
|
|||||||
@@ -39,10 +39,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }): Reac
|
|||||||
const theme = useMemo(() => createAppTheme(mode), [mode]);
|
const theme = useMemo(() => createAppTheme(mode), [mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
|
chartDefaults.color = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
|
||||||
const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
|
chartDefaults.borderColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
|
||||||
chartDefaults.color = textColor;
|
|
||||||
chartDefaults.borderColor = gridColor;
|
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
|
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type { RepositoryId } from "models/RepositoryId";
|
|||||||
|
|
||||||
export const QueryKeys = {
|
export const QueryKeys = {
|
||||||
|
|
||||||
|
artifacts: (packageBase: string, repository: RepositoryId) => ["artifacts", repository.key, packageBase] as const,
|
||||||
|
|
||||||
changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
|
changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
|
||||||
|
|
||||||
dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,
|
dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,
|
||||||
|
|||||||
@@ -20,10 +20,10 @@
|
|||||||
import { type RefObject, useCallback, useRef } from "react";
|
import { type RefObject, useCallback, useRef } from "react";
|
||||||
|
|
||||||
interface UseAutoScrollResult {
|
interface UseAutoScrollResult {
|
||||||
preRef: RefObject<HTMLElement | null>;
|
|
||||||
handleScroll: () => void;
|
handleScroll: () => void;
|
||||||
scrollToBottom: () => void;
|
preRef: RefObject<HTMLElement | null>;
|
||||||
resetScroll: () => void;
|
resetScroll: () => void;
|
||||||
|
scrollToBottom: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAutoScroll(): UseAutoScrollResult {
|
export function useAutoScroll(): UseAutoScrollResult {
|
||||||
@@ -59,5 +59,5 @@ export function useAutoScroll(): UseAutoScrollResult {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { preRef, handleScroll, scrollToBottom, resetScroll };
|
return { handleScroll, preRef, resetScroll, scrollToBottom };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import { useRepository } from "hooks/useRepository";
|
|||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
|
|
||||||
export interface UsePackageActionsResult {
|
export interface UsePackageActionsResult {
|
||||||
handleReload: () => void;
|
|
||||||
handleUpdate: () => Promise<void>;
|
|
||||||
handleRefreshDatabase: () => Promise<void>;
|
handleRefreshDatabase: () => Promise<void>;
|
||||||
|
handleReload: () => void;
|
||||||
handleRemove: () => Promise<void>;
|
handleRemove: () => Promise<void>;
|
||||||
|
handleUpdate: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageActions(
|
export function usePackageActions(
|
||||||
@@ -63,7 +63,7 @@ export function usePackageActions(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReload: () => void = () => {
|
const handleReload = (): void => {
|
||||||
if (currentRepository !== null) {
|
if (currentRepository !== null) {
|
||||||
invalidate(currentRepository);
|
invalidate(currentRepository);
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,11 @@ export function usePackageActions(
|
|||||||
|
|
||||||
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
|
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
|
||||||
await client.service.servicePackageUpdate(repository, {
|
await client.service.servicePackageUpdate(repository, {
|
||||||
packages: [],
|
|
||||||
refresh: true,
|
|
||||||
aur: false,
|
aur: false,
|
||||||
local: false,
|
local: false,
|
||||||
manual: false,
|
manual: false,
|
||||||
|
packages: [],
|
||||||
|
refresh: true,
|
||||||
});
|
});
|
||||||
return "Pacman database update has been requested";
|
return "Pacman database update has been requested";
|
||||||
}, "Could not update pacman databases");
|
}, "Could not update pacman databases");
|
||||||
@@ -100,9 +100,9 @@ export function usePackageActions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleReload,
|
|
||||||
handleUpdate,
|
|
||||||
handleRefreshDatabase,
|
handleRefreshDatabase,
|
||||||
|
handleReload,
|
||||||
handleRemove,
|
handleRemove,
|
||||||
|
handleUpdate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export function usePackageChanges(packageBase: string, repository: RepositoryId)
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const { data } = useQuery<Changes>({
|
const { data } = useQuery<Changes>({
|
||||||
queryKey: QueryKeys.changes(packageBase, repository),
|
|
||||||
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
|
|
||||||
enabled: !!packageBase,
|
enabled: !!packageBase,
|
||||||
|
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
|
||||||
|
queryKey: QueryKeys.changes(packageBase, repository),
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ import { useMemo } from "react";
|
|||||||
import { defaultInterval } from "utils";
|
import { defaultInterval } from "utils";
|
||||||
|
|
||||||
export interface UsePackageDataResult {
|
export interface UsePackageDataResult {
|
||||||
rows: PackageRow[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isAuthorized: boolean;
|
|
||||||
status: BuildStatus | undefined;
|
|
||||||
autoRefresh: ReturnType<typeof useAutoRefresh>;
|
autoRefresh: ReturnType<typeof useAutoRefresh>;
|
||||||
|
isAuthorized: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
rows: PackageRow[];
|
||||||
|
status: BuildStatus | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
|
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
|
||||||
@@ -45,24 +45,24 @@ export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): Use
|
|||||||
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
|
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
|
||||||
|
|
||||||
const { data: packages = [], isLoading } = useQuery({
|
const { data: packages = [], isLoading } = useQuery({
|
||||||
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
|
|
||||||
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
|
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
|
||||||
|
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
|
||||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: status } = useQuery({
|
const { data: status } = useQuery({
|
||||||
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
|
||||||
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
||||||
|
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
||||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
|
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows,
|
autoRefresh,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
|
rows,
|
||||||
status: status?.status.status,
|
status: status?.status.status,
|
||||||
autoRefresh,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,35 +27,30 @@ import type { PackageRow } from "models/PackageRow";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export interface UsePackageTableResult {
|
export interface UsePackageTableResult {
|
||||||
rows: PackageRow[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isAuthorized: boolean;
|
|
||||||
status: BuildStatus | undefined;
|
|
||||||
|
|
||||||
selectionModel: string[];
|
|
||||||
setSelectionModel: (model: string[]) => void;
|
|
||||||
|
|
||||||
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
|
|
||||||
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
|
|
||||||
selectedPackage: string | null;
|
|
||||||
setSelectedPackage: (base: string | null) => void;
|
|
||||||
|
|
||||||
paginationModel: { pageSize: number; page: number };
|
|
||||||
setPaginationModel: (model: { pageSize: number; page: number }) => void;
|
|
||||||
columnVisibility: Record<string, boolean>;
|
|
||||||
setColumnVisibility: (model: Record<string, boolean>) => void;
|
|
||||||
filterModel: GridFilterModel;
|
|
||||||
setFilterModel: (model: GridFilterModel) => void;
|
|
||||||
searchText: string;
|
|
||||||
setSearchText: (text: string) => void;
|
|
||||||
|
|
||||||
autoRefreshInterval: number;
|
autoRefreshInterval: number;
|
||||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
columnVisibility: Record<string, boolean>;
|
||||||
|
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
|
||||||
handleReload: () => void;
|
filterModel: GridFilterModel;
|
||||||
handleUpdate: () => Promise<void>;
|
|
||||||
handleRefreshDatabase: () => Promise<void>;
|
handleRefreshDatabase: () => Promise<void>;
|
||||||
|
handleReload: () => void;
|
||||||
handleRemove: () => Promise<void>;
|
handleRemove: () => Promise<void>;
|
||||||
|
handleUpdate: () => Promise<void>;
|
||||||
|
isAuthorized: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||||
|
paginationModel: { page: number; pageSize: number };
|
||||||
|
rows: PackageRow[];
|
||||||
|
searchText: string;
|
||||||
|
selectedPackage: string | null;
|
||||||
|
selectionModel: string[];
|
||||||
|
setColumnVisibility: (model: Record<string, boolean>) => void;
|
||||||
|
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
|
||||||
|
setFilterModel: (model: GridFilterModel) => void;
|
||||||
|
setPaginationModel: (model: { page: number; pageSize: number }) => void;
|
||||||
|
setSearchText: (text: string) => void;
|
||||||
|
setSelectedPackage: (base: string | null) => void;
|
||||||
|
setSelectionModel: (model: string[]) => void;
|
||||||
|
status: BuildStatus | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
|
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
|
||||||
@@ -71,16 +66,13 @@ export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): Us
|
|||||||
}, [isDialogOpen, setPaused]);
|
}, [isDialogOpen, setPaused]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows,
|
autoRefreshInterval: autoRefresh.interval,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
status,
|
|
||||||
|
|
||||||
...tableState,
|
|
||||||
|
|
||||||
autoRefreshInterval: autoRefresh.interval,
|
|
||||||
onAutoRefreshIntervalChange: autoRefresh.setInterval,
|
onAutoRefreshIntervalChange: autoRefresh.setInterval,
|
||||||
|
rows,
|
||||||
|
status,
|
||||||
...actions,
|
...actions,
|
||||||
|
...tableState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import type { RepositoryId } from "models/RepositoryId";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export interface SelectedRepositoryResult {
|
export interface SelectedRepositoryResult {
|
||||||
selectedKey: string;
|
|
||||||
setSelectedKey: (key: string) => void;
|
|
||||||
selectedRepository: RepositoryId | null;
|
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
selectedKey: string;
|
||||||
|
selectedRepository: RepositoryId | null;
|
||||||
|
setSelectedKey: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSelectedRepository(): SelectedRepositoryResult {
|
export function useSelectedRepository(): SelectedRepositoryResult {
|
||||||
@@ -40,9 +40,9 @@ export function useSelectedRepository(): SelectedRepositoryResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset: () => void = () => {
|
const reset = (): void => {
|
||||||
setSelectedKey("");
|
setSelectedKey("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return { selectedKey, setSelectedKey, selectedRepository, reset };
|
return { reset, selectedKey, selectedRepository, setSelectedKey };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,22 +24,20 @@ import { useState } from "react";
|
|||||||
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
|
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
|
||||||
|
|
||||||
export interface UseTableStateResult {
|
export interface UseTableStateResult {
|
||||||
selectionModel: string[];
|
|
||||||
setSelectionModel: (model: string[]) => void;
|
|
||||||
|
|
||||||
dialogOpen: DialogType | null;
|
|
||||||
setDialogOpen: (dialog: DialogType | null) => void;
|
|
||||||
selectedPackage: string | null;
|
|
||||||
setSelectedPackage: (base: string | null) => void;
|
|
||||||
|
|
||||||
paginationModel: { pageSize: number; page: number };
|
|
||||||
setPaginationModel: (model: { pageSize: number; page: number }) => void;
|
|
||||||
columnVisibility: Record<string, boolean>;
|
columnVisibility: Record<string, boolean>;
|
||||||
setColumnVisibility: (model: Record<string, boolean>) => void;
|
dialogOpen: DialogType | null;
|
||||||
filterModel: GridFilterModel;
|
filterModel: GridFilterModel;
|
||||||
setFilterModel: (model: GridFilterModel) => void;
|
paginationModel: { pageSize: number; page: number };
|
||||||
searchText: string;
|
searchText: string;
|
||||||
|
selectedPackage: string | null;
|
||||||
|
selectionModel: string[];
|
||||||
|
setColumnVisibility: (model: Record<string, boolean>) => void;
|
||||||
|
setDialogOpen: (dialog: DialogType | null) => void;
|
||||||
|
setFilterModel: (model: GridFilterModel) => void;
|
||||||
|
setPaginationModel: (model: { pageSize: number; page: number }) => void;
|
||||||
setSearchText: (text: string) => void;
|
setSearchText: (text: string) => void;
|
||||||
|
setSelectedPackage: (base: string | null) => void;
|
||||||
|
setSelectionModel: (model: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTableState(): UseTableStateResult {
|
export function useTableState(): UseTableStateResult {
|
||||||
@@ -49,8 +47,8 @@ export function useTableState(): UseTableStateResult {
|
|||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
|
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
|
||||||
pageSize: 10,
|
|
||||||
page: 0,
|
page: 0,
|
||||||
|
pageSize: 25,
|
||||||
});
|
});
|
||||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
|
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
|
||||||
"ahriman-packages-columns",
|
"ahriman-packages-columns",
|
||||||
@@ -62,21 +60,19 @@ export function useTableState(): UseTableStateResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectionModel,
|
|
||||||
setSelectionModel,
|
|
||||||
|
|
||||||
dialogOpen,
|
|
||||||
setDialogOpen,
|
|
||||||
selectedPackage,
|
|
||||||
setSelectedPackage,
|
|
||||||
|
|
||||||
paginationModel,
|
|
||||||
setPaginationModel,
|
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
setColumnVisibility,
|
dialogOpen,
|
||||||
filterModel,
|
filterModel,
|
||||||
setFilterModel,
|
paginationModel,
|
||||||
searchText,
|
searchText,
|
||||||
|
selectedPackage,
|
||||||
|
selectionModel,
|
||||||
|
setColumnVisibility,
|
||||||
|
setDialogOpen,
|
||||||
|
setFilterModel,
|
||||||
|
setPaginationModel,
|
||||||
setSearchText,
|
setSearchText,
|
||||||
|
setSelectedPackage,
|
||||||
|
setSelectionModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ import "chartSetup";
|
|||||||
import "utils";
|
import "utils";
|
||||||
|
|
||||||
import App from "App";
|
import App from "App";
|
||||||
|
import ErrorFallback from "components/common/ErrorBoundary";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ErrorBoundary
|
||||||
|
FallbackComponent={ErrorFallback}
|
||||||
|
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
|
||||||
|
>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export interface AURPackage {
|
export interface AURPackage {
|
||||||
package: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
|
package: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,4 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";
|
export type BuildStatus = "building" | "failed" | "pending" | "success" | "unknown";
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import type { RepositoryId } from "models/RepositoryId";
|
|||||||
|
|
||||||
export interface InfoResponse {
|
export interface InfoResponse {
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
repositories: RepositoryId[];
|
|
||||||
version: string;
|
|
||||||
autorefresh_intervals: AutoRefreshInterval[];
|
autorefresh_intervals: AutoRefreshInterval[];
|
||||||
docs_enabled: boolean;
|
docs_enabled: boolean;
|
||||||
index_url?: string;
|
index_url?: string;
|
||||||
|
repositories: RepositoryId[];
|
||||||
|
version: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import type { Status } from "models/Status";
|
|||||||
|
|
||||||
export interface InternalStatus {
|
export interface InternalStatus {
|
||||||
architecture: string;
|
architecture: string;
|
||||||
repository: string;
|
|
||||||
packages: Counters;
|
packages: Counters;
|
||||||
|
repository: string;
|
||||||
stats: RepositoryStats;
|
stats: RepositoryStats;
|
||||||
status: Status;
|
status: Status;
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string;
|
|
||||||
password: string;
|
password: string;
|
||||||
|
username: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { AlertColor } from "@mui/material";
|
|||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
|
||||||
message: string;
|
message: string;
|
||||||
severity: AlertColor;
|
severity: AlertColor;
|
||||||
|
title: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@
|
|||||||
import type { Patch } from "models/Patch";
|
import type { Patch } from "models/Patch";
|
||||||
|
|
||||||
export interface PackageActionRequest {
|
export interface PackageActionRequest {
|
||||||
packages: string[];
|
|
||||||
patches?: Patch[];
|
|
||||||
refresh?: boolean;
|
|
||||||
aur?: boolean;
|
aur?: boolean;
|
||||||
local?: boolean;
|
local?: boolean;
|
||||||
manual?: boolean;
|
manual?: boolean;
|
||||||
|
packages: string[];
|
||||||
|
patches?: Patch[];
|
||||||
|
refresh?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,32 +21,31 @@ import type { BuildStatus } from "models/BuildStatus";
|
|||||||
import type { PackageStatus } from "models/PackageStatus";
|
import type { PackageStatus } from "models/PackageStatus";
|
||||||
|
|
||||||
export class PackageRow {
|
export class PackageRow {
|
||||||
id: string;
|
|
||||||
base: string;
|
base: string;
|
||||||
webUrl?: string;
|
|
||||||
version: string;
|
|
||||||
packages: string[];
|
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
id: string;
|
||||||
|
isHeld: boolean;
|
||||||
licenses: string[];
|
licenses: string[];
|
||||||
packager: string;
|
packager: string;
|
||||||
timestamp: string;
|
packages: string[];
|
||||||
timestampValue: number;
|
|
||||||
status: BuildStatus;
|
status: BuildStatus;
|
||||||
isHeld: boolean;
|
timestamp: string;
|
||||||
|
version: string;
|
||||||
|
webUrl?: string;
|
||||||
|
|
||||||
constructor(descriptor: PackageStatus) {
|
constructor(descriptor: PackageStatus) {
|
||||||
this.id = descriptor.package.base;
|
|
||||||
this.base = 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.groups = PackageRow.extractListProperties(descriptor.package, "groups");
|
||||||
|
this.id = descriptor.package.base;
|
||||||
|
this.isHeld = descriptor.status.is_held ?? false;
|
||||||
this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses");
|
this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses");
|
||||||
this.packager = descriptor.package.packager ?? "";
|
this.packager = descriptor.package.packager ?? "";
|
||||||
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
|
this.packages = Object.keys(descriptor.package.packages).sort();
|
||||||
this.timestampValue = descriptor.status.timestamp;
|
|
||||||
this.status = descriptor.status.status;
|
this.status = descriptor.status.status;
|
||||||
this.isHeld = descriptor.status.is_held ?? false;
|
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
|
||||||
|
this.version = descriptor.package.version;
|
||||||
|
this.webUrl = descriptor.package.remote.web_url ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
|
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export class RepositoryId {
|
export class RepositoryId {
|
||||||
|
|
||||||
readonly architecture: string;
|
readonly architecture: string;
|
||||||
readonly repository: string;
|
readonly repository: string;
|
||||||
|
|
||||||
|
|||||||
23
frontend/src/models/RollbackRequest.ts
Normal file
23
frontend/src/models/RollbackRequest.ts
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
export interface RollbackRequest {
|
||||||
|
package: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
import type { BuildStatus } from "models/BuildStatus";
|
import type { BuildStatus } from "models/BuildStatus";
|
||||||
|
|
||||||
export interface Status {
|
export interface Status {
|
||||||
|
is_held?: boolean;
|
||||||
status: BuildStatus;
|
status: BuildStatus;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
is_held?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,19 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
|
|||||||
import type { BuildStatus } from "models/BuildStatus";
|
import type { BuildStatus } from "models/BuildStatus";
|
||||||
|
|
||||||
const base: Record<BuildStatus, string> = {
|
const base: Record<BuildStatus, string> = {
|
||||||
unknown: grey[600],
|
|
||||||
pending: amber[700],
|
|
||||||
building: orange[800],
|
building: orange[800],
|
||||||
failed: red[700],
|
failed: red[700],
|
||||||
|
pending: amber[700],
|
||||||
success: green[700],
|
success: green[700],
|
||||||
|
unknown: grey[600],
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerBase: Record<BuildStatus, string> = {
|
const headerBase: Record<BuildStatus, string> = {
|
||||||
unknown: grey[600],
|
|
||||||
pending: amber[700],
|
|
||||||
building: orange[600],
|
building: orange[600],
|
||||||
failed: red[500],
|
failed: red[500],
|
||||||
|
pending: amber[700],
|
||||||
success: green[600],
|
success: green[600],
|
||||||
|
unknown: grey[600],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StatusColors = base;
|
export const StatusColors = base;
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
*/
|
*/
|
||||||
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
||||||
|
|
||||||
|
export const DETAIL_TABLE_PROPS = {
|
||||||
|
density: "compact" as const,
|
||||||
|
disableColumnSorting: true,
|
||||||
|
disableRowSelectionOnClick: true,
|
||||||
|
paginationModel: { page: 0, pageSize: 25 },
|
||||||
|
sx: { height: 400, mt: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
|
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
|
||||||
return intervals.find(interval => interval.is_active)?.interval ?? 0;
|
return intervals.find(interval => interval.is_active)?.interval ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
"baseUrl": "src",
|
"baseUrl": "src",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noImplicitOverride": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2020",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true
|
"useDefineForClassFields": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ import { defineConfig, type Plugin } from "vite";
|
|||||||
|
|
||||||
function rename(oldName: string, newName: string): Plugin {
|
function rename(oldName: string, newName: string): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "rename",
|
|
||||||
enforce: "post",
|
enforce: "post",
|
||||||
generateBundle(_, bundle) {
|
generateBundle(_, bundle) {
|
||||||
if (bundle[oldName]) {
|
if (bundle[oldName]) {
|
||||||
bundle[oldName].fileName = newName;
|
bundle[oldName].fileName = newName;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
name: "rename",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), rename("index.html", "build-status.jinja2")],
|
|
||||||
base: "/",
|
base: "/",
|
||||||
resolve: {
|
|
||||||
tsconfigPaths: true,
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 10000,
|
chunkSizeWarningLimit: 10000,
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
|
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
|
||||||
rollupOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: "static/[name].js",
|
|
||||||
chunkFileNames: "static/[name].js",
|
|
||||||
assetFileNames: "static/[name].[ext]",
|
assetFileNames: "static/[name].[ext]",
|
||||||
|
chunkFileNames: "static/[name].js",
|
||||||
|
entryFileNames: "static/[name].js",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
plugins: [react(), rename("index.html", "build-status.jinja2")],
|
||||||
|
resolve: {
|
||||||
|
tsconfigPaths: true,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8080",
|
"/api": "http://localhost:8080",
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ allow_read_only = yes
|
|||||||
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
|
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
|
||||||
; by default.
|
; by default.
|
||||||
autorefresh_intervals = 5 1 10 30 60
|
autorefresh_intervals = 5 1 10 30 60
|
||||||
|
; Allowed CORS headers. By default everything is allowed.
|
||||||
|
;cors_allow_headers =
|
||||||
|
; Allowed CORS methods. By default everything is allowed.
|
||||||
|
;cors_allow_methods =
|
||||||
|
; Allowed CORS origins.
|
||||||
|
;cors_allow_origins = *
|
||||||
|
; Exposed CORS headers. By default everything is exposed.
|
||||||
|
;cors_expose_headers =
|
||||||
; Enable file upload endpoint used by some triggers.
|
; Enable file upload endpoint used by some triggers.
|
||||||
;enable_archive_upload = no
|
;enable_archive_upload = no
|
||||||
; Address to bind the server.
|
; Address to bind the server.
|
||||||
|
|||||||
@@ -156,12 +156,15 @@ class ApplicationRepository(ApplicationProperties):
|
|||||||
result = Result()
|
result = Result()
|
||||||
|
|
||||||
# process already built packages if any
|
# process already built packages if any
|
||||||
built_packages = self.repository.packages_built()
|
if built_packages := self.repository.packages_built(): # speedup a bit
|
||||||
if built_packages: # speedup a bit
|
|
||||||
build_result = self.repository.process_update(built_packages, packagers)
|
build_result = self.repository.process_update(built_packages, packagers)
|
||||||
self.on_result(build_result)
|
self.on_result(build_result)
|
||||||
result.merge(build_result)
|
result.merge(build_result)
|
||||||
|
|
||||||
|
# filter packages which were prebuilt
|
||||||
|
succeeded = {package.base for package in build_result.success}
|
||||||
|
updates = [package for package in updates if package.base not in succeeded]
|
||||||
|
|
||||||
builder = Updater.load(self.repository_id, self.configuration, self.repository)
|
builder = Updater.load(self.repository_id, self.configuration, self.repository)
|
||||||
|
|
||||||
# ok so for now we split all packages into chunks and process each chunk accordingly
|
# ok so for now we split all packages into chunks and process each chunk accordingly
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import argparse
|
|||||||
|
|
||||||
from ahriman.application.application import Application
|
from ahriman.application.application import Application
|
||||||
from ahriman.application.handlers.handler import Handler, SubParserAction
|
from ahriman.application.handlers.handler import Handler, SubParserAction
|
||||||
|
from ahriman.application.handlers.update import Update
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.utils import enum_values, extract_user
|
from ahriman.core.utils import enum_values, extract_user
|
||||||
from ahriman.models.package_source import PackageSource
|
from ahriman.models.package_source import PackageSource
|
||||||
from ahriman.models.packagers import Packagers
|
|
||||||
from ahriman.models.pkgbuild_patch import PkgbuildPatch
|
from ahriman.models.pkgbuild_patch import PkgbuildPatch
|
||||||
from ahriman.models.repository_id import RepositoryId
|
from ahriman.models.repository_id import RepositoryId
|
||||||
|
|
||||||
@@ -48,26 +48,7 @@ class Add(Handler):
|
|||||||
"""
|
"""
|
||||||
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
|
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
|
||||||
application.on_start()
|
application.on_start()
|
||||||
|
Add.perform_action(application, args)
|
||||||
application.add(args.package, args.source, args.username)
|
|
||||||
patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
|
|
||||||
for package in args.package: # for each requested package insert patch
|
|
||||||
for patch in patches:
|
|
||||||
application.reporter.package_patches_update(package, patch)
|
|
||||||
|
|
||||||
if not args.now:
|
|
||||||
return
|
|
||||||
|
|
||||||
packages = application.updates(args.package, aur=False, local=False, manual=True, vcs=False, check_files=False)
|
|
||||||
if args.changes: # generate changes if requested
|
|
||||||
application.changes(packages)
|
|
||||||
|
|
||||||
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
|
|
||||||
packagers = Packagers(args.username, {package.base: package.packager for package in packages})
|
|
||||||
|
|
||||||
application.print_updates(packages, log_fn=application.logger.info)
|
|
||||||
result = application.update(packages, packagers, bump_pkgrel=args.increment)
|
|
||||||
Add.check_status(args.exit_code, not result.is_empty)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||||
@@ -103,14 +84,34 @@ class Add(Handler):
|
|||||||
parser.add_argument("--increment", help="increment package release (pkgrel) version on duplicate",
|
parser.add_argument("--increment", help="increment package release (pkgrel) version on duplicate",
|
||||||
action=argparse.BooleanOptionalAction, default=True)
|
action=argparse.BooleanOptionalAction, default=True)
|
||||||
parser.add_argument("-n", "--now", help="run update function after", action="store_true")
|
parser.add_argument("-n", "--now", help="run update function after", action="store_true")
|
||||||
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
|
|
||||||
"-yy to force refresh even if up to date",
|
|
||||||
action="count", default=False)
|
|
||||||
parser.add_argument("-s", "--source", help="explicitly specify the package source for this command",
|
parser.add_argument("-s", "--source", help="explicitly specify the package source for this command",
|
||||||
type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto)
|
type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto)
|
||||||
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
|
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
|
||||||
parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build",
|
parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build",
|
||||||
action="append")
|
action="append")
|
||||||
|
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
|
||||||
|
"-yy to force refresh even if up to date",
|
||||||
|
action="count", default=False)
|
||||||
|
parser.set_defaults(aur=False, check_files=False, dry_run=False, local=False, manual=True, vcs=False)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def perform_action(application: Application, args: argparse.Namespace) -> None:
|
||||||
|
"""
|
||||||
|
perform add action
|
||||||
|
|
||||||
|
Args:
|
||||||
|
application(Application): application instance
|
||||||
|
args(argparse.Namespace): command line args
|
||||||
|
"""
|
||||||
|
application.add(args.package, args.source, args.username)
|
||||||
|
patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else []
|
||||||
|
for package in args.package: # for each requested package insert patch
|
||||||
|
for patch in patches:
|
||||||
|
application.reporter.package_patches_update(package, patch)
|
||||||
|
|
||||||
|
if not args.now:
|
||||||
|
return
|
||||||
|
Update.perform_action(application, args)
|
||||||
|
|
||||||
arguments = [_set_package_add_parser]
|
arguments = [_set_package_add_parser]
|
||||||
|
|||||||
131
src/ahriman/application/handlers/rollback.py
Normal file
131
src/ahriman/application/handlers/rollback.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2021-2026 ahriman team.
|
||||||
|
#
|
||||||
|
# This file is part of ahriman
|
||||||
|
# (see https://github.com/arcan1s/ahriman).
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ahriman.application.application import Application
|
||||||
|
from ahriman.application.handlers.add import Add
|
||||||
|
from ahriman.application.handlers.handler import Handler, SubParserAction
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.core.exceptions import UnknownPackageError
|
||||||
|
from ahriman.core.utils import extract_user
|
||||||
|
from ahriman.models.package import Package
|
||||||
|
from ahriman.models.package_source import PackageSource
|
||||||
|
from ahriman.models.repository_id import RepositoryId
|
||||||
|
|
||||||
|
|
||||||
|
class Rollback(Handler):
|
||||||
|
"""
|
||||||
|
package rollback handler
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
|
||||||
|
report: bool) -> None:
|
||||||
|
"""
|
||||||
|
callback for command line
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args(argparse.Namespace): command line args
|
||||||
|
repository_id(RepositoryId): repository unique identifier
|
||||||
|
configuration(Configuration): configuration instance
|
||||||
|
report(bool): force enable or disable reporting
|
||||||
|
"""
|
||||||
|
application = Application(repository_id, configuration, report=report)
|
||||||
|
application.on_start()
|
||||||
|
|
||||||
|
package = Rollback.package_load(application, args.package, args.version)
|
||||||
|
artifacts = Rollback.package_artifacts(application, package)
|
||||||
|
|
||||||
|
args.package = [str(artifact) for artifact in artifacts]
|
||||||
|
Add.perform_action(application, args)
|
||||||
|
|
||||||
|
if args.hold:
|
||||||
|
application.reporter.package_hold_update(package.base, enabled=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_package_rollback_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||||
|
"""
|
||||||
|
add parser for package rollback subcommand
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root(SubParserAction): subparsers for the commands
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
argparse.ArgumentParser: created argument parser
|
||||||
|
"""
|
||||||
|
parser = root.add_parser("package-rollback", help="rollback package",
|
||||||
|
description="rollback package to specified version from archives")
|
||||||
|
parser.add_argument("package", help="package base")
|
||||||
|
parser.add_argument("version", help="package version")
|
||||||
|
parser.add_argument("--hold", help="hold package afterwards",
|
||||||
|
action=argparse.BooleanOptionalAction, default=True)
|
||||||
|
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
|
||||||
|
parser.set_defaults(aur=False, changes=False, check_files=False, dependencies=False, dry_run=False,
|
||||||
|
exit_code=True, increment=False, now=True, local=False, manual=False, refresh=False,
|
||||||
|
source=PackageSource.Archive, variable=None, vcs=False)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def package_artifacts(application: Application, package: Package) -> list[Path]:
|
||||||
|
"""
|
||||||
|
look for requested package artifacts and return paths to them
|
||||||
|
|
||||||
|
Args:
|
||||||
|
application(Application): application instance
|
||||||
|
package(Package): package descriptor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Path]: paths to found artifacts
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnknownPackageError: if artifacts do not exist
|
||||||
|
"""
|
||||||
|
# lookup for built artifacts
|
||||||
|
artifacts = application.repository.package_archives_lookup(package)
|
||||||
|
if not artifacts:
|
||||||
|
raise UnknownPackageError(package.base)
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def package_load(application: Application, package_base: str, version: str) -> Package:
|
||||||
|
"""
|
||||||
|
load package from repository, while setting requested version
|
||||||
|
|
||||||
|
Args:
|
||||||
|
application(Application): application instance
|
||||||
|
package_base(str): package base
|
||||||
|
version(str): package version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Package: loaded package
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnknownPackageError: if package does not exist
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
package, _ = next(iter(application.reporter.package_get(package_base)))
|
||||||
|
return replace(package, version=version)
|
||||||
|
except StopIteration:
|
||||||
|
raise UnknownPackageError(package_base) from None
|
||||||
|
|
||||||
|
arguments = [_set_package_rollback_parser]
|
||||||
@@ -48,22 +48,7 @@ class Update(Handler):
|
|||||||
"""
|
"""
|
||||||
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
|
application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh)
|
||||||
application.on_start()
|
application.on_start()
|
||||||
|
Update.perform_action(application, args)
|
||||||
packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs,
|
|
||||||
check_files=args.check_files)
|
|
||||||
if args.changes: # generate changes if requested
|
|
||||||
application.changes(packages)
|
|
||||||
|
|
||||||
if args.dry_run: # exit from application if no build requested
|
|
||||||
Update.check_status(args.exit_code, packages) # status code check
|
|
||||||
return
|
|
||||||
|
|
||||||
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
|
|
||||||
packagers = Packagers(args.username, {package.base: package.packager for package in packages})
|
|
||||||
|
|
||||||
application.print_updates(packages, log_fn=application.logger.info)
|
|
||||||
result = application.update(packages, packagers, bump_pkgrel=args.increment)
|
|
||||||
Update.check_status(args.exit_code, not result.is_empty)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||||
@@ -153,6 +138,31 @@ class Update(Handler):
|
|||||||
return print(line) if dry_run else application.logger.info(line) # pylint: disable=bad-builtin
|
return print(line) if dry_run else application.logger.info(line) # pylint: disable=bad-builtin
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def perform_action(application: Application, args: argparse.Namespace) -> None:
|
||||||
|
"""
|
||||||
|
perform update action
|
||||||
|
|
||||||
|
Args:
|
||||||
|
application(Application): application instance
|
||||||
|
args(argparse.Namespace): command line args
|
||||||
|
"""
|
||||||
|
packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs,
|
||||||
|
check_files=args.check_files)
|
||||||
|
if args.changes: # generate changes if requested
|
||||||
|
application.changes(packages)
|
||||||
|
|
||||||
|
if args.dry_run: # exit from application if no build requested
|
||||||
|
Update.check_status(args.exit_code, packages) # status code check
|
||||||
|
return
|
||||||
|
|
||||||
|
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
|
||||||
|
packagers = Packagers(args.username, {package.base: package.packager for package in packages})
|
||||||
|
|
||||||
|
application.print_updates(packages, log_fn=application.logger.info)
|
||||||
|
result = application.update(packages, packagers, bump_pkgrel=args.increment)
|
||||||
|
Update.check_status(args.exit_code, not result.is_empty)
|
||||||
|
|
||||||
arguments = [
|
arguments = [
|
||||||
_set_repo_check_parser,
|
_set_repo_check_parser,
|
||||||
_set_repo_update_parser,
|
_set_repo_update_parser,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class PkgbuildParser(shlex.shlex):
|
|||||||
|
|
||||||
# ignore substitution and extend bash symbols
|
# ignore substitution and extend bash symbols
|
||||||
self.wordchars += "${}#:+-@!"
|
self.wordchars += "${}#:+-@!"
|
||||||
# in case of default behaviour, it will ignore, for example, segment part of url outside of quotes
|
# in case of default behavior, it will ignore, for example, segment part of url outside of quotes
|
||||||
self.commenters = ""
|
self.commenters = ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class AUR(Remote):
|
|||||||
parse RPC response to package list
|
parse RPC response to package list
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response(dict[str, Any]): RPC response json
|
response(dict[str, Any]): RPC response JSON
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[AURPackage]: list of parsed packages
|
list[AURPackage]: list of parsed packages
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class Official(Remote):
|
|||||||
parse RPC response to package list
|
parse RPC response to package list
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response(dict[str, Any]): RPC response json
|
response(dict[str, Any]): RPC response JSON
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[AURPackage]: list of parsed packages
|
list[AURPackage]: list of parsed packages
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class OfficialSyncdb(Official):
|
|||||||
updates.
|
updates.
|
||||||
|
|
||||||
This approach also has limitations, because we don't require superuser rights (neither going to download database
|
This approach also has limitations, because we don't require superuser rights (neither going to download database
|
||||||
separately), the database file might be outdated and must be handled manually (or kind of). This behaviour might be
|
separately), the database file might be outdated and must be handled manually (or kind of). This behavior might be
|
||||||
changed in the future.
|
changed in the future.
|
||||||
|
|
||||||
Still we leave search function based on the official repositories RPC.
|
Still we leave search function based on the official repositories RPC.
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ class Auth(LazyLogging):
|
|||||||
@property
|
@property
|
||||||
def auth_control(self) -> str:
|
def auth_control(self) -> str:
|
||||||
"""
|
"""
|
||||||
This workaround is required to make different behaviour for login interface.
|
This workaround is required to make different behavior for login interface.
|
||||||
In case of internal authentication it must provide an interface (modal form) to log in with button sends POST
|
In case of internal authentication it must provide an interface (modal form) to log in with button sends POST
|
||||||
request. But for an external providers behaviour can be different: e.g. OAuth provider requires sending GET
|
request. But for an external providers behavior can be different: e.g. OAuth provider requires sending GET
|
||||||
request to external resource
|
request to external resource
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: login control as html code to insert
|
str: login control as HTML code to insert
|
||||||
"""
|
"""
|
||||||
return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>"
|
return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>"
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ except ImportError:
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["authorized_userid", "check_authorized", "forget", "remember"]
|
__all__ = [
|
||||||
|
"authorized_userid",
|
||||||
|
"check_authorized",
|
||||||
|
"forget",
|
||||||
|
"get_session",
|
||||||
|
"remember",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
|
async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ class OAuth(Mapping):
|
|||||||
@property
|
@property
|
||||||
def auth_control(self) -> str:
|
def auth_control(self) -> str:
|
||||||
"""
|
"""
|
||||||
get authorization html control
|
get authorization HTML control
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: login control as html code to insert
|
str: login control as HTML code to insert
|
||||||
"""
|
"""
|
||||||
return "<a class=\"nav-link\" href=\"/api/v1/login\" title=\"login via OAuth2\"><i class=\"bi bi-box-arrow-in-right\"></i> login</a>"
|
return "<a class=\"nav-link\" href=\"/api/v1/login\" title=\"login via OAuth2\"><i class=\"bi bi-box-arrow-in-right\"></i> login</a>"
|
||||||
|
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ class Sources(LazyLogging):
|
|||||||
else:
|
else:
|
||||||
patch.write(sources_dir / "PKGBUILD")
|
patch.write(sources_dir / "PKGBUILD")
|
||||||
|
|
||||||
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str:
|
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str | None:
|
||||||
"""
|
"""
|
||||||
read file content from the specified commit
|
read file content from the specified commit
|
||||||
|
|
||||||
@@ -426,6 +426,10 @@ class Sources(LazyLogging):
|
|||||||
path(Path): path to file inside the repository
|
path(Path): path to file inside the repository
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: file content at specified commit
|
str | None: file content at specified commit if available
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)
|
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)
|
||||||
|
except CalledProcessError:
|
||||||
|
self.logger.exception("failed to read file %s at %s", path, commit_sha)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class Configuration(configparser.RawConfigParser):
|
|||||||
def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None:
|
def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
allow_no_value(bool, optional): copies :class:`configparser.RawConfigParser` behaviour. In case if it is set
|
allow_no_value(bool, optional): copies :class:`configparser.RawConfigParser` behavior. In case if it is set
|
||||||
to ``True``, the keys without values will be allowed (Default value = False)
|
to ``True``, the keys without values will be allowed (Default value = False)
|
||||||
allow_multi_key(bool, optional): if set to ``False``, then the default dictionary class will be used to
|
allow_multi_key(bool, optional): if set to ``False``, then the default dictionary class will be used to
|
||||||
store keys internally. Otherwise, the special implementation will be used, which supports arrays
|
store keys internally. Otherwise, the special implementation will be used, which supports arrays
|
||||||
@@ -80,7 +80,7 @@ class Configuration(configparser.RawConfigParser):
|
|||||||
"""
|
"""
|
||||||
configparser.RawConfigParser.__init__(
|
configparser.RawConfigParser.__init__(
|
||||||
self,
|
self,
|
||||||
dict_type=ConfigurationMultiDict if allow_multi_key else dict, # type: ignore[arg-type]
|
dict_type=ConfigurationMultiDict if allow_multi_key else dict,
|
||||||
allow_no_value=allow_no_value,
|
allow_no_value=allow_no_value,
|
||||||
strict=False,
|
strict=False,
|
||||||
empty_lines_in_values=not allow_multi_key,
|
empty_lines_in_values=not allow_multi_key,
|
||||||
|
|||||||
@@ -358,6 +358,38 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
|||||||
"min": 0,
|
"min": 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"cors_allow_headers": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cors_allow_methods": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cors_allow_origins": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cors_expose_headers": {
|
||||||
|
"type": "list",
|
||||||
|
"coerce": "list",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
"enable_archive_upload": {
|
"enable_archive_upload": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"coerce": "boolean",
|
"coerce": "boolean",
|
||||||
|
|||||||
@@ -150,6 +150,6 @@ class ShellTemplate(Template):
|
|||||||
break
|
break
|
||||||
|
|
||||||
kwargs.update(mapping)
|
kwargs.update(mapping)
|
||||||
substituted = dict(generator(kwargs))
|
kwargs.update(dict(generator(kwargs)))
|
||||||
|
|
||||||
return self.safe_substitute(kwargs | substituted)
|
return self.safe_substitute(kwargs)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ChangesOperations(Operations):
|
|||||||
values
|
values
|
||||||
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
|
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
|
||||||
on conflict (package_base, repository) do update set
|
on conflict (package_base, repository) do update set
|
||||||
last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = :pkgbuild
|
last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = coalesce(:pkgbuild, pkgbuild)
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"package_base": package_base,
|
"package_base": package_base,
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class SyncHttpClient(LazyLogging):
|
|||||||
headers(dict[str, str] | None, optional): request headers (Default value = None)
|
headers(dict[str, str] | None, optional): request headers (Default value = None)
|
||||||
params(list[tuple[str, str]] | None, optional): request query parameters (Default value = None)
|
params(list[tuple[str, str]] | None, optional): request query parameters (Default value = None)
|
||||||
data(Any | None, optional): request raw data parameters (Default value = None)
|
data(Any | None, optional): request raw data parameters (Default value = None)
|
||||||
json(dict[str, Any] | None, optional): request json parameters (Default value = None)
|
json(dict[str, Any] | None, optional): request JSON parameters (Default value = None)
|
||||||
files(dict[str, MultipartType] | None, optional): multipart upload (Default value = None)
|
files(dict[str, MultipartType] | None, optional): multipart upload (Default value = None)
|
||||||
stream(bool | None, optional): handle response as stream (Default value = None)
|
stream(bool | None, optional): handle response as stream (Default value = None)
|
||||||
session(requests.Session | None, optional): session object if any (Default value = None)
|
session(requests.Session | None, optional): session object if any (Default value = None)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from ahriman.models.result import Result
|
|||||||
|
|
||||||
class Console(Report):
|
class Console(Report):
|
||||||
"""
|
"""
|
||||||
html report generator
|
HTML report generator
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
use_utf(bool): print utf8 symbols instead of ASCII
|
use_utf(bool): print utf8 symbols instead of ASCII
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ from ahriman.models.result import Result
|
|||||||
|
|
||||||
class HTML(Report, JinjaTemplate):
|
class HTML(Report, JinjaTemplate):
|
||||||
"""
|
"""
|
||||||
html report generator
|
HTML report generator
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
report_path(Path): output path to html report
|
report_path(Path): output path to HTML report
|
||||||
template(Path | str): name or path to template for full package list
|
template(Path | str): name or path to template for full package list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class Report(LazyLogging):
|
|||||||
Args:
|
Args:
|
||||||
repository_id(RepositoryId): repository unique identifier
|
repository_id(RepositoryId): repository unique identifier
|
||||||
configuration(Configuration): configuration instance
|
configuration(Configuration): configuration instance
|
||||||
target(str): target to generate report aka section name (e.g. html)
|
target(str): target to generate report aka section name (e.g. HTML)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Report: client according to current settings
|
Report: client according to current settings
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from ahriman.core.build_tools.package_archive import PackageArchive
|
|||||||
from ahriman.core.build_tools.task import Task
|
from ahriman.core.build_tools.task import Task
|
||||||
from ahriman.core.repository.cleaner import Cleaner
|
from ahriman.core.repository.cleaner import Cleaner
|
||||||
from ahriman.core.repository.package_info import PackageInfo
|
from ahriman.core.repository.package_info import PackageInfo
|
||||||
from ahriman.core.utils import atomic_move, filelock, list_flatmap, package_like, safe_filename, symlink_relative
|
from ahriman.core.utils import atomic_move, filelock, safe_filename, symlink_relative
|
||||||
from ahriman.models.changes import Changes
|
from ahriman.models.changes import Changes
|
||||||
from ahriman.models.event import EventType
|
from ahriman.models.event import EventType
|
||||||
from ahriman.models.package import Package
|
from ahriman.models.package import Package
|
||||||
@@ -41,34 +41,6 @@ class Executor(PackageInfo, Cleaner):
|
|||||||
trait for common repository update processes
|
trait for common repository update processes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _archive_lookup(self, package: Package) -> list[Path]:
|
|
||||||
"""
|
|
||||||
check if there is a rebuilt package already
|
|
||||||
|
|
||||||
Args:
|
|
||||||
package(Package): package to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[Path]: list of built packages and signatures if available, empty list otherwise
|
|
||||||
"""
|
|
||||||
archive = self.paths.archive_for(package.base)
|
|
||||||
if not archive.is_dir():
|
|
||||||
return []
|
|
||||||
|
|
||||||
for path in filter(package_like, archive.iterdir()):
|
|
||||||
# check if package version is the same
|
|
||||||
built = Package.from_archive(path)
|
|
||||||
if built.version != package.version:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# all packages must be either any or same architecture
|
|
||||||
if not built.supports_architecture(self.repository_id.architecture):
|
|
||||||
continue
|
|
||||||
|
|
||||||
return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _archive_rename(self, description: PackageDescription, package_base: str) -> None:
|
def _archive_rename(self, description: PackageDescription, package_base: str) -> None:
|
||||||
"""
|
"""
|
||||||
rename package archive removing special symbols
|
rename package archive removing special symbols
|
||||||
@@ -106,7 +78,7 @@ class Executor(PackageInfo, Cleaner):
|
|||||||
commit_sha = task.init(path, patches, local_version)
|
commit_sha = task.init(path, patches, local_version)
|
||||||
|
|
||||||
loaded_package = Package.from_build(path, self.repository_id.architecture, None)
|
loaded_package = Package.from_build(path, self.repository_id.architecture, None)
|
||||||
if prebuilt := list(self._archive_lookup(loaded_package)):
|
if prebuilt := self.package_archives_lookup(loaded_package):
|
||||||
self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version)
|
self.logger.info("using prebuilt packages for %s-%s", loaded_package.base, loaded_package.version)
|
||||||
built = []
|
built = []
|
||||||
for artifact in prebuilt:
|
for artifact in prebuilt:
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ from pathlib import Path
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from ahriman.core.alpm.pacman import Pacman
|
from ahriman.core.alpm.pacman import Pacman
|
||||||
from ahriman.core.build_tools.package_version import PackageVersion
|
|
||||||
from ahriman.core.build_tools.sources import Sources
|
from ahriman.core.build_tools.sources import Sources
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.log import LazyLogging
|
from ahriman.core.log import LazyLogging
|
||||||
from ahriman.core.status import Client
|
from ahriman.core.status import Client
|
||||||
from ahriman.core.utils import package_like
|
from ahriman.core.utils import list_flatmap, package_like
|
||||||
from ahriman.models.changes import Changes
|
from ahriman.models.changes import Changes
|
||||||
from ahriman.models.package import Package
|
from ahriman.models.package import Package
|
||||||
from ahriman.models.repository_id import RepositoryId
|
from ahriman.models.repository_id import RepositoryId
|
||||||
|
from ahriman.models.repository_paths import RepositoryPaths
|
||||||
|
|
||||||
|
|
||||||
class PackageInfo(LazyLogging):
|
class PackageInfo(LazyLogging):
|
||||||
@@ -43,12 +43,14 @@ class PackageInfo(LazyLogging):
|
|||||||
Attributes:
|
Attributes:
|
||||||
configuration(Configuration): configuration instance
|
configuration(Configuration): configuration instance
|
||||||
pacman(Pacman): alpm wrapper instance
|
pacman(Pacman): alpm wrapper instance
|
||||||
|
paths(RepositoryPaths): repository paths instance
|
||||||
reporter(Client): build status reporter instance
|
reporter(Client): build status reporter instance
|
||||||
repository_id(RepositoryId): repository unique identifier
|
repository_id(RepositoryId): repository unique identifier
|
||||||
"""
|
"""
|
||||||
|
|
||||||
configuration: Configuration
|
configuration: Configuration
|
||||||
pacman: Pacman
|
pacman: Pacman
|
||||||
|
paths: RepositoryPaths
|
||||||
reporter: Client
|
reporter: Client
|
||||||
repository_id: RepositoryId
|
repository_id: RepositoryId
|
||||||
|
|
||||||
@@ -86,19 +88,21 @@ class PackageInfo(LazyLogging):
|
|||||||
|
|
||||||
return sorted(result)
|
return sorted(result)
|
||||||
|
|
||||||
def load_archives(self, packages: Iterable[Path]) -> list[Package]:
|
def load_archives(self, packages: Iterable[Path], *, latest_only: bool = True) -> list[Package]:
|
||||||
"""
|
"""
|
||||||
load packages from list of archives
|
load packages from list of archives
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
packages(Iterable[Path]): paths to package archives
|
packages(Iterable[Path]): paths to package archives
|
||||||
|
latest_only(bool, optional): filter packages with the same base, keeping only fresh packages installed
|
||||||
|
(Default value = True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[Package]: list of read packages
|
list[Package]: list of read packages
|
||||||
"""
|
"""
|
||||||
sources = {package.base: package.remote for package, _, in self.reporter.package_get(None)}
|
sources = {package.base: package.remote for package, _, in self.reporter.package_get(None)}
|
||||||
|
|
||||||
result: dict[str, Package] = {}
|
result: dict[str, dict[str, Package]] = {}
|
||||||
# we are iterating over bases, not single packages
|
# we are iterating over bases, not single packages
|
||||||
for full_path in packages:
|
for full_path in packages:
|
||||||
try:
|
try:
|
||||||
@@ -106,17 +110,23 @@ class PackageInfo(LazyLogging):
|
|||||||
if (source := sources.get(local.base)) is not None: # update source with remote
|
if (source := sources.get(local.base)) is not None: # update source with remote
|
||||||
local.remote = source
|
local.remote = source
|
||||||
|
|
||||||
current = result.setdefault(local.base, local)
|
loaded_versions = result.setdefault(local.base, {})
|
||||||
if current.version != local.version:
|
current = loaded_versions.setdefault(local.version, local)
|
||||||
# force version to max of them
|
|
||||||
self.logger.warning("version of %s differs, found %s and %s",
|
|
||||||
current.base, current.version, local.version)
|
|
||||||
if PackageVersion(current).is_outdated(local, self.configuration, calculate_version=False):
|
|
||||||
current.version = local.version
|
|
||||||
current.packages.update(local.packages)
|
current.packages.update(local.packages)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("could not load package from %s", full_path)
|
self.logger.exception("could not load package from %s", full_path)
|
||||||
return list(result.values())
|
|
||||||
|
if latest_only:
|
||||||
|
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
|
||||||
|
for package_base, versions in result.items():
|
||||||
|
newest = max(versions.values(), key=cmp_to_key(comparator))
|
||||||
|
result[package_base] = {newest.version: newest}
|
||||||
|
|
||||||
|
return [
|
||||||
|
package
|
||||||
|
for versions in result.values()
|
||||||
|
for package in versions.values()
|
||||||
|
]
|
||||||
|
|
||||||
def package_archives(self, package_base: str) -> list[Package]:
|
def package_archives(self, package_base: str) -> list[Package]:
|
||||||
"""
|
"""
|
||||||
@@ -130,18 +140,37 @@ class PackageInfo(LazyLogging):
|
|||||||
Returns:
|
Returns:
|
||||||
list[Package]: list of packages belonging to this base, sorted by version by ascension
|
list[Package]: list of packages belonging to this base, sorted by version by ascension
|
||||||
"""
|
"""
|
||||||
paths = self.configuration.repository_paths
|
archive = self.paths.archive_for(package_base)
|
||||||
|
if not archive.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
packages: dict[tuple[str, str], Package] = {}
|
packages = self.load_archives(filter(package_like, archive.iterdir()), latest_only=False)
|
||||||
# we can't use here load_archives, because it ignores versions
|
|
||||||
for full_path in filter(package_like, paths.archive_for(package_base).iterdir()):
|
|
||||||
local = Package.from_archive(full_path)
|
|
||||||
if not local.supports_architecture(self.repository_id.architecture):
|
|
||||||
continue
|
|
||||||
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
|
|
||||||
|
|
||||||
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
|
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
|
||||||
return sorted(packages.values(), key=cmp_to_key(comparator))
|
return sorted(
|
||||||
|
(package for package in packages if package.supports_architecture(self.repository_id.architecture)),
|
||||||
|
key=cmp_to_key(comparator),
|
||||||
|
)
|
||||||
|
|
||||||
|
def package_archives_lookup(self, package: Package) -> list[Path]:
|
||||||
|
"""
|
||||||
|
check if there is a rebuilt package already
|
||||||
|
|
||||||
|
Args:
|
||||||
|
package(Package): package to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Path]: list of built packages and signatures if available, empty list otherwise
|
||||||
|
"""
|
||||||
|
archive = self.paths.archive_for(package.base)
|
||||||
|
|
||||||
|
for built in self.package_archives(package.base):
|
||||||
|
if built.version != package.version:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
def package_changes(self, package: Package, last_commit_sha: str) -> Changes | None:
|
def package_changes(self, package: Package, last_commit_sha: str) -> Changes | None:
|
||||||
"""
|
"""
|
||||||
@@ -157,7 +186,7 @@ class PackageInfo(LazyLogging):
|
|||||||
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
|
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
|
||||||
dir_path = Path(dir_name)
|
dir_path = Path(dir_name)
|
||||||
patches = self.reporter.package_patches_get(package.base, None)
|
patches = self.reporter.package_patches_get(package.base, None)
|
||||||
current_commit_sha = Sources.load(dir_path, package, patches, self.configuration.repository_paths)
|
current_commit_sha = Sources.load(dir_path, package, patches, self.paths)
|
||||||
|
|
||||||
if current_commit_sha != last_commit_sha:
|
if current_commit_sha != last_commit_sha:
|
||||||
return Sources.changes(dir_path, last_commit_sha)
|
return Sources.changes(dir_path, last_commit_sha)
|
||||||
@@ -173,7 +202,7 @@ class PackageInfo(LazyLogging):
|
|||||||
Returns:
|
Returns:
|
||||||
list[Package]: list of packages properties
|
list[Package]: list of packages properties
|
||||||
"""
|
"""
|
||||||
packages = self.load_archives(filter(package_like, self.configuration.repository_paths.repository.iterdir()))
|
packages = self.load_archives(filter(package_like, self.paths.repository.iterdir()))
|
||||||
if filter_packages:
|
if filter_packages:
|
||||||
packages = [package for package in packages if package.base in filter_packages]
|
packages = [package for package in packages if package.base in filter_packages]
|
||||||
|
|
||||||
@@ -186,7 +215,7 @@ class PackageInfo(LazyLogging):
|
|||||||
Returns:
|
Returns:
|
||||||
list[Path]: list of filenames from the directory
|
list[Path]: list of filenames from the directory
|
||||||
"""
|
"""
|
||||||
return list(filter(package_like, self.configuration.repository_paths.packages.iterdir()))
|
return list(filter(package_like, self.paths.packages.iterdir()))
|
||||||
|
|
||||||
def packages_depend_on(self, packages: list[Package], depends_on: Iterable[str] | None) -> list[Package]:
|
def packages_depend_on(self, packages: list[Package], depends_on: Iterable[str] | None) -> list[Package]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -232,6 +232,27 @@ class Spawn(Thread, LazyLogging):
|
|||||||
"""
|
"""
|
||||||
return self._spawn_process(repository_id, "package-remove", *packages)
|
return self._spawn_process(repository_id, "package-remove", *packages)
|
||||||
|
|
||||||
|
def packages_rollback(self, repository_id: RepositoryId, package: str, version: str, username: str | None, *,
|
||||||
|
hold: bool) -> str:
|
||||||
|
"""
|
||||||
|
rollback package
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repository_id(RepositoryId): repository unique identifier
|
||||||
|
package(str): package base to rollback
|
||||||
|
version(str): package version to rollback
|
||||||
|
username(str | None): optional override of username for build process
|
||||||
|
hold(bool): hold package after rollback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: spawned process identifier
|
||||||
|
"""
|
||||||
|
kwargs = {
|
||||||
|
"username": username,
|
||||||
|
self.boolean_action_argument("hold", hold): "",
|
||||||
|
}
|
||||||
|
return self._spawn_process(repository_id, "package-rollback", package, version, **kwargs)
|
||||||
|
|
||||||
def packages_update(self, repository_id: RepositoryId, username: str | None, *,
|
def packages_update(self, repository_id: RepositoryId, username: str | None, *,
|
||||||
aur: bool, local: bool, manual: bool, increment: bool, refresh: bool) -> str:
|
aur: bool, local: bool, manual: bool, increment: bool, refresh: bool) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user