From 5bd89c32916ecdfdd9203db3f8241af9436d5604 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Wed, 25 Feb 2026 22:49:38 +0200 Subject: [PATCH] upload ai slop --- .dockerignore | 3 + .gitignore | 6 + docs/configuration.rst | 1 + frontend/eslint.config.js | 49 +++ frontend/index.html | 16 + frontend/package.json | 40 +++ frontend/src/App.tsx | 35 ++ frontend/src/api/QueryKeys.ts | 30 ++ frontend/src/api/client/AhrimanClient.ts | 216 ++++++++++++ frontend/src/api/client/ApiError.ts | 21 ++ frontend/src/api/client/RequestOptions.ts | 5 + frontend/src/api/types/AURPackage.ts | 4 + frontend/src/api/types/AuthInfo.ts | 5 + frontend/src/api/types/AutoRefreshInterval.ts | 5 + frontend/src/api/types/BuildStatus.ts | 1 + frontend/src/api/types/Changes.ts | 4 + frontend/src/api/types/Counters.ts | 8 + frontend/src/api/types/Dependencies.ts | 3 + frontend/src/api/types/Event.ts | 7 + frontend/src/api/types/InfoResponse.ts | 12 + frontend/src/api/types/InternalStatus.ts | 12 + frontend/src/api/types/LogRecord.ts | 6 + frontend/src/api/types/LoginRequest.ts | 4 + frontend/src/api/types/PGPKey.ts | 3 + frontend/src/api/types/PGPKeyRequest.ts | 4 + frontend/src/api/types/Package.ts | 10 + .../src/api/types/PackageActionRequest.ts | 10 + frontend/src/api/types/PackageProperties.ts | 16 + frontend/src/api/types/PackageRow.ts | 15 + frontend/src/api/types/PackageStatus.ts | 9 + frontend/src/api/types/Patch.ts | 4 + frontend/src/api/types/Remote.ts | 7 + frontend/src/api/types/RepositoryId.ts | 4 + frontend/src/api/types/RepositoryStats.ts | 6 + frontend/src/api/types/Status.ts | 6 + .../charts/EventDurationLineChart.tsx | 33 ++ .../charts/PackageCountBarChart.tsx | 39 +++ .../src/components/charts/StatusPieChart.tsx | 27 ++ .../components/common/AutoRefreshControl.tsx | 75 +++++ frontend/src/components/common/CopyButton.tsx | 26 ++ .../src/components/common/formatTimestamp.ts | 5 + .../components/dialogs/DashboardDialog.tsx | 88 +++++ .../components/dialogs/KeyImportDialog.tsx | 106 ++++++ .../src/components/dialogs/LoginDialog.tsx | 91 +++++ .../components/dialogs/PackageAddDialog.tsx | 194 +++++++++++ .../components/dialogs/PackageInfoDialog.tsx | 299 +++++++++++++++++ .../dialogs/PackageRebuildDialog.tsx | 89 +++++ frontend/src/components/layout/AppLayout.tsx | 58 ++++ frontend/src/components/layout/Footer.tsx | 79 +++++ frontend/src/components/layout/Navbar.tsx | 33 ++ .../src/components/package/BuildLogsTab.tsx | 192 +++++++++++ .../src/components/package/ChangesTab.tsx | 60 ++++ frontend/src/components/package/EventsTab.tsx | 60 ++++ .../src/components/table/PackageTable.tsx | 317 ++++++++++++++++++ .../components/table/PackageTableToolbar.tsx | 133 ++++++++ frontend/src/components/table/StatusCell.tsx | 22 ++ frontend/src/contexts/AuthContext.ts | 14 + frontend/src/contexts/AuthProvider.tsx | 23 ++ frontend/src/contexts/NotificationContext.ts | 8 + .../src/contexts/NotificationProvider.tsx | 53 +++ frontend/src/contexts/RepositoryContext.ts | 11 + frontend/src/contexts/RepositoryProvider.tsx | 47 +++ frontend/src/hooks/useAuth.ts | 10 + frontend/src/hooks/useAutoRefresh.ts | 52 +++ frontend/src/hooks/useDebounce.ts | 12 + frontend/src/hooks/useLocalStorage.ts | 25 ++ frontend/src/hooks/useNotification.ts | 10 + frontend/src/hooks/useRepository.ts | 10 + frontend/src/main.tsx | 9 + frontend/src/theme/Theme.ts | 67 ++++ frontend/src/theme/status/StatusColors.ts | 20 ++ frontend/tsconfig.json | 21 ++ frontend/vite.config.ts | 41 +++ .../ahriman/settings/ahriman.ini.d/00-web.ini | 2 + 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 | 69 ++++ src/ahriman/web/views/api/swagger.py | 4 +- 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 | 2 - 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 | 18 + 119 files changed, 3513 insertions(+), 129 deletions(-) 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/QueryKeys.ts 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/RequestOptions.ts create mode 100644 frontend/src/api/types/AURPackage.ts create mode 100644 frontend/src/api/types/AuthInfo.ts create mode 100644 frontend/src/api/types/AutoRefreshInterval.ts create mode 100644 frontend/src/api/types/BuildStatus.ts create mode 100644 frontend/src/api/types/Changes.ts create mode 100644 frontend/src/api/types/Counters.ts create mode 100644 frontend/src/api/types/Dependencies.ts create mode 100644 frontend/src/api/types/Event.ts create mode 100644 frontend/src/api/types/InfoResponse.ts create mode 100644 frontend/src/api/types/InternalStatus.ts create mode 100644 frontend/src/api/types/LogRecord.ts create mode 100644 frontend/src/api/types/LoginRequest.ts create mode 100644 frontend/src/api/types/PGPKey.ts create mode 100644 frontend/src/api/types/PGPKeyRequest.ts create mode 100644 frontend/src/api/types/Package.ts create mode 100644 frontend/src/api/types/PackageActionRequest.ts create mode 100644 frontend/src/api/types/PackageProperties.ts create mode 100644 frontend/src/api/types/PackageRow.ts create mode 100644 frontend/src/api/types/PackageStatus.ts create mode 100644 frontend/src/api/types/Patch.ts create mode 100644 frontend/src/api/types/Remote.ts create mode 100644 frontend/src/api/types/RepositoryId.ts create mode 100644 frontend/src/api/types/RepositoryStats.ts create mode 100644 frontend/src/api/types/Status.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/CopyButton.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/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/NotificationContext.ts 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/useAuth.ts create mode 100644 frontend/src/hooks/useAutoRefresh.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/useRepository.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/theme/Theme.ts create mode 100644 frontend/src/theme/status/StatusColors.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts 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..0d9f1e4d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,6 @@ __pycache__/ *.pyc *.pyd *.pyo + +node_modules/ +frontend/ diff --git a/.gitignore b/.gitignore index 1fa4cc9d..5a856dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,9 @@ status_cache.json *.db docs/html/ + +# Frontend +node_modules/ +package-lock.json +package/share/ahriman/templates/static/index.js +package/share/ahriman/templates/static/index.css diff --git a/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..b09d87ff --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,49 @@ +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 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, + "@stylistic": stylistic, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + + "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", "always"], + "@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/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..36348dd9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "ahriman-frontend", + "private": true, + "version": "0.0.0", + "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": "^5.15.0", + "@mui/material": "^5.15.0", + "@mui/x-data-grid": "^7.0.0", + "@tanstack/react-query": "^5.0.0", + "chart.js": "^4.5.0", + "highlight.js": "^11.11.0", + "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.3", + "@stylistic/eslint-plugin": "^5.9.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "eslint": "^9.39.3", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "typescript": "^5.3.0", + "typescript-eslint": "^8.56.1", + "vite": "^5.0.0", + "vite-tsconfig-paths": "^6.1.1" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..cc2fba7a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,35 @@ +import type React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import Theme from "theme/Theme"; +import { AuthProvider } from "contexts/AuthProvider"; +import { RepositoryProvider } from "contexts/RepositoryProvider"; +import { NotificationProvider } from "contexts/NotificationProvider"; +import AppLayout from "components/layout/AppLayout"; + +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/QueryKeys.ts b/frontend/src/api/QueryKeys.ts new file mode 100644 index 00000000..40c8108d --- /dev/null +++ b/frontend/src/api/QueryKeys.ts @@ -0,0 +1,30 @@ +import type { RepositoryId } from "api/types/RepositoryId"; + +function repoKey(repo: RepositoryId): string { + return `${repo.architecture}-${repo.repository}`; +} + +export const QueryKeys = { + info: ["info"] as const, + + packages: (repo: RepositoryId) => ["packages", repoKey(repo)] as const, + package: (base: string, repo: RepositoryId) => ["packages", repoKey(repo), base] as const, + + status: (repo: RepositoryId) => ["status", repoKey(repo)] as const, + + logs: (base: string, repo: RepositoryId) => ["logs", repoKey(repo), base] as const, + logsVersion: (base: string, repo: RepositoryId, version: string, processId: string) => + ["logs", repoKey(repo), base, version, processId] as const, + + changes: (base: string, repo: RepositoryId) => ["changes", repoKey(repo), base] as const, + + dependencies: (base: string, repo: RepositoryId) => ["dependencies", repoKey(repo), base] as const, + + patches: (base: string) => ["patches", base] as const, + + events: (repo: RepositoryId, objectId?: string) => ["events", repoKey(repo), objectId] as const, + + search: (query: string) => ["search", query] as const, + + pgpKey: (key: string, server: string) => ["pgp", key, server] as const, +}; diff --git a/frontend/src/api/client/AhrimanClient.ts b/frontend/src/api/client/AhrimanClient.ts new file mode 100644 index 00000000..d41568b3 --- /dev/null +++ b/frontend/src/api/client/AhrimanClient.ts @@ -0,0 +1,216 @@ +import type { AURPackage } from "api/types/AURPackage"; +import type { Changes } from "api/types/Changes"; +import type { Dependencies } from "api/types/Dependencies"; +import type { Event } from "api/types/Event"; +import type { InfoResponse } from "api/types/InfoResponse"; +import type { InternalStatus } from "api/types/InternalStatus"; +import type { LogRecord } from "api/types/LogRecord"; +import type { LoginRequest } from "api/types/LoginRequest"; +import type { PackageActionRequest } from "api/types/PackageActionRequest"; +import type { PackageStatus } from "api/types/PackageStatus"; +import type { Patch } from "api/types/Patch"; +import type { PGPKey } from "api/types/PGPKey"; +import type { PGPKeyRequest } from "api/types/PGPKeyRequest"; +import type { RepositoryId } from "api/types/RepositoryId"; + +import { ApiError } from "api/client/ApiError"; +import type { RequestOptions } from "api/client/RequestOptions"; + +export class AhrimanClient { + private static repoQuery(repo: RepositoryId): Record { + return { architecture: repo.architecture, repository: repo.repository }; + } + + private 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 requestInit: RequestInit = { + method: method || (json ? "POST" : "GET"), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }; + + 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; + } + + // Info + + async fetchInfo(): Promise { + return this.request("/api/v2/info"); + } + + // Packages + + async fetchPackages(repo: RepositoryId): Promise { + return this.request("/api/v1/packages", { query: AhrimanClient.repoQuery(repo) }); + } + + async fetchPackage(base: string, repo: RepositoryId): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}`, { + query: AhrimanClient.repoQuery(repo), + }); + } + + // Status + + async fetchStatus(repo: RepositoryId): Promise { + return this.request("/api/v1/status", { query: AhrimanClient.repoQuery(repo) }); + } + + // Logs + + async fetchLogs( + base: string, + repo: RepositoryId, + version?: string, + processId?: string, + head?: boolean, + ): Promise { + const query: Record = AhrimanClient.repoQuery(repo); + 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 }); + } + + // Changes + + async fetchChanges(base: string, repo: RepositoryId): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/changes`, { + query: AhrimanClient.repoQuery(repo), + }); + } + + // Dependencies + + async fetchDependencies(base: string, repo: RepositoryId): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/dependencies`, { + query: AhrimanClient.repoQuery(repo), + }); + } + + // Patches + + async fetchPatches(base: string): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches`); + } + + async deletePatch(base: string, key: string): Promise { + return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches/${encodeURIComponent(key)}`, { + method: "DELETE", + }); + } + + // Events + + async fetchEvents(repo: RepositoryId, objectId?: string, limit?: number): Promise { + const query: Record = AhrimanClient.repoQuery(repo); + if (objectId) { + query.object_id = objectId; + } + if (limit) { + query.limit = limit; + } + return this.request("/api/v1/events", { query }); + } + + // Service actions + + async addPackages(repo: RepositoryId, data: PackageActionRequest): Promise { + return this.request("/api/v1/service/add", { method: "POST", query: AhrimanClient.repoQuery(repo), json: data }); + } + + async removePackages(repo: RepositoryId, packages: string[]): Promise { + return this.request("/api/v1/service/remove", { + method: "POST", + query: AhrimanClient.repoQuery(repo), + json: { packages }, + }); + } + + async updatePackages(repo: RepositoryId, data: PackageActionRequest): Promise { + return this.request("/api/v1/service/update", { + method: "POST", + query: AhrimanClient.repoQuery(repo), + json: data, + }); + } + + async rebuildPackages(repo: RepositoryId, packages: string[]): Promise { + return this.request("/api/v1/service/rebuild", { + method: "POST", + query: AhrimanClient.repoQuery(repo), + json: { packages }, + }); + } + + async requestPackages(repo: RepositoryId, data: PackageActionRequest): Promise { + return this.request("/api/v1/service/request", { + method: "POST", + query: AhrimanClient.repoQuery(repo), + json: data, + }); + } + + // Search + + async searchPackages(query: string): Promise { + return this.request("/api/v1/service/search", { query: { for: query } }); + } + + // PGP + + async fetchPGPKey(key: string, server: string): Promise { + return this.request("/api/v1/service/pgp", { query: { key, server } }); + } + + async importPGPKey(data: PGPKeyRequest): Promise { + return this.request("/api/v1/service/pgp", { method: "POST", json: data }); + } + + // Auth + + 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 const Client = new AhrimanClient(); diff --git a/frontend/src/api/client/ApiError.ts b/frontend/src/api/client/ApiError.ts new file mode 100644 index 00000000..068df81c --- /dev/null +++ b/frontend/src/api/client/ApiError.ts @@ -0,0 +1,21 @@ +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; + } + } +} diff --git a/frontend/src/api/client/RequestOptions.ts b/frontend/src/api/client/RequestOptions.ts new file mode 100644 index 00000000..24fc042e --- /dev/null +++ b/frontend/src/api/client/RequestOptions.ts @@ -0,0 +1,5 @@ +export interface RequestOptions { + method?: string; + query?: Record; + json?: unknown; +} diff --git a/frontend/src/api/types/AURPackage.ts b/frontend/src/api/types/AURPackage.ts new file mode 100644 index 00000000..f0655690 --- /dev/null +++ b/frontend/src/api/types/AURPackage.ts @@ -0,0 +1,4 @@ +export interface AURPackage { + package: string; + description: string; +} diff --git a/frontend/src/api/types/AuthInfo.ts b/frontend/src/api/types/AuthInfo.ts new file mode 100644 index 00000000..f38db219 --- /dev/null +++ b/frontend/src/api/types/AuthInfo.ts @@ -0,0 +1,5 @@ +export interface AuthInfo { + control: string; + enabled: boolean; + username?: string; +} diff --git a/frontend/src/api/types/AutoRefreshInterval.ts b/frontend/src/api/types/AutoRefreshInterval.ts new file mode 100644 index 00000000..155a19c9 --- /dev/null +++ b/frontend/src/api/types/AutoRefreshInterval.ts @@ -0,0 +1,5 @@ +export interface AutoRefreshInterval { + interval: number; + is_active: boolean; + text: string; +} diff --git a/frontend/src/api/types/BuildStatus.ts b/frontend/src/api/types/BuildStatus.ts new file mode 100644 index 00000000..2ce972cf --- /dev/null +++ b/frontend/src/api/types/BuildStatus.ts @@ -0,0 +1 @@ +export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success"; diff --git a/frontend/src/api/types/Changes.ts b/frontend/src/api/types/Changes.ts new file mode 100644 index 00000000..ad3dbc59 --- /dev/null +++ b/frontend/src/api/types/Changes.ts @@ -0,0 +1,4 @@ +export interface Changes { + changes?: string; + last_commit_sha?: string; +} diff --git a/frontend/src/api/types/Counters.ts b/frontend/src/api/types/Counters.ts new file mode 100644 index 00000000..8812831c --- /dev/null +++ b/frontend/src/api/types/Counters.ts @@ -0,0 +1,8 @@ +export interface Counters { + building: number; + failed: number; + pending: number; + success: number; + total: number; + unknown: number; +} diff --git a/frontend/src/api/types/Dependencies.ts b/frontend/src/api/types/Dependencies.ts new file mode 100644 index 00000000..e1295452 --- /dev/null +++ b/frontend/src/api/types/Dependencies.ts @@ -0,0 +1,3 @@ +export interface Dependencies { + paths: Record; +} diff --git a/frontend/src/api/types/Event.ts b/frontend/src/api/types/Event.ts new file mode 100644 index 00000000..db642531 --- /dev/null +++ b/frontend/src/api/types/Event.ts @@ -0,0 +1,7 @@ +export interface Event { + created: number; + data?: Record; + event: string; + message?: string; + object_id: string; +} diff --git a/frontend/src/api/types/InfoResponse.ts b/frontend/src/api/types/InfoResponse.ts new file mode 100644 index 00000000..b7069721 --- /dev/null +++ b/frontend/src/api/types/InfoResponse.ts @@ -0,0 +1,12 @@ +import type { AuthInfo } from "api/types/AuthInfo"; +import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval"; +import type { RepositoryId } from "api/types/RepositoryId"; + +export interface InfoResponse { + auth: AuthInfo; + repositories: RepositoryId[]; + version: string; + autorefresh_intervals: AutoRefreshInterval[]; + docs_enabled: boolean; + index_url?: string; +} diff --git a/frontend/src/api/types/InternalStatus.ts b/frontend/src/api/types/InternalStatus.ts new file mode 100644 index 00000000..d9d7d663 --- /dev/null +++ b/frontend/src/api/types/InternalStatus.ts @@ -0,0 +1,12 @@ +import type { Counters } from "api/types/Counters"; +import type { RepositoryStats } from "api/types/RepositoryStats"; +import type { Status } from "api/types/Status"; + +export interface InternalStatus { + architecture: string; + repository: string; + packages: Counters; + stats: RepositoryStats; + status: Status; + version: string; +} diff --git a/frontend/src/api/types/LogRecord.ts b/frontend/src/api/types/LogRecord.ts new file mode 100644 index 00000000..09fc197a --- /dev/null +++ b/frontend/src/api/types/LogRecord.ts @@ -0,0 +1,6 @@ +export interface LogRecord { + created: number; + message: string; + process_id: string; + version: string; +} diff --git a/frontend/src/api/types/LoginRequest.ts b/frontend/src/api/types/LoginRequest.ts new file mode 100644 index 00000000..eb971180 --- /dev/null +++ b/frontend/src/api/types/LoginRequest.ts @@ -0,0 +1,4 @@ +export interface LoginRequest { + username: string; + password: string; +} diff --git a/frontend/src/api/types/PGPKey.ts b/frontend/src/api/types/PGPKey.ts new file mode 100644 index 00000000..b79367fa --- /dev/null +++ b/frontend/src/api/types/PGPKey.ts @@ -0,0 +1,3 @@ +export interface PGPKey { + key: string; +} diff --git a/frontend/src/api/types/PGPKeyRequest.ts b/frontend/src/api/types/PGPKeyRequest.ts new file mode 100644 index 00000000..ac76fc76 --- /dev/null +++ b/frontend/src/api/types/PGPKeyRequest.ts @@ -0,0 +1,4 @@ +export interface PGPKeyRequest { + key: string; + server: string; +} diff --git a/frontend/src/api/types/Package.ts b/frontend/src/api/types/Package.ts new file mode 100644 index 00000000..57f44c73 --- /dev/null +++ b/frontend/src/api/types/Package.ts @@ -0,0 +1,10 @@ +import type { PackageProperties } from "api/types/PackageProperties"; +import type { Remote } from "api/types/Remote"; + +export interface Package { + base: string; + packager?: string; + packages: Record; + remote: Remote; + version: string; +} diff --git a/frontend/src/api/types/PackageActionRequest.ts b/frontend/src/api/types/PackageActionRequest.ts new file mode 100644 index 00000000..9e49e814 --- /dev/null +++ b/frontend/src/api/types/PackageActionRequest.ts @@ -0,0 +1,10 @@ +import type { Patch } from "api/types/Patch"; + +export interface PackageActionRequest { + packages: string[]; + patches?: Patch[]; + refresh?: boolean; + aur?: boolean; + local?: boolean; + manual?: boolean; +} diff --git a/frontend/src/api/types/PackageProperties.ts b/frontend/src/api/types/PackageProperties.ts new file mode 100644 index 00000000..d27a69d8 --- /dev/null +++ b/frontend/src/api/types/PackageProperties.ts @@ -0,0 +1,16 @@ +export interface PackageProperties { + architecture?: string; + archive_size?: number; + build_date?: number; + check_depends?: string[]; + depends?: string[]; + description?: string; + filename?: string; + groups?: string[]; + installed_size?: number; + licenses?: string[]; + make_depends?: string[]; + opt_depends?: string[]; + provides?: string[]; + url?: string; +} diff --git a/frontend/src/api/types/PackageRow.ts b/frontend/src/api/types/PackageRow.ts new file mode 100644 index 00000000..315d5fe0 --- /dev/null +++ b/frontend/src/api/types/PackageRow.ts @@ -0,0 +1,15 @@ +import type { BuildStatus } from "api/types/BuildStatus"; + +export interface PackageRow { + id: string; + base: string; + webUrl?: string; + version: string; + packages: string[]; + groups: string[]; + licenses: string[]; + packager: string; + timestamp: string; + timestampValue: number; + status: BuildStatus; +} diff --git a/frontend/src/api/types/PackageStatus.ts b/frontend/src/api/types/PackageStatus.ts new file mode 100644 index 00000000..323040a1 --- /dev/null +++ b/frontend/src/api/types/PackageStatus.ts @@ -0,0 +1,9 @@ +import type { Package } from "api/types/Package"; +import type { RepositoryId } from "api/types/RepositoryId"; +import type { Status } from "api/types/Status"; + +export interface PackageStatus { + package: Package; + status: Status; + repository: RepositoryId; +} diff --git a/frontend/src/api/types/Patch.ts b/frontend/src/api/types/Patch.ts new file mode 100644 index 00000000..bb1d8a77 --- /dev/null +++ b/frontend/src/api/types/Patch.ts @@ -0,0 +1,4 @@ +export interface Patch { + key: string; + value: string; +} diff --git a/frontend/src/api/types/Remote.ts b/frontend/src/api/types/Remote.ts new file mode 100644 index 00000000..56616a30 --- /dev/null +++ b/frontend/src/api/types/Remote.ts @@ -0,0 +1,7 @@ +export interface Remote { + branch?: string; + git_url?: string; + path?: string; + source: string; + web_url?: string; +} diff --git a/frontend/src/api/types/RepositoryId.ts b/frontend/src/api/types/RepositoryId.ts new file mode 100644 index 00000000..c02d06b8 --- /dev/null +++ b/frontend/src/api/types/RepositoryId.ts @@ -0,0 +1,4 @@ +export interface RepositoryId { + architecture: string; + repository: string; +} diff --git a/frontend/src/api/types/RepositoryStats.ts b/frontend/src/api/types/RepositoryStats.ts new file mode 100644 index 00000000..d52eb8ea --- /dev/null +++ b/frontend/src/api/types/RepositoryStats.ts @@ -0,0 +1,6 @@ +export interface RepositoryStats { + archive_size?: number; + bases?: number; + installed_size?: number; + packages?: number; +} diff --git a/frontend/src/api/types/Status.ts b/frontend/src/api/types/Status.ts new file mode 100644 index 00000000..a28804ef --- /dev/null +++ b/frontend/src/api/types/Status.ts @@ -0,0 +1,6 @@ +import type { BuildStatus } from "api/types/BuildStatus"; + +export interface Status { + status: BuildStatus; + timestamp: number; +} diff --git a/frontend/src/components/charts/EventDurationLineChart.tsx b/frontend/src/components/charts/EventDurationLineChart.tsx new file mode 100644 index 00000000..2f564ce8 --- /dev/null +++ b/frontend/src/components/charts/EventDurationLineChart.tsx @@ -0,0 +1,33 @@ +import type React from "react"; +import { Line } from "react-chartjs-2"; +import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from "chart.js"; +import type { Event } from "api/types/Event"; +import { formatTimestamp } from "components/common/formatTimestamp"; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend); + +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 as Record).took), + 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..f533853a --- /dev/null +++ b/frontend/src/components/charts/PackageCountBarChart.tsx @@ -0,0 +1,39 @@ +import type React from "react"; +import { Bar } from "react-chartjs-2"; +import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js"; +import type { RepositoryStats } from "api/types/RepositoryStats"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend); + +interface PackageCountBarChartProps { + stats: RepositoryStats; +} + +export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element { + const data = { + labels: ["packages"], + datasets: [ + { + label: "archives", + data: [stats.packages ?? 0], + }, + { + label: "bases", + data: [stats.bases ?? 0], + }, + ], + }; + + return ( + + ); +} diff --git a/frontend/src/components/charts/StatusPieChart.tsx b/frontend/src/components/charts/StatusPieChart.tsx new file mode 100644 index 00000000..e5552bec --- /dev/null +++ b/frontend/src/components/charts/StatusPieChart.tsx @@ -0,0 +1,27 @@ +import type React from "react"; +import { Pie } from "react-chartjs-2"; +import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js"; +import type { Counters } from "api/types/Counters"; +import { StatusColors } from "theme/status/StatusColors"; + +ChartJS.register(ArcElement, Tooltip, Legend); + +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.map((l) => l), + 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..dae0aa08 --- /dev/null +++ b/frontend/src/components/common/AutoRefreshControl.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; +import { IconButton, Menu, MenuItem, Tooltip, ListItemIcon, ListItemText } from "@mui/material"; +import TimerIcon from "@mui/icons-material/Timer"; +import TimerOffIcon from "@mui/icons-material/TimerOff"; +import CheckIcon from "@mui/icons-material/Check"; +import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval"; + +interface AutoRefreshControlProps { + intervals: AutoRefreshInterval[]; + enabled: boolean; + currentInterval: number; + onToggle: (enabled: boolean) => void; + onIntervalChange: (interval: number) => void; +} + +export default function AutoRefreshControl({ + intervals, + enabled, + currentInterval, + onToggle, + onIntervalChange, +}: AutoRefreshControlProps): React.JSX.Element | null { + const [anchorEl, setAnchorEl] = useState(null); + + if (intervals.length === 0) { + return null; + } + + return ( + <> + + setAnchorEl(e.currentTarget)} + color={enabled ? "primary" : "default"} + > + {enabled ? : } + + + setAnchorEl(null)} + > + { + onToggle(false); + 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/CopyButton.tsx b/frontend/src/components/common/CopyButton.tsx new file mode 100644 index 00000000..0aa594da --- /dev/null +++ b/frontend/src/components/common/CopyButton.tsx @@ -0,0 +1,26 @@ +import React, { useState } from "react"; +import { IconButton, Tooltip } from "@mui/material"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import CheckIcon from "@mui/icons-material/Check"; + +interface CopyButtonProps { + getText: () => string; +} + +export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element { + const [copied, setCopied] = useState(false); + + const handleCopy = async (): Promise => { + await navigator.clipboard.writeText(getText()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + void handleCopy()}> + {copied ? : } + + + ); +} diff --git a/frontend/src/components/common/formatTimestamp.ts b/frontend/src/components/common/formatTimestamp.ts new file mode 100644 index 00000000..a4259465 --- /dev/null +++ b/frontend/src/components/common/formatTimestamp.ts @@ -0,0 +1,5 @@ +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..56fe8a4a --- /dev/null +++ b/frontend/src/components/dialogs/DashboardDialog.tsx @@ -0,0 +1,88 @@ +import type React from "react"; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Typography, Box } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useQuery } from "@tanstack/react-query"; +import StatusPieChart from "components/charts/StatusPieChart"; +import PackageCountBarChart from "components/charts/PackageCountBarChart"; +import { Client } from "api/client/AhrimanClient"; +import { QueryKeys } from "api/QueryKeys"; +import { useRepository } from "hooks/useRepository"; +import { StatusHeaderStyles } from "theme/status/StatusColors"; +import { formatTimestamp } from "components/common/formatTimestamp"; +import type { InternalStatus } from "api/types/InternalStatus"; + +interface DashboardDialogProps { + open: boolean; + onClose: () => void; +} + +export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element { + const { current } = useRepository(); + + const { data: status } = useQuery({ + queryKey: current ? QueryKeys.status(current) : ["status"], + queryFn: () => Client.fetchStatus(current!), + enabled: !!current && 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..d2a9beb3 --- /dev/null +++ b/frontend/src/components/dialogs/KeyImportDialog.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + TextField, Box, +} from "@mui/material"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { useNotification } from "hooks/useNotification"; +import { Client } from "api/client/AhrimanClient"; +import { ApiError } from "api/client/ApiError"; +import CopyButton from "components/common/CopyButton"; + +interface KeyImportDialogProps { + open: boolean; + onClose: () => void; +} + +export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element { + const { showSuccess, showError } = useNotification(); + + const [fingerprint, setFingerprint] = useState(""); + const [server, setServer] = useState("keyserver.ubuntu.com"); + const [keyBody, setKeyBody] = useState(""); + + const handleFetch = async (): Promise => { + if (!fingerprint || !server) { + return; + } + try { + const result = await Client.fetchPGPKey(fingerprint, server); + setKeyBody(result.key); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Could not fetch key: ${detail}`); + } + }; + + const handleImport = async (): Promise => { + if (!fingerprint || !server) { + return; + } + try { + await Client.importPGPKey({ key: fingerprint, server }); + onClose(); + showSuccess("Success", `Key ${fingerprint} has been imported`); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`); + } + }; + + const handleClose = (): void => { + setFingerprint(""); + setServer("keyserver.ubuntu.com"); + setKeyBody(""); + onClose(); + }; + + return ( + + Import key from PGP server + + setFingerprint(e.target.value)} + /> + setServer(e.target.value)} + /> + {keyBody && ( + + + {keyBody} + + + keyBody} /> + + + )} + + + + + + + ); +} diff --git a/frontend/src/components/dialogs/LoginDialog.tsx b/frontend/src/components/dialogs/LoginDialog.tsx new file mode 100644 index 00000000..7ffafa4f --- /dev/null +++ b/frontend/src/components/dialogs/LoginDialog.tsx @@ -0,0 +1,91 @@ +import React, { useState } from "react"; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, + InputAdornment, IconButton, +} from "@mui/material"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import PersonIcon from "@mui/icons-material/Person"; +import { useAuth } from "hooks/useAuth"; +import { useNotification } from "hooks/useNotification"; +import { ApiError } from "api/client/ApiError"; + +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 handleSubmit = async (): Promise => { + if (!username || !password) { + return; + } + try { + await login(username, password); + onClose(); + showSuccess("Logged in", `Successfully logged in as ${username}`); + window.location.href = "/"; + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(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}`); + } + } + }; + + const handleClose = (): void => { + setUsername(""); + setPassword(""); + setShowPassword(false); + onClose(); + }; + + return ( + + Login + + setUsername(e.target.value)} + autoFocus + /> + setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleSubmit(); + } + }} + InputProps={{ + 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..89b2f73c --- /dev/null +++ b/frontend/src/components/dialogs/PackageAddDialog.tsx @@ -0,0 +1,194 @@ +import React, { useState, useCallback } from "react"; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox, Select, MenuItem, InputLabel, FormControl, +} from "@mui/material"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useQuery } from "@tanstack/react-query"; +import { useRepository } from "hooks/useRepository"; +import { useNotification } from "hooks/useNotification"; +import { useDebounce } from "hooks/useDebounce"; +import { Client } from "api/client/AhrimanClient"; +import { ApiError } from "api/client/ApiError"; +import { QueryKeys } from "api/QueryKeys"; +import type { AURPackage } from "api/types/AURPackage"; +import type { RepositoryId } from "api/types/RepositoryId"; + +interface EnvVar { + key: string; + value: string; +} + +interface PackageAddDialogProps { + open: boolean; + onClose: () => void; +} + +export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element { + const { repositories, current } = useRepository(); + const { showSuccess, showError } = useNotification(); + + const [packageName, setPackageName] = useState(""); + const [selectedRepo, setSelectedRepo] = useState(""); + const [refresh, setRefresh] = useState(true); + const [envVars, setEnvVars] = useState([]); + + const debouncedSearch = useDebounce(packageName, 500); + + const { data: searchResults = [] } = useQuery({ + queryKey: QueryKeys.search(debouncedSearch), + queryFn: () => Client.searchPackages(debouncedSearch), + enabled: debouncedSearch.length >= 3, + }); + + const getSelectedRepo = useCallback((): RepositoryId => { + if (selectedRepo) { + const repo = repositories.find( + (r) => `${r.architecture}-${r.repository}` === selectedRepo, + ); + if (repo) { + return repo; + } + } + return current!; + }, [selectedRepo, repositories, current]); + + const handleAdd = async (): Promise => { + if (!packageName) { + return; + } + const repo = getSelectedRepo(); + try { + const patches = envVars.filter((v) => v.key); + await Client.addPackages(repo, { + packages: [packageName], + patches: patches.length > 0 ? patches : undefined, + refresh, + }); + onClose(); + showSuccess("Success", `Packages ${packageName} have been added`); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Package addition failed: ${detail}`); + } + }; + + const handleRequest = async (): Promise => { + if (!packageName) { + return; + } + const repo = getSelectedRepo(); + try { + const patches = envVars.filter((v) => v.key); + await Client.requestPackages(repo, { + packages: [packageName], + patches: patches.length > 0 ? patches : undefined, + }); + onClose(); + showSuccess("Success", `Packages ${packageName} have been requested`); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Package request failed: ${detail}`); + } + }; + + const handleClose = (): void => { + setPackageName(""); + setSelectedRepo(""); + setRefresh(true); + setEnvVars([]); + onClose(); + }; + + return ( + + Add new packages + + + repository + + + + 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, index) => ( + + { + const updated = [...envVars]; + updated[index] = { ...updated[index], key: e.target.value }; + setEnvVars(updated); + }} + sx={{ flex: 1 }} + /> + = + { + const updated = [...envVars]; + updated[index] = { ...updated[index], value: e.target.value }; + setEnvVars(updated); + }} + sx={{ flex: 1 }} + /> + setEnvVars(envVars.filter((_, i) => i !== index))}> + + + + ))} +
    + + + + +
    + ); +} diff --git a/frontend/src/components/dialogs/PackageInfoDialog.tsx b/frontend/src/components/dialogs/PackageInfoDialog.tsx new file mode 100644 index 00000000..57930f69 --- /dev/null +++ b/frontend/src/components/dialogs/PackageInfoDialog.tsx @@ -0,0 +1,299 @@ +import React, { useState } from "react"; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + Grid, Typography, Link, Box, Tab, Tabs, IconButton, Chip, FormControlLabel, Checkbox, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import DeleteIcon from "@mui/icons-material/Delete"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import BuildLogsTab from "components/package/BuildLogsTab"; +import ChangesTab from "components/package/ChangesTab"; +import EventsTab from "components/package/EventsTab"; +import AutoRefreshControl from "components/common/AutoRefreshControl"; +import { useRepository } from "hooks/useRepository"; +import { useAuth } from "hooks/useAuth"; +import { useNotification } from "hooks/useNotification"; +import { useAutoRefresh } from "hooks/useAutoRefresh"; +import { Client } from "api/client/AhrimanClient"; +import { ApiError } from "api/client/ApiError"; +import { QueryKeys } from "api/QueryKeys"; +import { StatusHeaderStyles } from "theme/status/StatusColors"; +import { formatTimestamp } from "components/common/formatTimestamp"; +import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval"; +import type { Dependencies } from "api/types/Dependencies"; +import type { PackageProperties } from "api/types/PackageProperties"; +import type { PackageStatus } from "api/types/PackageStatus"; +import type { Patch } from "api/types/Patch"; +import type { RepositoryId } from "api/types/RepositoryId"; + +interface PackageInfoDialogProps { + packageBase: string | null; + open: boolean; + onClose: () => void; + autorefreshIntervals: AutoRefreshInterval[]; +} + +function listToString(items: string[]): React.ReactNode { + const unique = [...new Set(items)].sort(); + return unique.map((item, i) => ( + + {item} + {i < unique.length - 1 &&
    } +
    + )); +} + +export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element { + const { current } = useRepository(); + const { enabled: authEnabled, username } = useAuth(); + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + const hasAuth = !authEnabled || username !== null; + + const [tabIndex, setTabIndex] = useState(0); + const [refreshDb, setRefreshDb] = useState(true); + + const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0; + const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval); + + const repo = current as RepositoryId; + + const { data: packageData } = useQuery({ + queryKey: packageBase && repo ? QueryKeys.package(packageBase, repo) : ["package-none"], + queryFn: () => Client.fetchPackage(packageBase!, repo), + enabled: !!packageBase && !!repo && open, + refetchInterval: autoRefresh.refetchInterval, + }); + + const { data: dependencies } = useQuery({ + queryKey: packageBase && repo ? QueryKeys.dependencies(packageBase, repo) : ["deps-none"], + queryFn: () => Client.fetchDependencies(packageBase!, repo), + enabled: !!packageBase && !!repo && open, + }); + + const { data: patches = [] } = useQuery({ + queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"], + queryFn: () => Client.fetchPatches(packageBase!), + enabled: !!packageBase && open, + }); + + const description: PackageStatus | undefined = packageData?.[0]; + const pkg = description?.package; + const status = description?.status; + + const headerStyle = status ? StatusHeaderStyles[status.status] : {}; + + // Flatten depends from all sub-packages + const allDepends: string[] = pkg + ? Object.values(pkg.packages).flatMap((p: PackageProperties) => { + const pkgNames = Object.keys(pkg.packages); + const deps = (p.depends ?? []).filter((d: string) => !pkgNames.includes(d)); + const makeDeps = (p.make_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (make)`); + const optDeps = (p.opt_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (optional)`); + return [...deps, ...makeDeps, ...optDeps]; + }) + : []; + + const implicitDepends: string[] = dependencies + ? Object.values(dependencies.paths).flat() + : []; + + const groups: string[] = pkg + ? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.groups ?? []) + : []; + + const licenses: string[] = pkg + ? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.licenses ?? []) + : []; + + const upstreamUrls: string[] = pkg + ? [...new Set(Object.values(pkg.packages).map((p: PackageProperties) => p.url).filter((u): u is string => !!u))].sort() + : []; + + const aurUrl = pkg?.remote.web_url; + + const packagesList: string[] = pkg + ? Object.entries(pkg.packages).map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`) + : []; + + const handleUpdate = async (): Promise => { + if (!packageBase || !repo) { + return; + } + try { + await Client.addPackages(repo, { packages: [packageBase], refresh: refreshDb }); + showSuccess("Success", `Run update for packages ${packageBase}`); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Package update failed: ${detail}`); + } + }; + + const handleRemove = async (): Promise => { + if (!packageBase || !repo) { + return; + } + try { + await Client.removePackages(repo, [packageBase]); + showSuccess("Success", `Packages ${packageBase} have been removed`); + onClose(); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Could not remove package: ${detail}`); + } + }; + + const handleDeletePatch = async (key: string): Promise => { + if (!packageBase) { + return; + } + try { + await Client.deletePatch(packageBase, key); + void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) }); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Could not delete variable: ${detail}`); + } + }; + + const handleReload = (): void => { + if (!packageBase || !repo) { + return; + } + void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repo) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, repo) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, repo) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.events(repo, packageBase) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, repo) }); + void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) }); + }; + + const handleClose = (): void => { + setTabIndex(0); + setRefreshDb(true); + onClose(); + }; + + return ( + + + {pkg && status + ? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}` + : packageBase ?? ""} + + + {pkg && ( + <> + + packages + {listToString(packagesList)} + version + {pkg.version} + + + packager + {pkg.packager ?? ""} + + + + + groups + {listToString(groups)} + licenses + {listToString(licenses)} + + + upstream + + {upstreamUrls.map((url) => ( + + {url} + + ))} + + AUR + + {aurUrl && ( + AUR link + )} + + + + depends + {listToString(allDepends)} + implicitly depends + {listToString(implicitDepends)} + + + {patches.length > 0 && ( + + Environment variables + {patches.map((patch) => ( + + + = + {JSON.stringify(patch.value)} + {hasAuth && ( + void handleDeletePatch(patch.key)}> + + + )} + + ))} + + )} + + + setTabIndex(v)}> + + + + + + + {tabIndex === 0 && packageBase && repo && ( + + )} + {tabIndex === 1 && packageBase && repo && ( + + )} + {tabIndex === 2 && packageBase && repo && ( + + )} + + )} + + + {hasAuth && ( + <> + setRefreshDb(checked)} size="small" />} + label="update pacman databases" + /> + + + + )} + + + + + + ); +} diff --git a/frontend/src/components/dialogs/PackageRebuildDialog.tsx b/frontend/src/components/dialogs/PackageRebuildDialog.tsx new file mode 100644 index 00000000..1b444709 --- /dev/null +++ b/frontend/src/components/dialogs/PackageRebuildDialog.tsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + TextField, Select, MenuItem, InputLabel, FormControl, +} from "@mui/material"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import { useRepository } from "hooks/useRepository"; +import { useNotification } from "hooks/useNotification"; +import { Client } from "api/client/AhrimanClient"; +import { ApiError } from "api/client/ApiError"; +import type { RepositoryId } from "api/types/RepositoryId"; + +interface PackageRebuildDialogProps { + open: boolean; + onClose: () => void; +} + +export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element { + const { repositories, current } = useRepository(); + const { showSuccess, showError } = useNotification(); + + const [dependency, setDependency] = useState(""); + const [selectedRepo, setSelectedRepo] = useState(""); + + const getSelectedRepo = (): RepositoryId => { + if (selectedRepo) { + const repo = repositories.find((r) => `${r.architecture}-${r.repository}` === selectedRepo); + if (repo) { + return repo; + } + } + return current!; + }; + + const handleRebuild = async (): Promise => { + if (!dependency) { + return; + } + const repo = getSelectedRepo(); + try { + await Client.rebuildPackages(repo, [dependency]); + onClose(); + showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`); + } catch (e) { + const detail = e instanceof ApiError ? e.detail : String(e); + showError("Action failed", `Repository rebuild failed: ${detail}`); + } + }; + + const handleClose = (): void => { + setDependency(""); + setSelectedRepo(""); + onClose(); + }; + + return ( + + Rebuild depending packages + + + repository + + + + 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..995c4a1f --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect } from "react"; +import { Container, Box } from "@mui/material"; +import { useQuery } from "@tanstack/react-query"; +import Navbar from "components/layout/Navbar"; +import Footer from "components/layout/Footer"; +import PackageTable from "components/table/PackageTable"; +import LoginDialog from "components/dialogs/LoginDialog"; +import { useAuth } from "hooks/useAuth"; +import { useRepository } from "hooks/useRepository"; +import { Client } from "api/client/AhrimanClient"; +import { QueryKeys } from "api/QueryKeys"; +import type { InfoResponse } from "api/types/InfoResponse"; + +export default function AppLayout(): React.JSX.Element { + const { setAuthState } = useAuth(); + const { setRepositories } = useRepository(); + const [loginOpen, setLoginOpen] = useState(false); + + const { data: info } = useQuery({ + queryKey: QueryKeys.info, + queryFn: () => Client.fetchInfo(), + 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 ( + + + + + + + + + + + + +