From 2fcff480942569e942aa63c111d2f84ac457ee0f Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Wed, 25 Feb 2026 22:49:38 +0200 Subject: [PATCH] feat: brand-new interface This was initally generated by ai, but later has been heavily edited. The reason why it has been implemented is that there are plans to implement more features to ui, but it becomes hard to add new features to plain js, so I decided to rewrite it in typescript. Yet because it is still ai slop, it is still possible to enable old interface via configuration, even though new interface is turned on by default to get feedback --- .dockerignore | 3 + .github/workflows/setup.sh | 2 +- .github/workflows/tests.yml | 2 +- .gitignore | 7 + docker/Dockerfile | 1 + docs/ahriman.web.rst | 8 + docs/ahriman.web.schemas.rst | 24 +++ docs/ahriman.web.views.v2.rst | 1 + docs/ahriman.web.views.v2.status.rst | 21 ++ docs/configuration.rst | 1 + frontend/eslint.config.js | 56 +++++ frontend/index.html | 16 ++ frontend/package.json | 41 ++++ frontend/src/App.tsx | 57 +++++ frontend/src/api/client/AhrimanClient.ts | 40 ++++ frontend/src/api/client/ApiError.ts | 44 ++++ frontend/src/api/client/BaseClient.ts | 68 ++++++ frontend/src/api/client/FetchMixin.ts | 100 +++++++++ frontend/src/api/client/RequestOptions.ts | 24 +++ frontend/src/api/client/ServiceMixin.ts | 82 +++++++ frontend/src/chartSetup.ts | 41 ++++ .../charts/EventDurationLineChart.tsx | 49 +++++ .../charts/PackageCountBarChart.tsx | 59 +++++ .../src/components/charts/StatusPieChart.tsx | 43 ++++ .../components/common/AutoRefreshControl.tsx | 92 ++++++++ frontend/src/components/common/CodeBlock.tsx | 75 +++++++ frontend/src/components/common/CopyButton.tsx | 49 +++++ .../src/components/common/DialogHeader.tsx | 39 ++++ .../components/common/RepositorySelect.tsx | 44 ++++ .../src/components/common/formatTimestamp.ts | 24 +++ .../components/dialogs/DashboardDialog.tsx | 108 ++++++++++ .../components/dialogs/KeyImportDialog.tsx | 119 ++++++++++ .../src/components/dialogs/LoginDialog.tsx | 118 ++++++++++ .../components/dialogs/PackageAddDialog.tsx | 203 ++++++++++++++++++ .../components/dialogs/PackageInfoDialog.tsx | 197 +++++++++++++++++ .../dialogs/PackageRebuildDialog.tsx | 94 ++++++++ frontend/src/components/layout/AppLayout.tsx | 78 +++++++ frontend/src/components/layout/Footer.tsx | 98 +++++++++ frontend/src/components/layout/Navbar.tsx | 57 +++++ .../src/components/package/BuildLogsTab.tsx | 194 +++++++++++++++++ .../src/components/package/ChangesTab.tsx | 63 ++++++ frontend/src/components/package/EventsTab.tsx | 82 +++++++ .../components/package/PackageDetailsGrid.tsx | 117 ++++++++++ .../components/package/PackageInfoActions.tsx | 77 +++++++ .../components/package/PackagePatchesList.tsx | 53 +++++ .../src/components/table/PackageTable.tsx | 202 +++++++++++++++++ .../components/table/PackageTableToolbar.tsx | 187 ++++++++++++++++ frontend/src/components/table/StatusCell.tsx | 41 ++++ frontend/src/contexts/AuthContext.ts | 34 +++ frontend/src/contexts/AuthProvider.tsx | 49 +++++ frontend/src/contexts/ClientContext.ts | 23 ++ frontend/src/contexts/ClientProvider.tsx | 32 +++ frontend/src/contexts/Notification.ts | 27 +++ frontend/src/contexts/NotificationContext.ts | 27 +++ frontend/src/contexts/NotificationItem.tsx | 50 +++++ .../src/contexts/NotificationProvider.tsx | 74 +++++++ frontend/src/contexts/RepositoryContext.ts | 30 +++ frontend/src/contexts/RepositoryProvider.tsx | 57 +++++ frontend/src/hooks/QueryKeys.ts | 46 ++++ frontend/src/hooks/useAuth.ts | 25 +++ frontend/src/hooks/useAutoRefresh.ts | 47 ++++ frontend/src/hooks/useAutoScroll.ts | 63 ++++++ frontend/src/hooks/useClient.ts | 26 +++ frontend/src/hooks/useContextNotNull.ts | 28 +++ frontend/src/hooks/useDebounce.ts | 31 +++ frontend/src/hooks/useLocalStorage.ts | 44 ++++ frontend/src/hooks/useNotification.ts | 25 +++ frontend/src/hooks/usePackageActions.ts | 114 ++++++++++ frontend/src/hooks/usePackageData.ts | 69 ++++++ frontend/src/hooks/usePackageTable.ts | 88 ++++++++ frontend/src/hooks/useRepository.ts | 25 +++ frontend/src/hooks/useSelectedRepository.ts | 50 +++++ frontend/src/hooks/useTableState.ts | 82 +++++++ frontend/src/main.tsx | 30 +++ frontend/src/models/AURPackage.ts | 23 ++ frontend/src/models/AuthInfo.ts | 24 +++ frontend/src/models/AutoRefreshInterval.ts | 24 +++ frontend/src/models/BuildStatus.ts | 20 ++ frontend/src/models/Changes.ts | 23 ++ frontend/src/models/Counters.ts | 27 +++ frontend/src/models/Dependencies.ts | 22 ++ frontend/src/models/Event.ts | 26 +++ frontend/src/models/InfoResponse.ts | 31 +++ frontend/src/models/InternalStatus.ts | 31 +++ frontend/src/models/LogRecord.ts | 25 +++ frontend/src/models/LoginRequest.ts | 23 ++ frontend/src/models/PGPKey.ts | 22 ++ frontend/src/models/PGPKeyRequest.ts | 23 ++ frontend/src/models/Package.ts | 29 +++ frontend/src/models/PackageActionRequest.ts | 29 +++ frontend/src/models/PackageProperties.ts | 35 +++ frontend/src/models/PackageRow.ts | 59 +++++ frontend/src/models/PackageSource.ts | 20 ++ frontend/src/models/PackageStatus.ts | 28 +++ frontend/src/models/Patch.ts | 23 ++ frontend/src/models/Remote.ts | 28 +++ frontend/src/models/RepositoryId.ts | 40 ++++ frontend/src/models/RepositoryStats.ts | 25 +++ frontend/src/models/Status.ts | 25 +++ frontend/src/theme/StatusColors.ts | 43 ++++ frontend/src/theme/Theme.ts | 33 +++ frontend/src/utils.ts | 39 ++++ frontend/tsconfig.json | 22 ++ frontend/vite.config.ts | 38 ++++ package/archlinux/PKGBUILD | 6 +- .../ahriman/settings/ahriman.ini.d/00-web.ini | 2 + ...atus.jinja2 => build-status-legacy.jinja2} | 0 pyproject.toml | 3 + src/ahriman/core/configuration/schema.py | 4 + src/ahriman/core/utils.py | 23 +- src/ahriman/web/schemas/__init__.py | 3 + src/ahriman/web/schemas/auth_info_schema.py | 36 ++++ .../schemas/auto_refresh_interval_schema.py | 36 ++++ src/ahriman/web/schemas/info_schema.py | 2 +- src/ahriman/web/schemas/info_v2_schema.py | 50 +++++ .../web/schemas/repository_id_schema.py | 4 + src/ahriman/web/server_info.py | 71 ++++++ src/ahriman/web/views/base.py | 19 +- src/ahriman/web/views/index.py | 45 +--- src/ahriman/web/views/v1/auditlog/events.py | 4 +- .../web/views/v1/distributed/workers.py | 4 +- src/ahriman/web/views/v1/packages/changes.py | 4 +- .../web/views/v1/packages/dependencies.py | 4 +- src/ahriman/web/views/v1/packages/logs.py | 4 +- src/ahriman/web/views/v1/packages/package.py | 4 +- src/ahriman/web/views/v1/packages/packages.py | 4 +- src/ahriman/web/views/v1/packages/patch.py | 4 +- src/ahriman/web/views/v1/packages/patches.py | 4 +- src/ahriman/web/views/v1/service/add.py | 4 +- src/ahriman/web/views/v1/service/config.py | 4 +- src/ahriman/web/views/v1/service/pgp.py | 6 +- src/ahriman/web/views/v1/service/process.py | 4 +- src/ahriman/web/views/v1/service/rebuild.py | 4 +- src/ahriman/web/views/v1/service/remove.py | 4 +- src/ahriman/web/views/v1/service/request.py | 4 +- src/ahriman/web/views/v1/service/search.py | 4 +- src/ahriman/web/views/v1/service/update.py | 4 +- src/ahriman/web/views/v1/status/info.py | 16 +- .../web/views/v1/status/repositories.py | 4 +- src/ahriman/web/views/v1/status/status.py | 4 +- src/ahriman/web/views/v2/packages/logs.py | 7 +- src/ahriman/web/views/v2/status/__init__.py | 19 ++ src/ahriman/web/views/v2/status/info.py | 56 +++++ subpackages.py | 4 +- tests/ahriman/core/test_utils.py | 25 +-- .../web/schemas/test_auth_info_schema.py | 1 + .../test_auto_refresh_interval_schema.py | 1 + .../web/schemas/test_info_v2_schema.py | 1 + tests/ahriman/web/test_server_info.py | 26 +++ .../v1/status/test_view_v1_status_info.py | 2 +- .../v2/status/test_view_v2_status_info.py | 43 ++++ tox.toml | 38 ++++ 152 files changed, 5753 insertions(+), 129 deletions(-) create mode 100644 docs/ahriman.web.views.v2.status.rst create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client/AhrimanClient.ts create mode 100644 frontend/src/api/client/ApiError.ts create mode 100644 frontend/src/api/client/BaseClient.ts create mode 100644 frontend/src/api/client/FetchMixin.ts create mode 100644 frontend/src/api/client/RequestOptions.ts create mode 100644 frontend/src/api/client/ServiceMixin.ts create mode 100644 frontend/src/chartSetup.ts create mode 100644 frontend/src/components/charts/EventDurationLineChart.tsx create mode 100644 frontend/src/components/charts/PackageCountBarChart.tsx create mode 100644 frontend/src/components/charts/StatusPieChart.tsx create mode 100644 frontend/src/components/common/AutoRefreshControl.tsx create mode 100644 frontend/src/components/common/CodeBlock.tsx create mode 100644 frontend/src/components/common/CopyButton.tsx create mode 100644 frontend/src/components/common/DialogHeader.tsx create mode 100644 frontend/src/components/common/RepositorySelect.tsx create mode 100644 frontend/src/components/common/formatTimestamp.ts create mode 100644 frontend/src/components/dialogs/DashboardDialog.tsx create mode 100644 frontend/src/components/dialogs/KeyImportDialog.tsx create mode 100644 frontend/src/components/dialogs/LoginDialog.tsx create mode 100644 frontend/src/components/dialogs/PackageAddDialog.tsx create mode 100644 frontend/src/components/dialogs/PackageInfoDialog.tsx create mode 100644 frontend/src/components/dialogs/PackageRebuildDialog.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/layout/Footer.tsx create mode 100644 frontend/src/components/layout/Navbar.tsx create mode 100644 frontend/src/components/package/BuildLogsTab.tsx create mode 100644 frontend/src/components/package/ChangesTab.tsx create mode 100644 frontend/src/components/package/EventsTab.tsx create mode 100644 frontend/src/components/package/PackageDetailsGrid.tsx create mode 100644 frontend/src/components/package/PackageInfoActions.tsx create mode 100644 frontend/src/components/package/PackagePatchesList.tsx create mode 100644 frontend/src/components/table/PackageTable.tsx create mode 100644 frontend/src/components/table/PackageTableToolbar.tsx create mode 100644 frontend/src/components/table/StatusCell.tsx create mode 100644 frontend/src/contexts/AuthContext.ts create mode 100644 frontend/src/contexts/AuthProvider.tsx create mode 100644 frontend/src/contexts/ClientContext.ts create mode 100644 frontend/src/contexts/ClientProvider.tsx create mode 100644 frontend/src/contexts/Notification.ts create mode 100644 frontend/src/contexts/NotificationContext.ts create mode 100644 frontend/src/contexts/NotificationItem.tsx create mode 100644 frontend/src/contexts/NotificationProvider.tsx create mode 100644 frontend/src/contexts/RepositoryContext.ts create mode 100644 frontend/src/contexts/RepositoryProvider.tsx create mode 100644 frontend/src/hooks/QueryKeys.ts create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useAutoRefresh.ts create mode 100644 frontend/src/hooks/useAutoScroll.ts create mode 100644 frontend/src/hooks/useClient.ts create mode 100644 frontend/src/hooks/useContextNotNull.ts create mode 100644 frontend/src/hooks/useDebounce.ts create mode 100644 frontend/src/hooks/useLocalStorage.ts create mode 100644 frontend/src/hooks/useNotification.ts create mode 100644 frontend/src/hooks/usePackageActions.ts create mode 100644 frontend/src/hooks/usePackageData.ts create mode 100644 frontend/src/hooks/usePackageTable.ts create mode 100644 frontend/src/hooks/useRepository.ts create mode 100644 frontend/src/hooks/useSelectedRepository.ts create mode 100644 frontend/src/hooks/useTableState.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/models/AURPackage.ts create mode 100644 frontend/src/models/AuthInfo.ts create mode 100644 frontend/src/models/AutoRefreshInterval.ts create mode 100644 frontend/src/models/BuildStatus.ts create mode 100644 frontend/src/models/Changes.ts create mode 100644 frontend/src/models/Counters.ts create mode 100644 frontend/src/models/Dependencies.ts create mode 100644 frontend/src/models/Event.ts create mode 100644 frontend/src/models/InfoResponse.ts create mode 100644 frontend/src/models/InternalStatus.ts create mode 100644 frontend/src/models/LogRecord.ts create mode 100644 frontend/src/models/LoginRequest.ts create mode 100644 frontend/src/models/PGPKey.ts create mode 100644 frontend/src/models/PGPKeyRequest.ts create mode 100644 frontend/src/models/Package.ts create mode 100644 frontend/src/models/PackageActionRequest.ts create mode 100644 frontend/src/models/PackageProperties.ts create mode 100644 frontend/src/models/PackageRow.ts create mode 100644 frontend/src/models/PackageSource.ts create mode 100644 frontend/src/models/PackageStatus.ts create mode 100644 frontend/src/models/Patch.ts create mode 100644 frontend/src/models/Remote.ts create mode 100644 frontend/src/models/RepositoryId.ts create mode 100644 frontend/src/models/RepositoryStats.ts create mode 100644 frontend/src/models/Status.ts create mode 100644 frontend/src/theme/StatusColors.ts create mode 100644 frontend/src/theme/Theme.ts create mode 100644 frontend/src/utils.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts rename package/share/ahriman/templates/{build-status.jinja2 => build-status-legacy.jinja2} (100%) create mode 100644 src/ahriman/web/schemas/auth_info_schema.py create mode 100644 src/ahriman/web/schemas/auto_refresh_interval_schema.py create mode 100644 src/ahriman/web/schemas/info_v2_schema.py create mode 100644 src/ahriman/web/server_info.py create mode 100644 src/ahriman/web/views/v2/status/__init__.py create mode 100644 src/ahriman/web/views/v2/status/info.py create mode 100644 tests/ahriman/web/schemas/test_auth_info_schema.py create mode 100644 tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py create mode 100644 tests/ahriman/web/schemas/test_info_v2_schema.py create mode 100644 tests/ahriman/web/test_server_info.py create mode 100644 tests/ahriman/web/views/v2/status/test_view_v2_status_info.py diff --git a/.dockerignore b/.dockerignore index b461b8d7..cd04d110 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,6 @@ __pycache__/ *.pyc *.pyd *.pyo + +node_modules/ +package-lock.json diff --git a/.github/workflows/setup.sh b/.github/workflows/setup.sh index 6731e824..e61926cd 100755 --- a/.github/workflows/setup.sh +++ b/.github/workflows/setup.sh @@ -10,7 +10,7 @@ echo -e '[arcanisrepo]\nServer = https://repo.arcanis.me/$arch\nSigLevel = Never # refresh the image pacman -Syyu --noconfirm # main dependencies -pacman -S --noconfirm devtools git pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo +pacman -S --noconfirm devtools git npm pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo # make dependencies pacman -S --noconfirm --asdeps base-devel python-build python-flit python-installer python-tox python-wheel # optional dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57781a0c..2ad62ad0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: - ${{ github.workspace }}:/build steps: - - run: pacman --noconfirm -Syu base-devel git python-tox + - run: pacman --noconfirm -Syu base-devel git npm python-tox - run: git config --global --add safe.directory * diff --git a/.gitignore b/.gitignore index 1fa4cc9d..789d309e 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,10 @@ status_cache.json *.db docs/html/ + +# Frontend +node_modules/ +package-lock.json +package/share/ahriman/templates/build-status.jinja2 +package/share/ahriman/templates/static/index.js +package/share/ahriman/templates/static/index.css diff --git a/docker/Dockerfile b/docker/Dockerfile index fa5eed5c..061f9ff9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -23,6 +23,7 @@ COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package" RUN pacman -S --noconfirm --asdeps \ devtools \ git \ + npm \ pyalpm \ python-bcrypt \ python-filelock \ diff --git a/docs/ahriman.web.rst b/docs/ahriman.web.rst index c175f4ac..3164f3f4 100644 --- a/docs/ahriman.web.rst +++ b/docs/ahriman.web.rst @@ -39,6 +39,14 @@ ahriman.web.routes module :no-undoc-members: :show-inheritance: +ahriman.web.server\_info module +------------------------------- + +.. automodule:: ahriman.web.server_info + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.web module ---------------------- diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst index 1900afaf..425ae914 100644 --- a/docs/ahriman.web.schemas.rst +++ b/docs/ahriman.web.schemas.rst @@ -20,6 +20,14 @@ ahriman.web.schemas.aur\_package\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.auth\_info\_schema module +--------------------------------------------- + +.. automodule:: ahriman.web.schemas.auth_info_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.auth\_schema module --------------------------------------- @@ -28,6 +36,14 @@ ahriman.web.schemas.auth\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.auto\_refresh\_interval\_schema module +---------------------------------------------------------- + +.. automodule:: ahriman.web.schemas.auto_refresh_interval_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.build\_options\_schema module ------------------------------------------------- @@ -108,6 +124,14 @@ ahriman.web.schemas.info\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.info\_v2\_schema module +------------------------------------------- + +.. automodule:: ahriman.web.schemas.info_v2_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.internal\_status\_schema module --------------------------------------------------- diff --git a/docs/ahriman.web.views.v2.rst b/docs/ahriman.web.views.v2.rst index 0d06409a..f5e97f6c 100644 --- a/docs/ahriman.web.views.v2.rst +++ b/docs/ahriman.web.views.v2.rst @@ -8,6 +8,7 @@ Subpackages :maxdepth: 4 ahriman.web.views.v2.packages + ahriman.web.views.v2.status Module contents --------------- diff --git a/docs/ahriman.web.views.v2.status.rst b/docs/ahriman.web.views.v2.status.rst new file mode 100644 index 00000000..65174688 --- /dev/null +++ b/docs/ahriman.web.views.v2.status.rst @@ -0,0 +1,21 @@ +ahriman.web.views.v2.status package +=================================== + +Submodules +---------- + +ahriman.web.views.v2.status.info module +--------------------------------------- + +.. automodule:: ahriman.web.views.v2.status.info + :members: + :no-undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: ahriman.web.views.v2.status + :members: + :no-undoc-members: + :show-inheritance: diff --git a/docs/configuration.rst b/docs/configuration.rst index 71446cc6..cf38295e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -188,6 +188,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed * ``port`` - port to bind, integer, optional. * ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``. * ``static_path`` - path to directory with static files, string, required. +* ``template`` - Jinja2 template name for the index page, string, optional, default ``build-status.jinja2``. * ``templates`` - path to templates directories, space separated list of paths, required. * ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization. * ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..8c9875fc --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,56 @@ +import js from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], + files: ["src/**/*.{ts,tsx}"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + "simple-import-sort": simpleImportSort, + "@stylistic": stylistic, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + + // imports + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + + "curly": "error", + "@stylistic/brace-style": ["error", "1tbs"], + + // stylistic + "@stylistic/indent": ["error", 4], + "@stylistic/quotes": ["error", "double"], + "@stylistic/semi": ["error", "always"], + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/object-curly-spacing": ["error", "always"], + "@stylistic/array-bracket-spacing": ["error", "never"], + "@stylistic/arrow-parens": ["error", "as-needed"], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/no-trailing-spaces": "error", + "@stylistic/no-multiple-empty-lines": ["error", { max: 1 }], + "@stylistic/jsx-quotes": ["error", "prefer-double"], + + // typescript + "@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }], + "@typescript-eslint/no-deprecated": "error", + "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..7246ce66 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + ahriman + + + + + +
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..5b87e58b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "ahriman-frontend", + "private": true, + "version": "2.20.0-rc4", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint src/", + "lint:fix": "eslint --fix src/", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^7.3.8", + "@mui/material": "^7.3.8", + "@mui/x-data-grid": "^8.27.3", + "@tanstack/react-query": "^5.0.0", + "chart.js": "^4.5.0", + "highlight.js": "^11.11.0", + "react": "^19.2.4", + "react-chartjs-2": "^5.2.0", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.3", + "@stylistic/eslint-plugin": "^5.9.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.39.3", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-simple-import-sort": "^12.1.1", + "typescript": "^5.3.0", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.1.1" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..7ec3be07 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import CssBaseline from "@mui/material/CssBaseline"; +import { ThemeProvider } from "@mui/material/styles"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import AppLayout from "components/layout/AppLayout"; +import { AuthProvider } from "contexts/AuthProvider"; +import { ClientProvider } from "contexts/ClientProvider"; +import { NotificationProvider } from "contexts/NotificationProvider"; +import { RepositoryProvider } from "contexts/RepositoryProvider"; +import type React from "react"; +import Theme from "theme/Theme"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + }, + }, +}); + +export default function App(): React.JSX.Element { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/api/client/AhrimanClient.ts b/frontend/src/api/client/AhrimanClient.ts new file mode 100644 index 00000000..7db9aca6 --- /dev/null +++ b/frontend/src/api/client/AhrimanClient.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { BaseClient } from "api/client/BaseClient"; +import { FetchMixin } from "api/client/FetchMixin"; +import { ServiceMixin } from "api/client/ServiceMixin"; +import type { LoginRequest } from "models/LoginRequest"; +import { applyMixins } from "utils"; + +/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ +export class AhrimanClient extends BaseClient { + + async login(data: LoginRequest): Promise { + return this.request("/api/v1/login", { method: "POST", json: data }); + } + + async logout(): Promise { + return this.request("/api/v1/logout", { method: "POST" }); + } +} + +export interface AhrimanClient extends FetchMixin, ServiceMixin {} +/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */ +applyMixins(AhrimanClient, [FetchMixin, ServiceMixin]); diff --git a/frontend/src/api/client/ApiError.ts b/frontend/src/api/client/ApiError.ts new file mode 100644 index 00000000..28505f60 --- /dev/null +++ b/frontend/src/api/client/ApiError.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +export class ApiError extends Error { + status: number; + statusText: string; + body: string; + + constructor(status: number, statusText: string, body: string) { + super(`${status} ${statusText}`); + this.status = status; + this.statusText = statusText; + this.body = body; + } + + get detail(): string { + try { + const parsed = JSON.parse(this.body) as Record; + return parsed.error ?? (this.body || this.message); + } catch { + return this.body || this.message; + } + } + + static errorDetail(e: unknown): string { + return e instanceof ApiError ? e.detail : String(e); + } +} diff --git a/frontend/src/api/client/BaseClient.ts b/frontend/src/api/client/BaseClient.ts new file mode 100644 index 00000000..38f6b9e0 --- /dev/null +++ b/frontend/src/api/client/BaseClient.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { ApiError } from "api/client/ApiError"; +import type { RequestOptions } from "api/client/RequestOptions"; + +export class BaseClient { + + protected async request(url: string, options: RequestOptions = {}): Promise { + const { method, query, json } = options; + + let fullUrl = url; + if (query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + params.set(key, String(value)); + } + } + fullUrl += `?${params.toString()}`; + } + + const headers: Record = { + Accept: "application/json", + }; + if (json !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const requestInit: RequestInit = { + method: method || (json ? "POST" : "GET"), + headers, + }; + + if (json !== undefined) { + requestInit.body = JSON.stringify(json); + } + + const response = await fetch(fullUrl, requestInit); + + if (!response.ok) { + const body = await response.text(); + throw new ApiError(response.status, response.statusText, body); + } + + const contentType = response.headers.get("Content-Type") ?? ""; + if (contentType.includes("application/json")) { + return await response.json() as T; + } + return await response.text() as T; + } +} diff --git a/frontend/src/api/client/FetchMixin.ts b/frontend/src/api/client/FetchMixin.ts new file mode 100644 index 00000000..1f5bda3b --- /dev/null +++ b/frontend/src/api/client/FetchMixin.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { BaseClient } from "api/client/BaseClient"; +import type { Changes } from "models/Changes"; +import type { Dependencies } from "models/Dependencies"; +import type { Event } from "models/Event"; +import type { InfoResponse } from "models/InfoResponse"; +import type { InternalStatus } from "models/InternalStatus"; +import type { LogRecord } from "models/LogRecord"; +import type { PackageStatus } from "models/PackageStatus"; +import type { Patch } from "models/Patch"; +import { RepositoryId } from "models/RepositoryId"; + +export class FetchMixin extends BaseClient { + + async fetchPackage(base: string, repo: RepositoryId): Promise { + const result = await this.request(`/api/v1/packages/${encodeURIComponent(base)}`, { + query: repo.toQuery(), + }); + return result.map(ps => ({ ...ps, repository: new RepositoryId(ps.repository.architecture, ps.repository.repository) })); + } + + async fetchPackageChanges(base: string, repo: RepositoryId): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/changes`, { + query: repo.toQuery(), + }); + } + + async fetchPackageDependencies(base: string, repo: RepositoryId): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/dependencies`, { + query: repo.toQuery(), + }); + } + + async fetchPackageEvents(repo: RepositoryId, objectId?: string, limit?: number): Promise { + const query: Record = repo.toQuery(); + if (objectId) { + query.object_id = objectId; + } + if (limit) { + query.limit = limit; + } + return this.request("/api/v1/events", { query }); + } + + async fetchPackageLogs( + base: string, + repo: RepositoryId, + version?: string, + processId?: string, + head?: boolean, + ): Promise { + const query: Record = { ...repo.toQuery() }; + if (version) { + query.version = version; + } + if (processId) { + query.process_id = processId; + } + if (head) { + query.head = true; + } + return this.request(`/api/v2/packages/${encodeURIComponent(base)}/logs`, { query }); + } + + async fetchPackagePatches(base: string): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches`); + } + + async fetchPackages(repo: RepositoryId): Promise { + const result = await this.request("/api/v1/packages", { query: repo.toQuery() }); + return result.map(ps => ({ ...ps, repository: new RepositoryId(ps.repository.architecture, ps.repository.repository) })); + } + + async fetchServerInfo(): Promise { + const result = await this.request("/api/v2/info"); + return { ...result, repositories: result.repositories.map(r => new RepositoryId(r.architecture, r.repository)) }; + } + + async fetchServerStatus(repo: RepositoryId): Promise { + return this.request("/api/v1/status", { query: repo.toQuery() }); + } +} diff --git a/frontend/src/api/client/RequestOptions.ts b/frontend/src/api/client/RequestOptions.ts new file mode 100644 index 00000000..ae4f3f8b --- /dev/null +++ b/frontend/src/api/client/RequestOptions.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +export interface RequestOptions { + method?: string; + query?: Record; + json?: unknown; +} diff --git a/frontend/src/api/client/ServiceMixin.ts b/frontend/src/api/client/ServiceMixin.ts new file mode 100644 index 00000000..62612630 --- /dev/null +++ b/frontend/src/api/client/ServiceMixin.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { BaseClient } from "api/client/BaseClient"; +import type { AURPackage } from "models/AURPackage"; +import type { PackageActionRequest } from "models/PackageActionRequest"; +import type { PGPKey } from "models/PGPKey"; +import type { PGPKeyRequest } from "models/PGPKeyRequest"; +import type { RepositoryId } from "models/RepositoryId"; + +export class ServiceMixin extends BaseClient { + + async servicePackageAdd(repo: RepositoryId, data: PackageActionRequest): Promise { + return this.request("/api/v1/service/add", { method: "POST", query: repo.toQuery(), json: data }); + } + + async servicePackagePatchRemove(base: string, key: string): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches/${encodeURIComponent(key)}`, { + method: "DELETE", + }); + } + + async servicePackageRemove(repo: RepositoryId, packages: string[]): Promise { + return this.request("/api/v1/service/remove", { + method: "POST", + query: repo.toQuery(), + json: { packages }, + }); + } + + async servicePackageRequest(repo: RepositoryId, data: PackageActionRequest): Promise { + return this.request("/api/v1/service/request", { + method: "POST", + query: repo.toQuery(), + json: data, + }); + } + + async servicePackageSearch(query: string): Promise { + return this.request("/api/v1/service/search", { query: { for: query } }); + } + + async servicePackageUpdate(repo: RepositoryId, data: PackageActionRequest): Promise { + return this.request("/api/v1/service/update", { + method: "POST", + query: repo.toQuery(), + json: data, + }); + } + + async servicePGPFetch(key: string, server: string): Promise { + return this.request("/api/v1/service/pgp", { query: { key, server } }); + } + + async servicePGPImport(data: PGPKeyRequest): Promise { + return this.request("/api/v1/service/pgp", { method: "POST", json: data }); + } + + async serviceRebuild(repo: RepositoryId, packages: string[]): Promise { + return this.request("/api/v1/service/rebuild", { + method: "POST", + query: repo.toQuery(), + json: { packages }, + }); + } +} diff --git a/frontend/src/chartSetup.ts b/frontend/src/chartSetup.ts new file mode 100644 index 00000000..f2463226 --- /dev/null +++ b/frontend/src/chartSetup.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { + ArcElement, + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + PointElement, + Tooltip, +} from "chart.js"; + +ChartJS.register( + ArcElement, + BarElement, + CategoryScale, + Legend, + LinearScale, + LineElement, + PointElement, + Tooltip, +); diff --git a/frontend/src/components/charts/EventDurationLineChart.tsx b/frontend/src/components/charts/EventDurationLineChart.tsx new file mode 100644 index 00000000..b42c1a17 --- /dev/null +++ b/frontend/src/components/charts/EventDurationLineChart.tsx @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { formatTimestamp } from "components/common/formatTimestamp"; +import type { Event } from "models/Event"; +import type React from "react"; +import { Line } from "react-chartjs-2"; + +interface EventDurationLineChartProps { + events: Event[]; +} + +export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element | null { + const updateEvents = events.filter(e => e.event === "package-updated" && e.data?.took); + + if (updateEvents.length === 0) { + return null; + } + + const data = { + labels: updateEvents.map(e => formatTimestamp(e.created)), + datasets: [ + { + label: "update duration, s", + data: updateEvents.map(e => (e.data?.took as number | undefined) ?? 0), + cubicInterpolationMode: "monotone" as const, + tension: 0.4, + }, + ], + }; + + return ; +} diff --git a/frontend/src/components/charts/PackageCountBarChart.tsx b/frontend/src/components/charts/PackageCountBarChart.tsx new file mode 100644 index 00000000..d12d60ca --- /dev/null +++ b/frontend/src/components/charts/PackageCountBarChart.tsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { blue, indigo } from "@mui/material/colors"; +import type { RepositoryStats } from "models/RepositoryStats"; +import type React from "react"; +import { Bar } from "react-chartjs-2"; + +interface PackageCountBarChartProps { + stats: RepositoryStats; +} + +export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element { + const data = { + labels: ["packages"], + datasets: [ + { + label: "archives", + data: [stats.packages ?? 0], + backgroundColor: blue[500], + }, + { + label: "bases", + data: [stats.bases ?? 0], + backgroundColor: indigo[300], + }, + ], + }; + + return ( + + ); +} diff --git a/frontend/src/components/charts/StatusPieChart.tsx b/frontend/src/components/charts/StatusPieChart.tsx new file mode 100644 index 00000000..24f32687 --- /dev/null +++ b/frontend/src/components/charts/StatusPieChart.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import type { Counters } from "models/Counters"; +import type React from "react"; +import { Pie } from "react-chartjs-2"; +import { StatusColors } from "theme/StatusColors"; + +interface StatusPieChartProps { + counters: Counters; +} + +export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element { + const labels = ["unknown", "pending", "building", "failed", "success"] as const; + const data = { + labels: [...labels], + datasets: [ + { + label: "packages in status", + data: labels.map(label => counters[label]), + backgroundColor: labels.map(label => StatusColors[label]), + }, + ], + }; + + return ; +} diff --git a/frontend/src/components/common/AutoRefreshControl.tsx b/frontend/src/components/common/AutoRefreshControl.tsx new file mode 100644 index 00000000..caf1b045 --- /dev/null +++ b/frontend/src/components/common/AutoRefreshControl.tsx @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import CheckIcon from "@mui/icons-material/Check"; +import TimerIcon from "@mui/icons-material/Timer"; +import TimerOffIcon from "@mui/icons-material/TimerOff"; +import { IconButton, ListItemIcon, ListItemText,Menu, MenuItem, Tooltip } from "@mui/material"; +import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; +import React, { useState } from "react"; + +interface AutoRefreshControlProps { + intervals: AutoRefreshInterval[]; + currentInterval: number; + onIntervalChange: (interval: number) => void; +} + +export default function AutoRefreshControl({ + intervals, + currentInterval, + onIntervalChange, +}: AutoRefreshControlProps): React.JSX.Element | null { + const [anchorEl, setAnchorEl] = useState(null); + + if (intervals.length === 0) { + return null; + } + + const enabled = currentInterval > 0; + + return ( + <> + + setAnchorEl(e.currentTarget)} + color={enabled ? "primary" : "default"} + > + {enabled ? : } + + + setAnchorEl(null)} + > + { + onIntervalChange(0); + setAnchorEl(null); + }} + > + + {!enabled && } + + Off + + {intervals.map(iv => ( + { + onIntervalChange(iv.interval); + setAnchorEl(null); + }} + > + + {enabled && iv.interval === currentInterval && } + + {iv.text} + + ))} + + + ); +} diff --git a/frontend/src/components/common/CodeBlock.tsx b/frontend/src/components/common/CodeBlock.tsx new file mode 100644 index 00000000..5fc7b051 --- /dev/null +++ b/frontend/src/components/common/CodeBlock.tsx @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { Box } from "@mui/material"; +import CopyButton from "components/common/CopyButton"; +import React, { type RefObject } from "react"; + +interface CodeBlockProps { + codeRef?: RefObject; + preRef?: RefObject; + className?: string; + children?: React.ReactNode; + getText: () => string; + maxHeight?: number | string; + height?: number | string; + onScroll?: () => void; + wordBreak?: boolean; +} + +export default function CodeBlock({ + codeRef, + preRef, + className, + children, + getText, + maxHeight, + height, + onScroll, + wordBreak, +}: CodeBlockProps): React.JSX.Element { + return ( + + + {codeRef + ? + : {children} + } + + + + + + ); +} diff --git a/frontend/src/components/common/CopyButton.tsx b/frontend/src/components/common/CopyButton.tsx new file mode 100644 index 00000000..927f6c9b --- /dev/null +++ b/frontend/src/components/common/CopyButton.tsx @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import CheckIcon from "@mui/icons-material/Check"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { IconButton, Tooltip } from "@mui/material"; +import React, { useEffect, useRef, useState } from "react"; + +interface CopyButtonProps { + getText: () => string; +} + +export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element { + const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = async (): Promise => { + await navigator.clipboard.writeText(getText()); + setCopied(true); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 2000); + }; + + return ( + + void handleCopy()}> + {copied ? : } + + + ); +} diff --git a/frontend/src/components/common/DialogHeader.tsx b/frontend/src/components/common/DialogHeader.tsx new file mode 100644 index 00000000..e53d8f78 --- /dev/null +++ b/frontend/src/components/common/DialogHeader.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import CloseIcon from "@mui/icons-material/Close"; +import { DialogTitle, IconButton, type SxProps, type Theme } from "@mui/material"; +import type React from "react"; + +interface DialogHeaderProps { + children: React.ReactNode; + onClose: () => void; + sx?: SxProps; +} + +export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element { + return ( + + {children} + + + + + ); +} diff --git a/frontend/src/components/common/RepositorySelect.tsx b/frontend/src/components/common/RepositorySelect.tsx new file mode 100644 index 00000000..078607b4 --- /dev/null +++ b/frontend/src/components/common/RepositorySelect.tsx @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { FormControl,InputLabel, MenuItem, Select } from "@mui/material"; +import { useRepository } from "hooks/useRepository"; +import type { SelectedRepositoryResult } from "hooks/useSelectedRepository"; +import type React from "react"; + +export default function RepositorySelect({ repoSelect }: { repoSelect: SelectedRepositoryResult }): React.JSX.Element { + const { repositories, current } = useRepository(); + + return ( + + repository + + + ); +} diff --git a/frontend/src/components/common/formatTimestamp.ts b/frontend/src/components/common/formatTimestamp.ts new file mode 100644 index 00000000..1e642096 --- /dev/null +++ b/frontend/src/components/common/formatTimestamp.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +export function formatTimestamp(unixSeconds: number): string { + const d = new Date(unixSeconds * 1000); + const pad = (n: number): string => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} diff --git a/frontend/src/components/dialogs/DashboardDialog.tsx b/frontend/src/components/dialogs/DashboardDialog.tsx new file mode 100644 index 00000000..b6e6d54b --- /dev/null +++ b/frontend/src/components/dialogs/DashboardDialog.tsx @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { Box,Dialog, DialogContent, Grid, Typography } from "@mui/material"; +import { skipToken, useQuery } from "@tanstack/react-query"; +import PackageCountBarChart from "components/charts/PackageCountBarChart"; +import StatusPieChart from "components/charts/StatusPieChart"; +import DialogHeader from "components/common/DialogHeader"; +import { formatTimestamp } from "components/common/formatTimestamp"; +import { QueryKeys } from "hooks/QueryKeys"; +import { useClient } from "hooks/useClient"; +import { useRepository } from "hooks/useRepository"; +import type { InternalStatus } from "models/InternalStatus"; +import { RepositoryId } from "models/RepositoryId"; +import type React from "react"; +import { StatusHeaderStyles } from "theme/StatusColors"; + +interface DashboardDialogProps { + open: boolean; + onClose: () => void; +} + +export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element { + const client = useClient(); + const { current } = useRepository(); + + const { data: status } = useQuery({ + queryKey: QueryKeys.status(current ?? new RepositoryId("", "")), + queryFn: current ? () => client.fetchServerStatus(current) : skipToken, + enabled: open, + }); + + const headerStyle = status ? StatusHeaderStyles[status.status.status] : {}; + + return ( + + + System health + + + {status && ( + <> + + + Repository name + + + {status.repository} + + + Repository architecture + + + {status.architecture} + + + + + + Current status + + + {status.status.status} + + + Updated at + + + {formatTimestamp(status.status.timestamp)} + + + + {status.packages.total > 0 && ( + + + + + + + + + + + + + )} + + )} + + + ); +} diff --git a/frontend/src/components/dialogs/KeyImportDialog.tsx b/frontend/src/components/dialogs/KeyImportDialog.tsx new file mode 100644 index 00000000..c894240a --- /dev/null +++ b/frontend/src/components/dialogs/KeyImportDialog.tsx @@ -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 . + */ +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + Box, + Button, + Dialog, DialogActions, DialogContent, TextField } from "@mui/material"; +import { ApiError } from "api/client/ApiError"; +import CodeBlock from "components/common/CodeBlock"; +import DialogHeader from "components/common/DialogHeader"; +import { useClient } from "hooks/useClient"; +import { useNotification } from "hooks/useNotification"; +import React, { useState } from "react"; + +interface KeyImportDialogProps { + open: boolean; + onClose: () => void; +} + +export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element { + const client = useClient(); + const { showSuccess, showError } = useNotification(); + + const [fingerprint, setFingerprint] = useState(""); + const [server, setServer] = useState("keyserver.ubuntu.com"); + const [keyBody, setKeyBody] = useState(""); + + const [prevOpen, setPrevOpen] = useState(false); + if (open && !prevOpen) { + setFingerprint(""); + setServer("keyserver.ubuntu.com"); + setKeyBody(""); + } + if (open !== prevOpen) { + setPrevOpen(open); + } + + const handleFetch = async (): Promise => { + if (!fingerprint || !server) { + return; + } + try { + const result = await client.servicePGPFetch(fingerprint, server); + setKeyBody(result.key); + } catch (e) { + const detail = ApiError.errorDetail(e); + showError("Action failed", `Could not fetch key: ${detail}`); + } + }; + + const handleImport = async (): Promise => { + if (!fingerprint || !server) { + return; + } + try { + await client.servicePGPImport({ key: fingerprint, server }); + onClose(); + showSuccess("Success", `Key ${fingerprint} has been imported`); + } catch (e) { + const detail = ApiError.errorDetail(e); + showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`); + } + }; + + return ( + + + Import key from PGP server + + + setFingerprint(e.target.value)} + /> + setServer(e.target.value)} + /> + {keyBody && ( + + keyBody} maxHeight={300}> + {keyBody} + + + )} + + + + + + + ); +} diff --git a/frontend/src/components/dialogs/LoginDialog.tsx b/frontend/src/components/dialogs/LoginDialog.tsx new file mode 100644 index 00000000..d37a6469 --- /dev/null +++ b/frontend/src/components/dialogs/LoginDialog.tsx @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import PersonIcon from "@mui/icons-material/Person"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import { + Button, Dialog, DialogActions, DialogContent, IconButton, + InputAdornment, TextField, +} from "@mui/material"; +import { ApiError } from "api/client/ApiError"; +import DialogHeader from "components/common/DialogHeader"; +import { useAuth } from "hooks/useAuth"; +import { useNotification } from "hooks/useNotification"; +import React, { useState } from "react"; + +interface LoginDialogProps { + open: boolean; + onClose: () => void; +} + +export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const { login } = useAuth(); + const { showSuccess, showError } = useNotification(); + + const [prevOpen, setPrevOpen] = useState(false); + if (open && !prevOpen) { + setUsername(""); + setPassword(""); + setShowPassword(false); + } + if (open !== prevOpen) { + setPrevOpen(open); + } + + const handleSubmit = async (): Promise => { + if (!username || !password) { + return; + } + try { + await login(username, password); + onClose(); + showSuccess("Logged in", `Successfully logged in as ${username}`); + window.location.reload(); + } catch (e) { + const detail = ApiError.errorDetail(e); + if (username === "admin" && password === "admin") { + showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?"); + } else { + showError("Login error", `Could not login as ${username}: ${detail}`); + } + } + }; + + return ( + + + Login + + + setUsername(e.target.value)} + autoFocus + /> + setPassword(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + void handleSubmit(); + } + }} + slotProps={{ + input: { + endAdornment: ( + + setShowPassword(!showPassword)} edge="end" size="small"> + {showPassword ? : } + + + ), + }, + }} + /> + + + + + + ); +} diff --git a/frontend/src/components/dialogs/PackageAddDialog.tsx b/frontend/src/components/dialogs/PackageAddDialog.tsx new file mode 100644 index 00000000..70bde19e --- /dev/null +++ b/frontend/src/components/dialogs/PackageAddDialog.tsx @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import { + Autocomplete, Box, Button, + Checkbox, + Dialog, DialogActions, DialogContent, FormControlLabel, IconButton, TextField } from "@mui/material"; +import { useQuery } from "@tanstack/react-query"; +import { ApiError } from "api/client/ApiError"; +import DialogHeader from "components/common/DialogHeader"; +import RepositorySelect from "components/common/RepositorySelect"; +import { QueryKeys } from "hooks/QueryKeys"; +import { useClient } from "hooks/useClient"; +import { useDebounce } from "hooks/useDebounce"; +import { useNotification } from "hooks/useNotification"; +import { useSelectedRepository } from "hooks/useSelectedRepository"; +import type { AURPackage } from "models/AURPackage"; +import React, { useRef, useState } from "react"; + +interface EnvVar { + id: number; + key: string; + value: string; +} + +interface PackageAddDialogProps { + open: boolean; + onClose: () => void; +} + +export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element { + const client = useClient(); + const { showSuccess, showError } = useNotification(); + const repoSelect = useSelectedRepository(); + + const [packageName, setPackageName] = useState(""); + const [refresh, setRefresh] = useState(true); + const [envVars, setEnvVars] = useState([]); + const envIdCounter = useRef(0); + + const [prevOpen, setPrevOpen] = useState(false); + if (open && !prevOpen) { + setPackageName(""); + repoSelect.reset(); + setRefresh(true); + setEnvVars([]); + } + if (open !== prevOpen) { + setPrevOpen(open); + } + + const debouncedSearch = useDebounce(packageName, 500); + + const { data: searchResults = [] } = useQuery({ + queryKey: QueryKeys.search(debouncedSearch), + queryFn: () => client.servicePackageSearch(debouncedSearch), + enabled: debouncedSearch.length >= 3, + }); + + const handleAdd = async (): Promise => { + if (!packageName) { + return; + } + const repo = repoSelect.selectedRepo; + if (!repo) { + return; + } + try { + const patches = envVars.filter(v => v.key); + await client.servicePackageAdd(repo, { + packages: [packageName], + patches: patches.length > 0 ? patches : undefined, + refresh, + }); + onClose(); + showSuccess("Success", `Packages ${packageName} have been added`); + } catch (e) { + const detail = ApiError.errorDetail(e); + showError("Action failed", `Package addition failed: ${detail}`); + } + }; + + const handleRequest = async (): Promise => { + if (!packageName) { + return; + } + const repo = repoSelect.selectedRepo; + if (!repo) { + return; + } + try { + const patches = envVars.filter(v => v.key); + await client.servicePackageRequest(repo, { + packages: [packageName], + patches: patches.length > 0 ? patches : undefined, + }); + onClose(); + showSuccess("Success", `Packages ${packageName} have been requested`); + } catch (e) { + const detail = ApiError.errorDetail(e); + showError("Action failed", `Package request failed: ${detail}`); + } + }; + + return ( + + + Add new packages + + + + + p.package)} + inputValue={packageName} + onInputChange={(_, value) => setPackageName(value)} + renderOption={(props, option) => { + const pkg = searchResults.find(p => p.package === option); + return ( +
  • + {option}{pkg ? ` (${pkg.description})` : ""} +
  • + ); + }} + renderInput={params => ( + + )} + /> + + setRefresh(checked)} />} + label="update pacman databases" + /> + + + + {envVars.map(env => ( + + { + const newKey = e.target.value; + setEnvVars(prev => prev.map(v => v.id === env.id ? { ...v, key: newKey } : v)); + }} + sx={{ flex: 1 }} + /> + = + { + const newValue = e.target.value; + setEnvVars(prev => prev.map(v => v.id === env.id ? { ...v, value: newValue } : v)); + }} + sx={{ flex: 1 }} + /> + setEnvVars(prev => prev.filter(v => v.id !== env.id))}> + + + + ))} +
    + + + + +
    + ); +} diff --git a/frontend/src/components/dialogs/PackageInfoDialog.tsx b/frontend/src/components/dialogs/PackageInfoDialog.tsx new file mode 100644 index 00000000..4457893d --- /dev/null +++ b/frontend/src/components/dialogs/PackageInfoDialog.tsx @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { Box, Dialog, DialogContent, Tab, Tabs } from "@mui/material"; +import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ApiError } from "api/client/ApiError"; +import DialogHeader from "components/common/DialogHeader"; +import { formatTimestamp } from "components/common/formatTimestamp"; +import BuildLogsTab from "components/package/BuildLogsTab"; +import ChangesTab from "components/package/ChangesTab"; +import EventsTab from "components/package/EventsTab"; +import PackageDetailsGrid from "components/package/PackageDetailsGrid"; +import PackageInfoActions from "components/package/PackageInfoActions"; +import PackagePatchesList from "components/package/PackagePatchesList"; +import { QueryKeys } from "hooks/QueryKeys"; +import { useAuth } from "hooks/useAuth"; +import { useAutoRefresh } from "hooks/useAutoRefresh"; +import { useClient } from "hooks/useClient"; +import { useNotification } from "hooks/useNotification"; +import { useRepository } from "hooks/useRepository"; +import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; +import type { Dependencies } from "models/Dependencies"; +import type { PackageStatus } from "models/PackageStatus"; +import type { Patch } from "models/Patch"; +import { RepositoryId } from "models/RepositoryId"; +import React, { useState } from "react"; +import { StatusHeaderStyles } from "theme/StatusColors"; +import { defaultInterval } from "utils"; + +interface PackageInfoDialogProps { + packageBase: string | null; + open: boolean; + onClose: () => void; + autorefreshIntervals: AutoRefreshInterval[]; +} + +export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element { + const client = useClient(); + const { current } = useRepository(); + const { isAuthorized } = useAuth(); + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + + const [tabIndex, setTabIndex] = useState(0); + const [refreshDb, setRefreshDb] = useState(true); + + const [prevOpen, setPrevOpen] = useState(false); + if (open && !prevOpen) { + setTabIndex(0); + setRefreshDb(true); + } + if (open !== prevOpen) { + setPrevOpen(open); + } + + const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autorefreshIntervals)); + + const { data: packageData } = useQuery({ + queryKey: QueryKeys.package(packageBase ?? "", current ?? new RepositoryId("", "")), + queryFn: packageBase && current ? () => client.fetchPackage(packageBase, current) : skipToken, + enabled: open, + refetchInterval: autoRefresh.interval, + }); + + const { data: dependencies } = useQuery({ + queryKey: QueryKeys.dependencies(packageBase ?? "", current ?? new RepositoryId("", "")), + queryFn: packageBase && current ? () => client.fetchPackageDependencies(packageBase, current) : skipToken, + enabled: open, + }); + + const { data: patches = [] } = useQuery({ + queryKey: QueryKeys.patches(packageBase ?? ""), + queryFn: packageBase ? () => client.fetchPackagePatches(packageBase) : skipToken, + enabled: open, + }); + + const description: PackageStatus | undefined = packageData?.[0]; + const pkg = description?.package; + const status = description?.status; + const headerStyle = status ? StatusHeaderStyles[status.status] : {}; + + const handleUpdate = async (): Promise => { + if (!packageBase || !current) { + return; + } + try { + await client.servicePackageAdd(current, { packages: [packageBase], refresh: refreshDb }); + showSuccess("Success", `Run update for packages ${packageBase}`); + } catch (e) { + showError("Action failed", `Package update failed: ${ApiError.errorDetail(e)}`); + } + }; + + const handleRemove = async (): Promise => { + if (!packageBase || !current) { + return; + } + try { + await client.servicePackageRemove(current, [packageBase]); + showSuccess("Success", `Packages ${packageBase} have been removed`); + onClose(); + } catch (e) { + showError("Action failed", `Could not remove package: ${ApiError.errorDetail(e)}`); + } + }; + + const handleDeletePatch = async (key: string): Promise => { + if (!packageBase) { + return; + } + try { + await client.servicePackagePatchRemove(packageBase, key); + void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) }); + } catch (e) { + showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(e)}`); + } + }; + + const handleReload = (): void => { + if (!packageBase || !current) { + return; + } + void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, current) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, current) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, current) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.events(current, packageBase) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, current) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) }); + }; + + return ( + + + {pkg && status + ? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}` + : packageBase ?? ""} + + + {pkg && ( + <> + + void handleDeletePatch(key)} + /> + + + setTabIndex(v)}> + + + + + + + {tabIndex === 0 && packageBase && current && ( + + )} + {tabIndex === 1 && packageBase && current && ( + + )} + {tabIndex === 2 && packageBase && current && ( + + )} + + )} + + void handleUpdate()} + onRemove={() => void handleRemove()} + onReload={handleReload} + autorefreshIntervals={autorefreshIntervals} + autoRefreshInterval={autoRefresh.interval} + onAutoRefreshIntervalChange={autoRefresh.changeInterval} + /> + + ); +} diff --git a/frontend/src/components/dialogs/PackageRebuildDialog.tsx b/frontend/src/components/dialogs/PackageRebuildDialog.tsx new file mode 100644 index 00000000..b7a2a98f --- /dev/null +++ b/frontend/src/components/dialogs/PackageRebuildDialog.tsx @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import { + Button, + Dialog, DialogActions, DialogContent, TextField, +} from "@mui/material"; +import { ApiError } from "api/client/ApiError"; +import DialogHeader from "components/common/DialogHeader"; +import RepositorySelect from "components/common/RepositorySelect"; +import { useClient } from "hooks/useClient"; +import { useNotification } from "hooks/useNotification"; +import { useSelectedRepository } from "hooks/useSelectedRepository"; +import React, { useState } from "react"; + +interface PackageRebuildDialogProps { + open: boolean; + onClose: () => void; +} + +export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element { + const client = useClient(); + const { showSuccess, showError } = useNotification(); + const repoSelect = useSelectedRepository(); + + const [dependency, setDependency] = useState(""); + + const [prevOpen, setPrevOpen] = useState(false); + if (open && !prevOpen) { + setDependency(""); + repoSelect.reset(); + } + if (open !== prevOpen) { + setPrevOpen(open); + } + + const handleRebuild = async (): Promise => { + if (!dependency) { + return; + } + const repo = repoSelect.selectedRepo; + if (!repo) { + return; + } + try { + await client.serviceRebuild(repo, [dependency]); + onClose(); + showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`); + } catch (e) { + const detail = ApiError.errorDetail(e); + showError("Action failed", `Repository rebuild failed: ${detail}`); + } + }; + + return ( + + + Rebuild depending packages + + + + + setDependency(e.target.value)} + /> + + + + + + ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 00000000..4ccb2510 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2021-2026 ahriman team. + * + * This file is part of ahriman + * (see https://github.com/arcan1s/ahriman). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { Box,Container } from "@mui/material"; +import { useQuery } from "@tanstack/react-query"; +import LoginDialog from "components/dialogs/LoginDialog"; +import Footer from "components/layout/Footer"; +import Navbar from "components/layout/Navbar"; +import PackageTable from "components/table/PackageTable"; +import { QueryKeys } from "hooks/QueryKeys"; +import { useAuth } from "hooks/useAuth"; +import { useClient } from "hooks/useClient"; +import { useRepository } from "hooks/useRepository"; +import type { InfoResponse } from "models/InfoResponse"; +import React, { useEffect,useState } from "react"; + +export default function AppLayout(): React.JSX.Element { + const client = useClient(); + const { setAuthState } = useAuth(); + const { setRepositories } = useRepository(); + const [loginOpen, setLoginOpen] = useState(false); + + const { data: info } = useQuery({ + queryKey: QueryKeys.info, + queryFn: () => client.fetchServerInfo(), + staleTime: Infinity, + }); + + // Sync info to contexts when loaded + useEffect(() => { + if (info) { + setAuthState({ enabled: info.auth.enabled, username: info.auth.username ?? null }); + setRepositories(info.repositories); + } + }, [info, setAuthState, setRepositories]); + + return ( + + + + + + + + + + + + +